<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet href="/rss/styles.xsl" type="text/xsl"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Bitdoze</title><description>Practical DevOps, programming, and self-hosting guides for developers and operators.</description><link>https://www.bitdoze.com/</link><item><title>FastHTML Multi-Page Website: Complete Structure Tutorial</title><link>https://www.bitdoze.com/fasthtml-multiple-pages/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-multiple-pages/</guid><description>Build a FastHTML multi-page website with reusable components, shared layouts, and APIRouter. Includes FastHTML 0.14 404 handling, forms, and project structure.</description><pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

In the first article, [FastHTML: Getting Started](/fasthtml-start/), we covered the basics: installing FastHTML, building a simple page, and adding Tailwind CSS styling. This article picks up where that left off and builds a full **FastHTML multi-page website** with shared layouts, reusable components, proper 404 handling, and form processing (updated for FastHTML 0.14).

By the end you&apos;ll have a working Home, About, and Contact site using modern idioms like `fast_app()`, `Titled()`, and dataclass-based form binding. If you want to scale the project later, we also cover `APIRouter` for multi-file routing.

A video walkthrough is embedded below if you prefer to follow along visually.

## Why multiple pages?

A multi-page website lets you organize content logically. While single-page applications have their place, most websites benefit from distinct pages for different purposes:

- **Organization**: Separate pages keep your content structured. &quot;Home&quot; for an overview, &quot;About&quot; for your story, &quot;Contact&quot; for communication details.
- **Navigation**: Users expect to click links to explore different sections. It makes the site intuitive.
- **Scalability**: Adding new pages is a matter of creating a new file and a new route.
- **SEO benefits**: Search engines index individual pages, improving discoverability.
- **User experience**: Users can bookmark specific pages and use browser navigation (back/forward) naturally.

FastHTML makes multi-page sites straightforward with its routing system and reusable Python components. If you&apos;re evaluating Python web frameworks in general, check our comparison of the [best Python web frameworks](/best-python-web-frameworks/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Zc8APrgknug&quot;
  label=&quot;FastHTML Multiple Pages Walkthrough&quot;
/&gt;

## FastHTML series

Below are the articles in this series to help you get started:

- [FastHTML Get Started](/fasthtml-start/)
- [FastHTML Multiple Pages](/fasthtml-multiple-pages/) (this article)
- [FastHTML Complex AI Tools](/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](/fasthtml-sqlite-db/)
- [FastHTML Authentication](/fasthtml-user-auth/)

## Prerequisites

Before you start, make sure you have the following:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.10 or newer installed&lt;/li&gt;
&lt;li&gt;`pip install python-fasthtml` (this tutorial targets v0.14.x)&lt;/li&gt;
&lt;li&gt;A code editor (VS Code, Cursor, or similar)&lt;/li&gt;
&lt;li&gt;Basic Python knowledge (functions, imports, decorators)&lt;/li&gt;
&lt;li&gt;Terminal or command-line access&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Pin your FastHTML version&quot;&gt;
FastHTML is pre-1.0 and ships breaking changes between minor versions. Always pin the exact version in `requirements.txt` (e.g., `python-fasthtml==0.14.11`) and re-test after upgrading. Read the CHANGELOG before bumping.
&lt;/Notice&gt;

## Creating a multi-page website with FastHTML

We&apos;ll build a 3-page site (Home, About, Contact) step by step, using modern FastHTML idioms: `fast_app()` for app initialization, `Title()` tuples for page shells, and dataclass-based form binding with Post/Redirect/Get. The structure is modular: a `main.py` entry point, a `components.py` for shared UI, and a `pages/` directory for per-page content.

### Step 1: Project structure

A clean directory layout keeps the project maintainable as it grows:

```
mywebsite/
├── main.py              # Application entry point and routing
├── components.py        # Reusable header, footer, and layout
├── requirements.txt     # Pin dependencies
└── pages/               # Per-page content modules
    ├── __init__.py      # Makes pages a Python package
    ├── home.py          # Home page content
    ├── about.py         # About page content
    └── contact.py       # Contact page with form
```

Create a `requirements.txt` to pin the version:

```
python-fasthtml==0.14.11
```

Each file has a clear role:

- **`main.py`** handles routing (URL → handler) and starts the server.
- **`components.py`** holds reusable UI pieces (header, footer, page layout) that appear on every page.
- **`pages/`** contains one module per page. Each returns FT components for that page&apos;s content.
- **`pages/__init__.py`** is an empty file that makes the directory a proper Python package.

The advantages: separation of concerns, easy to find and update specific pieces, and straightforward to add new files to `pages/` as the site grows.

### Step 2: Reusable components (header, footer, layout)

Create `components.py` with three functions: `header()`, `footer()`, and `page_layout()`. The update from older tutorials: we use `Title()` tuples instead of manual `Html(Head(...))` wrapping. FastHTML auto-wraps the tuple in a full HTML document including any `hdrs` you passed to `fast_app()`.

**File: `mywebsite/components.py`**

```python
from fasthtml.common import *
from datetime import datetime

def header(current_page=&quot;/&quot;):
    &quot;&quot;&quot;Navigation bar shared across all pages.

    Args:
        current_page: Current page path, used to highlight the active link.
    &quot;&quot;&quot;
    nav_items = [
        (&quot;Home&quot;, &quot;/&quot;),
        (&quot;About&quot;, &quot;/about&quot;),
        (&quot;Contact&quot;, &quot;/contact&quot;),
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = &quot;text-white hover:text-gray-300 px-3 py-2&quot;
        if is_current:
            link_class += &quot; font-bold underline&quot;
        nav_links.append(Li(A(title, href=path, cls=link_class)))

    return Header(
        Div(
            A(&quot;MyWebsite&quot;, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),
            Nav(Ul(*nav_links, cls=&quot;flex space-x-2&quot;), cls=&quot;ml-auto&quot;),
            cls=&quot;container mx-auto flex items-center justify-between px-4 py-3&quot;,
        ),
        cls=&quot;bg-blue-600 shadow-md&quot;,
    )


def footer():
    &quot;&quot;&quot;Footer shared across all pages.&quot;&quot;&quot;
    current_year = datetime.now().year

    return Footer(
        Div(
            Div(
                P(f&quot;\u00a9 {current_year} MyWebsite. All rights reserved.&quot;,
                  cls=&quot;text-gray-500&quot;),
                cls=&quot;mb-4&quot;,
            ),
            Div(
                A(&quot;Privacy Policy&quot;, href=&quot;#&quot;,
                  cls=&quot;text-blue-500 hover:underline mr-4&quot;),
                A(&quot;Terms of Service&quot;, href=&quot;#&quot;,
                  cls=&quot;text-blue-500 hover:underline&quot;),
                cls=&quot;text-sm&quot;,
            ),
            cls=&quot;container mx-auto px-4 py-6 text-center&quot;,
        ),
        cls=&quot;bg-gray-100 mt-8&quot;,
    )


def page_layout(title, content, current_page=&quot;/&quot;):
    &quot;&quot;&quot;Wraps page content with header, footer, and meta tags.

    Returns a tuple that FastHTML auto-wraps into a full HTML document.
    The Tailwind CDN script and viewport meta are injected here for
    simplicity in this tutorial.
    &quot;&quot;&quot;
    return (
        Title(title),
        Meta(name=&quot;viewport&quot;, content=&quot;width=device-width, initial-scale=1.0&quot;),
        Meta(name=&quot;description&quot;, content=f&quot;{title} - MyWebsite built with FastHTML&quot;),
        Script(src=&quot;https://cdn.tailwindcss.com&quot;),
        header(current_page),
        Main(
            Div(content, cls=&quot;container mx-auto px-4 py-8&quot;),
            cls=&quot;min-h-screen&quot;,
        ),
        footer(),
    )
```

What changed from older tutorials:

1. **`datetime.now().year`** instead of hardcoding `2025`.
2. **`page_layout` returns a tuple**: `(Title(...), Meta(...), header(...), Main(...), footer())`. FastHTML sees this tuple and wraps it in `Html(Head(...), Body(...))` automatically. No need for manual `Html(Head(Title(...)))`.
3. **Tailwind CDN script stays in `page_layout`** for simplicity. In production you&apos;d move it to `fast_app(hdrs=...)` instead (see Production notes below).

&lt;Notice type=&quot;warning&quot; title=&quot;Tailwind Play CDN is for development only&quot;&gt;
Tailwind&apos;s own docs state the Play CDN &quot;is designed for development purposes only, and is not intended for production.&quot; The browser console will also warn you. For production, build a static CSS file with `npx @tailwindcss/cli -o public/styles.css` and serve it via a `&lt;link&gt;` tag, or use FastHTML&apos;s default Pico CSS for zero-build styling.
&lt;/Notice&gt;

### Step 3: Individual page content

Create the page modules inside `pages/`. Start with an empty `pages/__init__.py` (just `touch pages/__init__.py`), then add each page.

**File: `mywebsite/pages/home.py`**

```python
from fasthtml.common import *

def home():
    &quot;&quot;&quot;Home page content.&quot;&quot;&quot;
    return Div(
        # Hero section
        Div(
            H1(&quot;Welcome to MyWebsite&quot;,
               cls=&quot;text-4xl font-bold text-gray-800 mb-4&quot;),
            P(&quot;Build web applications with FastHTML and Python.&quot;,
              cls=&quot;text-xl text-gray-600 mb-6&quot;),
            Div(
                A(&quot;Get Started&quot;, href=&quot;/about&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-4&quot;),
                A(&quot;Learn More&quot;, href=&quot;/contact&quot;,
                  cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                cls=&quot;flex&quot;,
            ),
            cls=&quot;py-12 text-center&quot;,
        ),
        # Features section
        Div(
            H2(&quot;Key Features&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                Div(
                    H3(&quot;Easy to Learn&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Built on Python, making web development accessible to everyone.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;,
                ),
                Div(
                    H3(&quot;Highly Productive&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Create web applications faster with fewer lines of code.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;,
                ),
                Div(
                    H3(&quot;Scalable&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Easily expand your application as your needs grow.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;,
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-6 mb-12&quot;,
            ),
            cls=&quot;py-8&quot;,
        ),
    )
```

**File: `mywebsite/pages/about.py`**

```python
from fasthtml.common import *

def about():
    &quot;&quot;&quot;About page content.&quot;&quot;&quot;
    return Div(
        H1(&quot;About Us&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6 text-center&quot;),
        Div(
            Div(
                H2(&quot;Our Story&quot;, cls=&quot;text-2xl font-semibold mb-4&quot;),
                P(&quot;MyWebsite was created to help developers build &quot;
                  &quot;web applications using Python. Our mission is to make &quot;
                  &quot;web development accessible, enjoyable, and productive.&quot;,
                  cls=&quot;text-gray-600 mb-4&quot;),
                P(&quot;We believe that Python developers should be able to create &quot;
                  &quot;stunning web applications without having to learn multiple &quot;
                  &quot;languages and frameworks.&quot;,
                  cls=&quot;text-gray-600 mb-4&quot;),
                cls=&quot;mb-8&quot;,
            ),
            Div(
                H2(&quot;Our Team&quot;, cls=&quot;text-2xl font-semibold mb-4&quot;),
                Div(
                    Div(
                        H3(&quot;Jane Doe&quot;, cls=&quot;text-xl font-semibold&quot;),
                        P(&quot;Founder &amp; CEO&quot;, cls=&quot;text-gray-500 italic mb-2&quot;),
                        P(&quot;Python enthusiast with 15 years of experience in web development.&quot;,
                          cls=&quot;text-gray-600&quot;),
                        cls=&quot;bg-white p-4 rounded shadow-md&quot;,
                    ),
                    Div(
                        H3(&quot;John Smith&quot;, cls=&quot;text-xl font-semibold&quot;),
                        P(&quot;CTO&quot;, cls=&quot;text-gray-500 italic mb-2&quot;),
                        P(&quot;Full-stack developer with a passion for clean, maintainable code.&quot;,
                          cls=&quot;text-gray-600&quot;),
                        cls=&quot;bg-white p-4 rounded shadow-md&quot;,
                    ),
                    cls=&quot;grid grid-cols-1 md:grid-cols-2 gap-6&quot;,
                ),
            ),
        ),
    )
```

**File: `mywebsite/pages/contact.py`**

```python
from fasthtml.common import *

def contact():
    &quot;&quot;&quot;Contact page content with a form.&quot;&quot;&quot;
    return Div(
        H1(&quot;Contact Us&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6 text-center&quot;),
        Div(
            # Contact info
            Div(
                H2(&quot;Get in Touch&quot;, cls=&quot;text-2xl font-semibold mb-4&quot;),
                P(&quot;We&apos;d love to hear from you! Use the form or the &quot;
                  &quot;information below to reach out.&quot;,
                  cls=&quot;text-gray-600 mb-4&quot;),
                Div(
                    P(Strong(&quot;Email: &quot;), &quot;info@mywebsite.com&quot;, cls=&quot;mb-2&quot;),
                    P(Strong(&quot;Phone: &quot;), &quot;+1 (555) 123-4567&quot;, cls=&quot;mb-2&quot;),
                    P(Strong(&quot;Address: &quot;), &quot;123 Web Street, Internet City, 10101&quot;,
                      cls=&quot;mb-2&quot;),
                    cls=&quot;mb-6&quot;,
                ),
                cls=&quot;mb-8 md:pr-8&quot;,
            ),
            # Contact form
            Div(
                H2(&quot;Send a Message&quot;, cls=&quot;text-2xl font-semibold mb-4&quot;),
                Form(
                    Div(
                        Label(&quot;Name&quot;, For=&quot;name&quot;, cls=&quot;block text-gray-700 mb-1&quot;),
                        Input(type=&quot;text&quot;, id=&quot;name&quot;, name=&quot;name&quot;,
                              placeholder=&quot;Your name&quot;,
                              cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;),
                        cls=&quot;mb-4&quot;,
                    ),
                    Div(
                        Label(&quot;Email&quot;, For=&quot;email&quot;, cls=&quot;block text-gray-700 mb-1&quot;),
                        Input(type=&quot;email&quot;, id=&quot;email&quot;, name=&quot;email&quot;,
                              placeholder=&quot;Your email&quot;,
                              cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;),
                        cls=&quot;mb-4&quot;,
                    ),
                    Div(
                        Label(&quot;Message&quot;, For=&quot;message&quot;, cls=&quot;block text-gray-700 mb-1&quot;),
                        Textarea(id=&quot;message&quot;, name=&quot;message&quot;,
                                 placeholder=&quot;Your message&quot;,
                                 rows=5,
                                 cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;),
                        cls=&quot;mb-6&quot;,
                    ),
                    Button(&quot;Send Message&quot;, type=&quot;submit&quot;,
                           cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
                    action=&quot;/submit-contact&quot;,
                    method=&quot;post&quot;,
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;,
                ),
            ),
            cls=&quot;md:flex&quot;,
        ),
    )
```

If you&apos;ve built HTML forms before, this is straightforward Python. Each function returns FT components that render to standard HTML. The form `action` points to `/submit-contact`. We&apos;ll handle that POST in the next step.

For adding contact forms to other types of sites, see [adding a contact form to static websites](/add-contact-form-static-websites/).

### Step 4: Routing in the main application

Here&apos;s where the modern FastHTML idioms matter. The updated `main.py` uses `fast_app()` instead of `FastHTML()`, `@rt` instead of `@app.get`, a real 404 handler, and no `if __name__` guard.

**File: `mywebsite/main.py`**

```python
from fasthtml.common import *
from dataclasses import dataclass

from pages.home import home as home_page
from pages.about import about as about_page
from pages.contact import contact as contact_page
from components import page_layout

# --- 404 handler (defined before fast_app so we can pass it in) ---

def not_found(req, exc):
    error_content = Div(
        H1(&quot;404 - Page Not Found&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-4&quot;),
        P(&quot;Sorry, that page doesn&apos;t exist.&quot;,
          cls=&quot;text-xl text-gray-600 mb-6&quot;),
        A(&quot;Return Home&quot;, href=&quot;/&quot;,
          cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
        cls=&quot;text-center py-12&quot;,
    )
    return page_layout(&quot;404 Not Found - MyWebsite&quot;, error_content, current_page=&quot;/&quot;)

# Initialize the app.
# pico=False disables the default Pico CSS since we&apos;re using Tailwind.
# Tailwind CDN is injected via page_layout() for this tutorial.
# exception_handlers registers our 404 handler for real HTTP 404 status codes.
app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

# --- Routes ---

@rt(&quot;/&quot;)
def get():
    return page_layout(&quot;Home - MyWebsite&quot;, home_page(), current_page=&quot;/&quot;)

@rt(&quot;/about&quot;)
def get():
    return page_layout(&quot;About Us - MyWebsite&quot;, about_page(), current_page=&quot;/about&quot;)

@rt(&quot;/contact&quot;)
def get():
    return page_layout(&quot;Contact Us - MyWebsite&quot;, contact_page(), current_page=&quot;/contact&quot;)

# --- Form handling with dataclass binding + PRG ---

@dataclass
class Contact:
    name: str
    email: str
    message: str

@app.post(&quot;/submit-contact&quot;)
def submit_contact(contact: Contact):
    # In a real app, save to DB or send email here.
    # contact.name, contact.email, contact.message are typed and validated.
    return RedirectResponse(&quot;/thanks&quot;, status_code=303)

@rt(&quot;/thanks&quot;)
def get():
    thanks_content = Div(
        H1(&quot;Thank You!&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-4&quot;),
        P(&quot;We&apos;ve received your message and will respond soon.&quot;,
          cls=&quot;text-xl text-gray-600 mb-6&quot;),
        A(&quot;Return Home&quot;, href=&quot;/&quot;,
          cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
        cls=&quot;text-center py-12&quot;,
    )
    return page_layout(&quot;Thank You - MyWebsite&quot;, thanks_content, current_page=&quot;/contact&quot;)

serve()
```

&lt;Notice type=&quot;error&quot; title=&quot;The old catch-all 404 returns HTTP 200&quot;&gt;
The previous tutorial used `@app.get(&quot;/{path:path}&quot;)` for 404 handling. That returns **HTTP 200 OK** with a page that *says* &quot;404&quot;. Crawlers and monitoring tools see a successful response, which is wrong for SEO and alerting. Use `exception_handlers={404: handler}` passed to `fast_app()` to return a genuine 404 status code.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;serve() doesn&apos;t need a __main__ guard&quot;&gt;
The official docs are explicit: &quot;Never write `if __name__ == &apos;__main__&apos;` since `serve` checks it internally.&quot; The guard is harmless but unnecessary and flagged as non-idiomatic. Just call `serve()` at module level.
&lt;/Notice&gt;

Key changes explained:

1. **`app, rt = fast_app(pico=False)`**: `fast_app()` is the modern initialization. It returns `(app, rt)` where `rt` is shorthand for `app.route`. By default it includes Pico CSS, htmx 2.x, and surreal.js headers. We pass `pico=False` because we&apos;re using Tailwind instead.

2. **`@rt(&quot;/&quot;)` decorator**: instead of `@app.get(&quot;/&quot;)`. The function name `get` maps to the HTTP method. Both styles work; `@rt` is the idiomatic shorthand.

3. **`exception_handlers={404: not_found}`**: registered at app creation. The handler receives `(req, exc)` and must return a response. FastHTML wraps the FT component in a full HTML doc automatically, and the response gets a real HTTP 404 status.

4. **`serve()` without a guard**: runs uvicorn on `0.0.0.0:5001` with live-reload by default.

### Step 5: Contact form with dataclass binding and PRG pattern

The contact form in Step 4 uses two patterns worth understanding:

**Dataclass binding**: instead of loose function parameters (`name: str, email: str, message: str`), we define a `@dataclass class Contact`. FastHTML resolves form fields to the dataclass constructor automatically. This gives you type validation and cleaner code.

**Post/Redirect/Get (PRG)**: the POST handler returns `RedirectResponse(&quot;/thanks&quot;, status_code=303)` instead of rendering the thank-you page directly. If a user refreshes after a direct POST render, the browser re-submits the form. A 303 redirect to a GET endpoint makes refresh safe.

&lt;Accordion label=&quot;Why use Post/Redirect/Get (PRG)?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
After a successful form POST, the browser holds the POST request in its history. If the user hits refresh, the browser asks &quot;Re-send form data?&quot; or silently re-submits. With PRG, the POST returns a 303 redirect to a GET endpoint. The browser&apos;s address bar shows the GET URL, and refreshing just reloads that page. No duplicate submissions.

This is a standard web pattern. The FastHTML community discussed it in [GitHub issue #389](https://github.com/AnswerDotAI/fasthtml/issues/389), and `RedirectResponse` is the documented way to do it.
&lt;/Accordion&gt;

### Step 6 — Running and testing your website

Now let&apos;s run it:

```bash
cd mywebsite
pip install -r requirements.txt
python main.py
```

You should see uvicorn start on `http://0.0.0.0:5001`. Visit:

- `http://localhost:5001/` — Home page
- `http://localhost:5001/about` — About page
- `http://localhost:5001/contact` — Contact page with form

Click the nav links to move between pages. The current page should be highlighted in the header. Submit the contact form — you should be redirected to `/thanks`. Refresh that page — no duplicate form submission.

## Organizing routes with APIRouter

The pattern above (import page functions, define routes in `main.py`) works fine for small sites. When your project grows past 3-4 pages, FastHTML offers `APIRouter` — introduced in v0.8.0 — to let each module own its routes.

&lt;Tabs&gt;
&lt;Tab name=&quot;Simple import pattern&quot;&gt;
Routes are defined in `main.py` by importing page functions:

```python
# main.py
from pages.home import home as home_page
from components import page_layout

app, rt = fast_app(pico=False)

@rt(&quot;/&quot;)
def get():
    return page_layout(&quot;Home&quot;, home_page(), current_page=&quot;/&quot;)
```

This is the pattern from Steps 1-6. Works well for small projects.
&lt;/Tab&gt;
&lt;Tab name=&quot;APIRouter (modern pattern)&quot;&gt;
Each page module creates its own `APIRouter` and registers itself with the app:

```python
# pages/home.py
from fasthtml.common import *
from components import page_layout

ar = APIRouter()

@ar(&quot;/&quot;)
def get():
    return page_layout(&quot;Home&quot;, home_content(), current_page=&quot;/&quot;)

def home_content():
    return Div(
        H1(&quot;Welcome to MyWebsite&quot;, cls=&quot;text-4xl font-bold text-gray-800 mb-4&quot;),
        P(&quot;Build web applications with FastHTML and Python.&quot;,
          cls=&quot;text-xl text-gray-600 mb-6&quot;),
        cls=&quot;py-12 text-center&quot;,
    )
```

```python
# main.py
from fasthtml.common import *
from components import not_found
from pages.home import ar as home_routes

app, rt = fast_app(
    pico=False,
    exception_handlers={404: not_found},
)

home_routes.to_app(app)

serve()
```

Each page module is self-contained — routes and content live together. The `main.py` just wires them to the app.
&lt;/Tab&gt;
&lt;/Tabs&gt;

**Prefix support**: `APIRouter(&quot;/blog&quot;)` adds a URL prefix to all routes in that module. Useful for namespacing, e.g., all blog routes live under `/blog/*` without repeating the prefix in every decorator.

For a real-world example of `APIRouter` in a multi-page project, see [building a multi-page AI tools website with FastHTML](/fasthtml-complex-ai-tools/).

## Verify and troubleshoot

After building the site, verify it works:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Visit `/`, `/about`, `/contact` — content renders correctly&lt;/li&gt;
&lt;li&gt;`curl -i http://127.0.0.1:5001/nope` — returns `HTTP/1.1 404 Not Found` (not 200)&lt;/li&gt;
&lt;li&gt;Submit the contact form — browser redirects to `/thanks` (check the URL bar)&lt;/li&gt;
&lt;li&gt;Refresh the thank-you page — no duplicate form submission&lt;/li&gt;
&lt;li&gt;Check browser console — no JS errors from Tailwind CDN&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Common errors and fixes

| Symptom | Likely cause | Fix |
|---|---|---|
| `ModuleNotFoundError: No module named &apos;pages&apos;` | Missing `pages/__init__.py` or wrong working directory | Create the empty `__init__.py`; run from project root |
| 404 page returns HTTP 200 | Old catch-all `@app.get(&quot;/{path:path}&quot;)` still in code | Replace with `exception_handlers={404: handler}` |
| Port 5001 already in use | Another process on the port | Kill it (`lsof -i :5001`) or set `PORT=8000` env var |
| `AttributeError: &apos;NoneType&apos;` on form submit | Missing `name` attribute on `&lt;input&gt;` | Ensure inputs have `name=&quot;name&quot;`, `name=&quot;email&quot;`, etc. |
| Tailwind styles not loading | Play CDN blocked or offline | Check browser network tab; use a local CSS build for reliability |

### Debug mode

Pass `debug=True` to `fast_app()` to get detailed error pages in the browser:

```python
app, rt = fast_app(pico=False, debug=True)
```

Never use this in production — it exposes stack traces to users.

### Testing with TestClient

You can test routes without a browser using Starlette&apos;s `TestClient`:

```python
from starlette.testclient import TestClient

client = TestClient(app)
print(client.get(&quot;/&quot;).status_code)       # 200
print(client.get(&quot;/nope&quot;).status_code)   # 404
print(client.post(&quot;/submit-contact&quot;, data={
    &quot;name&quot;: &quot;Test&quot;, &quot;email&quot;: &quot;a@b.c&quot;, &quot;message&quot;: &quot;hi&quot;
}).status_code)                          # 303
```

This is useful for CI or quick smoke tests after changes.

## Production and deployment notes

The tutorial above is optimized for local development. Here&apos;s what changes when you deploy beyond localhost.

### Serving in production

`serve()` is a dev-friendly wrapper. It binds `0.0.0.0:5001` with live-reload on by default. For production, use uvicorn directly behind a reverse proxy for TLS termination and multiple workers:

```bash
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
```

Put Caddy, Nginx, or Traefik in front for automatic HTTPS. [Deploying a Python project with Dokploy](/dokploy-python-railpack-uv/) is one way to automate this on a VPS.

### CSS strategy

The Tailwind Play CDN compiles CSS in the browser on every page load — fine for development, slow and unreliable in production. Options:

1. **Build a static CSS file**: `npx @tailwindcss/cli -o public/styles.css` and serve it via `fast_app(hdrs=(Link(rel=&quot;stylesheet&quot;, href=&quot;/styles.css&quot;),))`.
2. **Use Pico CSS**: FastHTML&apos;s default. Zero build step, classless, looks decent out of the box. Drop `pico=False` and remove the Tailwind script.

### Static files

`fast_app(static_path=&apos;public&apos;)` serves files from a `public/` directory automatically. Useful for images, CSS, and JS assets.

### Version pinning

FastHTML is 0.x with frequent breaking changes. v0.14.1 removed several imports from `fasthtml.common`. Always pin `python-fasthtml` in `requirements.txt` and re-test before upgrading.

### Sessions and secrets

`fast_app(secret_key=&quot;your-secret&quot;)` enables signed session cookies via Starlette&apos;s session middleware. Never hardcode secrets — use environment variables.

### Deploying to a VPS

For affordable hosting, a [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) starts at around €4/month and runs a FastHTML app without issues. You can [run any Python app in Docker](/docker-run-python/) or set up a full deployment pipeline by [deploying a Python project with Dokploy](/dokploy-python-railpack-uv/).

## Extending your multi-page website

The foundation is in place. Here&apos;s how to grow it:

### Adding more pages

Create a new file in `pages/`, add a route (or `APIRouter`), and add a nav link in `components.py`. That&apos;s it.

### Adding dynamic content

Fetch data from APIs, read from a database, or generate content based on user input. See [adding a SQLite database to your FastHTML app](/fasthtml-sqlite-db/) and [building an AI-powered web app with FastHTML and PydanticAI](/fasthtml-pydenticai-tools/) for concrete examples.

### Implementing user authentication

For protected pages: login/register routes, session management, and redirects for unauthenticated users. Follow [adding user authentication to your FastHTML app](/fasthtml-user-auth/).

### Enhancing the UI

Add HTMX interactions for partial page updates, Tailwind animations, and client-side form validation. [Building a multi-page AI tools website with FastHTML](/fasthtml-complex-ai-tools/) shows HTMX in action with a real project.

## Conclusion

You&apos;ve built a FastHTML multi-page website with:

- **Modular project structure**: `main.py`, `components.py`, and per-page modules
- **Reusable shared layout**: header, footer, and page shell in one place
- **Modern FastHTML idioms**: `fast_app()`, `@rt`, `Title()` tuples
- **Proper 404 handling**: `exception_handlers={404: handler}` returning real HTTP 404
- **Form processing**: dataclass binding with Post/Redirect/Get pattern
- **A path to scale**: `APIRouter` for multi-file routing when the project grows

The code in this tutorial targets FastHTML 0.14.x. Pin your version, read the CHANGELOG before upgrading, and check the Production notes before deploying.

&lt;Button text=&quot;Next: FastHTML Complex AI Tools \u2192&quot; link=&quot;/fasthtml-complex-ai-tools/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;What is the difference between FastHTML() and fast_app()?&quot; group=&quot;faq&quot;&gt;
`fast_app()` is the modern shorthand. It returns `(app, rt)` where `rt` is `app.route`. It also injects default headers (Pico CSS, htmx 2.x, surreal.js) and session middleware. `FastHTML()` still works but requires manual setup of headers and middleware. Use `fast_app()` unless you need full control over every default.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why does my 404 page return HTTP 200?&quot; group=&quot;faq&quot;&gt;
If you&apos;re using `@app.get(&quot;/{path:path}&quot;)` as a catch-all, the route handler returns HTTP 200 by default — the page *says* &quot;404&quot; but the status code is wrong. Replace it with `exception_handlers={404: handler}` passed to `fast_app()`. The handler receives `(req, exc)` and must return a response; FastHTML sets the 404 status automatically.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I use Tailwind or Pico CSS with FastHTML?&quot; group=&quot;faq&quot;&gt;
FastHTML defaults to Pico CSS — a classless CSS framework that styles semantic HTML with zero configuration. It&apos;s fine for internal tools and simple sites. Tailwind gives more control but requires either the Play CDN (dev only) or a build step for production. Use `pico=False` plus `hdrs=(Script(...),)` to switch to Tailwind. For production Tailwind, build a static CSS file with the Tailwind CLI.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I deploy a FastHTML app to production?&quot; group=&quot;faq&quot;&gt;
Run `uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4` behind a reverse proxy (Caddy, Nginx, or Traefik) for TLS termination. Pin `python-fasthtml` in your requirements file. Build static CSS if using Tailwind. Use `fast_app(secret_key=...)` with an environment variable for session signing. A [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) at ~€4/month handles this easily.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is APIRouter and when should I use it?&quot; group=&quot;faq&quot;&gt;
`APIRouter` (available since FastHTML v0.8.0) lets each page module own its routes. Instead of importing page functions into `main.py` and defining all routes there, each module creates an `APIRouter`, decorates its own handlers, and calls `ar.to_app(app)` to register with the main app. Use it when your project grows beyond 3-4 pages. It supports `prefix=` for URL namespacing (e.g., `APIRouter(&quot;/blog&quot;)`).
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>fasthtml</category><category>python</category><category>web-development</category></item><item><title>Self-Hosted Shared Inbox for $5/Month: HQBase Review</title><link>https://www.bitdoze.com/hqbase-self-hosted-shared-inbox/</link><guid isPermaLink="true">https://www.bitdoze.com/hqbase-self-hosted-shared-inbox/</guid><description>HQBase review: a self-hosted shared inbox that runs on Cloudflare for ~$5/month. No VPS, no mail server. Setup, cost math, MCP, and Agentic Inbox comparison.</description><pubDate>Thu, 13 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

HQBase is a free, open-source, self-hosted shared email workspace that deploys entirely into your own Cloudflare account. No VPS. No mail server. No per-seat pricing. It gives your team shared mailboxes like `support@yourdomain.com` and `sales@yourdomain.com`, with per-mailbox access control, MCP integration for AI tools, and a PWA with push notifications. All of it runs on Cloudflare&apos;s serverless platform for roughly $5/month. This article covers what HQBase is, how the Cloudflare architecture works, real cost math compared to Google Workspace and alternatives, step-by-step setup, and the honest caveats you should know before relying on it.

## What is HQBase? A self-hosted shared inbox on Cloudflare

HQBase is an AGPL-3.0 licensed, TypeScript-based shared inbox that runs in **your** Cloudflare account, not a SaaS operated by someone else. It gives you:

- **Shared mailboxes**: `support@`, `sales@`, `hello@` across multiple domains
- **Per-mailbox team access control**: Read, Agent, Manager roles per mailbox
- **Drafts, audit history, and trash auto-deletion** (30 days)
- **PWA with self-hosted push notifications** (VAPID/Web Push)
- **OAuth-protected MCP server** so AI tools can search, draft, and send mail
- **Multiple domain support** in a single workspace

The main difference: mail, data, and Cloudflare credentials never leave your account. There&apos;s no HQBase-operated middleman. The OAuth relay at `auth.hqbase.io` only returns a short-lived authorization code to your Worker. It never sees your access tokens or email content.

As of August 2026, HQBase is at v1.0.1, about five days old. That&apos;s young, but the project ships with signed releases (Ed25519 + SHA-256 verification), e2e tests with Playwright, architecture tests, and a proper rollback story. For a brand-new project, that&apos;s more process than most. If you&apos;ve been [configuring Postfix to send email via an external SMTP relay](https://www.bitdoze.com/postfix-external-smtp/) for your self-hosted tools, HQBase offers a simpler path: no mail server at all.

For teams exploring [self-hosted alternatives](https://www.bitdoze.com/self-hosted-airtable-alternatives/) to SaaS tools, HQBase fits the same philosophy: own your data, run it yourself, but with far fewer moving parts than traditional self-hosted email.

## How HQBase works: Cloudflare Workers, D1, R2 and Queues

HQBase decomposes into Cloudflare services you probably already know:

| Cloudflare service | Role in HQBase |
|---|---|
| **Workers** | Serves the web app, APIs, receives inbound email, performs approved actions |
| **D1** | Stores people, mailbox access, searchable email index, drafts, app state |
| **R2** | Stores original email files and attachments |
| **Queues** | Background and maintenance jobs; failed jobs go to a dead-letter queue |
| **Email Routing** | Inbound delivery (catch-all rule forwards to the Worker) |
| **Email Sending** | Outbound send from shared mailboxes |

When deployed, HQBase creates these resources in your account: a Worker named `hqbase`, a D1 database `hqbase`, an R2 bucket `hqbase-mail`, a Queue `hqbase-jobs`, a dead-letter queue `hqbase-jobs-dlq`, and a nightly cron trigger (`17 3 * * *`) for cleanup. It also generates a `BETTER_AUTH_SECRET` and VAPID keypair for push notifications.

There&apos;s no separate mail server, no IMAP, no Postfix, no Docker containers. Cloudflare handles email delivery and receipt at the edge. If you&apos;ve [deployed apps on Cloudflare Workers with a D1 database](https://www.bitdoze.com/sink-install/) before, the deployment pattern is the same, just with more bindings. The same applies if you&apos;re familiar with [running apps on Cloudflare](https://www.bitdoze.com/migrate-astro-bun/) from other projects.

![HQBase architecture diagram showing User, Cloudflare DNS, Email Routing, Worker, D1, R2, Queues, and Email Sending](../../assets/images/26/08/hqbase-architecture.webp)

&lt;Notice type=&quot;info&quot; title=&quot;No VPS required&quot;&gt;
HQBase runs entirely on Cloudflare&apos;s serverless platform. There&apos;s no server to patch, no Docker containers to manage, and no IMAP/SMTP daemons to babysit.
&lt;/Notice&gt;

## HQBase pricing: the real cost of a Cloudflare shared inbox

This is where HQBase gets interesting. The software itself is free (AGPL-3.0). Your only cost is the Cloudflare infrastructure it runs on.

### Cloudflare cost breakdown

| Cloudflare service | Free tier | Paid / overage |
|---|---|---|
| **Workers Paid** (required) | — | **$5/mo** minimum per account; 10M requests/mo included, +$0.30/M after |
| **Email Routing** (inbound) | **Free, unlimited** | — |
| **Email Sending** (outbound, beta) | **3,000 emails/mo** included | $0.35 per 1,000 after that |
| **D1** (database) | 5M rows-read/day, 100K rows-written/day, 5 GB storage | +$0.001/M reads, +$1.00/M writes, +$0.75/GB-mo |
| **R2** (attachments) | 10 GB storage, 1M Class A ops/mo | $0.015/GB-month, egress free |
| **Queues** | 1M ops/mo included (Paid plan) | +$0.40/M ops |

A domain costs roughly $10 to $15/year on top of that, but you probably already have one.

### Realistic scenario

Three mailboxes (`support@`, `sales@`, `hello@`), five team members, 500 inbound + 200 outbound emails/month, under 10 GB of attachments in R2:

- Workers Paid: **$5/mo** (base)
- Email Routing: **$0** (free, unlimited inbound)
- Email Sending: **$0** (under 3,000/mo free tier)
- D1: **$0** (well within free tier)
- R2: **$0** (under 10 GB free tier)
- Queues: **$0** (under 1M ops)

**Total: ~$5/month. Flat. For unlimited seats and unlimited mailboxes.**

### How that compares

| Solution | Monthly cost (3 users, 3 mailboxes) | VPS/server needed? | MCP/AI integration? |
|---|---|---|---|
| **HQBase** | ~$5/mo flat | No | Built-in, scoped OAuth |
| **Google Workspace** | ~$21/mo ($7/user × 3, annual billing) | No | None native |
| **Zoho Mail** | ~$3 to $12/mo ($1 to $4/user × 3) | No | None native |
| **Front** | ~$57/mo ($19/seat × 3) | No | Limited integrations |
| **FreeScout** | ~$5/mo VPS + maintenance | Yes | None (add-ons only) |

HQBase&apos;s cost advantage is clearest against per-seat SaaS tools. Google Workspace charges per user: scale to 10 people and you&apos;re at $70/mo on annual billing. HQBase stays at $5. FreeScout is also cheap, but you&apos;re running a VPS, PHP, a database, and a mail relay. More moving parts. If you want that VPS path, an [affordable Hetzner VPS](https://go.bitdoze.com/hetzner) is a solid choice, but you&apos;ll be doing more ops work.

&lt;Notice type=&quot;warning&quot; title=&quot;Cloudflare Email Sending is still in beta&quot;&gt;
Email Sending is in public beta as of August 2026. Pricing and limits may change. Monitor deliverability over the first few weeks and keep an eye on Cloudflare announcements.
&lt;/Notice&gt;

## How to set up HQBase step by step

### Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Cloudflare account with a domain using Cloudflare DNS (nameservers pointed to Cloudflare)&lt;/li&gt;
&lt;li&gt;Cloudflare Workers Paid plan ($5/mo, activate in your Cloudflare dashboard)&lt;/li&gt;
&lt;li&gt;R2 subscription activated (even though it has a free tier, you must complete the R2 checkout in the dashboard)&lt;/li&gt;
&lt;li&gt;Node.js 20+ and pnpm 11+ installed locally&lt;/li&gt;
&lt;li&gt;Wrangler CLI authenticated (&lt;code&gt;wrangler login&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;An email address &lt;strong&gt;not on any domain connected to the workspace&lt;/strong&gt; for the owner/recovery account&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;error&quot; title=&quot;Owner email must be on a different domain&quot;&gt;
The owner&apos;s login email must NOT be on a domain you plan to connect to HQBase. If HQBase goes down, you need recovery access via an external email. Use a Gmail, Outlook, or another personal address as the owner account. This is enforced by design.
&lt;/Notice&gt;

For [Cloudflare DNS](https://www.bitdoze.com/traefik-wildcard-certificate/) setup, if your domain&apos;s nameservers aren&apos;t pointing to Cloudflare yet, you&apos;ll need to update them at your registrar and wait for the status to show &quot;Active.&quot; This is typically the slowest step (can take hours).

### Deploy HQBase

You can use the &quot;Deploy to Cloudflare&quot; button from the repo or deploy manually via Wrangler.

**Option A: One-click deploy**

Click the deploy button on the [HQBase GitHub repo](https://github.com/HQBase/hqbase). It&apos;ll prompt you to authenticate with Cloudflare and create the required resources.

**Option B: Manual deploy via CLI**

```sh
git clone https://github.com/HQBase/hqbase.git
cd hqbase
pnpm install
wrangler login
pnpm run deploy
```

The installer creates all resources (Worker, D1 database, R2 bucket, Queues), applies database migrations, verifies the signed release, and saves a non-secret deployment record. It installs the current signed stable release and refuses to overwrite a non-empty Worker unless it can verify a valid HQBase release.

**Verify deployment:** After the command completes, visit the Worker URL shown in the output. You should see the HQBase setup wizard.

**What failure looks like:** If you haven&apos;t activated Workers Paid or R2, the deploy will fail with binding errors. If your domain isn&apos;t on Cloudflare DNS, email routing setup will fail later.

### Configure email routing and domains

The HQBase setup wizard walks you through adding your email domains. During this step, HQBase creates the required DNS records automatically:

- **MX records** — point to Cloudflare&apos;s email receiving infrastructure
- **SPF** — authorizes Cloudflare to send on your behalf
- **DKIM** — signing key for outbound email authentication
- **DMARC** — policy record for email authentication alignment

HQBase also enables Email Routing with a catch-all rule that forwards all inbound mail to the Worker.

&lt;Notice type=&quot;info&quot; title=&quot;DNS records are auto-created&quot;&gt;
HQBase&apos;s setup wizard creates MX, SPF, DKIM, and DMARC records automatically during domain configuration. After setup, verify them in your Cloudflare DNS dashboard under your domain&apos;s DNS settings.
&lt;/Notice&gt;

**Verify:** Check your Cloudflare dashboard → your domain → DNS records. You should see MX, SPF, DKIM, and DMARC entries. Cloudflare will show Email Routing as &quot;Enabled&quot; for the domain.

**What failure looks like:** If Cloudflare shows &quot;Authentication error&quot; for Email Routing DNS, re-approve the Zone Settings / Edit permission in your Cloudflare API token settings.

### Create mailboxes and invite teammates

Once domains are configured:

1. Create shared mailboxes (`support@yourdomain.com`, `sales@yourdomain.com`, etc.)
2. Set access levels for each mailbox (None / Read / Agent / Manager)
3. Invite teammates — they&apos;ll receive a password-setup link (fixed in v1.0.1 to properly reach the `/set-password` form)

**Verify:** Send a test email to `support@yourdomain.com` from an external address (Gmail, etc.). It should land in the HQBase inbox within seconds. This validates the full inbound path: Email Routing catch-all → Worker → D1 index → R2 storage.

## HQBase access control: mailbox roles and workspace permissions

This is one of HQBase&apos;s strongest features. The access model has two layers:

### Workspace roles

| Role | Can do |
|---|---|
| **Owner** | Everything. Recovery account. Manager of every mailbox by default. Login email must be on a domain NOT in the workspace. |
| **Admin** | Manage people, settings, and access — but cannot read a mailbox unless explicitly granted access. |
| **Member** | Can only access mailboxes where they&apos;ve been granted a role. |

### Mailbox access levels

| Level | Capabilities |
|---|---|
| **None** | No access to the mailbox |
| **Read** | Read, search, download messages |
| **Agent** | Read + send, reply, mark read, star, archive, trash |
| **Manager** | Agent + mailbox settings and deletion rules |

The critical point: **the same access rules are enforced across the web app, REST API, and MCP server.** If a team member can&apos;t read `finance@`, their AI agent connected via MCP can&apos;t read it either. You can give an MCP connection fewer abilities than its user, but never more.

This matters when thinking about [MCP servers vs native agent tools](https://www.bitdoze.com/mastra-tools-vs-mcp/). With HQBase, the permission boundary is enforced at the data layer, not bolted on as an afterthought in the AI integration.

![HQBase access control matrix showing Owner, Admin, Member roles against mailbox capabilities](../../assets/images/26/08/hqbase-access-control.webp)

&lt;Notice type=&quot;success&quot; title=&quot;Unified permissions&quot;&gt;
The same access rules apply everywhere — web UI, REST API, and MCP. If a user can&apos;t read a mailbox, their AI agent can&apos;t either. This is a meaningful security improvement over tools with separate AI integration layers.
&lt;/Notice&gt;

Additional safety: audit history records sensitive actions (without exposing email content, passwords, or tokens). Trash is auto-deleted after 30 days. Messages are otherwise kept indefinitely by default.

## Connect AI tools to HQBase over MCP

HQBase ships an OAuth-protected MCP server, so [AI coding tools like Claude Code and Cursor](https://www.bitdoze.com/best-ai-coding-tools/) can search, read, draft, and send email on your behalf.

### MCP profiles

| Profile | Endpoint | Capabilities |
|---|---|---|
| **Read-only** | `/mcp` | `list_mailboxes`, `search_messages`, `list_conversations`, `get_message`, `get_thread`, `get_attachment` |
| **Mail actions** | `/mcp/full` | Everything above + `send_email`, `reply_to_message`, `forward_message`, draft CRUD, `update_message`, `update_conversation` |

Switching from read-only to mail actions requires a new OAuth connection and approval. Scopes: `mail:read`, `mail:write`, `mail:send`, `offline_access`.

### OAuth flow

HQBase uses Streamable HTTP with OAuth 2.0 discovery, dynamic client registration, authorization code flow with PKCE, and user consent. The OAuth relay at `auth.hqbase.io` only returns a short-lived authorization code to your Worker. It never exchanges the code or sees your access token or mail content.

For organizations that block public OAuth apps, you can register a private Cloudflare OAuth client (Authorization Code + PKCE, no client secret required).

### Connecting a client

Add HQBase as an MCP server in your client configuration. For Claude Code, add to your MCP settings:

```json
{
  &quot;mcpServers&quot;: {
    &quot;hqbase&quot;: {
      &quot;url&quot;: &quot;https://your-worker.your-subdomain.workers.dev/mcp&quot;,
      &quot;oauth&quot;: true
    }
  }
}
```

For the full mail-actions profile, use `/mcp/full` instead of `/mcp`.

&lt;Notice type=&quot;warning&quot; title=&quot;MCP double-send risk&quot;&gt;
HQBase docs explicitly warn that send/reply/forward can send more than once if an MCP client retries. Start with read-only mode and test thoroughly before enabling mail actions for production workflows. Do not blindly retry failed sends.
&lt;/Notice&gt;

**Other MCP limitations to know:**

- Attachments via MCP are capped at **10 MiB**
- No live push/streaming of new mail: you must poll via search or list
- MCP cannot manage people, mailboxes, domains, setup, updates, audit, sessions, or secrets
- HQBase does **not** ship a built-in LLM. It exposes mail to external AI tools via MCP (unlike Cloudflare Agentic Inbox, which has a built-in AI agent)

## HQBase ops: updates, backups, rollback and doctor

HQBase treats operations seriously — signed releases, verification, backup, and rollback are all first-class CLI features.

### Updates

The updater downloads the release, verifies the `stable.json` manifest (Ed25519 signature + SHA-256 digest), checks product/channel/version and database compatibility, then deploys. If an update fails, the updater prints exact recovery commands.

Never skip the verification step. The signed manifest is what protects you from deploying a tampered or incompatible release.

### Backups

```sh
pnpm hqbase -- backup
```

**Critical caveat:** this records D1 bookmarks, the active Worker version, and an R2 inventory. It does **not** download email or attachments to your local machine. It&apos;s a deployment snapshot, not an offline mail export. For offline copies, you&apos;d need to export from R2 separately.

Always run `backup` before any manual change.

### Rollback

```sh
pnpm hqbase -- restore
```

Worker rollback and D1 restore are deliberately separate operations. D1 restore is **destructive** — it discards all mail received after the restore point. Never restore D1 casually. The recommended sequence:

1. Run `backup` first
2. Roll back the Worker
3. Only if necessary, restore D1 (understanding you&apos;ll lose newer messages)

### The doctor command

```sh
pnpm hqbase -- doctor            # diagnostics (read-only)
pnpm hqbase -- doctor --repair --yes   # fix detected issues
```

`doctor` checks version, database, Worker health, storage, queues, email domains, and update channel. Run it before and after any manual change.

&lt;Notice type=&quot;error&quot; title=&quot;D1 restore is destructive&quot;&gt;
Restoring D1 discards all mail received after the restore point. Worker rollback and D1 restore are separate operations — never restore D1 without understanding you&apos;ll lose newer messages. Always back up first.
&lt;/Notice&gt;

Unlike VPS-based self-hosted email where you need [self-hosted monitoring tools](https://www.bitdoze.com/sever-monitoring/) to watch your mail server, HQBase removes the server entirely — Cloudflare handles the infrastructure health. You&apos;re monitoring bindings and configuration, not CPU and disk.

## HQBase vs Cloudflare Agentic Inbox vs FreeScout

There are three main paths to &quot;AI-aware shared email.&quot; They solve different problems.

| Feature | HQBase | Cloudflare Agentic Inbox | FreeScout | Google Workspace |
|---|---|---|---|---|
| **Hosting** | Your Cloudflare account | Cloudflare-managed | Your VPS | Google SaaS |
| **Cost (3 users, 3 mailboxes)** | ~$5/mo | TBD (beta/preview) | ~$5/mo VPS + your time | ~$21.60/mo |
| **Mailbox-level RBAC** | Yes (None/Read/Agent/Manager) | No — single trust boundary | Basic roles | Yes |
| **MCP / AI integration** | Built-in, scoped OAuth | Native (Cloudflare AI + built-in agent) | None (add-ons only) | None native |
| **Data ownership** | Full (your Cloudflare account) | Cloudflare-managed | Full (your VPS) | Google |
| **Open source** | Yes (AGPL-3.0) | Yes (Apache-2.0) | Yes (GPL-3.0) | No |
| **Maturity** | Days old | Public preview, no releases | Years, established | Enterprise-grade |
| **Mail server ops needed** | None | None | Yes (Postfix/relay + DB) | None |

### The sharpest difference: HQBase vs Agentic Inbox

Agentic Inbox&apos;s README states directly: &quot;Any user who passes the shared Cloudflare Access policy can access all mailboxes... There is no per-mailbox authorization.&quot; That single trust boundary is fine for a personal AI experiment, but it&apos;s a non-starter for teams. You can&apos;t give an AI agent access to `support@` while blocking it from `finance@`.

HQBase solves this with proper per-mailbox RBAC, enforced consistently across web, API, and MCP. It also ships signed releases, rollback tooling, backup commands, and audit history — none of which Agentic Inbox provides.

Agentic Inbox&apos;s advantage: it has a built-in AI agent (using Workers AI / kimi-k2.5) with auto-drafting. HQBase takes the opposite approach — no built-in LLM, but full MCP support so you bring your own AI tools.

### HQBase vs FreeScout

FreeScout is the battle-tested self-hosted option (PHP, GPL-3.0, years of production use). But you&apos;re running a VPS, a database, and an SMTP relay. That&apos;s more moving parts, more things to patch, and more things to break. HQBase eliminates all of that by running on Cloudflare&apos;s serverless stack.

If you&apos;re already invested in VPS-based self-hosting and want the full control, FreeScout makes sense. If you want fewer moving parts and a flat $5/month bill with no server maintenance, HQBase is the simpler path.

## HQBase limitations and caveats before you rely on it

Be honest about the trade-offs:

1. **Brand-new project.** Well-architected and tested, but no production track record yet. Small team, fast-moving. Re-check the GitHub repo before adopting for anything critical.

2. **Cloudflare lock-in.** Requires Cloudflare DNS, Workers Paid, Email Routing, and Email Sending. You cannot run this on AWS, GCP, or a VPS. If you leave Cloudflare, you leave HQBase.

3. **Email Sending is beta.** Pricing, limits, and deliverability may change. Cloudflare auto-configures SPF/DKIM/DMARC, but the service is young. Monitor deliverability over the first few weeks.

4. **AGPL-3.0 license.** If you modify HQBase and offer it as a service, you must release your changes under AGPL. Fine for internal use — understand the implications for commercial products.

5. **&quot;Backup&quot; is not offline mail export.** The backup command snapshots deployment state (D1 bookmarks, Worker version, R2 inventory). It does not download email to your machine. Plan separately for compliance or data portability needs.

6. **No IMAP/POP.** All access is via the web UI, REST API, or MCP. No traditional email clients like Thunderbird or Apple Mail.

7. **MCP limitations.** 10 MiB attachment cap, no live push notifications for new mail (must poll), and double-send risk on retries.

## Verification checklist: is your inbox working?

Run through this after setup and after any manual change:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Send a test email to &lt;code&gt;support@yourdomain.com&lt;/code&gt; → confirm it lands in the HQBase inbox (validates Email Routing catch-all + D1 + R2)&lt;/li&gt;
&lt;li&gt;Reply from the shared mailbox → check received headers for passing SPF/DKIM/DMARC (look for &quot;signed-by&quot; in Gmail, or test at mail-tester.com)&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;pnpm hqbase -- doctor&lt;/code&gt; and confirm all checks pass&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;pnpm hqbase -- backup&lt;/code&gt; and note the Worker version + D1 bookmark&lt;/li&gt;
&lt;li&gt;Connect an MCP client in read-only mode → confirm &lt;code&gt;list_mailboxes&lt;/code&gt; and &lt;code&gt;search_messages&lt;/code&gt; return expected data&lt;/li&gt;
&lt;li&gt;(Optional) Re-connect MCP with Mail actions → draft and send a test reply&lt;/li&gt;
&lt;li&gt;Check Cloudflare dashboard: Worker analytics, D1 metrics, R2 bucket, Queue health&lt;/li&gt;
&lt;li&gt;Confirm Email Sending is enabled and check the first outbound emails for deliverability&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## FAQ

&lt;Accordion label=&quot;Does HQBase cost anything beyond the $5/month Cloudflare Workers plan?&quot; group=&quot;faq&quot;&gt;
The base HQBase software is free (AGPL-3.0). Your only cost is Cloudflare Workers Paid ($5/mo) plus any overage from Email Sending (first 3,000 emails/month are free, then $0.35/1,000). For most small teams with 3 mailboxes and a few hundred emails, the total stays at ~$5/month. Optional paid setup or support may be offered separately but doesn&apos;t gate any product features.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use HQBase with Google Workspace or Microsoft 365 domains?&quot; group=&quot;faq&quot;&gt;
No. HQBase requires Cloudflare to manage DNS for your email domains (for Email Routing and Sending). If your domain&apos;s nameservers point to Google or Microsoft, you&apos;d need to migrate DNS to Cloudflare first. The owner/recovery email must also be on a domain NOT connected to the workspace.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is my email data encrypted at rest?&quot; group=&quot;faq&quot;&gt;
Email files and attachments are stored in Cloudflare R2, and the searchable index lives in Cloudflare D1. Cloudflare encrypts data at rest by default. However, HQBase itself doesn&apos;t add a separate application-level encryption layer. Your data&apos;s security depends on your Cloudflare account security — enable 2FA and use API tokens with minimal scopes.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does HQBase compare to Cloudflare Agentic Inbox?&quot; group=&quot;faq&quot;&gt;
HQBase gives you per-mailbox RBAC (Read/Agent/Manager), so you can give an AI agent access to support@ but not finance@. Cloudflare&apos;s Agentic Inbox uses a single trust boundary — all mail is accessible or none is. HQBase is also fully open source (AGPL-3.0) and self-hosted in your account, while Agentic Inbox is a Cloudflare-managed service. If you need granular team permissions and data ownership, HQBase is the stronger choice. If you want a built-in AI agent with auto-drafting and don&apos;t need per-mailbox control, Agentic Inbox might be simpler.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I export my email if I decide to leave HQBase?&quot; group=&quot;faq&quot;&gt;
HQBase&apos;s `backup` command records deployment state (D1 bookmarks, Worker version, R2 inventory) but does not download email to your local machine. To export mail, you&apos;d need to access the R2 bucket directly and copy the raw email files. There&apos;s no built-in one-click export to MBOX or EML format as of v1.0.1. Plan for this if compliance or data portability matters to your team.
&lt;/Accordion&gt;

## Updates

| Date | Change |
|---|---|
| **2026-08-13** | Article published. HQBase v1.0.1. Cloudflare Email Sending in public beta. |

## Wrapping up

HQBase is a genuinely different approach to shared team email. Serverless, open-source, with real per-mailbox access control and MCP integration — all for about $5/month with unlimited seats and mailboxes. The trade-off is hard Cloudflare platform lock-in and a brand-new project with no production track record.

For teams already on Cloudflare who want to eliminate mail-server ops and flatten per-seat email costs, it&apos;s worth evaluating. Start with read-only MCP, run `doctor` regularly, and back up before every change.

The cost math is hard to argue with: $5/month flat vs $7+/user/month on Google Workspace, with better AI integration and full data ownership. Just understand what you&apos;re signing up for — Cloudflare is the platform, Email Sending is beta, and the project is days old.

&lt;Button text=&quot;View HQBase on GitHub&quot; link=&quot;https://github.com/HQBase/hqbase&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>cloudflare</category><category>shared-inbox</category><category>mcp</category></item><item><title>Best AI Coding Tools and Agents in 2026 (Compared)</title><link>https://www.bitdoze.com/ai-coading-tools/</link><guid isPermaLink="true">https://www.bitdoze.com/ai-coading-tools/</guid><description>Compare the best AI coding tools and agents in 2026: Claude Code, Codex, Copilot, Cursor &amp; more. Pricing, free tiers, and honest picks for every workflow.</description><pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

The AI coding tools market has shifted hard since last year. What used to be autocomplete and chat-in-the-sidebar is now agentic coding: multi-file autonomous edits, cloud agents that open PRs, and issue-to-production flows. The best AI coding tools in 2026 don&apos;t just suggest the next line; they refactor entire modules, run your test suite, and ship code while you review.

If you&apos;re [getting started programming with AI](/ai-programming-beginners-guide/) or you&apos;ve been using AI coding assistants for a while, the landscape looks very different now. Three new players (Claude Code, OpenAI Codex, and Google Antigravity) have joined GitHub Copilot and Cursor as the dominant AI coding agents. Most tools have moved to credit or usage-based billing. The real pain points have shifted from &quot;will it generate good code?&quot; to &quot;how do I keep my token bill sane?&quot; and &quot;how do I verify that the agent didn&apos;t break working code?&quot;

&lt;Notice type=&quot;info&quot; title=&quot;What&apos;s changed in 2026&quot;&gt;
Agentic coding is now mainstream. Claude Code, Codex, and Antigravity handle multi-file edits autonomously. Most major tools (Copilot, Codex, Augment, Cursor, Replit) have moved to credit or usage-based billing. Three dominant new players entered the market alongside Copilot and Cursor.
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NC5u6Lce65w&quot;
  label=&quot;Best AI Coding Tools and Assistants in 2026&quot;
/&gt;

## What are AI coding tools and assistants?

AI coding tools use large language models to help you write, review, debug, and ship code. In 2026, they&apos;ve evolved into four distinct categories, though the lines between them have blurred. Cursor has cloud agents, Copilot has cloud agents, and Codex ships as both a CLI and a cloud service.

### 1. Web tools

Browser-based platforms for building apps without a local setup. These range from quick prototyping tools to full-stack builders that handle databases, auth, and deployment.

**Examples:** v0.dev, bolt.new, lovable.dev, Replit, Tempo, Databutton

### 2. VS Code extensions

AI agents that run inside VS Code or VS Code forks. This is where most professional developers interact with AI daily: inline completions, chat panels, and autonomous editing modes.

**Examples:** GitHub Copilot, Cline, Kilo Code, Augment Code, [Amp Code (free AI coding agent)](/amp-code-free-ai-coding-agent/)

### 3. Dedicated AI-first IDEs

Purpose-built editors with deep AI integration. They&apos;re optimized for AI-assisted workflows and tend to have the tightest model integration.

**Examples:** Cursor, [Devin Desktop (formerly Windsurf)](https://go.bitdoze.com/windsurf), Trae, Zed, [Amazon&apos;s Kiro AI IDE](/kiro-ai-ide/)

### 4. Terminal and agentic CLIs

The hottest category in 2026. These tools run in your terminal, edit across your entire codebase, run tests, and some spin up cloud VMs to produce PRs. The lines between &quot;CLI&quot; and &quot;cloud agent&quot; have blurred: Codex has both a local CLI and cloud background agents, Copilot has a cloud agent, and Claude Code runs sub-agents in parallel.

**Examples:** Claude Code, OpenAI Codex, Google Antigravity, Aider, Goose, [OpenCode (open-source Claude Code alternative)](/opencode-setup-guide/), [Factory Droid CLI](https://go.bitdoze.com/droid-cli)

| Category | Best for | Typical users |
|----------|----------|---------------|
| Web tools | Prototypes, MVPs, full-stack apps | Founders, product teams, non-coders |
| VS Code extensions | Daily coding, existing workflows | Professional developers |
| Dedicated IDEs | Full-time AI-assisted dev | Teams deep on AI integration |
| Terminal and agentic CLIs | Autonomous edits, CI/headless, git workflows | Backend devs, DevOps, terminal-first engineers |

## What should you look for in AI code tools?

The evaluation criteria have shifted. Code quality and language support still matter, but two new factors dominate the 2026 decision: how the billing works, and how much review work the tool creates for you.

### 1. Code generation and agentic capabilities

- **Quality of output:** How accurate and maintainable is the generated code?
- **Language and framework support:** Does it cover your stack?
- **Context understanding:** Can it work across your codebase, not just the open file?
- **Autonomous multi-file edits:** Can it refactor across files without hand-holding?
- **Sub-agents and parallel tasks:** Can it run background jobs or spin up cloud VMs?
- **Headless / CI mode:** Can it run in pipelines without a GUI?

### 2. Integration and workflow

| Feature | Why it matters |
|---------|---------------|
| IDE integration | Works inside your existing editor |
| Git support | Commits, branches, PRs handled by the agent |
| Multi-file editing | Handles complex refactors |
| Cloud agent | Spins up background VMs to produce PRs (Codex Cloud, Copilot cloud agent, Cursor Bugbot) |
| CLI access | Terminal-first workflows for servers and CI |

### 3. AI model and performance

- **Model quality:** GPT-5.5, Claude Opus 4.8, Gemini 2.5, and their cheaper variants (Haiku, GPT mini, Flash).
- **Multi-model switching:** Copilot, Cursor, and Cline all let you pick models. This matters when you want cheap models for routine edits and powerful ones for complex refactors.
- **Context window:** Claude Code&apos;s 1M-token context is a real differentiator for large codebases.
- **Response time:** Codex Cloud runs at ~240 tokens/second, roughly 2.5x faster than Claude Opus.

### 4. Security and privacy

- **Code privacy:** Where does your code go? Is it used for training?
- **Training opt-out:** GitHub Copilot Free/Pro/Pro+ trains on your code by default (since April 2026). Business and Enterprise plans don&apos;t train. Cursor has privacy mode. Augment Business plans don&apos;t train either. This is a concrete, actionable differentiator. Check the settings.
- **Network egress:** Sandboxed agents (Codex CLI blocks network by default) vs. open agents that can hit any endpoint.

For more on protecting your site from AI scrapers, see our guide on [blocking AI crawlers](/block-ai-crawlers/).

### 5. Cost and usage-based billing

This is the single biggest change in 2026. Most tools have moved from flat subscriptions to credit or token-based billing:

- **GitHub Copilot** moved to &quot;GitHub AI Credits&quot; (1 credit = $0.01) on June 1, 2026.
- **OpenAI Codex** moved to token-based billing on April 2, 2026.
- **Augment Code** switched to credits in October 2025.
- **Cursor** has on-demand usage billing on top of subscriptions.
- **Replit** uses effort/credit-based pricing.

&lt;Notice type=&quot;warning&quot; title=&quot;Watch your token burn&quot;&gt;
Agentic coding burns 3-4x more tokens than autocomplete. Claude Code in particular uses significantly more tokens than Codex for similar tasks. If you&apos;re on API billing, set a hard spend limit on the provider&apos;s billing page. Prefer flat-rate Max plans for heavy use. They&apos;re far cheaper than equivalent API billing. For [cheaper GitHub Copilot alternatives](/github-copilot-alternatives-2026/), consider Cline or Kilo Code with your own API keys.
&lt;/Notice&gt;

Concrete advice:
- Set **budget alerts.** Copilot has alerts at 75/90/100% of your budget. Enable them.
- Prefer flat-rate plans (Copilot Max $100, Claude Max $100-200) over API billing for heavy use.
- Use cheap models for routine tasks: Haiku, GPT mini, Gemini Flash for autocomplete and style edits. Save Opus/GPT-5.5 for complex refactors.
- Unsupervised agents can burn 10-50x more tokens than interactive sessions. Don&apos;t let agents run wild.

### 6. Review burden and verification

Here&apos;s the uncomfortable truth: AI coding agents break things. A 2026 study found that 75% of AI coding agents broke previously working code during CI workflows. Devin&apos;s vendor-claimed 67% PR merge rate dropped to 15% real-world success in independent testing (Answer.AI, 20 tasks).

&lt;ListCheck&gt;
&lt;ul&gt;&lt;li&gt;Write tight, scoped tickets — don&apos;t give agents vague instructions&lt;/li&gt;&lt;li&gt;Require tests — agents should run and pass your test suite before you review&lt;/li&gt;&lt;li&gt;Review PRs line by line — don&apos;t trust &quot;looks good&quot; auto-approvals&lt;/li&gt;&lt;li&gt;Run the full test suite locally after the agent finishes&lt;/li&gt;&lt;li&gt;Scope API tokens to the minimum needed&lt;/li&gt;&lt;li&gt;Block network egress by default for sandboxed agents&lt;/li&gt;&lt;li&gt;Use CLAUDE.md, AGENTS.md, or .cursor/rules to pin build/test/lint commands&lt;/li&gt;&lt;/ul&gt;
&lt;/ListCheck&gt;

### 7. Enterprise requirements

- **Team management:** Roles, permissions, seat limits
- **Audit trails:** Activity logging for compliance
- **SSO / SAML:** Required for most orgs
- **Data residency:** EU/US options matter for GDPR

## Best AI coding tools and agents

The lines between IDE, CLI, and cloud agent have blurred. Cursor has cloud agents, Copilot has a cloud agent, and Codex ships as both a local CLI and a cloud service. I&apos;ve organized by category, but keep in mind that many tools now span multiple categories.

### Master comparison table

| Tool | Category | Rating | Free option | Price range | Best for |
|------|----------|--------|-------------|-------------|----------|
| v0.dev | Web | ⭐⭐⭐⭐½ | Yes | Free / Team $30 | Full-stack app generation |
| bolt.new | Web | ⭐⭐⭐⭐ | Yes | Free / Pro $25 | Rapid prototyping |
| lovable.dev | Web | ⭐⭐⭐⭐ | Yes | Free / Pro $25 | Full-stack development |
| Replit | Web | ⭐⭐⭐½ | Yes | Free / Core $20 | Learning and prototyping |
| Tempo | Web | ⭐⭐⭐⭐ | Yes | Free / Pro $30 | Issue-to-PR automation |
| Databutton | Web | ⭐⭐⭐⭐ | No | $20+ | Data-driven apps |
| GitHub Copilot | VS Code | ⭐⭐⭐⭐½ | Yes | Free / Pro $10 | General coding (flagship) |
| Cline | VS Code | ⭐⭐⭐⭐½ | Yes | Free (BYOK) | Autonomous coding agent |
| Kilo Code | VS Code | ⭐⭐⭐⭐ | Yes | Free (BYOK) | Roo Code replacement |
| Augment Code | VS Code | ⭐⭐⭐⭐ | Yes | Free / Indie $20 | Large codebases |
| Amp Code | VS Code | ⭐⭐⭐⭐ | Yes | Free | Free agent in editor |
| Cursor | IDE | ⭐⭐⭐⭐½ | Yes | Free / Pro $20 | AI-first development |
| Devin Desktop | IDE | ⭐⭐⭐⭐ | Yes | Free / Pro $20 | Full-stack + cloud agent |
| Trae | IDE | ⭐⭐⭐⭐ | Yes | Free / Pro $10 | Budget AI IDE |
| Zed | IDE | ⭐⭐⭐⭐ | Yes | Free / AI $10 | Performance-focused |
| Amazon Kiro | IDE | ⭐⭐⭐⭐ | Yes | Free / Pro $20 | Spec-driven development |
| Claude Code | CLI | ⭐⭐⭐⭐½ | Limited | Pro $20 / Max $100 | Agentic terminal coding |
| OpenAI Codex | CLI | ⭐⭐⭐⭐½ | Limited | Go $8 / Plus $20 | CLI + cloud agents |
| Google Antigravity | CLI | ⭐⭐⭐⭐ | Yes | Free / AI Pro $20 | Google ecosystem |
| Aider | CLI | ⭐⭐⭐⭐½ | Yes | Free (BYOK) | Git-based development |
| Goose | CLI | ⭐⭐⭐⭐ | Yes | Free (BYOK) | Local/secure development |
| OpenCode | CLI | ⭐⭐⭐⭐ | Yes | Free (BYOK) | Self-hosted agent |

### Web tools

#### [v0.dev](https://v0.dev) (⭐⭐⭐⭐½)

v0 has evolved from a UI component generator into a full-stack app builder. It now handles databases, tasks, and complete applications, not just React components. The credit model uses v0 Mini/Pro/Max model tiers, and you get $5 in credits per month on the free plan with a 7 messages/day limit.

**Pricing (Aug 2026):**
- **Free:** $0, $5 credits/month, 7 messages/day
- **Team:** $30/user/month
- **Business:** $100/user/month
- **Enterprise:** Custom

The old $20 &quot;Premium&quot; tier no longer exists. If you need more than the free tier, you&apos;re jumping to $30/user for Teams.

**Best for:** Rapid UI prototyping, full-stack app generation, non-coders building MVPs.

#### [bolt.new](https://bolt.new) (⭐⭐⭐⭐)

StackBlitz&apos;s bolt.new lets you prompt, run, edit, and deploy full-stack apps directly in the browser. The pricing has been streamlined. The old Pro 50/100/200 tiers are gone.

**Pricing (Aug 2026):**
- **Free:** 300K daily tokens (1M monthly)
- **Pro:** $25/month (10M tokens)
- **Teams:** $30/member/month
- **Enterprise:** Custom

Tokens roll over for one month. That&apos;s a nice touch for intermittent use.

**Best for:** Startups building MVPs, solo developers, proof-of-concept projects.

#### [lovable.dev](https://lovable.dev) (⭐⭐⭐⭐)

Lovable moved to a credit-based model. &quot;Plan Mode&quot; uses 1 credit per message. Credits expire after 2 months, so don&apos;t hoard them.

**Pricing (Aug 2026):**
- **Free:** 5 credits/day (~30/month)
- **Pro:** $25/month (100 credits)
- **Business:** $50/month
- **Enterprise:** Custom

**Best for:** Full-stack developers, agencies, rapid prototyping with team collaboration.

#### [Replit](https://replit.com/ai) (⭐⭐⭐½)

The biggest pricing shift of any tool in this article. Replit Core went from $25 to $20/month, and the new Replit Pro tier at $100/month targets power users. Replit Agent uses effort-based credits.

**Pricing (Aug 2026):**
- **Starter:** Free
- **Core:** $20/month ($18 annual)
- **Pro:** $100/month ($90 annual)
- **Enterprise:** Custom

**Best for:** Students, educators, quick prototyping, collaborative coding.

#### [Tempo](https://tempo.new) (⭐⭐⭐⭐)

Tempo pivoted from &quot;React builder&quot; to &quot;AI agents that turn feedback and issues into reviewed PRs.&quot; That&apos;s a significant change. It&apos;s now competing with the agentic tools rather than the web builders.

**Pricing (Aug 2026):**
- **Free:** 30 prompts (5/day)
- **Pro:** $30/month (150 prompts)
- **Scale:** $50/month (250 prompts)
- **Ultimate:** $100/month (555 prompts)
- **Agent+:** $4,500/month

**Best for:** Teams wanting automated issue-to-PR workflows.

#### [Databutton](https://databutton.com) (⭐⭐⭐⭐)

Databutton now tiers its offering around agent access plus varying levels of human support.

**Pricing (Aug 2026):**
- **Agent + Community:** $20/month
- **Agent + Human advisor:** $699/month
- **Agent + Human devs:** $1,999/month

The jump from $20 to $699 is steep. You&apos;re paying for human expertise, not just the AI.

**Best for:** Data-driven apps, analytics dashboards, Python/FastAPI backends.

If you want a free alternative for building web apps without subscriptions, check out [Freebuff Web](https://go.bitdoze.com/freebuff) — a free AI app builder with no subscription or API keys needed.

&lt;Accordion label=&quot;How do web-based AI coding tools compare?&quot; group=&quot;faq&quot;&gt;
Web tools are best for prototyping and MVPs — they handle deployment, databases, and auth out of the box. But they lock you into their platform. For production apps you&apos;ll maintain long-term, a local IDE or CLI tool with proper version control is usually the better bet. Use web tools to validate an idea fast, then move to your own stack.
&lt;/Accordion&gt;

### VS Code extensions

#### [GitHub Copilot](https://github.com/features/copilot) (⭐⭐⭐⭐½) — flagship

GitHub Copilot is now the most feature-rich option in this category. The free tier is available to everyone (not just students/OSS), and the tool has expanded from autocomplete into a full agentic platform with cloud agents, code review, and third-party agent integration.

**Key features:**
- Multi-model support (Claude, GPT, Gemini — you pick per task)
- Agent mode for autonomous multi-file edits
- Cloud agent (spins up background VMs for complex tasks)
- Copilot CLI (`gh copilot suggest &quot;add rate limiting&quot;`)
- Code review on pull requests
- Third-party agent integration (Claude Code, Codex)
- `AGENTS.md` file for repo-level instructions

**Pricing (Aug 2026):**
- **Free:** $0 — 2,000 completions + 50 premium requests/month (for everyone)
- **Pro:** $10/month
- **Pro+:** $39/month
- **Max:** $100/month
- **Business:** $19/user/month
- **Enterprise:** $39/user/month

Copilot moved to usage-based billing with &quot;GitHub AI Credits&quot; (1 credit = $0.01) on June 1, 2026. If you&apos;re a heavy user, the [GitHub Copilot Pro plan](/github-copilot-complete-guide/) at $10 is still the best entry point. For heavier use, look at Max at $100 or consider [cheaper GitHub Copilot alternatives](/github-copilot-alternatives-2026/) like Cline.

**Privacy note:** Copilot Free/Pro/Pro+ trains on your interactions by default since April 2026. Opt out in settings. Business and Enterprise plans don&apos;t train.

**Best for:** Individual developers, enterprise teams, anyone wanting the broadest feature set in one tool.

#### [Cline](https://cline.bot) (⭐⭐⭐⭐½)

Cline remains free and open-source (Apache-2.0, ~66k GitHub stars). It&apos;s positioned as an &quot;autonomous coding agent&quot; available as an SDK, IDE extension, or CLI assistant. You only pay for the API tokens you use.

&lt;Notice type=&quot;info&quot; title=&quot;Cline is free&quot;&gt;
Cline is free — you only pay for the API tokens you use. Point it at Ollama for zero cost. It supports Plan/Act modes for controlled autonomous editing.
&lt;/Notice&gt;

**Key features:**
- Autonomous code generation with human-in-the-loop control
- Multi-file editing and project-wide refactoring
- Terminal command execution and browser automation
- Custom tool creation via Model Context Protocol (MCP)
- Plan/Act modes for step-by-step or fully autonomous operation

**Best for:** Professional developers who want maximum control, self-hosters running local models, anyone allergic to subscriptions.

#### [Kilo Code](https://kilo.ai) (⭐⭐⭐⭐) — new, replaces Roo Code

Roo Code shut down on May 15, 2026 (repo archived read-only). Kilo Code is the open-source successor fork — a drop-in replacement. Free extension, you pay model API at cost with no markup.

**Best for:** Former Roo Code users, developers wanting a free VS Code agent with BYOK (bring your own key).

If you want to run with [affordable open-source LLM coding alternatives](/best-open-source-llms-claude-alternative/), Kilo Code pairs well with local models.

#### [Augment Code](https://augmentcode.com) (⭐⭐⭐⭐)

Augment switched to credit-based billing in October 2025. It&apos;s SOC 2 Type II certified and Business plans guarantee no AI training on your code.

**Pricing (Aug 2026):**
- **Free (trial):** Limited
- **Indie:** $20/month
- **Business:** $100/month flat (up to 50 seats)

**Best for:** Enterprise teams managing large codebases, organizations needing SOC 2 compliance.

#### [Amp Code](https://ampcode.com) (⭐⭐⭐⭐) — new

Amp Code is a free AI coding agent that works directly in your editor. Good free-tier option if you want an agentic experience without paying for another subscription.

See our full [Amp Code guide](/amp-code-free-ai-coding-agent/) for setup and features.

**Best for:** Developers wanting a free agent experience in their editor.

### Dedicated AI-first IDEs

#### [Cursor](https://www.cursor.com) (⭐⭐⭐⭐½)

Cursor has expanded significantly. The Hobby tier is still free, and they&apos;ve added Pro+ and Ultra tiers for power users. Cloud agents and Bugbot (autonomous bug-fixing) are the headline features now.

**Key features:**
- Built-in AI chat, inline editing, and agent mode
- Cloud agents for background tasks
- Bugbot for autonomous bug fixes
- `.cursor/rules` file for pinning build/test/lint commands
- VS Code extension compatibility
- Usage-based on-demand billing

**Pricing (Aug 2026):**
- **Hobby:** Free
- **Pro:** $20/month
- **Pro+:** $60/month
- **Ultra:** $200/month
- **Teams:** $40/user/month
- **Enterprise:** Custom

**Best for:** Developers who want the tightest AI-IDE integration, teams doing full-time AI-assisted development.

#### [Devin Desktop (formerly Windsurf)](https://go.bitdoze.com/windsurf) (⭐⭐⭐⭐)

Windsurf was acquired by Cognition (the Devin company) in late 2025 (~$250M, per CNBC) and rebranded to Devin Desktop on June 2, 2026. The `windsurf.com/pricing` URL now redirects to `devin.ai/pricing`. It combines the IDE experience with Devin&apos;s cloud agent capabilities.

**Pricing (Aug 2026):**
- **Free:** $0
- **Pro:** $20/month
- **Max:** $200/month
- **Teams:** $80/month + $40/seat
- **Enterprise:** Custom

Devin cloud features use ACU-based billing (Agent Compute Units). If you&apos;re just using the IDE locally, the Pro $20 tier covers most needs.

**Best for:** Full-stack developers, teams wanting IDE + cloud agent in one tool.

#### [Trae](https://trae.ai) (⭐⭐⭐⭐)

ByteDance&apos;s entry into the AI IDE space. The pricing is aggressive — $3/month for Lite and $10/month for Pro makes it the cheapest paid AI IDE.

**Pricing (Aug 2026):**
- **Free:** ~5,000 autocompletions/month + premium models
- **Lite:** $3/month
- **Pro:** $10/month (600+ premium requests)
- **Enterprise:** Custom

**Best for:** Budget-conscious developers, anyone wanting AI IDE features without the $20/month Cursor price tag.

#### [Zed](https://zed.dev) (⭐⭐⭐⭐)

The editor itself is free and open-source, built in Rust. Zed AI is the optional paid layer.

**Pricing (Aug 2026):**
- **Editor:** Free, open-source
- **Zed AI:** $10/month (unlimited edit predictions + $5 of tokens, usage-based beyond)
- **Business:** $30/seat/month

**Best for:** Performance-focused developers, terminal enthusiasts, open-source contributors.

#### [Amazon Kiro](https://kiro.dev) (⭐⭐⭐⭐) — new

Kiro is Amazon&apos;s spec-driven agentic IDE, built on Code OSS. It replaces Amazon Q Developer (which stopped accepting new sign-ups on May 15, 2026). Kiro went GA in March 2026.

The spec-driven approach is interesting — you write specs and Kiro generates implementation plans before writing code. Read more in our [Amazon Kiro AI IDE](/kiro-ai-ide/) deep dive.

**Pricing (Aug 2026):**
- **Free:** 50 credits/month
- **Pro:** $20/month (1,000 credits)
- **Pro+:** $40/month
- **Pro Max:** $100/month
- **Power:** $200/month
- **Enterprise:** Custom

**Best for:** AWS-heavy teams, developers wanting structured/spec-driven AI workflows.

&lt;Accordion label=&quot;Cursor vs Devin Desktop vs Kiro — which IDE should I pick?&quot; group=&quot;faq&quot;&gt;
Cursor is the mature, feature-rich choice with the largest ecosystem. Devin Desktop brings cloud agent capabilities (autonomous PRs) into the IDE. Kiro&apos;s spec-driven approach suits teams that want structured, plan-first AI development. For most solo developers, Cursor Pro at $20 is the default pick. For teams already on AWS, Kiro&apos;s free tier is worth trying first.
&lt;/Accordion&gt;

### Terminal and agentic CLIs

This is the hottest category in 2026. These tools run in your terminal, edit across your entire codebase, run tests, and some spin up cloud VMs to produce PRs. If you spend most of your day in a terminal, these are where the real productivity gains are.

#### [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (⭐⭐⭐⭐½) — new, flagship

Anthropic&apos;s terminal-first agent. Claude Code has a 1M-token context window, handles multi-file refactors, and can spawn sub-agents for parallel tasks. Claude Opus 4.8 scored 88.6% on SWE-bench Verified (one of the highest published scores).

**Key features:**
- 1M-token context window (largest in class)
- Multi-file refactors with full project understanding
- Sub-agents for parallel task execution
- `CLAUDE.md` file for repo-level rules
- Headless/CI mode: `claude -p &quot;fix the failing tests&quot; --output-format json`

**Pricing (Aug 2026):**
- **Free:** Limited (with weekly caps)
- **Pro:** $20/month (light use, included with Claude subscription)
- **Max 5x:** $100/month
- **Max 20x:** $200/month
- **API:** Pay-as-you-go (Opus 4.8 ≈ $5/M input, $25/M output)

&lt;Notice type=&quot;warning&quot; title=&quot;Claude Code token burn&quot;&gt;
Claude Code uses 3–4x more tokens than Codex for similar tasks. If you&apos;re on API billing, set a hard spend limit on the Anthropic billing page. For heavy use, the Max plans are far cheaper than equivalent API billing.
&lt;/Notice&gt;

**Install and get started:**

```bash
npm install -g @anthropic-ai/claude-code
cd your-project
claude                    # interactive mode
claude -p &quot;explain this repo&quot; --output-format json   # headless/CI
```

Auth: `claude` prompts for Claude Pro/Max login, or set `ANTHROPIC_API_KEY` for API billing.

**Best for:** Terminal-first developers, complex multi-file refactors, large codebases.

#### [OpenAI Codex](https://github.com/openai/codex) (⭐⭐⭐⭐½) — new, flagship

OpenAI&apos;s dual offering: an open-source CLI (`openai/codex`, Apache-2.0, ~105k GitHub stars, written in Rust) and Codex Cloud (parallel background VMs that produce PRs at ~240 tokens/second). GPT-5.5 scores 88.8% on Terminal-Bench 2.1.

**Key features:**
- Open-source CLI (Apache-2.0)
- Codex Cloud: background VMs that produce PRs
- ~240 tok/s throughput (roughly 2.5x faster than Claude Opus)
- Sandbox network off by default (Docker/LocalStack blocked unless explicitly enabled)
- `codex exec` for non-interactive / CI use

**Pricing (Aug 2026):**
- **Go:** $8/month
- **Plus:** $20/month (bundled with ChatGPT Plus, 5-hour caps)
- **Pro 5x:** $100/month
- **Pro 20x:** $200/month
- **Business:** $25/user/month
- Token-based billing since April 2, 2026

**Install and get started:**

```bash
npm install -g @openai/codex
cd your-project
codex                       # interactive TUI
codex exec &quot;fix the failing tests&quot;   # non-interactive / CI
```

Auth: `codex login` (ChatGPT sign-in) or set `OPENAI_API_KEY`.

You can also [use the Codex app with any model](/codex-app-any-model/) for more flexibility.

**Best for:** Developers wanting CLI + cloud agent flexibility, teams already in the OpenAI ecosystem, CI/CD pipelines.

#### [Google Antigravity](https://antigravity.google) (⭐⭐⭐⭐) — new

Google open-sourced Gemini CLI, then replaced it with Antigravity CLI on June 18, 2026. Antigravity is Google&apos;s agentic IDE/CLI, integrated into the Google ecosystem.

**Pricing (Aug 2026):**
- **Free preview:** $0 (Individual)
- **AI Pro:** $20/month
- **AI Ultra:** $100–$200/month
- **Enterprise:** Custom

**Best for:** Developers in the Google ecosystem, teams using Google Cloud, anyone wanting a free agentic CLI.

#### [Aider](https://aider.chat/) (⭐⭐⭐⭐½)

Still the best no-subscription, bring-your-own-key option. Aider is free, open-source, and pairs with any LLM you have API access to. The `aider-ce` community edition fork is also gaining traction.

**Install and get started:**

```bash
pipx install aider-chat
cd your-project
aider --model sonnet --watch-files
```

**Best for:** Terminal power users, git-focused developers, anyone who doesn&apos;t want another subscription.

#### [Goose](https://goose-docs.ai) (⭐⭐⭐⭐)

Block donated Goose to the Linux Foundation (AI Application Foundation). The repo moved to `aaif-goose/goose` (~52k GitHub stars). Still free and open-source (Apache-2.0), with desktop app, CLI, and API.

**Best for:** Security-conscious teams, enterprise developers, DevOps engineers, local-first workflows.

#### [OpenCode](https://opencode.ai) (⭐⭐⭐⭐) — new

OpenCode is the most-starred open-source coding agent (~193k GitHub stars). It supports 75+ providers, is MIT-licensed, and runs as a terminal, desktop, or IDE agent. Perfect for the self-host, DIY crowd.

**Install and get started:**

```bash
curl -fsSL https://opencode.ai/install | bash
```

See our full [OpenCode setup guide](/opencode-setup-guide/) for VPS deployment and configuration.

**Best for:** Self-hosters, developers wanting maximum provider flexibility, no-vendor-lock-in workflows.

&lt;Tabs&gt;
&lt;Tab name=&quot;Claude Code&quot;&gt;
```bash
npm install -g @anthropic-ai/claude-code
cd your-project
claude
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Codex CLI&quot;&gt;
```bash
npm install -g @openai/codex
cd your-project
codex
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Aider&quot;&gt;
```bash
pipx install aider-chat
cd your-project
aider --model sonnet --watch-files
```
&lt;/Tab&gt;
&lt;Tab name=&quot;OpenCode&quot;&gt;
```bash
curl -fsSL https://opencode.ai/install | bash
cd your-project
opencode
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Cost control for AI coding agents

This is the section I wish existed when I started using agentic tools. The shift to usage-based billing means your monthly bill can jump 10–50x if you let agents run unsupervised. Here&apos;s how to keep costs under control.

**Set hard spend limits.** OpenAI and Anthropic both let you set monthly caps on the billing page. Do this before you start. Copilot has built-in alerts at 75/90/100% of your budget — enable them.

**Prefer flat-rate plans for heavy use.** If you&apos;re a daily user, Max plans ($100–200/month) are far cheaper than equivalent API billing. The math only works in your favor on API billing if you&apos;re a light, occasional user.

**Use cheap models for routine tasks.** Save Opus 4.8 and GPT-5.5 for complex refactors. For autocomplete, style edits, and boilerplate, use Haiku, GPT mini, or Gemini Flash. Most tools let you switch models per task.

**Route between models.** If you want unified access to Claude Code, Codex, and Gemini CLI through a single interface, [Agent Router](https://go.bitdoze.com/agentrouter) can help — one API key, multiple backends.

**Watch unsupervised agents.** An agent running in the background can burn through tokens fast. Check in frequently. Set time limits. Don&apos;t let agents iterate on failures endlessly.

&lt;Notice type=&quot;success&quot; title=&quot;Zero-cost stack for self-hosters&quot;&gt;
Run Cline or [OpenCode](https://go.bitdoze.com/opencode-go) + Ollama + a local model (Qwen 3.6, Llama 4) on your own hardware. Zero subscription, zero token costs. You pay for hardware and electricity only. See our guide on [affordable open-source LLM coding alternatives](/best-open-source-llms-claude-alternative/) for model recommendations. You can also explore [letting an AI assistant deploy Docker apps](/ai-docker-deploy-skill/) from your self-hosted setup.
&lt;/Notice&gt;

**Self-hosted / no-subscription stack:**
- **Aider, Cline, OpenCode, Goose, Kilo Code, Continue** + Ollama = zero subscription cost
- Point them at local models via `OLLAMA_API_BASE` or provider config
- Works great for routine edits; switch to cloud models for complex refactors

## Why should you use AI coding assistant tools?

The short version: they make you faster, but they don&apos;t make you worse — as long as you verify.

### Productivity gains

GitHub&apos;s data shows developers using Copilot are up to 55% more productive on certain tasks. That figure comes from controlled studies and is still the most-cited benchmark in the space. In practice, the gains depend heavily on the task: boilerplate, tests, and documentation see the biggest speedups. Novel architecture decisions? Less so.

### Code quality

AI tools standardize patterns, catch bugs early, and generate documentation you&apos;d otherwise skip. They&apos;re particularly good at writing tests — a task most developers underinvest in.

### Learning and skill development

Working with AI tools exposes you to patterns, libraries, and approaches you might not discover on your own. It&apos;s like pair programming with someone who&apos;s read every docs page (but occasionally hallucinates).

### Cost effectiveness

With free tiers now available from Copilot, Cline, Kilo Code, and others, the barrier to entry is zero. For self-hosters, the Ollama + open-source agent stack costs nothing beyond hardware. Compare that to the $150–300/year most paid plans charge.

### The honest downsides

AI coding agents break things. A 2026 study found 75% of agents broke previously working code during CI workflows. Review burden is real — you still need to read every line the agent writes. Token costs can surprise you if you&apos;re not paying attention. And privacy concerns are legitimate: some tools train on your code by default.

&lt;Accordion label=&quot;Are AI coding tools worth it despite the risks?&quot; group=&quot;faq&quot;&gt;
Yes, but only with verification discipline and cost controls. Use them for boilerplate, tests, documentation, and rapid prototyping. For production code, treat agent output like a junior developer&apos;s PR — review it line by line, run the full test suite, and don&apos;t merge on autopilot. The productivity gains are real, but so are the failure modes.
&lt;/Accordion&gt;

## Conclusions

No single tool wins everything. Here&apos;s my default recommendation for each use case:

- **Best free option:** GitHub Copilot Free (for everyone) or Cline/Kilo Code + a free model.
- **Best for agentic coding:** Claude Code or OpenAI Codex — pick based on ecosystem preference (Anthropic vs OpenAI).
- **Best budget IDE:** Cursor Pro ($20) or Trae Pro ($10).
- **Best for self-hosters:** [OpenCode](https://go.bitdoze.com/opencode-go) or Aider + Ollama.
- **Best web tool:** v0.dev (full-stack) or bolt.new (prototyping).

&lt;ListCheck&gt;
&lt;ul&gt;&lt;li&gt;&lt;strong&gt;Want the cheapest option?&lt;/strong&gt; → Copilot Free / Cline + Ollama&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Want maximum power?&lt;/strong&gt; → Claude Code Max or Codex Pro&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Want it in your terminal?&lt;/strong&gt; → Codex CLI, Claude Code, or Aider&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Want a web app builder?&lt;/strong&gt; → v0.dev or bolt.new&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Want zero subscription?&lt;/strong&gt; → OpenCode or Aider + Ollama + local models&lt;/li&gt;&lt;li&gt;&lt;strong&gt;Want the best all-rounder?&lt;/strong&gt; → GitHub Copilot Pro ($10) or Cursor Pro ($20)&lt;/li&gt;&lt;/ul&gt;
&lt;/ListCheck&gt;

The tools are only as good as your verification process. Set spend limits, write tight tickets, require tests, and review everything. The agents are fast — but you&apos;re the one responsible for what ships.

&lt;Button text=&quot;Explore more AI tools&quot; link=&quot;/ai/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>ai-coding</category><category>ai-agents</category></item><item><title>Creative Sound Blaster GS5 Review: Still Worth It in 2026?</title><link>https://www.bitdoze.com/sound-blaster-gs5-review/</link><guid isPermaLink="true">https://www.bitdoze.com/sound-blaster-gs5-review/</guid><description>Creative Sound Blaster GS5 review updated for 2026. See how this compact RGB desktop soundbar holds up after firmware fixes, price changes, and 18 months of use.</description><pubDate>Wed, 12 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;

**Rating: ⭐⭐⭐⭐ (4/5)**

The [Creative Sound Blaster GS5](https://amzn.to/418lEIk) is a compact desktop soundbar that&apos;s been sitting on my desk for over 18 months now. When I first reviewed it, it was a ~$70 buy. Today it runs closer to $90 to $100. A lot has changed since then. Firmware updates fixed the biggest complaints (including the always-on LED display that annoyed everyone), but new issues have surfaced in long-term use. This updated review covers all of it.

If you&apos;re looking for an affordable RGB gaming soundbar for a desktop setup, the GS5 still delivers good value, with some important caveats you need to know before buying.

&lt;Button text=&quot;Check Latest Price on Amazon&quot; link=&quot;https://amzn.to/418lEIk&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Sound Blaster GS5 Video

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NGgY3lcZdfgk&quot;
  label=&quot;Creative Sound Blaster GS5 Review&quot;
/&gt;

## Design &amp; Build Quality

![GS5](../../assets/images/25/02/img1.webp)

The GS5 has a clean design that fits under most monitors. At 510mm (about 20 inches) in length, it&apos;s sized well for desktop setups, particularly with monitors 32 inches and larger. It pairs well with something like the [ASUS ROG Strix OLED](/asus-rog-strix-oled-xg32ucwg-review/) and works for a [compact mini PC desk setup](/best-mini-pc-home-server/). The all-black look is clean, and the honeycomb grille looks good without being loud.

### Physical features &amp; specifications

&lt;Notice type=&quot;info&quot; title=&quot;Quick Specs&quot;&gt;
**Dimensions**: 510 x 102.7 x 82.6 mm (1.5 kg / 3.3 lbs) | **Power**: 30W RMS (2 x 15W), 60W peak, 24V/1.25A adapter | **Drivers**: 3.35 x 2.16&quot; full-range racetrack | **Frequency Response**: 65-20,000 Hz | **SNR**: 85 dB | **USB Audio**: 16-bit/48 kHz | **Bluetooth**: 5.3 (SBC only)
&lt;/Notice&gt;

- **Length**: 510 mm (~20 inches)
- **Power output**: 2 × 15W speakers (30W RMS, 60W peak)
- **Drivers**: 3.35 x 2.16&quot; full-range racetrack
- **Frequency response**: 65-20,000 Hz
- **Signal-to-noise ratio**: 85 dB
- **Display**: LED screen showing current settings
- **RGB lighting**: Customizable with multiple effects
- **Controls**: Physical buttons on unit + remote control
- **Weight**: 1.5 kg (3.3 lbs)
- **Power adapter**: 24V / 1.25A / 30W (required, no USB-only power option)

This pairs well with one of the [best 32-inch OLED monitors](/best-32-inch-oled-monitors-guide/) for a clean desk build.

### Connectivity options

![GS5 connectivity](../../assets/images/25/02/gs5-connectivity.webp)

- USB-C port for PC/Mac connection
- Optical input
- 3.5mm auxiliary input
- Bluetooth 5.3 (SBC codec only, no AAC or aptX)
- Headphone jack (front panel)

&lt;Notice type=&quot;warning&quot; title=&quot;USB Hub Warning&quot;&gt;
The GS5 must be connected directly to your computer&apos;s USB port. Do NOT use a USB hub or dock. Multiple users report audio cutouts and dropouts when connected through a hub. One Amazon reviewer (Feb 2026) had audio shutting off during Teams calls when using a hub; fixed by connecting directly to the laptop. If you use a docking station like the [ASUS Thunderbolt 5 dock](/asus-thunderbolt-5-dock-dc510-review/), make sure you have a spare direct USB port available. Check the [best Thunderbolt 5 docks](/best-thunderbolt-5-docks-guide/) for setups with dedicated USB ports for peripherals.
&lt;/Notice&gt;

A few important things the spec sheet won&apos;t tell you:

- **No HDMI/ARC/eARC.** If you&apos;re thinking about using this as a TV soundbar with HDMI-CEC control, look elsewhere. This is a desktop-first product.
- **Headphone jack is underpowered.** Fine for basic earbuds or low-impedance headphones, but it won&apos;t drive high-impedance or planar magnetic headphones properly. If you have serious headphones, use a dedicated amp.
- **Bluetooth is SBC only.** No AAC or aptX support. Fine for casual listening, not ideal if you care about wireless audio quality.

### Remote control

![GS5 remote](../../assets/images/25/02/gs5-remote.webp)

The included IR remote covers:

- Volume adjustment
- Input source selection
- Sound mode switching (Gaming/Movie modes)
- RGB lighting controls
- Tone adjustments
- SuperWide mode settings

The remote works, but it&apos;s IR (line-of-sight required), and button presses can miss, especially rapid repeated presses. You&apos;ll notice this most with volume adjustments where five quick taps might only register three. Also: AAA batteries are not included.

### Notable design drawbacks

&lt;Notice type=&quot;success&quot; title=&quot;Fixed: LED Display Can Now Be Turned Off&quot;&gt;
The #1 complaint in the original review, the always-on LED display, has been addressed via a firmware update. After updating to firmware v1.25+ and using the Creative App (Windows only), you can turn off the front display. Out of the box it still stays on by default, but at least it&apos;s fixable now. Caveat: Mac users are stuck with the always-on display since there&apos;s no macOS Creative App.
&lt;/Notice&gt;

Originally, the always-on LED display was the most significant design issue. That&apos;s been fixed via firmware, though it still requires some setup work. The process is documented in the [Firmware Updates section](#firmware-updates--known-issues) below.

## Sound Quality &amp; Performance

### Overall sound profile

The GS5 delivers solid audio quality for its price. The dual 15W speakers produce a balanced sound signature that works well for multimedia content and music playback. Frequency response spans 65-20,000 Hz with an 85 dB signal-to-noise ratio, perfectly adequate for a desktop soundbar, though don&apos;t expect audiophile-grade separation.

### Sound modes

- **Normal mode**: Well-balanced for general use
- **Gaming mode**: Enhanced spatial awareness and effects
- **Movie mode**: Improved dialogue clarity and ambient sounds
- **SuperWide mode**: Expands soundstage (with some limitations)

### SuperWide feature: the good and bad

The SuperWide feature can create a noticeable soundstage expansion for a compact soundbar. Near Field and Far Field options let you tune it for your sitting distance. The catch: at maximum volume with SuperWide enabled, it produces an unwanted buzzing sound. This is one of the main reasons for the 4-star rating instead of 5.

### Volume and clarity

- Maximum volume level: 32 steps
- Clear, distortion-free sound at normal listening levels
- Particularly good for YouTube content and voice-heavy media, great for content creators who need clear audio for [screen recordings](/screen-studio-review/)
- Performs well for desktop gaming and movie watching
- Decent bass response without a separate subwoofer

### Real-world performance

In daily use, the GS5 outperforms built-in monitor or laptop speakers. After 18 months of use, the sound quality hasn&apos;t degraded. It&apos;s a solid upgrade over default display speakers, especially for YouTube content and music playback at moderate volumes. For a desktop setup where you&apos;re sitting two to three feet away, it&apos;s more than enough.

## Features &amp; Functionality

### RGB lighting system

The GS5 comes with an RGB lighting system that adds ambiance to your setup:

- Multiple lighting effects and patterns
- Customizable colors and intensities
- Modes include: Chasers, Aurora, Peak Meter, Glow, Wave, Cycle

The RGB is a nice-to-have, not a selling point on its own. It looks good in a dark room and you can turn it off if it bothers you.

### Smart audio controls

- **Tone adjustment**: Fine-tune audio to your preference
- **Volume control**: Both on unit and remote
- **Source switching**: Easy toggling between inputs
- **SuperWide technology**: Near-field and far-field options

![GS5 controls](../../assets/images/25/02/g5controles.webp)

### Connectivity features

Beyond the inputs listed in the design section:

- **Bluetooth 5.3**: For wireless device connection (SBC codec only)
- **USB-C**: Direct PC/Mac connection with digital audio (16-bit/48 kHz)
- **Optical input**: For gaming consoles and TVs
- **Auxiliary input**: For analog audio sources
- **Headphone output**: Front-panel access (underpowered for high-impedance cans)

### Creative App integration

The soundbar can be controlled through Creative&apos;s software, offering:

- Custom EQ settings
- RGB customization
- Audio preset management
- Firmware updates

&lt;Notice type=&quot;warning&quot; title=&quot;Mac Users: Limited Functionality&quot;&gt;
The desktop Creative App is **Windows-only**. There is no macOS app. This means Mac users cannot customize EQ, update firmware, or disable the LED display through any Creative software. The mobile app (iOS/Android) exists but is very limited. It mostly duplicates what the remote already does. Also, EQ and sound mode customizations only apply when connected via USB. Over Bluetooth or optical, the soundbar reverts to factory defaults.
&lt;/Notice&gt;

### Power and performance

- 30W RMS total power (2 × 15W)
- 60W peak power capability
- Requires the included 24V/1.25A power adapter — there&apos;s no USB-only power option

## Firmware Updates &amp; Known Issues

&lt;Notice type=&quot;info&quot; title=&quot;Update Your Firmware First&quot;&gt;
If you buy a GS5, update the firmware immediately. The October 2025 firmware fixes USB detection problems that were widely reported. Download it from [Creative Support](https://support.creative.com/Products/ProductDetails.aspx?catID=4&amp;subCatID=1074&amp;prodID=24334).
&lt;/Notice&gt;

### Firmware v1.25 update (October 2025)

Creative released firmware v1.25.1009.1430 on October 22, 2025. It fixes two things:

1. **USB detection bug fixed** — Windows no longer fails to recognize the GS5 after reboot. This was a widely reported problem where you had to unplug and replug the USB cable every boot.
2. **Audio Left/Right Balance removed** — listed as a &quot;fix&quot; in the release notes, which is odd, but that&apos;s what happened.

This firmware also enables the ability to turn off the LED display through the Creative App.

### How to update your GS5 firmware

Follow these steps carefully — the process is a bit finicky:

1. Download `SBGS5FWInstaller_1.25.1009.1430.exe` from [Creative Support](https://support.creative.com/Products/ProductDetails.aspx?catID=4&amp;subCatID=1074&amp;prodID=24334)
2. Disconnect **both** power and USB from the soundbar
3. Wait a few seconds
4. Connect USB only (do NOT reconnect power yet)
5. Press and hold the power button until you hear a beep
6. Run the firmware installer on your PC
7. After the installer completes, reconnect the power adapter
8. The soundbar should power on normally

**Verify**: Open Creative App → Settings → confirm firmware version shows v1.25.1009.1430.

&lt;Notice type=&quot;warning&quot; title=&quot;Firmware Update Can Brick Your Unit&quot;&gt;
Several users on Reddit have reported the firmware update &quot;bricking&quot; their GS5. If this happens, don&apos;t panic. Recovery method: unplug both USB and power, wait 10 seconds, reconnect USB while holding the power button, then re-run the firmware installer. Multiple users confirmed this resurrects bricked units. Follow the instructions exactly — especially the step about connecting USB only (not power) before running the installer.
&lt;/Notice&gt;

### How to turn off the LED display

After updating the firmware:

1. Open the Creative App (Windows only)
2. Go to Settings → Display → Off

That&apos;s it. The front LED will stay off. Mac users: you&apos;re out of luck here since the Creative App doesn&apos;t exist for macOS.

### Known issues &amp; long-term reliability

After 18 months of ownership and monitoring community reports, here are the known issues worth knowing about:

&lt;Accordion label=&quot;USB Hub Incompatibility&quot; group=&quot;known-issues&quot;&gt;
The GS5 does not work reliably through USB hubs or docks. The manual explicitly says to connect directly to your computer. Users report audio cutouts, random disconnections, and complete audio loss when going through a hub. One user had audio dropping during Teams calls until they connected directly to their laptop. If you use a docking station, make sure you have a spare direct USB port.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Teams/Zoom Audio Drops&quot; group=&quot;known-issues&quot;&gt;
Some users report audio cutting off mid-video call, requiring a power cycle of the soundbar. This appears to happen regardless of direct USB connection, though it&apos;s more common through hubs. Not everyone experiences this, and it may depend on your specific USB controller or Windows audio drivers.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Audio Crackling After Extended Use&quot; group=&quot;known-issues&quot;&gt;
One Amazon reviewer reported crackling starting after about 6 weeks of use — just after the return window closed. This appears to be a hardware defect in some units rather than a widespread issue, but it&apos;s worth noting since you can&apos;t easily return it if it starts happening late.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;IR Remote Responsiveness&quot; group=&quot;known-issues&quot;&gt;
The IR remote can be slow to respond, especially with rapid repeated button presses. Five clicks on volume up might only register three. Requires line of sight. AAA batteries not included.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;End of Service Life Status&quot; group=&quot;known-issues&quot;&gt;
Creative has classified the GS5 as &quot;End of Service Life.&quot; This means no further firmware updates or feature development. The October 2025 firmware will likely be the last. The product is still widely available for purchase (Amazon, Creative store, Micro Center, Dell, Lenovo), but don&apos;t expect any future fixes.
&lt;/Accordion&gt;

## Price &amp; Value Proposition

### Current pricing &amp; price history

The GS5 has shifted significantly in price since launch:

- **Current (Aug 2026)**: $89.99 on Amazon (list $99.99, 10% off)
- **Lowest ever**: $60.11 (March 2025)
- **Average**: ~$87
- **Highest**: $129.81 (March 2026)

The price is volatile. It frequently drops to $80-$85 during sales. If you&apos;re not in a hurry, set a price alert and wait for a dip. You&apos;ll likely save $10-$15.

&lt;Button text=&quot;Check Current Price on Amazon&quot; link=&quot;https://amzn.to/418lEIk&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

### What you get for the money

- Quality desktop soundbar with solid build
- Full-featured IR remote control
- RGB lighting system with multiple modes
- Multiple connectivity options (USB-C, optical, AUX, Bluetooth 5.3)
- Creative App support (Windows) with EQ and firmware updates
- 30W RMS / 60W peak power

### Value comparison — pros and cons

&lt;Tabs&gt;
&lt;Tab name=&quot;Pros&quot;&gt;
- Clear, balanced sound with good voice clarity
- RGB lighting with multiple effects
- USB-C, optical, AUX, and Bluetooth 5.3 connectivity
- Remote control included
- Solid build quality with honeycomb grille
- Firmware updateable (display can be turned off)
- 30W RMS power is plenty for desktop use
- Fits neatly under 32&quot;+ monitors
&lt;/Tab&gt;
&lt;Tab name=&quot;Cons&quot;&gt;
- No HDMI/ARC/eARC — not suitable as a TV soundbar
- Creative App is Windows-only — Mac users lose out on customization
- Bluetooth is SBC codec only (no AAC/aptX)
- Headphone jack is underpowered for serious headphones
- Must connect USB directly to PC (no hubs/docks)
- IR remote can miss button presses
- End of Service Life — no further updates expected
- Settings (EQ, sound modes) only apply via USB connection
- Price has increased ~$20 since launch
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Better alternatives to consider

&lt;Tabs&gt;
&lt;Tab name=&quot;Creative GS3 (~$60)&quot;&gt;
Smaller sibling to the GS5. 24W peak power, Bluetooth 5.4 (newer than GS5), but no optical input, no remote, and no SuperWide modes. If you don&apos;t need optical or a remote and want to save $30, this is the cheaper option from the same brand.
&lt;/Tab&gt;
&lt;Tab name=&quot;Razer Leviathan V2X (~$100)&quot;&gt;
Frequently compared to the GS5 on Reddit. Similar price point, different feature set. Worth a look if you&apos;re already in the Razer ecosystem or prefer their design language.
&lt;/Tab&gt;
&lt;Tab name=&quot;Creative Stage SE (~$40-50)&quot;&gt;
Budget predecessor to the GS5. Cheaper, simpler, no subwoofer variant. If all you need is &quot;better than monitor speakers&quot; at the lowest price, this works.
&lt;/Tab&gt;
&lt;Tab name=&quot;Creative Pebble Pro 2.1 (~$35-50)&quot;&gt;
Compact 2.1 system with a subwoofer. Often recommended for budget setups that want actual bass. Different form factor but worth considering if bass matters more than a soundbar shape.
&lt;/Tab&gt;
&lt;Tab name=&quot;MEREDO Sound Bar (~$160+)&quot;&gt;
A [MEREDO Sound Bar](https://amzn.to/4hSlFFR) is a 3.1 channel system with subwoofer — fundamentally different from the GS5. Much larger, much more bass, much more money. Only consider this if you want a full sound system rather than a compact desktop soundbar.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Final Verdict

### Overall rating: ⭐⭐⭐⭐ (4/5)

The rating holds. The firmware fixes and display solution improve the value proposition, but the price increase, EOL status, and Windows-only app balance that out. It&apos;s still a good compact desktop soundbar for the money — just not the steal it was at $70.

### Who should buy the GS5?

- Desktop PC users seeking a meaningful audio upgrade over monitor speakers
- Those wanting a compact soundbar that fits under a 32&quot;+ display
- Windows users who want Creative App-based EQ and customization
- Users valuing RGB aesthetics in a gaming setup
- Content creators who need clear voice audio for [screen recordings](/screen-studio-review/)
- Anyone building a complete [home desk setup](/why-need-home-server/) who needs better audio without a full speaker system

### Who should skip it?

- Mac users who need full Creative App control (no macOS app)
- Anyone planning to use it as a TV soundbar (no HDMI/ARC/eARC)
- Users who connect everything through a USB hub or dock
- Audiophiles or anyone with high-impedance headphones
- Anyone who wants guaranteed future firmware updates (product is EOL)

### Final thoughts

After 18 months of daily use, the Creative Sound Blaster GS5 is still doing its job. It sounds good, it fits under my monitor, and the firmware update fixed the display issue that originally bothered me. The price has crept up, but sales bring it back to a reasonable $80–$85 range.

For Windows desktop users looking for a budget RGB gaming soundbar, it&apos;s still a solid pick. For Mac users or anyone who needs HDMI connectivity, look at the alternatives above. The EOL status means what you buy is what you get — no future improvements coming. But what&apos;s there works.

&lt;Button text=&quot;Buy the Sound Blaster GS5 on Amazon&quot; link=&quot;https://amzn.to/418lEIk&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>gadgets</category><category>reviews</category><category>soundbar</category><category>gaming</category></item><item><title>How to Secure a VPS Server with CrowdSec (2026 Guide)</title><link>https://www.bitdoze.com/crowdsec-secure-server/</link><guid isPermaLink="true">https://www.bitdoze.com/crowdsec-secure-server/</guid><description>Step-by-step guide to secure a VPS server with CrowdSec. Covers engine install, firewall bouncer (iptables &amp; nftables), SSH protection, Nginx monitoring, and AppSec WAF.</description><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;

VPS servers get scanned constantly: SSH brute force, web exploits, credential stuffing. CrowdSec is an open-source collaborative intrusion prevention system (IPS) that monitors your logs, detects attacks, and blocks malicious IPs at the firewall level. Version 1.7.8 with over 14,000 GitHub stars is the latest stable release, and it handles a lot more than Fail2Ban.

In this guide, you&apos;ll install the CrowdSec engine, configure the firewall bouncer for both iptables and nftables, protect SSH, monitor Nginx logs, and deploy the AppSec WAF for inline HTTP protection. If you&apos;re [securing your VPS from the ground up](/vps-ai-coding-setup/) or [securing Docker servers against real-world threats](/bsi-security-report-docker-ufw/), CrowdSec fits right into that stack.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Install CrowdSec 1.7.8 and configure SSH log monitoring&lt;/li&gt;
&lt;li&gt;Choose between nftables and iptables firewall bouncer&lt;/li&gt;
&lt;li&gt;Allowlist your admin IP before enabling the bouncer (avoid lockout)&lt;/li&gt;
&lt;li&gt;Handle Docker/Dokploy ports with the DOCKER-USER chain&lt;/li&gt;
&lt;li&gt;Monitor Nginx access and error logs for web attack detection&lt;/li&gt;
&lt;li&gt;Deploy the AppSec WAF for inline HTTP request inspection&lt;/li&gt;
&lt;li&gt;Use the CrowdSec Console for centralized management&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/9y6i2XjCVAw&quot;
  label=&quot;CrowdSec Install&quot;
/&gt;


## What is CrowdSec and how can it help you?

CrowdSec is an open-source, collaborative intrusion prevention system. It parses logs from services like SSH, Nginx, and custom applications, detects suspicious patterns using community-maintained scenarios, and pushes blocking decisions to remediation components called &quot;bouncers.&quot; The anonymized attack data feeds back into a global threat intelligence network. Your server both consumes and contributes to that pool.

![Understanding CrowdSec](../../assets/images/25/02/crowdsec1.png)

### Understanding CrowdSec

Three pillars make CrowdSec different from a basic fail2ban setup:

1. **Log parsing and detection.** CrowdSec ships with parsers for common services (SSH, Nginx, Apache, MySQL, and more). You install a collection, point it at log files, and it handles the rest.
2. **Bouncer-based remediation.** Decisions from the CrowdSec engine get pushed to bouncers, lightweight daemons that enforce blocks at the firewall, reverse proxy, or application layer. The firewall bouncer is the most critical; the Nginx bouncer adds AppSec WAF capabilities.
3. **Community threat intelligence.** CrowdSec shares anonymized attack data across its user base. When an IP brute-forces another CrowdSec user&apos;s server, your instance picks up that block decision too (if you subscribe to community blocklists via the CrowdSec Console).

The [CrowdSec Hub](https://hub.crowdsec.net/) hosts collections, scenarios, parsers, and bouncers maintained by both the CrowdSec team and the community.

### Benefits of using CrowdSec for SSH protection

SSH is the most common attack surface on a VPS. Leaving it exposed leads to brute force attempts, credential stuffing, and exploitation of weak configurations. CrowdSec improves SSH security by:

1. **Detecting intrusions.** Parsing SSH auth logs and identifying patterns like repeated failed login attempts.
2. **Blocking threats.** Pushing decisions to the firewall bouncer, which drops packets from flagged IPs.
3. **Providing visibility.** Metrics, alerts, and log entries that show exactly what was detected and what was blocked.
4. **Adapting dynamically.** Detection scenarios update through the CrowdSec Hub. No manual rule writing needed.

### Why choose CrowdSec over alternatives?

| Feature                   | CrowdSec                  | Fail2Ban                    | Traditional Firewalls       |
|---------------------------|---------------------------|-----------------------------|-----------------------------|
| **Community Intelligence**| Global threat sharing     | None                        | None                        |
| **Ease of Use**           | Simple configuration      | Moderate complexity         | Manual configuration        |
| **Multi-service Support** | SSH, Nginx, AppSec, and more | Limited per service       | Ports only                  |
| **WAF Capability**        | Yes (AppSec component)    | No                          | No                          |
| **Scalability**           | Cloud-friendly            | Local VPS only              | Network-based               |
| **Real-time Blocking**    | Fast with bouncers        | Moderate                    | Reactive only               |


## Install CrowdSec

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisites&quot;&gt;
**Before you start, you need:**
- A Linux VPS running Ubuntu 22.04/24.04 LTS or Debian 12+
- Root or sudo access
- Ports 8080 (LAPI) and 6060 (metrics) available (or willingness to change them)

If you don&apos;t have a VPS yet, you can grab [an affordable VPS from Hetzner](https://go.bitdoze.com/hetzner) or a [budget-friendly Hostinger VPS](https://go.bitdoze.com/hostinger-vps).
&lt;/Notice&gt;

### Step 1: Add the CrowdSec repository

```bash
curl -s https://install.crowdsec.net | sudo sh
```

This imports the CrowdSec GPG key and sets up the package repository. Expected output:

```
Detected operating system as ubuntu/24.
Detected apt version as 2.7.14
Checking for gpg...
Detected gpg...
...
Installing /etc/apt/sources.list.d/crowdsec_crowdsec.list...
```

### Step 2: Install the CrowdSec package

```bash
sudo apt update &amp;&amp; sudo apt install crowdsec
```

&lt;Notice type=&quot;warning&quot; title=&quot;Port 8080 conflict&quot;&gt;
CrowdSec&apos;s local API uses port 8080 by default. If something else occupies that port, CrowdSec will fail to start. Check with `netstat -tulpn` before installing. If there&apos;s a conflict, see the next step.
&lt;/Notice&gt;

Sample installation output:

```
Need to get 62 MB of archives.
After this operation, 255 MB of additional disk space will be used.
...
Setting up crowdsec (1.7.8) ...
Machine successfully added to the local API.
API credentials written to &apos;/etc/crowdsec/local_api_credentials.yaml&apos;.
...
Not attempting to start crowdsec, port 8080 is already used or lapi was disabled.
```

### Step 3: Modify API port (if necessary)

If the install output says port 8080 is already used, edit both config files:

```bash
sudo nano /etc/crowdsec/config.yaml
sudo nano /etc/crowdsec/local_api_credentials.yaml
```

Change `listen_uri` under `api` to a free port like 8081:

```yaml
listen_uri: 127.0.0.1:8081
```

Save, then start and verify:

```bash
sudo service crowdsec start
sudo service crowdsec status
```

Expected output:

```
● crowdsec.service - Crowdsec agent
     Loaded: loaded (/lib/systemd/system/crowdsec.service; enabled; vendor preset: enabled)
     Active: active (running) since ...
```

### Step 4: List installed collections

```bash
sudo cscli collections list
```

```
COLLECTIONS
 Name                               Status    Version  Local Path
─────────────────────────────────────────────────────────────────────
 crowdsecurity/linux                enabled  0.3      ...
 crowdsecurity/sshd                 enabled  0.6      ...
```

The `crowdsecurity/sshd` collection confirms SSH monitoring is active.


## Installing the firewall bouncer (remediation)

&lt;Notice type=&quot;info&quot; title=&quot;What are bouncers?&quot;&gt;
CrowdSec detects threats by analyzing logs. Bouncers enforce the decisions. They&apos;re lightweight daemons that block IPs at the firewall, reverse proxy, or application layer. The firewall bouncer is the most important one: it drops packets from flagged IPs before they reach any service.
&lt;/Notice&gt;

### Choose your bouncer: iptables vs nftables

Modern Linux distributions (Debian 12+, Ubuntu 24.04) use **nftables** as the native packet filter. iptables survives as a compatibility shim on these systems. CrowdSec offers separate bouncer packages for each backend.

Run this to check which backend your system uses:

```bash
iptables -V
```

&lt;Tabs&gt;
&lt;Tab name=&quot;Modern systems (nftables)&quot;&gt;
If the output contains `nf_tables`, your system runs nftables natively. Use the nftables bouncer:

```
iptables v1.8.10 (nf_tables)
```

Install command:

```bash
sudo apt install crowdsec-firewall-bouncer-nftables
```

This is the recommended path for new installs on Ubuntu 24.04+ and Debian 12+.
&lt;/Tab&gt;
&lt;Tab name=&quot;Legacy systems (iptables)&quot;&gt;
If the output says `legacy` without `nf_tables`, use the iptables bouncer:

```
iptables v1.8.9 (legacy)
```

Install command:

```bash
sudo apt install crowdsec-firewall-bouncer-iptables
```

This covers older Ubuntu 22.04 installs or systems explicitly configured for iptables.
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Lock yourself out? Allowlist your IP first

&lt;Notice type=&quot;error&quot; title=&quot;Do this BEFORE installing the bouncer&quot;&gt;
If the firewall bouncer enables blocking on the INPUT chain and your current IP gets flagged (or you mistype something), you can lock yourself out of SSH permanently. Allowlist your admin IP first.
&lt;/Notice&gt;

CrowdSec 1.6.8 introduced a proper allowlist system. This is now the preferred method. It integrates with AppSec, scenarios, and console blocklists, and changes take effect immediately without restarting CrowdSec:

```bash
cscli allowlist create admin_ips -d &quot;My trusted admin IPs&quot;
cscli allowlist add admin_ips YOUR.HOME.IP.ADDRESS
```

Replace `YOUR.HOME.IP.ADDRESS` with your actual public IP. You can check it with `curl -s ifconfig.me` from another terminal.

You can also add subnets for VPNs or monitoring services:

```bash
cscli allowlist add admin_ips 10.0.0.0/8
```

Verify the allowlist:

```bash
cscli allowlist inspect admin_ips
```

### Install the firewall bouncer

After allowlisting your IP, install the appropriate bouncer (see the nftables vs iptables tabs above). For example, on a modern system:

```bash
sudo apt install crowdsec-firewall-bouncer-nftables
```

Sample output:

```
Setting up crowdsec-firewall-bouncer (0.0.33) ...
```

The bouncer automatically registers itself with the CrowdSec LAPI and pulls decisions.

### Docker / Dokploy users: add DOCKER-USER chain

&lt;Notice type=&quot;warning&quot; title=&quot;Docker bypasses the INPUT chain&quot;&gt;
Docker-published ports go through the FORWARD → DOCKER chains, not INPUT. The firewall bouncer&apos;s default configuration only inserts rules into INPUT, which means your Docker containers remain reachable from banned IPs. If you run Docker or [Dokploy](/dokploy-install/) to manage your containers, this is a critical gap. [Docker can bypass your firewall rules](/docker-bypasses-firewall/) without this fix.
&lt;/Notice&gt;

Edit the bouncer configuration:

```bash
sudo nano /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml
```

Find the `iptables_chains` (or `nftables_chains`) section and add `DOCKER-USER`:

```yaml
# For iptables mode:
iptables_chains:
  - INPUT
  - DOCKER-USER

# For nftables mode, add DOCKER-USER to the nftables_chains list
```

Restart the bouncer:

```bash
sudo systemctl restart crowdsec-firewall-bouncer
```

This ensures CrowdSec blocks banned IPs from reaching your Docker containers too. For more on Docker networking and security, see [keeping your Docker server clean](/clean-docker-overlay2-dir/).

### Verify the bouncer installation

```bash
sudo cscli bouncers list
```

Expected output:

```
╭───────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Name                            IP Address  Valid  Last API pull         Type                       Version    │
├───────────────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ cs-firewall-bouncer-...         127.0.0.1   ✔️    2026-06-20T07:00:00Z  crowdsec-firewall-bouncer  v0.0.33    │
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
```

If the bouncer doesn&apos;t appear, restart the service and check logs:

```bash
sudo systemctl restart crowdsec-firewall-bouncer
sudo tail -f /var/log/crowdsec-firewall-bouncer.log
```

### End-to-end smoke test

Verify the bouncer actually blocks traffic. From your VPS, add a test decision for a harmless IP (don&apos;t use your own):

```bash
sudo cscli decisions add -i 192.0.2.1 -t ban -r &quot;smoke test&quot;
```

Check that the IP appears in your firewall:

&lt;Tabs&gt;
&lt;Tab name=&quot;nftables verification&quot;&gt;
```bash
sudo nft list sets | grep crowdsec
# Then check the specific set:
sudo nft list set inet crowdsec crowdsec-blacklists
```

You should see `192.0.2.1` in the set.
&lt;/Tab&gt;
&lt;Tab name=&quot;iptables verification&quot;&gt;
```bash
sudo ipset list crowdsec-blacklists
```

You should see `192.0.2.1` as a member.
&lt;/Tab&gt;
&lt;/Tabs&gt;

Clean up the test decision:

```bash
sudo cscli decisions delete -i 192.0.2.1
```

&lt;Notice type=&quot;info&quot; title=&quot;IPv6 support&quot;&gt;
IPv6 blocking works out of the box. CrowdSec&apos;s default configuration has `disable_ipv6: false`. Don&apos;t set it to true unless you&apos;ve explicitly disabled IPv6 on your system.
&lt;/Notice&gt;


## See what logs are monitored by CrowdSec

CrowdSec creates an acquisition file at `/etc/crowdsec/acquis.yaml` that defines which log sources it monitors. View it:

```bash
cat /etc/crowdsec/acquis.yaml
```

Sample output:

```yaml
# Generated acquisition file - wizard.sh (service: ssh) / files :
journalctl_filter:
  - _SYSTEMD_UNIT=ssh.service
labels:
  type: syslog
---
```

This shows CrowdSec monitoring SSH via journald. If you installed additional collections (like Nginx), corresponding entries appear here too.

### CrowdSec log files

CrowdSec generates its own logs for monitoring its operation:

| File                                    | Purpose                                    |
|-----------------------------------------|--------------------------------------------|
| `/var/log/crowdsec.log`                 | Main engine log: detections, parsing      |
| `/var/log/crowdsec_api.log`             | Local API (LAPI) requests and responses    |
| `/var/log/crowdsec-firewall-bouncer.log`| Firewall bouncer activity and errors       |

Watch the engine log in real time:

```bash
sudo tail -f /var/log/crowdsec.log
```


## Linking to the CrowdSec Console (optional)

The CrowdSec Console is a cloud-based dashboard that provides centralized monitoring, attack visualization, and community-sourced threat intelligence across all your CrowdSec instances.

&lt;Notice type=&quot;info&quot; title=&quot;Free Community tier&quot;&gt;
The free tier includes 3 community blocklists with daily updates, centralized alert monitoring, and bouncer management. Premium starts at $31/month for real-time blocklist updates and custom blocklists.
&lt;/Notice&gt;

### Step 1: Create an account

Visit the [CrowdSec Console signup page](https://app.crowdsec.net/signup) and register with an email address.

### Step 2: Generate an enrollment token

1. Log in to the Console.
2. Navigate to **Security Engines** → **Engines**.
3. Click **Generate Enrollment Token**.
4. Copy the token.

### Step 3: Enroll your VPS

```bash
sudo cscli console enroll --quick &lt;your-enrollment-token&gt;
```

The `--quick` flag (added in 1.7.8) speeds up enrollment. Sample output:

```
Successfully enrolled machine &apos;my-vps.example.com&apos; to the Console
```

### Step 4: Verify enrollment

Return to the Console&apos;s **Engines** section. Your VPS should appear as an active engine.

Once enrolled, you get centralized alert dashboards, attack trend graphs, and community intelligence feeds. This is useful for [monitoring your server health and security events](/beszel-uptime-kuma/) alongside CrowdSec&apos;s own metrics.


## Monitoring application logs (e.g., Nginx)

CrowdSec can detect web attacks by analyzing Nginx logs: bot traffic, SQL injection probes, directory traversal, bad user agents, and more. For full protection, monitor **both** `access.log` and `error.log`.

&lt;Notice type=&quot;info&quot; title=&quot;Monitor both log files&quot;&gt;
The current article&apos;s Nginx configuration only tracked `error.log`. Most HTTP attack detection scenarios (SQLi, path traversal, bad bots) parse `access.log`. Add both to get full coverage.
&lt;/Notice&gt;

### Step 1: Install the Nginx collection

```bash
sudo cscli collections install crowdsecurity/nginx
```

```
INFO crowdsecurity/nginx installed successfully
```

### Step 2: Update acquis.yaml

Edit `/etc/crowdsec/acquis.yaml` and add the Nginx log entries:

```yaml
filenames:
  - /var/log/nginx/access.log
  - /var/log/nginx/error.log
  - /home/*/logs/nginx/*.log
labels:
  type: nginx
---
```

- `access.log` catches SQL injection probes, directory traversal, bad user agents, crawler abuse
- `error.log` catches upstream failures, rate limiting, connection anomalies
- The `/home/*/logs/nginx/*.log` wildcard covers virtual host logs (useful with Dokploy or similar setups)

### Step 3: Restart CrowdSec

```bash
sudo systemctl restart crowdsec
```

### Step 4: Verify Nginx monitoring is active

```bash
sudo cscli alerts list
sudo cscli decisions list
```

You should see entries related to Nginx scenarios. Tail the engine log to watch detections in real time:

```bash
sudo tail -f /var/log/crowdsec.log
```

For more on managing web traffic and [blocking unwanted traffic at the application layer](/block-ai-crawlers/), CrowdSec&apos;s Nginx monitoring is the first step. The AppSec WAF below takes it further.


## Beyond the firewall: AppSec WAF protection

&lt;Notice type=&quot;info&quot; title=&quot;Firewall bouncer + AppSec = layered protection&quot;&gt;
The firewall bouncer blocks IPs at the packet level (reactive — after CrowdSec detects an attack in logs). The AppSec component inspects HTTP requests inline before they reach your application (proactive — blocks in real time). They complement each other. Keep both running.
&lt;/Notice&gt;

CrowdSec 1.6 introduced the **AppSec Component** — a full WAF that inspects HTTP requests inline using the Coraza engine (mod_security compatible). It detects SQL injection, XSS, path traversal, CVE exploits, and bad bots at the HTTP layer, before traffic reaches your application.

### What is the AppSec component?

AppSec runs inside the Nginx/OpenResty/Traefik remediation component (bouncer). When an HTTP request arrives, the bouncer forwards it to the AppSec engine for inspection. If the request matches a rule (e.g., SQLi pattern, known CVE exploit), it gets blocked immediately — no log tail, no delay.

This is fundamentally different from the log-based detection you&apos;ve configured above. Log-based detection analyzes logs after the fact; AppSec blocks malicious requests before they touch your app.

If you use [Traefik as your reverse proxy](/traefik-proxy-docker/), the Traefik bouncer supports AppSec too.

### Install and configure AppSec

Install the AppSec virtual patching collection:

```bash
sudo cscli collections install crowdsecurity/appsec-virtual-patching
```

&lt;Notice type=&quot;warning&quot; title=&quot;Ensure CrowdSec &gt;= 1.7.8&quot;&gt;
CrowdSec 1.7.8 fixes CVE-2026-44982, a WAF bypass vulnerability via chunked transfer-encoding. If you run AppSec, updating to 1.7.8 is critical. Check your version: `cscli version`.
&lt;/Notice&gt;

Next, enable AppSec in the Nginx remediation component. Edit the Nginx bouncer configuration:

```bash
sudo nano /etc/crowdsec/bouncers/crowdsec-nginx-bouncer.yaml
```

Ensure the AppSec section is enabled:

```yaml
appsec:
  enabled: true
  # Path to the AppSec configuration
  # CrowdSec will use the default if not specified
```

If you don&apos;t have the Nginx bouncer installed yet (it&apos;s separate from the firewall bouncer):

```bash
sudo apt install crowdsec-nginx-bouncer
```

Restart CrowdSec and Nginx:

```bash
sudo systemctl restart crowdsec
sudo systemctl restart nginx
```

Verify AppSec is active:

```bash
sudo cscli appsec-configs list
sudo cscli appsec-rules list
```

You should see the virtual patching rules loaded. These cover common web exploits and CVEs without you having to write custom rules.

The AppSec component runs alongside your existing firewall bouncer — they don&apos;t interfere with each other. Firewall bouncer handles IP-level blocking (bulk bans from SSH brute force, community blocklists); AppSec handles HTTP-level inspection (inline blocking of web exploits).


## Useful CrowdSec commands

After configuring CrowdSec, these commands help you monitor activity, manage blocks, and troubleshoot issues.


### View active decisions (current blocks/bans)

```bash
sudo cscli decisions list
```

```
╭────────────┬─────────────┬────────────┬──────────────────────╮
│  IP        │  Reason     │  Action    │  Expires at          │
├────────────┼─────────────┼────────────┼──────────────────────┤
│ 192.168.1.1│ ssh-bf      │ ban        │ 2026-06-20T12:00:00Z │
│ 203.0.113.5│ nginx-bot   │ ban        │ 2026-06-20T23:59:59Z │
╰────────────┴─────────────┴────────────┴──────────────────────╯
```


### Check recent alerts

```bash
sudo cscli alerts list
```

```
╭───────┬──────────────────────┬────────────────────┬──────────────╮
│  ID   │  Scenario            │  Source IP         │  Created at  │
├───────┼──────────────────────┼────────────────────┼──────────────┤
│   1   │ ssh-bf               │ 203.0.113.5        │ 2026-06-20   │
│   2   │ nginx-bad-user-agent │ 198.51.100.10      │ 2026-06-20   │
╰───────┴──────────────────────┴────────────────────┴──────────────╯
```


### View security metrics

```bash
sudo cscli metrics
```

This shows parsed log counts, detected threats, bouncer activity, and dropped connections. Use it to get a quick health check.


### Monitor logs in real time

```bash
sudo tail -f /var/log/crowdsec.log
```

Useful for watching detections happen live and troubleshooting parser issues.


### List active detection scenarios

```bash
sudo cscli scenarios list
```

```
╭───────────────────────────────────────┬────────╮
│ Name                                  │ Status │
├───────────────────────────────────────┼────────┤
│ crowdsecurity/ssh-bf                  │ enabled  │
│ crowdsecurity/nginx-bad-user-agent    │ enabled  │
│ crowdsecurity/http-crawl-non_statics  │ enabled  │
╰───────────────────────────────────────┴────────╯
```


### Add or remove a block manually

**Block an IP:**
```bash
sudo cscli decisions add -i &lt;IP&gt; -t ban -r &quot;Manual ban&quot;
```

**Unblock an IP:**
```bash
sudo cscli decisions delete --ip &lt;IP&gt;
```


### Using the allowlist system (cscli allowlist)

The allowlist system (added in CrowdSec 1.6.8) is the preferred method for whitelisting trusted IPs. Unlike the older `cscli decisions add --type whitelist` approach, allowlists integrate with AppSec, scenarios, and console blocklists. Changes take effect immediately — no restart needed.

```bash
# Create an allowlist
cscli allowlist create my_allowlist -d &quot;Trusted admin IPs&quot;

# Add individual IPs or subnets
cscli allowlist add my_allowlist 203.0.113.5
cscli allowlist add my_allowlist 198.51.100.0/24

# Inspect what&apos;s in the allowlist
cscli allowlist inspect my_allowlist
```

Practical examples:
- Your home/office IP (dynamic DNS works too — update the allowlist when it changes)
- VPN subnet for your admin access
- Monitoring service IPs (Uptime Kuma, health checkers)

### Summary of useful commands

| Command                                 | Purpose                                    |
|-----------------------------------------|--------------------------------------------|
| `sudo cscli decisions list`             | List all active bans and blocks            |
| `sudo cscli alerts list`               | View recent alerts triggered by attacks    |
| `sudo cscli metrics`                    | Display security metrics and performance   |
| `sudo tail -f /var/log/crowdsec.log`    | Watch logs and detections in real time     |
| `sudo cscli scenarios list`             | Show all active detection scenarios        |
| `sudo cscli decisions add`              | Manually block specific IPs                |
| `sudo cscli decisions delete`           | Remove a block for a specific IP           |
| `cscli allowlist create`               | Create a new allowlist                     |
| `cscli allowlist add`                  | Add an IP/subnet to an allowlist           |
| `cscli allowlist inspect`              | View allowlist contents                    |


## Keeping CrowdSec updated and secure

Keeping CrowdSec up to date is straightforward:

```bash
sudo apt update &amp;&amp; sudo apt upgrade crowdsec
```

&lt;Notice type=&quot;warning&quot; title=&quot;CrowdSec 1.7.8 security release&quot;&gt;
CrowdSec 1.7.8 fixes two security vulnerabilities: CVE-2026-44982 (WAF bypass via chunked transfer-encoding) and CVE-2026-44981 (LAPI denial of service). If you run AppSec, updating is critical. If you only use the firewall bouncer, it&apos;s still strongly recommended.
&lt;/Notice&gt;

Additional notes:

- **RE2 regex engine.** Since version 1.7.7, CrowdSec uses the RE2 regex engine by default on Linux, which brings significant performance improvements for log parsing. This matters on busy servers with high log volume.
- **Security announcements.** Subscribe to CrowdSec&apos;s [GitHub releases](https://github.com/crowdsecurity/crowdsec/releases) or the [Discourse forum](https://discourse.crowdsec.net/) to stay informed about security patches.
- **Bouncer updates.** Don&apos;t forget to update bouncers too: `sudo apt update &amp;&amp; sudo apt upgrade crowdsec-firewall-bouncer-nftables` (or `-iptables`).

The `cscli dashboard` command (Metabase-based local dashboard) was deprecated in CrowdSec 1.7.0. Use the CrowdSec Console instead — it&apos;s the officially supported way to visualize your security data.


## Conclusion

CrowdSec gives you community-driven intrusion prevention that covers the full stack — SSH brute force blocking at the firewall level, Nginx log analysis for web attack detection, and inline HTTP inspection through the AppSec WAF. Here&apos;s what we walked through:

- Installing CrowdSec 1.7.8 and configuring SSH log monitoring
- Choosing between nftables (recommended for modern systems) and iptables firewall bouncer
- Allowlisting your admin IP to prevent lockout
- Configuring the DOCKER-USER chain for Docker/Dokploy hosts
- Monitoring both Nginx access.log and error.log for web attack detection
- Deploying the AppSec WAF for inline HTTP request blocking
- Connecting to the CrowdSec Console for centralized management
- Using allowlists and commands to manage CrowdSec day-to-day

CrowdSec handles IP-level and HTTP-level threats. Pair it with a [layered security approach with DNS-level protection](/block-ads-malware-dns-protection/) for defense in depth. And if you run [server management panels that integrate with your security stack](/best-self-hosted-panels/), CrowdSec fits alongside those tools without conflicts.

Keep it updated, watch your metrics, and explore the [CrowdSec Hub](https://hub.crowdsec.net/) for additional collections beyond SSH and Nginx — there are parsers for MySQL, PostgreSQL, WordPress, and dozens more services.</content:encoded><category>linux</category><category>linux</category><category>crowdsec</category><category>vps-security</category></item><item><title>How To Do SSH Port Forwarding (SSH Tunneling) in Linux</title><link>https://www.bitdoze.com/ssh-tunneling-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/ssh-tunneling-linux/</guid><description>Learn SSH port forwarding in Linux: local, remote, and dynamic tunneling. Set up persistent tunnels with systemd, SSH config, and security best practices.</description><pubDate>Tue, 11 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

SSH port forwarding (also called SSH tunneling) lets you route network traffic through an encrypted SSH connection. It solves a common operator problem: accessing a private database on a VPS, exposing a local dev server temporarily, or browsing securely over untrusted Wi-Fi, all without opening extra ports to the public internet.

SSH port forwarding comes in three flavors (local, remote, and dynamic), each serving different scenarios. This guide covers all three, plus the operational bits that most tutorials skip: keeping tunnels alive with systemd, defining reusable tunnels in `~/.ssh/config`, chaining through jump hosts, and locking down forwarding permissions.


## Types of SSH port forwarding

SSH port forwarding comes in three types, each serving specific use cases. Understanding their differences helps you pick the right one.

### Local port forwarding

![Local Port Forwarding](../../assets/images/25/02/local-port-forward.png)

Local port forwarding lets you forward a port on your local machine to a specific port on a remote server. This is the most common type — use it to securely access services running on a remote server from your local machine.

- **How it works**: Your local machine forwards traffic to a remote host via an SSH tunnel.
- **Use case**: Securely accessing a remote MySQL database server from your local machine.

### Remote port forwarding

![Remote Port Forwarding](../../assets/images/25/02/remote-port-forward.png)

Remote port forwarding does the opposite. It forwards a port on the remote server back to a specific port on your local machine. This allows a remote user or server to access services running locally on your system.

- **How it works**: The remote server sends traffic back to the specified local machine and port via the SSH tunnel.
- **Use case**: Exposing a local development web server so that remote stakeholders can access it.

### Dynamic port forwarding (SOCKS proxy)

![Dynamic Port Forwarding](../../assets/images/25/02/dinamic-port-forward.png)

Dynamic port forwarding creates a SOCKS proxy server on your local machine that dynamically routes traffic to different destinations through the SSH tunnel. This is useful for secure web browsing or bypassing network restrictions.

- **How it works**: A single local port is opened as a SOCKS proxy. You configure client applications (e.g., web browsers) to route traffic through this proxy.
- **Use case**: Bypassing regional content restrictions or securing browsing on public Wi-Fi.


## Prerequisites

Before you start, make sure you have:

1. **SSH access to a remote server**. A valid username and password, or preferably an SSH key pair. If you don&apos;t have a VPS yet, [Hetzner](https://go.bitdoze.com/hetzner) offers affordable cloud servers starting at ~4 EUR/month. See our guide on [setting up a VPS for remote access](/vps-ai-coding-setup/) if you&apos;re starting from scratch.

2. **OpenSSH installed** — Most Linux distributions ship it. Verify with:
   ```bash
   ssh -V
   ```
   If not installed: `sudo apt install openssh-client` (Ubuntu/Debian) or `sudo yum install openssh` (CentOS).

3. **Network and firewall configuration** — Ensure the ports you plan to use aren&apos;t blocked. For remote port forwarding, the SSH server must allow it — check `AllowTcpForwarding` in `/etc/ssh/sshd_config`.

4. **Port knowledge** — Know which ports your services run on (MySQL: 3306, PostgreSQL: 5432, HTTP: 80, HTTPS: 443).

&lt;Notice type=&quot;info&quot; title=&quot;Key generation: use Ed25519, not RSA&quot;&gt;
Ed25519 has been the OpenSSH default since version 9.5 (October 2023). It&apos;s faster and produces smaller keys than RSA. If you&apos;re setting up SSH keys for the first time:

```bash
ssh-keygen -t ed25519 -C &quot;your-comment&quot;
```

RSA-4096 still works for legacy systems. Use `ssh-keygen -t rsa -b 4096` only if your target server doesn&apos;t support Ed25519.

For a full hardening walkthrough, see [hardening your SSH server](/secure-ssh-server-linux/).
&lt;/Notice&gt;


## How to perform SSH port forwarding

&lt;Notice type=&quot;info&quot; title=&quot;The -f and -N flags&quot;&gt;
Every tunnel command below includes two flags you&apos;ll use every time:

- **`-N`**: Do not execute a remote command. Without this, SSH opens an interactive shell you don&apos;t need.
- **`-f`**: Fork to the background after authentication. Without this, the tunnel blocks your terminal.

For interactive exploration (testing), omit `-f` so you can see the output. For production use, always include both.
&lt;/Notice&gt;

### Local port forwarding

Forward a local port to a service on a remote machine:

```bash
ssh -f -N -L [local_port]:[remote_host]:[remote_port] user@remote-server
```

- `[local_port]` — Port on your local machine (e.g., 3306).
- `[remote_host]` — Address of the remote host (usually `127.0.0.1` if the service runs on the remote server itself).
- `[remote_port]` — Port of the service on the remote host.
- `user@remote-server` — SSH user and remote server address.

**Example** — Forward remote MySQL (port 3306) to your local machine:

```bash
ssh -f -N -L 3306:127.0.0.1:3306 user@remote-server
```

**Verify the tunnel is working:**

```bash
# Confirm the port is listening locally
ss -tuln | grep 3306

# Test the connection
mysql -h 127.0.0.1 -P 3306 -u username -p
```

### Remote port forwarding

Expose a local service to a remote server:

```bash
ssh -f -N -R [remote_port]:[local_host]:[local_port] user@remote-server
```

- `[remote_port]` — Port on the remote server (e.g., 8080).
- `[local_host]` — Address of your local host (usually `127.0.0.1`).
- `[local_port]` — Port of the service on your local machine (e.g., 80 for a web server).

**Example** — Expose a local web server (port 80) on the remote server at port 8080:

```bash
ssh -f -N -R 8080:127.0.0.1:80 user@remote-server
```

**Verify** — On the remote server:

```bash
ss -tuln | grep 8080
curl http://localhost:8080
```

&lt;Notice type=&quot;warning&quot; title=&quot;Remote forwarding needs GatewayPorts configured&quot;&gt;
For the forwarded port to be accessible from outside the remote server (not just localhost), the SSH server needs `GatewayPorts` configured. See the [SSH Tunnel Security Best Practices](#ssh-tunnel-security-best-practices) section for the safe way to set this up.
&lt;/Notice&gt;

### Dynamic port forwarding (SOCKS proxy)

Set up a SOCKS proxy on your local machine:

```bash
ssh -f -N -D [local_port] user@remote-server
```

- `[local_port]` — Port on your local machine for the SOCKS proxy (e.g., 1080).

**Example:**

```bash
ssh -f -N -D 1080 user@remote-server
```

**Verify** — Configure your browser to use SOCKS5 proxy at `127.0.0.1:1080`, then visit `https://ifconfig.me` to confirm your traffic routes through the remote server.


## Using ~/.ssh/config for reusable tunnels

Typing long SSH commands every time gets old fast. Define your tunnels in `~/.ssh/config` and start them with a single short command.

&lt;Tabs&gt;
&lt;Tab name=&quot;Local forward&quot;&gt;
```ssh-config
# ~/.ssh/config

Host db-tunnel
    HostName remote-server.example.com
    User deploy
    LocalForward 5432 127.0.0.1:5432
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
    IdentityFile ~/.ssh/id_ed25519
```

Start the tunnel:
```bash
ssh -f -N db-tunnel
```

Now `localhost:5432` connects to the remote PostgreSQL server.
&lt;/Tab&gt;
&lt;Tab name=&quot;Remote forward&quot;&gt;
```ssh-config
# ~/.ssh/config

Host expose-local-app
    HostName public-vps.example.com
    User deploy
    RemoteForward 8080 127.0.0.1:3000
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
```

Start the tunnel:
```bash
ssh -f -N expose-local-app
```

Your local app on port 3000 is now accessible at `public-vps.example.com:8080`.
&lt;/Tab&gt;
&lt;Tab name=&quot;Dynamic forward (SOCKS)&quot;&gt;
```ssh-config
# ~/.ssh/config

Host vpn-tunnel
    HostName vps.example.com
    User deploy
    DynamicForward 1080
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
```

Start the tunnel:
```bash
ssh -f -N vpn-tunnel
```

Configure your browser with SOCKS5 proxy `127.0.0.1:1080`.
&lt;/Tab&gt;
&lt;/Tabs&gt;

**Verify your config resolves correctly:**

```bash
ssh -G db-tunnel
```

This dumps the resolved configuration — check that `localforward`, `hostname`, and `user` are correct.


## Persistent SSH tunnels

Tunnels die silently when NAT or firewall timeouts drop idle TCP connections. This is the number-one operational pain point with SSH tunnels. Here&apos;s how to make them survive.

### Keeping tunnels alive with ServerAliveInterval

The client-side directive `ServerAliveInterval` sends keepalive packets at regular intervals to prevent idle connection timeouts.

**Recommended values in `~/.ssh/config`:**

```ssh-config
ServerAliveInterval 60
ServerAliveCountMax 3
```

This sends a keepalive every 60 seconds. If three consecutive keepalives go unanswered (180 seconds total), SSH closes the connection.

You can also pass these as flags:

```bash
ssh -o ServerAliveInterval=60 -o ServerAliveCountMax=3 -f -N -L 5432:127.0.0.1:5432 user@host
```

&lt;Notice type=&quot;warning&quot; title=&quot;ClientAliveInterval vs ServerAliveInterval&quot;&gt;
These are easy to mix up:

- **`ServerAliveInterval`**: Client-side. The **client** asks the server &quot;are you alive?&quot; This is what you want for tunnels. Goes in `~/.ssh/config` or as an `-o` flag.
- **`ClientAliveInterval`**: Server-side. The **server** asks the client &quot;are you alive?&quot; Goes in `/etc/ssh/sshd_config`. The original article showed this as a client option, which was incorrect.

For keeping SSH tunnels alive, use `ServerAliveInterval` on the client.
&lt;/Notice&gt;

### ExitOnForwardFailure: don&apos;t silently fail

If a port forward fails to bind (port already in use, permission denied), SSH connects anyway **without the tunnel**. You think it&apos;s working, but traffic isn&apos;t being forwarded.

`ExitOnForwardFailure yes` makes SSH exit with an error instead of silently connecting without the tunnel.

```bash
ssh -o ExitOnForwardFailure=yes -f -N -L 8080:localhost:80 user@host
```

Or in `~/.ssh/config`:

```ssh-config
Host my-tunnel
    HostName host.example.com
    ExitOnForwardFailure yes
    LocalForward 8080 localhost:80
```

Always include this. It prevents the most common silent failure mode with SSH tunnels.

### Running SSH tunnels as systemd services

This is the boring-reliable way to keep a tunnel alive across reboots. No need for `autossh` — systemd&apos;s `Restart=always` handles restarts, and `ServerAliveInterval` handles dead connection detection.

**Create the service file:**

```ini
# /etc/systemd/system/ssh-tunnel-db.service

[Unit]
Description=SSH Tunnel to production database
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=deploy
ExecStart=/usr/bin/ssh -N -o ExitOnForwardFailure=yes \
    -o ServerAliveInterval=60 -o ServerAliveCountMax=3 \
    -L 5432:127.0.0.1:5432 tunnel-user@db-host.example.com
Restart=always
RestartSec=15

[Install]
WantedBy=multi-user.target
```

**Enable and start:**

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now ssh-tunnel-db
```

**Verify:**

```bash
systemctl status ssh-tunnel-db
ss -tuln | grep 5432
```

&lt;Notice type=&quot;success&quot; title=&quot;No autossh needed&quot;&gt;
systemd&apos;s `Restart=always` combined with `ServerAliveInterval` handles reconnection. When SSH detects a dead connection (three missed keepalives), it exits. systemd restarts the service after 15 seconds. `autossh` still works but is often unnecessary on modern systems.
&lt;/Notice&gt;

**Common failure mode:** The service user needs access to the SSH key. If the tunnel fails to start, check:

```bash
sudo journalctl -u ssh-tunnel-db -n 20
```

Make sure the key file has correct permissions:

```bash
sudo chmod 600 /home/deploy/.ssh/id_ed25519
sudo chown deploy:deploy /home/deploy/.ssh/id_ed25519
```


## Tunneling through jump hosts (ProxyJump)

A common setup: your database lives on a private server that&apos;s only accessible through a bastion/jump host. SSH can chain through the bastion with the `-J` flag (requires OpenSSH 7.3+, available on Ubuntu 18.04+, Debian 10+, and all current distros).

**Command:**

```bash
ssh -J jump-user@bastion.example.com \
    -f -N \
    -L 5432:127.0.0.1:5432 \
    db-user@private-db.internal
```

This connects to the bastion first, then opens a tunnel through it to the private database server.

**In `~/.ssh/config`:**

```ssh-config
Host db-via-bastion
    HostName private-db.internal
    User db-user
    ProxyJump jump-user@bastion.example.com
    LocalForward 5432 127.0.0.1:5432
    ServerAliveInterval 60
    ExitOnForwardFailure yes
```

Then just:

```bash
ssh -f -N db-via-bastion
```

&lt;Notice type=&quot;info&quot; title=&quot;ProxyJump replaces ProxyCommand&quot;&gt;
`-J` (ProxyJump) is the modern approach. The older `ProxyCommand ssh jump-host nc %h %h` pattern still works but is harder to configure and less efficient. If you&apos;re on OpenSSH 7.3+, use `-J`.

For a deeper dive, see our guide on [SSH ProxyJump and jump hosts](/ssh-proxyjump-jumphost/).
&lt;/Notice&gt;


## SSH tunnel security best practices

Tunnels forward traffic by design, which means misconfigured forwarding can expose private services to the internet. Here&apos;s how to lock things down.

### GatewayPorts: no vs clientspecified vs yes

The `GatewayPorts` directive in `/etc/ssh/sshd_config` controls whether remote port forwards are accessible from outside the server.

```ssh-config
# In /etc/ssh/sshd_config on the remote server:

# GatewayPorts no              # default: forwarded ports only on localhost (safest)
# GatewayPorts clientspecified # client chooses the bind address per connection
# GatewayPorts yes             # ⚠️ binds to ALL interfaces (0.0.0.0)
```

| Value | Behavior | When to use |
|-------|----------|-------------|
| `no` (default) | Forwarded ports only accessible from localhost on the remote server | Most cases — this is safe |
| `clientspecified` | Client decides the bind address per connection | **Recommended** when you need external access selectively |
| `yes` | Always binds to all interfaces (0.0.0.0) | Trusted networks only — never on a public VPS |

&lt;Notice type=&quot;warning&quot; title=&quot;GatewayPorts yes exposes ports to the internet&quot;&gt;
On a public VPS, `GatewayPorts yes` binds forwarded ports to 0.0.0.0, making them accessible from anywhere on the internet. Use `clientspecified` instead, and bind to specific interfaces in your SSH command when needed.
&lt;/Notice&gt;

After changing this, restart the SSH daemon:

```bash
sudo systemctl restart sshd
```

### SSH key authentication

Always use SSH key pairs over passwords. Generate an Ed25519 key:

```bash
ssh-keygen -t ed25519 -C &quot;your-comment&quot;
```

Copy the public key to the remote server:

```bash
ssh-copy-id user@remote-server
```

Disable password authentication in `/etc/ssh/sshd_config` on the server:

```ssh-config
PasswordAuthentication no
PubkeyAuthentication yes
```

### Restricting forwarding with PermitOpen and PermitListen

For dedicated tunnel accounts, restrict which ports can be forwarded:

```ssh-config
# In /etc/ssh/sshd_config

Match User tunnel-user
    PermitOpen 127.0.0.1:5432
    PermitListen 127.0.0.1:8080
    AllowTcpForwarding yes
```

- `PermitOpen` limits which hosts/ports the user can forward traffic **to** (for `-L`).
- `PermitListen` limits which hosts/ports the user can listen **on** (for `-R`). Available since OpenSSH 7.8.

This is critical when you create a user account specifically for database tunnels.

### AllowTcpForwarding granularity

The default `AllowTcpForwarding yes` (or `all`) allows both local and remote forwarding. You can restrict it:

| Value | Allowed |
|-------|---------|
| `yes` / `all` | Both `-L` and `-R` |
| `local` | Only `-L` (local forwarding) |
| `remote` | Only `-R` (remote forwarding) |
| `no` | Neither |

Available since OpenSSH 6.2.

### Creating a tunnel-only user with Match blocks

For production database tunnels, create a user that can only forward ports — no shell access:

```ssh-config
# In /etc/ssh/sshd_config

Match User tunnel-only
    AllowTcpForwarding remote
    PermitListen 127.0.0.1:8080
    PermitOpen 127.0.0.1:5432
    ForceCommand /bin/false
    X11Forwarding no
    PermitTunnel no
```

This user can set up remote forwards on port 8080 and open tunnels to 127.0.0.1:5432, and nothing else. Even if the SSH key is compromised, the blast radius is limited.

For broader server hardening, see [securing a VPS with CrowdSec](/crowdsec-secure-server/).

### Firewalls

Keep a properly configured firewall on both local and remote machines:

```bash
sudo ufw enable
sudo ufw allow ssh
```

&lt;Notice type=&quot;warning&quot; title=&quot;Docker bypasses UFW&quot;&gt;
If you&apos;re running Docker on the remote server, be aware that [Docker bypasses your firewall](/docker-bypasses-firewall/) by inserting its own iptables rules. A port exposed via Docker Compose is open to the internet regardless of UFW rules. See [securing a Docker server with UFW](/bsi-security-report-docker-ufw/) for the fix.
&lt;/Notice&gt;


## Troubleshooting SSH tunnels

### Verify the tunnel is listening

```bash
# Replace 5432 with your port
ss -tuln | grep 5432
```

Expected output — a line showing `LISTEN` on `127.0.0.1:5432`.

### Test connectivity

```bash
curl -v http://localhost:8080
# or for non-HTTP services:
telnet localhost 5432
```

For checking remote ports specifically, see [checking remote ports with nc](/check-remote-port-in-linux-nc/).

### Debug with verbose logging

Add `-v` for basic debug output, `-vvv` for maximum verbosity:

```bash
ssh -v -N -L 8080:localhost:80 user@host
```

This shows the SSH handshake, authentication details, and forwarding setup. If the tunnel fails to bind, you&apos;ll see the error here.

### Check SSH logs

```bash
# On the server
sudo journalctl -u ssh -f
# or
sudo tail -f /var/log/auth.log
```

### Common failure modes

| Symptom | Cause | Fix |
|---------|-------|-----|
| `bind: Address already in use` | Port is in use | `ss -tuln \| grep &lt;port&gt;`, kill the process or pick another port |
| `Permission denied` on low ports (&amp;lt;1024) | Non-root user | Use a port &gt;1024 or set up an iptables redirect |
| Tunnel connects but port not listening | Missing `-N` or forward failed silently | Add `ExitOnForwardFailure yes` |
| Tunnel dies after idle minutes | NAT/firewall timeout | Add `ServerAliveInterval 60` |
| `Connection refused` from remote | Firewall blocking, or `AllowTcpForwarding no` | Check `sshd_config`, check `ufw status` |
| `Warning: remote port forwarding failed` | `GatewayPorts` not configured, or port in use on remote | Check `GatewayPorts` in sshd_config, check port on remote |

&lt;Accordion label=&quot;How do I kill a background SSH tunnel?&quot; group=&quot;faq&quot;&gt;

Find the tunnel process:

```bash
ps aux | grep &apos;ssh -f -N&apos;
```

Kill it by PID:

```bash
kill &lt;PID&gt;
```

Or kill all matching tunnels at once:

```bash
pkill -f &apos;ssh -f -N -L 5432&apos;
```

If the tunnel is running as a systemd service, stop it with:

```bash
sudo systemctl stop ssh-tunnel-db
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why restrict PermitOpen and PermitListen?&quot; group=&quot;faq&quot;&gt;

Without `PermitOpen` and `PermitListen`, a user with SSH access can forward **any** port to **any** host reachable from the server. If an attacker compromises the tunnel user&apos;s SSH key, they could reach internal services, databases, or management interfaces.

Restricting these directives limits the blast radius — the compromised key only grants access to the specific ports you&apos;ve explicitly allowed. This is defense-in-depth: even if the key leaks, the damage is contained.
&lt;/Accordion&gt;


## Conclusion

SSH port forwarding is a core tool for any Linux operator. The three types (local, remote, and dynamic) cover most network access scenarios without exposing extra ports publicly.

What this guide covered:

- The three types of SSH port forwarding and when to use each.
- Practical commands with `-f -N` flags for tunnel-only sessions.
- Reusable `~/.ssh/config` definitions so you don&apos;t retype commands.
- Persistent tunnels using systemd services and `ServerAliveInterval`, no `autossh` needed.
- Jump host chaining with ProxyJump for bastion-based access patterns.
- Security hardening: `GatewayPorts` nuance, `PermitOpen`/`PermitListen` restrictions, and tunnel-only users.
- Troubleshooting: verify commands, verbose logging, and common failure modes.

SSH tunnels are the quick-access tool in your networking kit. For permanent service exposure, look at dedicated reverse proxies or mesh VPNs. Start with the basics, get a tunnel working, verify it with `ss` and `curl`, then harden it with the security practices above.

&gt; **Looking for more tunneling options?** SSH tunnels are great for quick access, but if you need persistent tunnels for exposing services, check out [Pangolin](/pangolin-cloudflare-tunnels-alternative/) (self-hosted tunnel alternative) or our [mesh VPN comparison](/netbird-vs-headscale-vs-tailscale/) for connecting entire networks with WireGuard. If you want to self-host your own Tailscale-compatible network, see [Headscale](/headscale-self-hosted-tailscale-setup/).</content:encoded><category>linux</category><category>linux</category><category>ssh</category><category>networking</category></item><item><title>How to Fix Docker Bypassing Firewall: A Complete Guide</title><link>https://www.bitdoze.com/docker-bypasses-firewall/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-bypasses-firewall/</guid><description>Docker bypasses UFW firewall rules. Secure your containers with localhost binding, DOCKER-USER iptables chain rules, the ufw-docker tool, and a Traefik reverse proxy.</description><pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

If you&apos;re [managing your own servers](https://www.bitdoze.com/secure-ssh-server-linux/) and running Docker containers, there&apos;s a security issue you need to know about: Docker bypasses your system&apos;s firewall by default. It&apos;s not a bug, it&apos;s how Docker&apos;s networking works. But it means the UFW or iptables rules you set up are not protecting your containers the way you&apos;d expect.

This guide covers every practical fix, from the two-minute localhost binding to architectural changes like rootless Docker. Everything here is tested with Docker 28.x and 29.x.

&lt;Notice type=&quot;warning&quot; title=&quot;Tested with Docker 28.x and 29.x&quot;&gt;
Docker&apos;s networking changed substantially in 28.0.0 (Feb 2025) and 29.0.0 (Nov 2025). The iptables chain structure was reworked, and nftables support arrived as experimental. If you&apos;re on 27.x or older, the iptables commands below will still work, but the internal chain layout differs.
&lt;/Notice&gt;

## Understanding the problem: why Docker bypasses the firewall

Docker creates its own bridge network (usually `docker0`) and manages container networking through iptables (or nftables on newer setups). When you run a container with a port mapping like this:

```yaml
services:
  myapp:
    image: nginx
    ports:
      - &quot;8080:80&quot;
```

You might think you&apos;re opening port 8080 on localhost only. What actually happens: Docker binds to `0.0.0.0:8080` and inserts DNAT rules into your iptables, ahead of your regular firewall rules. So your UFW rules saying &quot;block everything except 22, 80, 443&quot; don&apos;t apply to Docker&apos;s forwarded traffic.

If you&apos;re [setting up containers for your home server](https://www.bitdoze.com/docker-containers-home-server/) or a VPS, this can expose services you thought were internal.

You can see Docker&apos;s rules in action:

```bash
sudo iptables -L -n -v | grep DOCKER
```

You&apos;ll see rules Docker added automatically. These are what bypass your firewall.

This is by design. Docker needs network control for inter-container communication and published ports. But &quot;by design&quot; doesn&apos;t mean &quot;acceptable for your setup.&quot; Here&apos;s how to fix it.

&lt;Notice type=&quot;error&quot; title=&quot;CVE-2025-54388 (fixed in Docker 28.3.3)&quot;&gt;
After a firewalld reload, published container ports could be accessed directly from the local network, even when bound to loopback. If you&apos;re running Docker 28.0 through 28.3.2, update immediately. This was fixed in 28.3.3.
&lt;/Notice&gt;

&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Solutions: configuring the firewall to control Docker traffic

These solutions range from quick fixes to architectural changes. Start with the first one and work down as needed.

### 1. Using localhost binding (the simplest fix)

The fastest mitigation: bind containers to `127.0.0.1` so they&apos;re only accessible from the host itself.

```yaml
services:
  myapp:
    image: nginx
    ports:
      - &quot;127.0.0.1:8080:80&quot;
```

This works because Docker skips its DNAT chain for loopback-bound ports. The container is reachable from localhost but invisible to the outside world.

**Limitations**: Other hosts on the same network can&apos;t reach the container directly. For external access, you need a reverse proxy (Traefik, Caddy, Nginx) in front. That&apos;s the recommended pattern anyway.

### 2. Using a Cloud firewall

Cloud-level firewalls operate at the network layer, before traffic ever hits your VPS. Docker can&apos;t touch them because they run outside the host.

Providers with network-level firewalls:

- **AWS Security Groups**: instance-level inbound/outbound rules
- **[Hetzner Cloud Firewalls](https://go.bitdoze.com/hetzner)**: network-level filtering, free, easy to configure
- **[DigitalOcean Cloud Firewalls](https://go.bitdoze.com/do)**: similar to security groups
- **Google Cloud Firewall Rules**: tag-based network filtering

These can&apos;t be bypassed by Docker&apos;s iptables manipulation. My typical setup:

1. Allow ports 80, 443 (web traffic) and 22 (SSH)
2. Block all other incoming traffic
3. Add specific rules for any additional services

This is your outermost defense layer. It doesn&apos;t replace fixing Docker&apos;s behavior on the host, but it catches anything that slips through.

### 3. Using Traefik as a reverse proxy

Using [Traefik as a reverse proxy](https://www.bitdoze.com/traefik-proxy-docker/) is one of the best long-term solutions. Only Traefik&apos;s ports are exposed. Everything else stays hidden.

&lt;Notice type=&quot;info&quot; title=&quot;Why Traefik over direct port exposure?&quot;&gt;
Only Traefik&apos;s ports are exposed. All other containers remain hidden behind it. Built-in Let&apos;s Encrypt, rate limiting, and middleware. No need to manage TLS certs per container.
&lt;/Notice&gt;

Here&apos;s a working setup:

```yaml
services:
  traefik:
    image: traefik:v3
    command:
      - &quot;--providers.docker=true&quot;
      - &quot;--api.dashboard=true&quot;
      - &quot;--certificatesresolvers.letsencrypt.acme.tlschallenge=true&quot;
      - &quot;--certificatesresolvers.letsencrypt.acme.email=your@email.com&quot;
      - &quot;--entrypoints.web.address=:80&quot;
      - &quot;--entrypoints.websecure.address=:443&quot;
      - &quot;--entrypoints.web.http.redirections.entrypoint.to=websecure&quot;
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    labels:
      - &quot;traefik.enable=true&quot;

  myapp:
    image: nginx
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.myapp.rule=Host(`your-domain.com`)&quot;
      - &quot;traefik.http.routers.myapp.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.myapp.tls.certresolver=letsencrypt&quot;
    # Notice: no ports exposed directly
volumes:
  letsencrypt:
```

For the Traefik dashboard, secure it with basic authentication. The placeholder hash `$$apr1$$xyz123$$` won&apos;t work. Generate a real one:

```bash
echo $(htpasswd -nbB admin &apos;your-password&apos;) | sed -e &apos;s/\$/\$\$/g&apos;
```

Add the result to your Traefik labels or a file provider. For a full walkthrough on [securing the proxy itself with basic authentication](https://www.bitdoze.com/traefik-basic-authentication/), see that dedicated guide.

### 4. Using Caddy + caddy-docker-proxy (lightweight alternative)

If Traefik feels like overkill, [Caddy](https://caddyserver.com/) with the `lucaslorentz/caddy-docker-proxy` plugin gives you automatic HTTPS with simpler config:

```yaml
services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:ci-alpine
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - caddy_data:/data
    environment:
      - CADDY_INGRESS_NETWORK=caddy

  myapp:
    image: nginx
    labels:
      caddy: your-domain.com
      caddy.reverse_proxy: &quot;{{upstreams 80}}&quot;
    # No ports exposed directly

networks:
  default:
    name: caddy
    external: true

volumes:
  caddy_data:
```

Create the external network first: `docker network create caddy`.

**Caddy vs Traefik**: Caddy has a simpler config model and automatic HTTPS by default (no explicit certresolver config). Traefik has more middleware options, a richer dashboard, and better support for complex routing. For solo operators running a handful of services, Caddy is often the better fit.

### 5. Using ufw-docker: the simplest UFW + Docker fix

If you&apos;re using UFW, this is the tool you probably didn&apos;t know existed. [chaifeng/ufw-docker](https://github.com/chaifeng/ufw-docker) (6.6k+ stars on GitHub) automates the fix for Docker bypassing UFW.

&lt;Notice type=&quot;success&quot; title=&quot;Recommended for UFW users&quot;&gt;
ufw-docker is the de facto community solution for UFW + Docker. It replaces manual after.rules editing with simple per-container commands.
&lt;/Notice&gt;

**Install:**

```bash
sudo wget -O /usr/local/bin/ufw-docker \
  https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install
```

The `install` command modifies `/etc/ufw/after.rules` (and `after6.rules` for IPv6) to add Docker-aware filtering rules. It backs up the existing file first.

**Allow a container&apos;s port through the firewall:**

```bash
sudo ufw-docker allow myapp 80
sudo ufw-docker allow myapp 443/tcp
```

**List rules for a container:**

```bash
sudo ufw-docker list myapp
```

**Remove a rule:**

```bash
sudo ufw-docker delete allow myapp 80
```

**Key advantage over manual rules**: `ufw-docker` keeps `DEFAULT_FORWARD_POLICY=&quot;DROP&quot;` (the safe default) and only opens what you explicitly allow. The manual approach of setting it to `ACCEPT` opens all forwarded traffic. That&apos;s a broad hammer.

I documented my full experience [securing a Docker server after a BSI security report](https://www.bitdoze.com/bsi-security-report-docker-ufw/) if you want the complete story.

## Advanced firewall configurations: working with iptables and UFW

If you need finer-grained control or you&apos;re not using UFW, these approaches work at the iptables level.

### Working with iptables and the DOCKER-USER chain

The `DOCKER-USER` chain is special: Docker intentionally does not modify rules you put there. It&apos;s processed before Docker&apos;s own forwarding rules, so it&apos;s the right place for admin-defined filtering.

&lt;Notice type=&quot;warning&quot; title=&quot;Docker 28.2.2 changed DOCKER-USER behavior&quot;&gt;
In Docker 28.2.2 (May 2025), Docker stopped adding an explicit RETURN rule to DOCKER-USER. This means you can now both append (-A) and insert (-I) rules. If you previously relied on the implicit RETURN, verify your rule order still works after upgrading.
&lt;/Notice&gt;

Here&apos;s the standard pattern:

```bash
# Allow established connections (don&apos;t break existing traffic)
sudo iptables -A DOCKER-USER -i eth0 -j ACCEPT -m conntrack --ctstate ESTABLISHED,RELATED

# Allow traffic from a specific IP
sudo iptables -A DOCKER-USER -i eth0 -s 203.0.113.1 -j ACCEPT

# Allow traffic from a trusted subnet
sudo iptables -A DOCKER-USER -i eth0 -s 10.0.0.0/8 -j ACCEPT

# Drop everything else coming from eth0 to containers
sudo iptables -A DOCKER-USER -i eth0 -j DROP
```

Replace `eth0` with your actual public interface (check with `ip a`).

**Make rules persistent across reboots:**

```bash
sudo apt install iptables-persistent
sudo netfilter-persistent save
sudo netfilter-persistent reload
```

Without `iptables-persistent`, your rules vanish on reboot.

**Verify your rules:**

```bash
sudo iptables -L DOCKER-USER -n -v --line-numbers
```

You should see your rules listed with packet counters. If counters stay at zero, the rules aren&apos;t matching. Check the interface name.

### Working with firewalld

firewalld uses zones to represent different network environments. You can create a dedicated zone for Docker interfaces:

**1. Create a zone for Docker:**

```bash
sudo firewall-cmd --permanent --new-zone=docker
```

**2. Bind Docker interfaces to the zone:**

```bash
# Default docker0 bridge
sudo firewall-cmd --permanent --zone=docker --add-interface=docker0

# User-defined networks (replace br-xxx with actual interface)
# Find the interface name:
docker network ls
docker network inspect &lt;network_name&gt; | grep &quot;Interface&quot;

sudo firewall-cmd --permanent --zone=docker --add-interface=br-xxxxx
```

**3. Define rules in the Docker zone:**

```bash
sudo firewall-cmd --permanent --zone=docker --add-port=8080/tcp
sudo firewall-cmd --permanent --zone=docker --add-service=http
```

**4. Apply and verify:**

```bash
sudo firewall-cmd --reload
sudo firewall-cmd --zone=docker --list-all
```

### Using UFW (Uncomplicated Firewall): manual approach

If you prefer manual control over `ufw-docker`, here&apos;s the traditional approach.

**1. Set the forward policy** in `/etc/default/ufw`:

```bash
DEFAULT_FORWARD_POLICY=&quot;ACCEPT&quot;
```

&lt;Notice type=&quot;info&quot; title=&quot;This is a broad setting&quot;&gt;
Setting DEFAULT_FORWARD_POLICY to ACCEPT opens all forwarded traffic. The ufw-docker tool (covered above) keeps this at DROP and only opens what you explicitly allow. Use ufw-docker unless you have a specific reason to manage the rules manually.
&lt;/Notice&gt;

**2. Add Docker-specific rules** to `/etc/ufw/after.rules` (append before the final `COMMIT`):

```bash
# NAT table rules
*nat
:POSTROUTING ACCEPT [0:0]

# Forward traffic through eth0
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

COMMIT

# Don&apos;t delete these required lines
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]

# Allow Docker internal traffic
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -j DROP

COMMIT
```

**3. Apply:**

```bash
sudo ufw reload
```

### Rootless Docker: the aggressive option

Rootless Docker runs the daemon as a non-root user. Because it can&apos;t manipulate iptables directly, published ports are forwarded via userland networking (`slirp4netns` or `gvisor-tap-vsock`), and host firewall rules are respected.

This effectively eliminates the bypass problem. Trade-offs:

- No `--net=host` mode
- Some features unavailable (AppArmor, certain storage drivers)
- Slightly different networking behavior
- Requires per-user setup with `dockerd-rootless-setuptool.sh`

&lt;Notice type=&quot;warning&quot; title=&quot;Rootless Docker trade-offs&quot;&gt;
Rootless Docker solves the firewall bypass but introduces operational complexity. Not all images work cleanly under rootless mode, and debugging networking issues is harder. Consider this for single-user VPS setups, not shared servers.
&lt;/Notice&gt;

If you&apos;re evaluating container runtimes, [Podman handles firewall rules differently](https://www.bitdoze.com/podman-vs-docker/) and may be worth considering as an alternative.

## Docker network isolation and best practices

Firewall rules are one layer. Network isolation is another. Use both.

### Using Docker Compose networks

Custom networks let you control which containers can talk to each other:

- **Internal networks** (`internal: true`): completely isolated. Containers can&apos;t reach the internet or external services. Perfect for databases.
- **External networks** (`internal: false`): containers can reach the internet and be reached from outside. Needs firewall rules.

Here&apos;s how I organize containers with network isolation:

```yaml
networks:
  frontend:
    internal: false  # Allows external access
  backend:
    internal: true   # Completely isolated from external access

services:
  web:
    image: nginx
    networks:
      - frontend
    ports:
      - &quot;127.0.0.1:8080:80&quot;
    security_opt:
      - no-new-privileges:true

  api:
    image: node
    networks:
      - frontend
      - backend
    depends_on:
      - database

  database:
    image: mysql
    networks:
      - backend    # Only connected to internal network
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    secrets:
      - db_root_password
    security_opt:
      - no-new-privileges:true

secrets:
  db_root_password:
    file: ./secrets/db_password.txt
```

This ensures the database is completely isolated from external access, the API bridges both networks, and all external entry goes through localhost. For more on [Docker Compose secrets](https://www.bitdoze.com/docker-compose-secrets/) and what actually works for secret management, see that dedicated guide.

You can also define explicit subnets for tighter control:

```yaml
networks:
  frontend:
    internal: false
    ipam:
      config:
        - subnet: 172.20.0.0/24
  backend:
    internal: true
    ipam:
      config:
        - subnet: 172.20.1.0/24
```

**Audit your networks regularly:**

```bash
docker network ls
docker network inspect frontend
docker stats --format &quot;table {{.Name}}\t{{.NetIO}}&quot;
docker network prune   # Remove unused networks
```

### Container resource limits

&lt;Notice type=&quot;warning&quot; title=&quot;deploy.resources is Swarm-only&quot;&gt;
The `deploy.resources.limits` syntax in docker-compose.yml is only honored by Docker Swarm (docker stack deploy). For standalone `docker compose`, use the top-level service keys shown below.
&lt;/Notice&gt;

**For standalone `docker compose`:**

```yaml
services:
  web:
    image: nginx
    cpu_count: 1
    cpu_percent: 50
    mem_limit: 512m
    memswap_limit: 512m  # Prevent swap abuse
```

**For Docker Swarm (`docker stack deploy`):**

```yaml
services:
  web:
    image: nginx
    deploy:
      resources:
        limits:
          cpus: &apos;0.50&apos;
          memory: 512M
        reservations:
          cpus: &apos;0.25&apos;
          memory: 256M
```

### Docker 28+ gateway modes

Docker 28.0.0 introduced bridge network gateway modes for finer-grained control over how container ports are exposed on the host:

- `nat-unprotected`: NAT is applied but no per-port iptables rules are created. Useful for when you want NAT but manage port access through DOCKER-USER.
- `isolated`: No bridge IP on the host. The host can&apos;t reach containers directly.
- `routed`: Containers are accessible from other bridge networks via routing.

Create a network with a specific mode:

```bash
docker network create --opt com.docker.network.bridge.gateway_mode=nat-unprotected mynet
```

This is for advanced operators. If the default behavior plus a reverse proxy solves your problem, you don&apos;t need to touch gateway modes.

### nftables backend: the future (Docker 29+)

&lt;Notice type=&quot;info&quot; title=&quot;Experimental in Docker 29+&quot;&gt;
nftables support is experimental. Use for testing and evaluation. The iptables backend remains the default and is fully supported. There is no DOCKER-USER chain equivalent with nftables yet. Filtering is done via nftables sets and rules.
&lt;/Notice&gt;

Docker 29.0.0 (Nov 2025) introduced experimental nftables support. Instead of iptables, Docker manages firewall rules through nftables:

```json
// /etc/docker/daemon.json
{
  &quot;firewall-backend&quot;: &quot;nftables&quot;
}
```

With the nftables backend, you must enable IP forwarding manually (Docker doesn&apos;t do it automatically as with iptables):

```bash
echo &apos;net.ipv4.ip_forward=1&apos; | sudo tee /etc/sysctl.d/99-docker.conf
sudo sysctl --system
sudo systemctl restart docker
```

Key differences from the iptables backend:
- No DOCKER-USER chain. Use nftables rules directly.
- Not yet supported in Swarm mode.
- Requires kernel with nftables support (all modern distros have this).

This will eventually become the default. For now, stick with iptables unless you&apos;re testing.

## Verifying your firewall configuration

After applying any of the fixes above, verify that it actually works. Don&apos;t assume.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Check what&apos;s actually exposed: &lt;code&gt;docker ps --format &quot;table {&apos;{&apos;}{&apos;{&apos;} .Names {&apos;}&apos;}{&apos;}&apos;}\t{&apos;{&apos;}{&apos;{&apos;} .Ports {&apos;}&apos;}{&apos;}&apos;}&quot;&lt;/code&gt;, verify no unexpected &lt;code&gt;0.0.0.0&lt;/code&gt; bindings&lt;/li&gt;
&lt;li&gt;Test from outside: &lt;code&gt;nc -zv &amp;lt;public-ip&amp;gt; &amp;lt;port&amp;gt;&lt;/code&gt; from a different host. Confirm a port is blocked when it should be&lt;/li&gt;
&lt;li&gt;Inspect iptables rules: &lt;code&gt;iptables -L DOCKER-USER -n -v --line-numbers&lt;/code&gt;. Verify your rules are in the right order&lt;/li&gt;
&lt;li&gt;Trace NAT rules: &lt;code&gt;iptables -t nat -L -n -v&lt;/code&gt;. See Docker&apos;s DNAT rules and where they sit relative to your firewall&lt;/li&gt;
&lt;li&gt;Check UFW status: &lt;code&gt;ufw status verbose&lt;/code&gt;. Confirm rules are active and correct&lt;/li&gt;
&lt;li&gt;Audit all networks: &lt;code&gt;docker network ls&lt;/code&gt; and &lt;code&gt;docker network inspect &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Here are the concrete commands:

```bash
# 1. See exactly what ports are exposed and where
docker ps --format &quot;table {{.Names}}\t{{.Ports}}&quot;

# 2. Test a port from an external host (run on a different machine)
nc -zv YOUR_PUBLIC_IP 8080

# 3. Check DOCKER-USER chain rules and packet counts
sudo iptables -L DOCKER-USER -n -v --line-numbers

# 4. Inspect all NAT rules (look for DNAT entries from Docker)
sudo iptables -t nat -L -n -v

# 5. UFW status
sudo ufw status verbose

# 6. List and inspect networks
docker network ls
docker network inspect bridge
```

If `nc` succeeds on a port you expected to be blocked, your fix didn&apos;t apply. Recheck the interface name in your iptables rules (`eth0` vs `ens3` vs `enp0s3`, it varies by distro and cloud provider).

## Conclusion: keeping your Docker containers secure

Docker bypasses host firewalls by default. That&apos;s the reality. But it&apos;s manageable with a layered approach:

1. **Localhost binding + reverse proxy** as the foundation. Bind containers to `127.0.0.1`, expose only Traefik or Caddy to the public. This single pattern eliminates most of the risk.
2. **Host firewall** with `ufw-docker` (for UFW users) or DOCKER-USER iptables rules (for iptables users). Control exactly which container ports are reachable and from where.
3. **Cloud firewall** as the outer layer. Network-level filtering that Docker can&apos;t touch.
4. **Docker network isolation** for defense in depth. Internal networks for databases and backend services.

Keep Docker updated. CVE-2025-54388 showed that even loopback-bound ports could be exposed after a firewalld reload on affected versions. If you&apos;re running security-critical containers, staying current isn&apos;t optional.

For layering security beyond Docker itself, consider [CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) for automated intrusion detection and response.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Bind all containers to &lt;code&gt;127.0.0.1&lt;/code&gt; unless they must be publicly accessible&lt;/li&gt;
&lt;li&gt;Deploy a reverse proxy (Traefik or Caddy) for all public services&lt;/li&gt;
&lt;li&gt;Install &lt;code&gt;ufw-docker&lt;/code&gt; or configure DOCKER-USER iptables rules&lt;/li&gt;
&lt;li&gt;Set up a cloud firewall allowing only ports 22, 80, 443&lt;/li&gt;
&lt;li&gt;Use internal networks for databases and backend services&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;nc -zv&lt;/code&gt; from an external host after every change&lt;/li&gt;
&lt;li&gt;Keep Docker updated. Check release notes before upgrading&lt;/li&gt;
&lt;li&gt;Regularly [clean up unused Docker resources](https://www.bitdoze.com/clean-docker-overlay2-dir/) to reduce attack surface&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Security isn&apos;t about implementing every possible measure. It&apos;s about the right combination for your situation. For most solo operators running Docker on a VPS: localhost binding + reverse proxy + `ufw-docker` + cloud firewall covers the vast majority of exposure. The rest is network isolation and keeping things updated.

If you&apos;re getting started with Docker, check out the [essential Docker commands](https://www.bitdoze.com/docker-commands/) and how to [copy multiple files efficiently in Dockerfiles](https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/).</content:encoded><category>self-hosting</category><category>docker</category><category>firewall</category><category>ufw</category></item><item><title>How To Access Remote Servers with SSH ProxyJump and Jump Hosts</title><link>https://www.bitdoze.com/ssh-proxyjump-jumphost/</link><guid isPermaLink="true">https://www.bitdoze.com/ssh-proxyjump-jumphost/</guid><description>Learn SSH ProxyJump and jump host setup for secure remote server access. Step-by-step bastion host configuration, security hardening, and troubleshooting.</description><pubDate>Mon, 10 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

Accessing servers tucked away in a private network shouldn&apos;t require a VPN appliance, a browser plugin, or three hours of firewall debugging. SSH jump hosts and the built-in ProxyJump feature solve this with one command and zero extra software.

A jump host is a single, hardened server exposed to the internet that acts as a relay to your internal machines. Instead of leaving every server reachable from the public internet (which is like leaving all your doors unlocked), you lock everything behind one well-guarded entrance. ProxyJump, built into OpenSSH since version 7.3, turns that two-step login into a single transparent connection. Before using jump hosts, I was relying on [basic SSH security measures](https://www.bitdoze.com/secure-ssh-server-linux/), which were not enough once servers span multiple private networks.

This guide covers the full picture: how jump hosts work, how to configure ProxyJump with SSH config files, port forwarding and multiplexing through jump hosts, security hardening for bastion servers, and troubleshooting the failures you&apos;ll actually hit.

![Benefits of using SSH jump hosts and ProxyJump for secure server access](../../assets/images/25/01/benefits-jumphosts.png)

## Prerequisites

Before you start, make sure you have the following:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS or dedicated server to act as your jump host (1 vCPU, 512 MB RAM, 10 GB disk is enough, SSH traffic bandwidth is negligible)&lt;/li&gt;
&lt;li&gt;OpenSSH 7.3+ on your client machine (for ProxyJump support; most modern distros qualify)&lt;/li&gt;
&lt;li&gt;SSH key pair generated (ed25519 recommended: `ssh-keygen -t ed25519`)&lt;/li&gt;
&lt;li&gt;Comfortable with &lt;a href=&quot;https://www.bitdoze.com/linux-commands/&quot;&gt;basic Linux commands&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Root or sudo access on the jump host to edit sshd_config and firewall rules&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Check your OpenSSH version&quot;&gt;
Run `ssh -V` on your client. Anything from 7.3 (2016) onwards supports ProxyJump. Ubuntu 24.04 ships 9.6p1, Debian 12 ships 9.2p1, both are fine. On the server side, OpenSSH 9.8+ gives you built-in abuse protection (PerSourcePenalties), which is covered later in this guide.
&lt;/Notice&gt;

A jump host needs almost no resources. A [Hetzner CX22](https://go.bitdoze.com/hetzner) at ~€4/month is more than enough. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) and [Vultr](https://go.bitdoze.com/vultr) are alternatives if you need a different region or billing model.

## Understanding the basics: what is SSH?

SSH (Secure Shell) is an encrypted protocol for running commands on a remote machine as if you were sitting in front of it. Every keystroke travels through an encrypted tunnel — nobody on the wire can read it.

Basic usage:

```bash
ssh username@remote-server
# For example:
ssh john@192.168.1.100
```

The connection follows a client-server model: your machine initiates, the remote machine authenticates, and from that point you have a shell. If you&apos;re new to server management, check out how to [secure your SSH server](https://www.bitdoze.com/secure-ssh-server-linux/) before exposing anything to the internet.

The straightforward connection works fine when the target has a public IP and you can reach port 22. What about servers in private subnets with no public address? That&apos;s where jump hosts come in.

## What is a jump host (or bastion host)?

A jump host (also called a bastion host) is a dedicated server that acts as the single gateway to your internal network. Think of it as the secure lobby in a building: everyone checks in through one well-guarded entrance instead of wandering in through random doors.

Here&apos;s a typical setup:

```
Internet -&gt; Jump Host -&gt; Internal Servers
(Public)    (Public)    (Private)
```

![Typical SSH jump host setup showing Internet, Jump Host, and Internal Servers flow](../../assets/images/25/01/jumphost1.png)

To reach an internal server through a jump host manually, you&apos;d do:

```bash
# First, connect to the jump host
ssh jumpuser@jump-host.example.com

# Then, from the jump host, connect to the internal server
ssh internaluser@internal-server
```

This two-step process works, but it&apos;s clunky. You have to manage credentials on the jump host, sessions are nested, and file transfers require extra hops. The main reasons to use a jump host despite the friction:

1. **Single exposure point**: only the jump host has a public IP, reducing your attack surface to one machine
2. **Centralized access control**: all connections flow through one place, making it easier to grant and revoke access
3. **Audit trail**: you can track who connected to what and when, all from one log
4. **Simplified firewall rules**: only the jump host needs SSH open to the internet

## Introducing SSH ProxyJump: a simpler way to use jump hosts

ProxyJump was introduced in OpenSSH 7.3 (2016) and it eliminated the two-step dance. Instead of manually connecting to the jump host and then to your target, you do it all in one command:

```bash
# Old way (two separate connections):
ssh jumpuser@jumphost
ssh internaluser@internal-server

# With ProxyJump (one command):
ssh -J jumpuser@jumphost internaluser@internal-server
```

The `-J` flag tells SSH to tunnel through the jump host transparently. Your session connects directly to the target server, but the traffic is relayed through the bastion. From the user&apos;s perspective it feels like a normal SSH session.

You can chain multiple jump hosts with commas:

```bash
# Going through two jump hosts
ssh -J user1@jump1.com,user2@jump2.com target_user@final-server.com
```

&lt;Notice type=&quot;info&quot; title=&quot;scp and sftp also support -J&quot;&gt;
Since OpenSSH 8.0 (2019), `scp` and `sftp` accept the `-J` flag too. Copy files through a jump host with:
`scp -J user@jump local-file.txt target-user@target:/path/`
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;ProxyCommand for older clients&quot;&gt;
If you&apos;re stuck on a pre-7.3 client, the legacy equivalent is:
`ssh -o ProxyCommand=&quot;ssh -W %h:%p user@jump&quot; user@target`
This is rarely needed today — even CentOS 7 ships OpenSSH 7.4.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Avoid agent forwarding with jump hosts&quot;&gt;
Do not use `ForwardAgent yes` with jump hosts. If the jump host is compromised, an attacker can hijack your forwarded agent and authenticate as you to other servers. ProxyJump is safer because your private keys never leave your local machine — the jump host only relays encrypted traffic.
&lt;/Notice&gt;

OpenSSH is built into Windows 10 and 11 (Settings &gt; Optional Features &gt; OpenSSH Client). ProxyJump works the same way on Windows.

## SSH ProxyJump configuration with SSH config file

Typing `-J` every time gets old fast. The `~/.ssh/config` file makes ProxyJump persistent:

```bash
# Jump host configuration
Host jumphost
    HostName jump.example.com
    User admin
    IdentityFile ~/.ssh/jump_key
    IdentitiesOnly yes

# Target server using the jump host
Host internal-server
    HostName 192.168.1.100
    User ubuntu
    ProxyJump jumphost
    IdentityFile ~/.ssh/internal_key
    IdentitiesOnly yes
```

After this, connecting is just:

```bash
ssh internal-server
```

You can use wildcards for multiple internal servers:

```bash
# In ~/.ssh/config
Host *.internal
    ProxyJump jumphost
    IdentitiesOnly yes
```

Now `ssh server1.internal` and `ssh server2.internal` both route through the jump host automatically.

`IdentitiesOnly yes` is important here — it tells SSH to only use the explicitly configured key, not every key loaded in ssh-agent. Without this, SSH tries keys one by one and you&apos;ll hit the [&quot;too many authentication failures&quot;](https://www.bitdoze.com/fix-ssh-too-many-authentication-failures/) error once you accumulate enough keys in your agent.

Make sure your config file has the right permissions:

```bash
chmod 600 ~/.ssh/config
```

&lt;Notice type=&quot;info&quot; title=&quot;Safer host key checking for automated workflows&quot;&gt;
For scripted or CI connections where you can&apos;t interactively accept host keys, use `StrictHostKeyChecking=accept-new` instead of `=no`. It automatically accepts new host keys but **rejects changed keys**, so you still get warned if a server&apos;s key changes (which could indicate a MITM attack). Available since OpenSSH 7.6.

`ssh -o StrictHostKeyChecking=accept-new -J user@jump target@server`
&lt;/Notice&gt;

## Port forwarding through SSH jump hosts

ProxyJump works with SSH port forwarding flags, which lets you access internal services that aren&apos;t SSH — databases, web UIs, monitoring dashboards. If you want a deeper dive into tunneling, see [SSH port forwarding techniques](https://www.bitdoze.com/ssh-tunneling-linux/).

### Local port forwarding (-L)

Access a PostgreSQL database on an internal server from your local machine:

```bash
ssh -L 5432:db.internal:5432 -J user@jump target-user@target
```

This binds local port 5432 to the database port on `db.internal` (which the target can reach). Connect to `localhost:5432` with any PostgreSQL client and the traffic is tunneled through the jump host.

### Remote port forwarding (-R)

Expose a local service to the internal network:

```bash
ssh -R 8080:localhost:80 -J user@jump target-user@target
```

This makes your local port 80 accessible on the target&apos;s port 8080.

### SOCKS proxy (-D)

Browse the web through the internal network:

```bash
ssh -D 1080 -J user@jump target-user@target
```

Configure your browser to use `localhost:1080` as a SOCKS5 proxy and all traffic routes through the jump host and target server.

## Advanced: conditional ProxyJump with Match exec

If you work from multiple locations (office LAN, home, VPN), you might want different routing — direct connection from the office, jump host from everywhere else. `Match exec` handles this:

```ssh-config
Match host internal-* !exec &quot;ip addr show en0 | grep -q 192.168.1.&quot;
    ProxyJump home-bastion

Host internal-*
    ProxyJump office-bastion
```

&lt;Notice type=&quot;info&quot; title=&quot;Match exec runs on every connection&quot;&gt;
The `exec` command runs on your client before each SSH connection. It adapts automatically — no manual switching needed. Replace `en0` and `192.168.1.` with your actual interface and subnet. On Linux the interface is typically `eth0` or `wlan0`.
&lt;/Notice&gt;

## Advanced: SSH multiplexing with jump hosts

If you connect through a jump host frequently (Ansible, rsync, multiple terminals), multiplexing saves a lot of time. SSH can reuse a single connection to the jump host instead of re-authenticating every time:

```ssh-config
Host jumphost
    HostName jump.example.com
    User admin
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 10m
```

`ControlMaster auto` tells SSH to create a persistent socket on the first connection and reuse it for subsequent connections. `ControlPersist 10m` keeps the socket open for 10 minutes after the last session closes.

You can also add multiplexing on the target side:

```ssh-config
Host *.internal
    ProxyJump jumphost
    ControlMaster auto
    ControlPath ~/.ssh/cm-%r@%h:%p
    ControlPersist 5m
```

The result: the first `ssh internal-server` authenticates normally. Any connection to `*.internal` within 5 minutes jumps straight through the already-open tunnel. This makes Ansible playbooks through jump hosts dramatically faster.

## Security hardening for SSH jump hosts

Your jump host is the front door to your infrastructure. If it&apos;s compromised, every internal server is potentially reachable. This section covers how to lock it down properly.

![Security hardening checklist for SSH jump host configuration](../../assets/images/25/01/enhance-jumphost-security.png)

### sshd_config for bastion hosts

Drop this into `/etc/ssh/sshd_config.d/50-bastion.conf` on your jump host:

```bash
# /etc/ssh/sshd_config.d/50-bastion.conf

# Authentication
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey

# Session limits
MaxSessions 2
MaxStartups 10:30:60
UnusedConnectionTimeout 2m

# Forwarding (allowed for jump users, restricted via Match blocks below)
AllowTcpForwarding yes
X11Forwarding no
PermitTunnel no
AllowAgentForwarding no
```

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t run Docker on your bastion&quot;&gt;
Docker manipulates iptables directly and can punch holes through your carefully crafted firewall rules. Keep the jump host lean — SSH and nothing else. If you run Docker elsewhere on your network, read about [how Docker bypasses firewall rules](https://www.bitdoze.com/docker-bypasses-firewall/) so you understand the risk.
&lt;/Notice&gt;

### PerSourcePenalties: built-in abuse protection

&lt;Notice type=&quot;success&quot; title=&quot;Built-in since OpenSSH 9.8&quot;&gt;
Since OpenSSH 9.8 (July 2024), sshd has built-in IP penalties for repeated authentication failures, incomplete connections, and crash triggers. This is **enabled by default** — you don&apos;t need to configure anything. It reduces the need for fail2ban on bastion hosts, though they work well together for additional protection.
&lt;/Notice&gt;

PerSourcePenalties temporarily blocks IPs that show attack patterns. The default thresholds work for most setups. You can tune them:

```bash
# In /etc/ssh/sshd_config.d/50-bastion.conf (optional)
PerSourcePenalties penalty=crash:2h,authfail:1h,noauth:1h
PerSourcePenaltyExemptList 10.0.0.0/8
```

### Dedicated jump users with Match blocks

Create a restricted group for jump users on the bastion:

```bash
# In /etc/ssh/sshd_config.d/50-bastion.conf
Match Group jumpusers
    AllowTcpForwarding yes
    X11Forwarding no
    PermitTunnel no
    MaxSessions 2
    AuthenticationMethods publickey
```

For non-jump service accounts (backups, monitoring), lock them down further:

```bash
Match User backup
    DisableForwarding yes
    PermitTTY no
    ForceCommand /usr/local/bin/authorized-backup
```

Create the group and user:

```bash
sudo groupadd jumpusers
sudo usermod -aG jumpusers your-jump-user
```

### Firewall configuration

UFW rules for the jump host — deny everything except SSH from known IPs:

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 203.0.113.50/32 to any port 22
sudo ufw enable
sudo ufw status verbose
```

Replace `203.0.113.50` with your office/home IP. If your IP changes frequently, consider a VPN or dynamic DNS-based allow rule.

### Authentication best practices

Generate a strong ed25519 key:

```bash
ssh-keygen -t ed25519 -a 100
```

The `-a 100` flag increases KDF rounds, which slows brute-force attacks on the key file. Note: it only has effect when you set a passphrase on the key. Always set a passphrase.

For high-security bastions, consider FIDO2 hardware keys (`ed25519-sk` or `ecdsa-sk`):

```bash
ssh-keygen -t ed25519-sk -O resident
```

This requires a hardware security key (YubiKey, etc.) for every authentication. Private key material stays on the hardware token — even if the bastion is compromised, the key can&apos;t be extracted.

### Keep your bastion updated

- Run `sudo apt update &amp;&amp; sudo apt upgrade -y` regularly (or set up `unattended-upgrades`)
- Monitor SSH logs: `journalctl -u sshd --since -1h`
- Review authorized keys periodically
- Keep the host lean — no extra packages, no extra services

For intrusion prevention beyond PerSourcePenalties, consider [securing your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/).

## Verification: testing your setup

Don&apos;t wait for production to find problems. Test your configuration before relying on it.

&lt;Notice type=&quot;info&quot; title=&quot;Always test sshd config before restarting&quot;&gt;
Run `sudo sshd -t` before `sudo systemctl restart sshd`. A syntax error in your config can lock you out of your bastion host. If you&apos;re making changes over SSH itself, keep a second session open while editing.
&lt;/Notice&gt;

```bash
# Check OpenSSH version (client and server)
ssh -V

# Dump the effective config for a host alias
ssh -G internal-server

# Test connection with verbose output
ssh -vv -J jumpuser@jump targetuser@target

# Verify jump host sshd settings
sudo sshd -T | grep -E &apos;AllowTcpForwarding|PermitTunnel|MaxSessions|PerSourcePenalties&apos;

# Test config syntax without restarting
sudo sshd -t

# Check that the jump users group exists
getent group jumpusers
```

If `ssh -G internal-server` shows `proxyjump jumphost` in the output, your config is being read correctly. If it shows nothing, check your config file path and permissions.

## Troubleshooting SSH ProxyJump connections

### Connection refused

```bash
ssh: connect to host jump-host port 22: Connection refused
```

Check if sshd is running and the port is open:

```bash
sudo systemctl status sshd
sudo ufw status
# or
sudo iptables -L -n

# Test the port directly
nc -zv jump-host 22
```

If the port is open but connections are refused, check `/var/log/auth.log` for startup errors. For more on port checking, see [how to check remote ports](https://www.bitdoze.com/check-remote-port-in-linux-nc/).

### Authentication issues

```bash
Permission denied (publickey)
```

Verify key permissions and that the right key is being offered:

```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 644 ~/.ssh/id_ed25519.pub

# Check which keys SSH is trying
ssh -vv -J jumpuser@jumphost targetuser@target 2&gt;&amp;1 | grep &quot;Offering\|Trying&quot;
```

If you see too many keys being tried, add `IdentitiesOnly yes` to the relevant Host block in `~/.ssh/config`. This is the most common fix for the [&quot;too many authentication failures&quot;](https://www.bitdoze.com/fix-ssh-too-many-authentication-failures/) error.

### ProxyJump not working

```bash
# Check SSH version (needs 7.3+)
ssh -V

# Dump effective config for the host
ssh -G internal-server

# Test with full verbose output
ssh -vvv -J jumpuser@jumphost targetuser@target
```

Look for lines like `proxyconnect: establishing to jump.example.com:22` in the verbose output. If you don&apos;t see ProxyJump being invoked, your config syntax might be wrong.

Common config mistakes:

```bash
# Wrong keywords (case-sensitive):
Host jumphost
    Hostname jump.example.com   # WRONG — should be HostName
    Username admin              # WRONG — should be User
```

### Permission denied after host key change

If the jump host or target was reinstalled, you&apos;ll see a warning about the host key changing:

```bash
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!     @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
```

If the change is expected (server rebuild, migration), remove the old key:

```bash
ssh-keygen -R jump-host.example.com
```

Then reconnect and accept the new key. If the change was not expected, investigate before proceeding — it could indicate a man-in-the-middle attack.

### Network connectivity

```bash
# Basic connectivity
ping jump-host

# Port check
nc -zv jump-host 22

# Route trace
traceroute jump-host
```

If you can reach the jump host but not the internal target, the problem is likely on the jump host&apos;s side — check its firewall rules, `AllowTcpForwarding` setting, and that the internal server is reachable from the jump host&apos;s network.

## Conclusion

SSH jump hosts and ProxyJump are the standard way to access servers in private networks without exposing everything to the internet. Here&apos;s the quick reference:

```bash
# One-time connection
ssh -J jumpuser@jumphost targetuser@internal-server

# Persistent config (~/.ssh/config)
Host internal-server
    HostName internal-server
    User targetuser
    ProxyJump jumphost
    IdentitiesOnly yes
    IdentityFile ~/.ssh/internal_key
```

Key takeaways:

- One hardened entry point beats exposing every server
- ProxyJump makes the jump transparent — `ssh internal-server` and you&apos;re in
- `~/.ssh/config` with wildcards handles dozens of servers with one rule
- Lock down the bastion: no root login, no passwords, no Docker, minimal services
- OpenSSH 9.8+ gives you PerSourcePenalties for free — use it
- Test with `ssh -G` and `ssh -vv` before relying on the setup

If you&apos;re managing multiple servers, [best self-hosted panels](https://www.bitdoze.com/best-self-hosted-panels/) can help with the broader picture, and the [Linux commands reference](https://www.bitdoze.com/linux-commands/) covers the tools you&apos;ll use alongside SSH. For managing SSH connections from a GUI, [Nexterm](https://www.bitdoze.com/nexterm-docker-install/) is worth a look.

&lt;Button text=&quot;SSH Tunneling Deep Dive&quot; link=&quot;/ssh-tunneling-linux/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>linux</category><category>linux</category><category>ssh</category><category>security</category></item><item><title>How to Generate AI Images Locally on Mac with Flux (2026)</title><link>https://www.bitdoze.com/ai-images-mac/</link><guid isPermaLink="true">https://www.bitdoze.com/ai-images-mac/</guid><description>Learn how to generate AI images locally on your Mac with FLUX using Draw Things. Step-by-step setup, performance benchmarks, and model picks for Apple Silicon.</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Generating AI images locally on a Mac is faster, cheaper, and more practical than ever in 2026. Apple Silicon&apos;s unified memory architecture handles this workload well: no discrete GPU, no cloud API bills, no uploading your prompts to someone else&apos;s server. If you&apos;ve been [running AI models locally on your Mac](/lm-studio-bionic/) for text, image generation is the next thing to try.

This guide covers local AI image generation on Apple Silicon using FLUX models. The recommended tool is **Draw Things**, a native macOS app that&apos;s actively maintained and much faster than the alternatives. Whether you&apos;re on an M1 with 16GB or an M4 Max with 48GB+, there&apos;s a workable path for your hardware.

If you&apos;re [getting started with AI](/ai-programming-beginners-guide/) and want to see what local models can do visually, this is a good place to begin.

&lt;Notice type=&quot;info&quot; title=&quot;2026 Update&quot;&gt;
This article was originally published in January 2025 using DiffusionBee as the recommended tool. DiffusionBee has been abandoned since August 2024 (last release v2.5.3). This guide has been completely rewritten with Draw Things as the primary tool, updated FLUX model information, and realistic performance benchmarks.
&lt;/Notice&gt;

## What is FLUX?

FLUX is a family of image generation models developed by Black Forest Labs. The original FLUX.1 models (dev and schnell) launched in 2024 and quickly became the go-to for local image generation on consumer hardware. Since then, the ecosystem has expanded:



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/lD9EJGjTKj8&quot;
  label=&quot;How To Locally Generate AI Images (Flux) on Mac&quot;
/&gt;
- **FLUX.1 dev**. High-quality output, open weights, runs on 24GB Macs with GGUF quantization. The default recommendation.
- **FLUX.1 schnell**. Faster, lower quality. Good for quick drafts and iteration on 16GB Macs.
- **FLUX.2 [dev]**. Released November 2025. 32 billion parameters, up to 4MP output. Excellent quality but needs ~20GB VRAM at 4-bit quantization. Only practical on 48GB+ Macs.
- **FLUX.2 [klein] 4B**. Released January 2026. Apache 2.0 license (commercial use OK), fits in ~13GB. The best FLUX.2 option for 16-24GB Macs.
- **FLUX 3**. Early access as of mid-2026. Multimodal (video + audio + image). API-only, not available for local use yet.

For most Mac users in 2026, **FLUX.1 dev with GGUF quantization remains the sweet spot**: best quality-to-RAM ratio on 24GB machines.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Photorealistic quality that rivals commercial APIs&lt;/li&gt;
&lt;li&gt;Open weights. Run everything locally, no internet required&lt;/li&gt;
&lt;li&gt;Active model ecosystem with regular improvements&lt;/li&gt;
&lt;li&gt;GGUF quantization support. Run 24GB models on 16-24GB Macs&lt;/li&gt;
&lt;li&gt;Multiple license tiers. FLUX.2 [klein] 4B is Apache 2.0 for commercial use&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

These are [open-source AI models](/best-open-source-llms-claude-alternative/) with real community backing. The FLUX inference repo on GitHub has over 25,000 stars and active development from Black Forest Labs.

## Why Draw Things (Not DiffusionBee)

&lt;Notice type=&quot;warning&quot; title=&quot;DiffusionBee Is Abandoned&quot;&gt;
DiffusionBee&apos;s last release was v2.5.3 on August 14, 2024, with no updates since. The project has no GGUF quantization support, no FLUX.2 support, and users report incomplete FLUX.1 features. Do not use it for new setups.
&lt;/Notice&gt;

**Draw Things** is a native Apple Silicon app built with SwiftUI and a custom inference engine (s4nnc). It&apos;s not a Python wrapper. It&apos;s a real macOS application distributed through the App Store with automatic updates.

Why it&apos;s the recommendation:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Native SwiftUI app**. Not Electron, not Python. Runs Metal FlashAttention directly, giving 20-40% faster generation than PyTorch MPS backends.&lt;/li&gt;
&lt;li&gt;**App Store install**. One click, automatic updates, no terminal commands, no Python environment headaches.&lt;/li&gt;
&lt;li&gt;**Broad model support**. FLUX.1, FLUX.2 [dev] and [klein], SD/SDXL, Z-Image, Qwen Image, and video models.&lt;/li&gt;
&lt;li&gt;**Actively maintained**. Latest release July 17, 2026, with dozens of releases in the past year.&lt;/li&gt;
&lt;li&gt;**Free base tier**. No cost to use. Optional Draw Things+ at $8.99/mo adds cloud compute offload (useful for 8GB Macs).&lt;/li&gt;
&lt;li&gt;**Cross-device**. Same app runs on Mac, iPhone, and iPad with parameter sync via iCloud.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The speed difference is real. On an M4 Pro with 24GB RAM, DiffusionBee took about 6 minutes to generate a single FLUX.1 dev image at 704x704 with 25 steps. Draw Things generates at 1024x1024 with 20 steps in roughly 50 seconds. Higher resolution, fewer steps, 7x faster.

## GGUF quantization: how FLUX runs on your Mac

FLUX.1 dev is a 12-billion parameter model. The full FP16 weights are about 24GB. That won&apos;t fit in 24GB of unified memory alongside macOS and other apps. GGUF quantization solves this by reducing the precision of the model weights, shrinking the file size at the cost of a small quality reduction.

Here&apos;s how the quantization levels break down:

| Quantization | File size | Quality | RAM needed | Best for |
|-------------|-----------|---------|------------|----------|
| FP16 (original) | ~24GB | Best | 48GB+ | Maximum quality |
| Q8 | ~13GB | Near-lossless | 32GB+ | High quality |
| Q6_K | ~10GB | Best balance | 24GB | **Sweet spot for M4 Pro** |
| Q4_KS | ~7GB | Some loss | 16GB | 16GB Macs |
| Q2_K | ~4GB | Noticeable loss | 16GB | Emergency only |

The key insight: **Q6_K at ~10GB gives you nearly the same visual quality as the full model while fitting comfortably in 24GB unified memory.** You&apos;ll see the difference in extremely fine details (text in images, tiny patterns), but for most use cases it&apos;s indistinguishable.

Draw Things handles GGUF models automatically. You pick the quantization level from the model selector and it downloads the right file. No manual conversion needed. If you need to [free up disk space on your Mac](/mac-find-big-files/) before downloading these models, do that first. Each model file is 7-13GB.

&lt;Tabs&gt;
&lt;Tab name=&quot;24GB Mac (Recommended)&quot;&gt;
**Model**: FLUX.1 dev (Q6_K)
**File size**: ~10GB
**Quality**: Near-lossless. Best balance of speed and fidelity
**Generation time**: ~50 seconds at 1024×1024, 20 steps (M4 Pro)
**RAM usage**: ~7GB during generation, 50% GPU utilization

This is the sweet spot. If you have an M4 Pro or M4 Mac Studio with 24GB+, start here.
&lt;/Tab&gt;
&lt;Tab name=&quot;16GB Mac&quot;&gt;
**Model**: FLUX.1 schnell (Q4_KS) or FLUX.2 [klein] 4B
**File size**: ~7GB (schnell) or ~13GB (klein 4B)
**Quality**: Noticeable quality reduction vs Q6_K, but still good for blog images and social media
**Generation time**: ~30-45 seconds at 1024×1024
**RAM usage**: Tight. Close other apps before generating

FLUX.1 schnell trades quality for speed and lower RAM. FLUX.2 [klein] 4B is a newer alternative that fits in 13GB. Worth trying if you want better quality than schnell.
&lt;/Tab&gt;
&lt;Tab name=&quot;48GB+ Mac&quot;&gt;
**Model**: FLUX.1 dev (FP16 or Q8) or FLUX.2 [dev] (Q4)
**File size**: 13-24GB
**Quality**: Best possible from FLUX models
**Generation time**: ~30-40 seconds at 1024×1024 (FLUX.1), ~60 seconds (FLUX.2 dev)
**RAM usage**: Comfortable headroom

You can run full-precision FLUX.1 or try FLUX.2 [dev] at 32B parameters. This is the professional tier.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Step-by-step: generate images with Draw Things

### 1. Install Draw Things

The easiest path is the Mac App Store:

1. Open the **App Store** on your Mac.
2. Search for **&quot;Draw Things&quot;**.
3. Click **Get** / **Install**.
4. Wait for the download (~200MB app, models are separate).

Alternatively, download directly from [drawthings.ai/downloads](https://drawthings.ai/downloads/).

**Verify**: Draw Things appears in your Applications folder and launches without errors.

### 2. Download a FLUX model

1. Open Draw Things.
2. Click the **model selector** at the top of the screen.
3. Scroll to the FLUX section.
4. Choose your model:
   - **24GB Mac**: Select &quot;FLUX.1 dev&quot; with Q6_K quantization
   - **16GB Mac**: Select &quot;FLUX.1 schnell&quot; with Q4_KS quantization
5. Click download and wait. The Q6_K model is ~10GB — this takes a few minutes depending on your connection.

**Verify**: The model name appears in the selector, and disk usage increases by the expected file size. You can check with `du -sh ~/Library/Containers/com.liuliu.draw-things/` in Terminal if you want to confirm.

### 3. Write your prompt and generate

1. Enter your prompt in the text field at the bottom of the screen.
2. Recommended starting settings:
   - **Steps**: 20
   - **Sampler**: Euler
   - **Resolution**: 1024×1024
   - **Guidance scale**: 3.5 (default for FLUX)
3. Click **Generate**.

**Verify**: An image appears in under 2 minutes on a 24GB Mac. On M4 Pro hardware with Q6_K, expect ~50 seconds. GPU activity will be visible in Activity Monitor under the GPU tab.

&lt;Notice type=&quot;success&quot; title=&quot;Verify it works&quot;&gt;
After your first generation, confirm three things: the image renders without visual corruption, generation time is under 2 minutes on 24GB Mac hardware, and you can see GPU usage in Activity Monitor during generation. If any of these fail, check the Troubleshooting section below.
&lt;/Notice&gt;

### 4. Adjust settings for quality vs speed

Once you&apos;ve confirmed the basic workflow works, tune for your needs:

- **Fewer steps (10–15)**: Faster generation, slightly lower quality. Good for quick iterations and prompt testing.
- **More steps (25–30)**: Slower, marginal quality gain. Not worth it for most images.
- **Resolution tradeoffs**: 768×768 for speed, 1024×1024 for quality, 1280×1280 if your RAM allows.
- **Seed**: Lock a seed to reproduce good results with prompt tweaks.

The default settings (20 steps, Euler, 1024×1024) are a good baseline. Adjust one variable at a time so you know what changed.

## Alternative: ComfyUI for power users

&lt;Notice type=&quot;info&quot;&gt;
ComfyUI is overkill for most users. Start with Draw Things. Move to ComfyUI when you need custom workflows, batch processing, or models that Draw Things doesn&apos;t support. If you just want to generate images from text prompts, Draw Things is the better experience on Mac.
&lt;/Notice&gt;

ComfyUI is a node-based workflow engine that gives you maximum control over the image generation pipeline. It&apos;s the tool of choice for people who build custom workflows — chaining models, applying ControlNet, doing inpainting pipelines, or batch processing hundreds of images.

Installation options:

&lt;Tabs&gt;
&lt;Tab name=&quot;Install via Homebrew&quot;&gt;
```bash
# Install prerequisites
brew install python@3.11 git
# Clone ComfyUI
git clone https://github.com/comfyanonymous/ComfyUI.git
cd ComfyUI
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
```

After installation:
1. Launch ComfyUI from your Applications folder.
2. Go to **Settings → GPU Backend** and select **MPS** (not CPU. This is critical for Apple Silicon performance).
3. Install the **ComfyUI-GGUF** plugin for quantized model support.
4. Install **ComfyUI Manager** for easy node management.
5. Download a GGUF model file (e.g., FLUX.1 dev Q6_K from HuggingFace).
&lt;/Tab&gt;
&lt;Tab name=&quot;Install via DMG&quot;&gt;
1. Download the official macOS DMG from [download.comfy.org](https://download.comfy.org/mac/dmg/arm64).
2. Open the DMG and drag ComfyUI to Applications.
3. Launch and select **MPS** as GPU backend in Settings.
4. Install the **ComfyUI-GGUF** plugin and **ComfyUI Manager**.
5. Download a GGUF model file.
&lt;/Tab&gt;
&lt;/Tabs&gt;

ComfyUI has the broadest model support of any local tool — FLUX.1, FLUX.2, SD series, and more. The tradeoff is a steep learning curve. If you want to [integrate AI image generation into automated workflows](/mastra-image-agent-kie-ai/), ComfyUI&apos;s API and node system make that possible.

## Which FLUX model should you use?

With multiple FLUX model generations available, choosing the right one depends on your hardware and use case.

&lt;Accordion label=&quot;FLUX.1 dev — Best quality for 24GB Macs&quot; group=&quot;flux-models&quot; expanded=&quot;true&quot;&gt;
- **Parameters**: 12B
- **RAM needed**: 24GB (with Q6_K GGUF)
- **Quantization options**: Q2_K through FP16
- **License**: FLUX Non-Commercial License (personal use OK, commercial use requires separate license)
- **Quality**: Best-in-class for the RAM requirement. Photorealistic output, good text rendering.
- **Best for**: Daily image generation on M4 Pro 24GB or similar. The default recommendation.
- **Generation time**: ~50 seconds at 1024×1024, 20 steps on M4 Pro (Draw Things)
&lt;/Accordion&gt;

&lt;Accordion label=&quot;FLUX.1 schnell — Fast drafts on 16GB Macs&quot; group=&quot;flux-models&quot;&gt;
- **Parameters**: 12B (distilled)
- **RAM needed**: 16GB (with Q4_KS GGUF)
- **Quantization options**: Q4_KS, Q2_K
- **License**: Apache 2.0 (commercial use OK)
- **Quality**: Noticeably lower than dev, but fast. Good enough for blog images and social media.
- **Best for**: Quick iterations, prompt testing, 16GB Macs that can&apos;t run dev comfortably.
- **Generation time**: ~30 seconds at 1024×1024
&lt;/Accordion&gt;

&lt;Accordion label=&quot;FLUX.2 [klein] 4B — New practical option for 16–24GB&quot; group=&quot;flux-models&quot;&gt;
- **Parameters**: 4B
- **RAM needed**: 16–24GB (~13GB file)
- **Quantization options**: GGUF variants available
- **License**: Apache 2.0 (commercial use OK)
- **Quality**: Lower than FLUX.1 dev, but much faster. Good for interactive use and prototyping.
- **Best for**: Users who want FLUX.2 features (multi-reference input, HEX color support) without 48GB RAM. Released January 2026.
- **Generation time**: Sub-second to a few seconds on capable hardware
&lt;/Accordion&gt;

&lt;Accordion label=&quot;FLUX.2 [dev] — Professional, 48GB+ only&quot; group=&quot;flux-models&quot;&gt;
- **Parameters**: 32B
- **RAM needed**: 48GB+ (~20GB at 4-bit quantization)
- **Quantization options**: Q4 and above
- **License**: FLUX Non-Commercial License
- **Quality**: Excellent. Supports up to 4MP output, multi-reference (up to 10 images), HEX color control.
- **Best for**: Professional work on M4 Max or M4 Ultra with 48GB+ unified memory.
- **Generation time**: ~60 seconds at 1024×1024 on M4 Max
&lt;/Accordion&gt;

&lt;Accordion label=&quot;FLUX 3 — Not yet available locally&quot; group=&quot;flux-models&quot;&gt;
- **Status**: Early access (mid-2026), API-only
- **Capabilities**: Multimodal — video, audio, and image generation in one model. Self-Flow architecture.
- **Local availability**: Not yet. Will likely need significant hardware when it lands.

If you need FLUX-level quality without buying a 48GB Mac, [cloud-based AI image APIs like Kie.ai](https://go.bitdoze.com/kie-ai) offer access to the latest models without hardware constraints. Check the [Kie.ai review](/kie-ai-review/) for pricing details.
&lt;/Accordion&gt;

## Performance benchmarks: what to expect

All numbers below are from community reports on M4 Pro 24GB hardware. Your results will vary based on quantization, resolution, steps, and what else is running on your Mac.

| Tool | Model | Resolution | Steps | Time |
|------|-------|-----------|-------|------|
| **Draw Things** | FLUX.1 dev (Q6_K) | 1024×1024 | 20 | **~50 seconds** |
| **ComfyUI (MPS)** | FLUX.1 dev (Q6_K) | 1024×1024 | 20 | ~50–90 seconds |
| DiffusionBee (old) | FLUX.1 dev | 704×704 | 25 | ~6 minutes |

The 7x speed improvement from DiffusionBee to Draw Things is primarily due to Metal FlashAttention — Draw Things&apos; proprietary Apple Silicon optimization that bypasses PyTorch&apos;s MPS backend. Draw Things also generates at a higher resolution (1024×1024 vs 704×704) in less time.

&lt;Notice type=&quot;info&quot;&gt;
These benchmark numbers are from community reports. Generation speed depends on your specific Mac, the model quantization, resolution, number of steps, and background processes. Close Chrome and other heavy apps before benchmarking — they compete for the same unified memory.
&lt;/Notice&gt;

M5 Macs with Neural Accelerators should see further improvements. Draw Things explicitly optimizes for M5 hardware with Metal FlashAttention v2.5.

## Hardware recommendations by Mac model

Not every Mac can run every model. Here&apos;s what to expect:

| Mac config | SD 1.5 | SDXL | FLUX.1 | FLUX.2 | Notes |
|-----------|--------|------|--------|--------|-------|
| M1 / 8GB | ✅ | 🟡 Slow | ❌ | ❌ | Basic experimentation only |
| M2 / 16GB | ✅ | ✅ | 🟡 (Q4_KS) | ❌ | Light use, blog images |
| M4 Pro / 24GB | ✅ | ✅ | ✅ (Q6_K) | 🟡 (klein 4B) | Daily creation — sweet spot |
| M4 Max / 48GB+ | ✅ | ✅ | ✅ (FP16) | ✅ (dev Q4) | Professional tier |
| M5 / any RAM | ✅ | ✅ | ✅ | ✅ | Latest hardware, best performance |

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Apple Silicon Mac (M1 or newer) — Intel Macs won&apos;t work well&lt;/li&gt;
&lt;li&gt;16GB+ RAM minimum for FLUX — 24GB recommended for comfortable generation&lt;/li&gt;
&lt;li&gt;macOS 13 Ventura or newer&lt;/li&gt;
&lt;li&gt;~15GB free disk space for model + app (Q6_K model is ~10GB)&lt;/li&gt;
&lt;li&gt;SSD strongly recommended — model loading from external HDD is painfully slow&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re considering a Mac Mini specifically as an AI workstation, it&apos;s one of the best value options for local inference. See the [best mini PC for home server](/best-mini-pc-home-server/) roundup for a full comparison — the Mac Mini M4 punches well above its price for AI workloads.

The M4 Mac Studio (released March 2025) scales up to M4 Max with 128GB unified memory, which is overkill for image generation but useful if you also run large language models locally.

## Troubleshooting common issues

&lt;Accordion label=&quot;Generation takes extremely long (1 hour+ per image)&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: The most common culprit is the GPU backend not being used. On ComfyUI, this means MPS isn&apos;t selected — the model falls back to CPU inference, which is 50–100x slower.

**Fix**:
1. In ComfyUI, verify **Settings → GPU Backend → MPS** is selected.
2. In Draw Things, this is handled automatically — if you see extreme slowness, restart the app.
3. Open Activity Monitor → CPU tab. If CPU usage is maxed out during generation (instead of GPU), the model is running on CPU.

**Verify**: During generation, Activity Monitor should show significant GPU utilization. CPU usage should be moderate, not maxed.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;App crashes when loading a model&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: Insufficient RAM. The model needs to fit in unified memory alongside macOS and other apps. Chrome alone can consume 4–8GB.

**Fix**:
1. Close all unnecessary applications before loading a model — especially Chrome, Electron apps, and anything with a large memory footprint.
2. Open Activity Monitor → Memory tab. Check &quot;Memory Pressure.&quot; If it&apos;s yellow or red, you don&apos;t have enough free RAM.
3. Switch to a smaller quantization: if Q6_K crashes, try Q4_KS. If Q4_KS crashes, try FLUX.1 schnell instead of dev.

**Verify**: Model loads without error and memory pressure stays green in Activity Monitor.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Black or corrupted output image&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: Sampler/model mismatch. Some samplers don&apos;t work well with FLUX models.

**Fix**:
1. Stick to **Euler** or **DPM++** samplers for FLUX. Avoid exotic samplers like DDIM or LMS.
2. Reset settings to defaults if you&apos;ve been experimenting.
3. If using ComfyUI, make sure your workflow connects the VAE correctly — a missing or wrong VAE produces garbage output.

**Verify**: Regenerate with Euler sampler, 20 steps, default guidance scale. Output should be a coherent image.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Model download fails or is very slow&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: Model files are large (7–13GB). Unstable connections or disk space issues can interrupt downloads.

**Fix**:
1. Draw Things handles downloads automatically with resume support — just retry.
2. Check available disk space first. You need at least as much free space as the model file size.
3. For manual downloads (ComfyUI), use a download manager or `curl -C -` to resume interrupted downloads.
4. If you&apos;re on a slow connection, consider downloading FLUX.1 schnell (smaller) first to verify the workflow, then download the larger model overnight.

**Verify**: Model file exists on disk and matches expected size. In Draw Things, the model appears in the selector with a checkmark.
&lt;/Accordion&gt;

## Cost comparison: local vs cloud

Generating images locally is essentially free after the hardware cost. Here&apos;s how the economics break down:

| Approach | Cost | Per-image cost | Notes |
|----------|------|---------------|-------|
| **Draw Things (local)** | Free | $0 (electricity negligible) | Base tier is free, no API keys needed |
| **Draw Things+ (cloud offload)** | $8.99/mo | Included | Useful for 8GB Macs that can&apos;t run models locally |
| **ComfyUI (local)** | Free | $0 | Open source, no account needed |
| **Black Forest Labs API** | Pay per use | $0.014–$0.07/image | For FLUX.2 [pro], [klein], [max] via API |

The breakeven is fast. If a cloud API charges $0.03/image, you&apos;d spend $15 for 500 images. Draw Things generates those same 500 images for free on hardware you already own. If you generate images weekly, local is the clear winner.

The case for cloud: if you need FLUX.2 [dev] (32B params) or FLUX 3 and you don&apos;t have 48GB+ RAM, [cloud AI image APIs like Kie.ai](https://go.bitdoze.com/kie-ai) give you access without buying new hardware. The [Kie.ai review](/kie-ai-review/) covers pricing and capabilities in detail.

&lt;Notice type=&quot;success&quot;&gt;
After roughly 500 images, local generation pays for itself vs cloud API pricing. If you generate images even casually — blog posts, social media, creative projects — local is the way to go.
&lt;/Notice&gt;

## Tips for optimal performance

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Close Chrome before generating.** It&apos;s the single biggest memory hog on most Macs. Safari uses less RAM if you need a browser open.&lt;/li&gt;
&lt;li&gt;**Use 20 steps for normal images, 10–15 for drafts.** Going above 25 steps adds time with minimal quality gain on FLUX models.&lt;/li&gt;
&lt;li&gt;**Keep models on your internal SSD.** Loading models from external drives or network storage adds significant delay.&lt;/li&gt;
&lt;li&gt;**Try FLUX.1 schnell for quick iterations.** It&apos;s faster than dev — use it to nail your prompt, then switch to dev for the final render.&lt;/li&gt;
&lt;li&gt;**Monitor RAM pressure in Activity Monitor.** If you see yellow or red memory pressure during generation, close more apps or use a smaller quantization.&lt;/li&gt;
&lt;li&gt;**Batch similar prompts together.** The model stays loaded in memory between generations in Draw Things, so consecutive images are faster than the first.&lt;/li&gt;
&lt;li&gt;**Restart Draw Things if generation gets slow over time.** Memory fragmentation can degrade performance after extended sessions.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Conclusion

Generating AI images locally on Mac in 2026 is a solved problem. Draw Things gives you a one-click install from the App Store, automatic model management, and generation speeds that would have been unthinkable two years ago. FLUX.1 dev with GGUF quantization hits the quality sweet spot on 24GB Macs, and the model ecosystem keeps expanding.

Start with Draw Things. It&apos;s free, it&apos;s fast, and it handles the complexity for you. If you eventually need custom workflows or batch processing, ComfyUI is the upgrade path — but most people won&apos;t outgrow Draw Things.

For models that need more RAM than you have — FLUX.2 [dev] at 32B parameters, or FLUX 3 when it becomes available — [cloud AI generation with Kie.ai](https://go.bitdoze.com/kie-ai) is a practical fallback. But for everyday image generation, local is cheaper, faster (no network round-trip), and private.

If you&apos;re also interested in [running AI models locally](/ollama-docker-install/) for text generation, the same Apple Silicon hardware that handles FLUX will run local LLMs through Ollama. Your Mac is a surprisingly capable AI workstation — you just need to point the right tools at it.

&lt;Button text=&quot;Get Draw Things on the App Store&quot; link=&quot;https://apps.apple.com/us/app/draw-things-offline-ai-art/id6444050820&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;</content:encoded><category>ai</category><category>mac</category><category>flux</category><category>image-generation</category></item><item><title>Find the Largest Files on Your Mac (Simple Script &amp; Tools)</title><link>https://www.bitdoze.com/mac-find-big-files/</link><guid isPermaLink="true">https://www.bitdoze.com/mac-find-big-files/</guid><description>Find and delete large files on your Mac with a free Bash script, Finder search, mdfind, and ncdu. Free up storage on macOS Sequoia &amp; Tahoe with safe cleanup tips.</description><pubDate>Sun, 09 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Running out of disk space on your Mac but can&apos;t figure out where it all went? You&apos;re not alone. Apple&apos;s own storage tools in macOS Sequoia and Tahoe give you a high-level overview but often hide the real culprits: orphaned AI models, Time Machine snapshots, and APFS clones that report misleading sizes. Third-party &quot;disk cleaner&quot; apps charge subscriptions for what a few terminal commands can do for free.

This guide covers five ways to find large files on Mac: a zero-setup Finder search, the instant `mdfind` Spotlight CLI, an improved Bash script for full system scans, modern interactive CLI tools, and the hidden space eaters that most guides ignore. Everything here works on macOS Sequoia 15.x and Tahoe (macOS 26).

&lt;Notice type=&quot;info&quot; title=&quot;Apple Intelligence uses ~7 GB of disk space&quot;&gt;
macOS Sequoia 15.3+ stores Apple Intelligence assets locally. You can see the storage usage in System Settings &amp;gt; General &amp;gt; Storage (click the info icon next to macOS). Terminal-based methods like the script below still give you the full picture of everything eating your disk, including files System Settings won&apos;t show.
&lt;/Notice&gt;

If you&apos;re looking for free Mac apps beyond disk cleanup, check [toolhunt.net mac apps section](https://toolhunt.net/mac/).

## Quick method: Finder search (no Terminal needed)

The simplest way to find large files uses Finder&apos;s built-in search. No Terminal, no scripts, no installs. This is enough for most casual cleanup.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Open Finder and press &lt;code&gt;Cmd + F&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Click &quot;This Mac&quot; to search the entire drive (not just the current folder)&lt;/li&gt;
&lt;li&gt;Click the first dropdown → select &quot;File Size&quot;&lt;/li&gt;
&lt;li&gt;Set the condition to &quot;is greater than&quot;&lt;/li&gt;
&lt;li&gt;Enter a threshold: &lt;code&gt;1 GB&lt;/code&gt; (adjust to 500 MB for smaller drives, or 5 GB if you only want the biggest offenders)&lt;/li&gt;
&lt;li&gt;Click the &quot;Size&quot; column header to sort results largest-first&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Threshold tips&quot;&gt;On a 256 GB drive, start with 500 MB. On a 1 TB+ drive, start with 1 GB. You&apos;ll get a manageable list instead of hundreds of results.&lt;/Notice&gt;

Finder search has limits: it won&apos;t show hidden directories (`~/Library`, `~/.ollama`), system files, or files inside app bundles. For those, you need Terminal methods.

## Spotlight CLI: `mdfind` for instant results

`mdfind` queries the Spotlight index, making it near-instant compared to walking the filesystem with `find`. It&apos;s the fastest way to locate large files if Spotlight indexing is enabled.

&lt;Tabs&gt;
&lt;Tab name=&quot;Find files &gt; 1 GB&quot;&gt;
```bash
# Find all files &gt; 1 GB using Spotlight index (near-instant)
mdfind &quot;kMDItemFSSize &gt; 1000000000&quot; -onlyin ~/ 2&gt;/dev/null | \
  while IFS= read -r f; do
    stat -f &apos;%z %N&apos; &quot;$f&quot; 2&gt;/dev/null
  done | sort -rn | head -20
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Find files &gt; 500 MB&quot;&gt;
```bash
# Find all files &gt; 500 MB in your home directory
mdfind &quot;kMDItemFSSize &gt; 500000000&quot; -onlyin ~/ 2&gt;/dev/null | \
  while IFS= read -r f; do
    stat -f &apos;%z %N&apos; &quot;$f&quot; 2&gt;/dev/null
  done | sort -rn | head -20
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

The output shows byte sizes and full paths, sorted largest-first. You can pipe it through `numfmt --to=iec` for human-readable sizes, though `stat` on macOS doesn&apos;t have the GNU `numfmt` by default.

&lt;Notice type=&quot;warning&quot; title=&quot;Check Spotlight status first&quot;&gt;If `mdfind` returns empty or incomplete results, Spotlight may be disabled or rebuilding. Check with:&lt;br/&gt;&lt;br/&gt;&lt;code&gt;mdutil -s /&lt;/code&gt;&lt;br/&gt;&lt;br/&gt;If it says indexing is disabled, re-enable it with:&lt;br/&gt;&lt;br/&gt;&lt;code&gt;sudo mdutil -E /&lt;/code&gt;&lt;br/&gt;&lt;br/&gt;Rebuilding the index takes 10-30 minutes depending on drive size.&lt;/Notice&gt;

**Caveat:** `mdfind` only finds files that Spotlight has indexed. Some system directories and external drives may not be indexed. For a comprehensive scan, use the Bash script below.

## The Bash script: full system scan

This is the core method — an improved macOS-optimized script that scans your filesystem, reports disk usage, and lists the largest files. The original version of this script had issues with modern macOS permissions and scanned external drives. This version fixes both.

### Prerequisites: Full Disk Access

Since macOS Mojave (10.14), even `sudo` can&apos;t access certain user directories without Terminal having **Full Disk Access**. Without it, the script silently skips entire directory trees. The `2&gt;/dev/null` hides the &quot;Operation not permitted&quot; errors.

&lt;Notice type=&quot;error&quot; title=&quot;Required: Full Disk Access&quot;&gt;Without this setting, the script silently skips ~/Library, Mail attachments, Messages, Photos databases, and other large files. You&apos;ll get incomplete results without knowing it.&lt;/Notice&gt;

**Grant Full Disk Access:**

1. Open **System Settings &gt; Privacy &amp; Security &gt; Full Disk Access**
2. Click the **+** button
3. Navigate to `/Applications/Utilities/Terminal.app` (or your terminal of choice, like [Ghostty](/ghostty-terminal/) or [WezTerm](/install-wezterm-mac/))
4. Toggle it **ON**
5. **Quit and reopen** the terminal for changes to take effect

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;macOS 12 (Monterey) or later&lt;/li&gt;
&lt;li&gt;Terminal.app (or Ghostty/WezTerm) added to Full Disk Access&lt;/li&gt;
&lt;li&gt;Homebrew installed (needed later for CLI tools like &lt;code&gt;dust&lt;/code&gt; and &lt;code&gt;gdu&lt;/code&gt;). Install from &lt;a href=&quot;https://brew.sh&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;brew.sh&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### The improved script

Key changes from the original:
- Scans your **home directory** by default (where most user files live), with `--all` flag for power users
- Uses `-x` to stay on one filesystem (won&apos;t wander into external drives or network mounts)
- Excludes noisy directories that waste scan time
- Safer output parsing with `IFS=$&apos;\t&apos;`
- Adds a Time Machine snapshot check at the end

```bash
#!/bin/bash

# Find the largest files on your Mac
# Usage: ./find_large_files.sh [number_of_files]
#        ./find_large_files.sh --all [number_of_files]  (scan entire system)

# Parse arguments
scan_path=&quot;$HOME&quot;
num_files=20

if [[ &quot;$1&quot; == &quot;--all&quot; ]]; then
    scan_path=&quot;/&quot;
    shift
fi

if [[ -n &quot;$1&quot; &amp;&amp; &quot;$1&quot; =~ ^[0-9]+$ ]]; then
    num_files=$1
fi

# Print disk space information
echo &quot;===========================================&quot;
echo &quot;DISK SPACE INFORMATION&quot;
echo &quot;===========================================&quot;
df -h &quot;$scan_path&quot; | awk &apos;NR==2 {
    printf &quot;Total Space: %s\n&quot;, $2
    printf &quot;Used Space:  %s\n&quot;, $3
    printf &quot;Free Space:  %s\n&quot;, $4
    printf &quot;Usage:       %s\n&quot;, $5
}&apos;
echo &quot;===========================================&quot;
echo

# Build exclusion list. Skip directories that waste time or produce noise
excludes=(
    -not \( -path &quot;/System/*&quot; -prune \)
    -not \( -path &quot;/Volumes/*&quot; -prune \)
    -not \( -path &quot;/private/var/vm/*&quot; -prune \)
    -not \( -path &quot;/private/var/folders/*&quot; -prune \)
    -not \( -path &quot;*/Library/Caches/*&quot; -prune \)
    -not \( -path &quot;*/Library/Containers/*&quot; -prune \)
    -not \( -path &quot;*/node_modules/*&quot; -prune \)
)

echo &quot;Searching for the $num_files largest files...&quot;
echo &quot;Scanning: $scan_path&quot;
echo &quot;This may take a minute on large drives.&quot;
echo

# Create temporary file for results
tmp_file=$(mktemp)

# Run the scan. -x keeps us on one filesystem
# sudo is needed when scanning / or other users&apos; directories
if [[ &quot;$scan_path&quot; == &quot;/&quot; ]]; then
    sudo find &quot;$scan_path&quot; -x &quot;${excludes[@]}&quot; \
        -type f -print0 2&gt;/dev/null | \
        xargs -0 du -h 2&gt;/dev/null | \
        sort -rh | \
        head -n &quot;$num_files&quot; &gt; &quot;$tmp_file&quot;
else
    find &quot;$scan_path&quot; -x &quot;${excludes[@]}&quot; \
        -type f -print0 2&gt;/dev/null | \
        xargs -0 du -h 2&gt;/dev/null | \
        sort -rh | \
        head -n &quot;$num_files&quot; &gt; &quot;$tmp_file&quot;
fi

# Print formatted results
echo &quot;===========================================&quot;
echo &quot;TOP $num_files LARGEST FILES&quot;
echo &quot;===========================================&quot;
echo
printf &quot;%-8s | %s\n&quot; &quot;Size&quot; &quot;File Path&quot;
echo &quot;-------------------------------------------&quot;
while IFS=$&apos;\t&apos; read -r size file; do
    printf &quot;%-8s | %s\n&quot; &quot;$size&quot; &quot;$file&quot;
done &lt; &quot;$tmp_file&quot;
echo &quot;===========================================&quot;

rm &quot;$tmp_file&quot;

# Check for Time Machine local snapshots
echo
echo &quot;===========================================&quot;
echo &quot;TIME MACHINE LOCAL SNAPSHOTS&quot;
echo &quot;===========================================&quot;
snapshots=$(tmutil listlocalsnapshots / 2&gt;/dev/null)
if [[ -n &quot;$snapshots&quot; ]]; then
    count=$(echo &quot;$snapshots&quot; | wc -l | tr -d &apos; &apos;)
    echo &quot;Found $count local snapshot(s). These are invisible to find/du:&quot;
    echo &quot;$snapshots&quot; | head -5
    if (( count &gt; 5 )); then
        echo &quot;  ... and $((count - 5)) more&quot;
    fi
    echo
    echo &quot;To reclaim this space: sudo tmutil thinlocalsnapshots / 999999999999 4&quot;
else
    echo &quot;No local snapshots found.&quot;
fi
echo &quot;===========================================&quot;

echo
echo &quot;Scan complete!&quot;
```

### Run the script step-by-step

1. **Open Terminal.** Find it in Applications &gt; Utilities or press `Cmd + Space` and type &quot;Terminal&quot;. If you want a better terminal experience, consider setting up [Fish shell on macOS](/fish-shell-macos-setup/) for better autocomplete and syntax highlighting.

2. **Create the script file:**
   ```bash
   nano ~/find_large_files.sh
   ```
   Paste the script above, then save with `Ctrl + X`, `Y`, `Enter`.

3. **Make it executable:**
   ```bash
   chmod +x ~/find_large_files.sh
   ```

4. **Run it:**

&lt;Tabs&gt;
&lt;Tab name=&quot;Home directory scan&quot;&gt;
```bash
# Scan your home directory (default, no sudo needed)
./find_large_files.sh

# Show top 30 files instead of 20
./find_large_files.sh 30
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Full system scan&quot;&gt;
```bash
# Scan the entire system (requires sudo for /System, /private, etc.)
./find_large_files.sh --all

# Full scan, top 50 files
./find_large_files.sh --all 50
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

When running a full system scan, you&apos;ll be prompted for your password. The scan takes 1-5 minutes depending on drive size and whether you have external drives connected.

&lt;Notice type=&quot;info&quot; title=&quot;df vs du: why the numbers don&apos;t always match&quot;&gt;The disk space header uses &lt;code&gt;df&lt;/code&gt; (filesystem-level view) while the file list uses &lt;code&gt;du&lt;/code&gt; (sums individual file sizes). On APFS, these numbers can differ significantly due to clones, sparse files, and purgeable space. See the &quot;Hidden Space Eaters&quot; section below. Don&apos;t panic if the math doesn&apos;t add up.&lt;/Notice&gt;

For long scans, consider running it inside a [tmux terminal multiplexer](/tmux-basics/) so you can detach and come back later.

**Sample output (home directory scan):**

```
===========================================
DISK SPACE INFORMATION
===========================================
Total Space: 460Gi
Used Space:  95Gi
Free Space:  341Gi
Usage:       22%
===========================================

Searching for the 20 largest files...
Scanning: /Users/dragos
This may take a minute on large drives.

===========================================
TOP 20 LARGEST FILES
===========================================

Size     | File Path
-------------------------------------------
13G      | /Users/dragos/.diffusionbee/downloaded_assets/FLUX.1-schnell_flux_schnell_q5p_NNC_all.sqlite
13G      | /Users/dragos/.diffusionbee/downloaded_assets/FLUX.1-dev_flux_dev_q5p_NNC_all.sqlite
8.4G     | /Users/dragos/.ollama/models/blobs/sha256-6e41c39f4490a9e8b7a65916425c6ed97f04ed95bab991c4ab6a462ff84d1608
1.9G     | /Users/dragos/.ollama/models/blobs/sha256-dde5aa3fc5ffc17176b5e8bdc82f587b24b2678c6c66101bf7da77af9f7ccdff
949M     | /Applications/DaVinci Resolve/DaVinci Resolve.app/Contents/MacOS/Resolve
...

===========================================
TIME MACHINE LOCAL SNAPSHOTS
===========================================
Found 3 local snapshot(s). These are invisible to find/du:
com.apple.TimeMachine.2025-01-09-183042.local
com.apple.TimeMachine.2025-01-09-193042.local
com.apple.TimeMachine.2025-01-09-203042.local

To reclaim this space: sudo tmutil thinlocalsnapshots / 999999999999 4
===========================================
```

If the output shows mostly `/System` or `/Volumes` paths, the `-x` flag or exclusions aren&apos;t working. Check that you&apos;re running the updated script, not the old version.

**Verify:** The disk info header should show your actual drive size and usage. The file list should contain user-level paths (home directory, Applications), not system internals.

## Modern CLI tools: `dust`, `gdu` &amp; `ncdu`

If you find yourself running disk cleanup regularly, a one-shot script isn&apos;t the best tool. These three CLI utilities give you interactive, browsable views of disk usage, much better for exploring and deleting on the fly.

All three install via [Homebrew](https://brew.sh):

&lt;Accordion label=&quot;dust - Rust-based tree view (12k ★)&quot; group=&quot;cli-tools&quot; expanded=&quot;true&quot;&gt;

```bash
brew install dust
```

`dust` shows the largest directories and files as a proportional tree visualization. It&apos;s fast (Rust-based) and the output is immediately readable without navigating a TUI.

```bash
dust ~/              # full home directory tree
dust -F ~/           # files only, no directory summaries
dust -n 30 ~/        # show top 30 entries
dust -d 2 ~/         # limit depth to 2 levels (quick overview)
```

GitHub: [bootandy/dust](https://github.com/bootandy/dust), 12k stars, actively maintained.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;gdu - Go-based interactive TUI (5.9k ★)&quot; group=&quot;cli-tools&quot;&gt;

```bash
brew install gdu
```

`gdu` is a fast interactive disk analyzer with arrow-key navigation. It uses parallel processing, making it significantly faster than `ncdu` on SSDs. You can navigate into directories and delete files directly.

```bash
gdu ~/               # interactive mode. Arrow keys to navigate, &apos;d&apos; to delete
gdu -t 20 ~/         # non-interactive: top 20 largest items
```

GitHub: [dundee/gdu](https://github.com/dundee/gdu), 5.9k stars, Go-based.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;ncdu - the classic&quot; group=&quot;cli-tools&quot;&gt;

```bash
brew install ncdu
```

`ncdu` (NCurses Disk Usage) has been the go-to TUI disk analyzer for over a decade. It&apos;s slower than `gdu` on modern SSDs but it&apos;s available everywhere: Linux, BSD, macOS. The interface is familiar to most sysadmins.

```bash
ncdu ~/              # scan and browse interactively
ncdu -x /            # scan root filesystem, don&apos;t cross mount points
```

&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Which one to use?&quot;&gt;Use &lt;strong&gt;dust&lt;/strong&gt; when you want a quick visual overview without leaving the command output. Use &lt;strong&gt;gdu&lt;/strong&gt; when you want to interactively explore and delete files. Use &lt;strong&gt;ncdu&lt;/strong&gt; if you&apos;re on a system where it&apos;s already installed (it&apos;s everywhere). All three are free and open source.&lt;/Notice&gt;

If you spend a lot of time in the terminal, you can [supercharge your Fish shell with plugins](/best-fish-shell-plugins/) for better tab completion on these commands.

## Where is my &quot;missing&quot; disk space?

You ran `df` and it says 200 GB used. You ran `du` on your home directory and it only adds up to 120 GB. Where did the other 80 GB go? On APFS (the default filesystem since High Sierra), the answer is usually one of three things.

### Time Machine local snapshots

Time Machine stores hourly local snapshots directly on your startup disk. These can consume **tens of GB** but are completely invisible to `find`, `du`, and Finder. They don&apos;t show up as files because they&apos;re filesystem-level snapshots.

```bash
# Check if snapshots are eating your space
tmutil listlocalsnapshots /

# If you see many snapshots and need space now:
sudo tmutil thinlocalsnapshots / 999999999999 4
```

The `thinlocalsnapshots` command tells macOS to purge local snapshots, freeing space immediately. The priority parameter (`4`) means &quot;low priority.&quot; It won&apos;t interrupt other operations. These snapshots auto-delete when disk pressure occurs, but manual thinning is safe if you need space now.

&lt;Notice type=&quot;warning&quot; title=&quot;Hidden snapshots can consume 50+ GB&quot;&gt;These won&apos;t show up in Finder, du, or the script output. Always check with &lt;code&gt;tmutil&lt;/code&gt; if you&apos;re mysteriously low on space. The snapshots are separate from your Time Machine backup drive — thinning local snapshots doesn&apos;t affect your backups.&lt;/Notice&gt;

### APFS clones, sparse files &amp; purgeable space

APFS has features that make `du` output misleading:

- **Clones:** `cp -c` (and many apps like pnpm, uv, git worktrees) creates copy-on-write clones. `du` reports the full size of every clone, but deleting one clone frees **zero** blocks if another clone exists. A `node_modules` tree can report 20 GB in `du` but deleting it frees 3 MB.

- **Sparse files:** Docker&apos;s `Docker.raw` disk image and VM images (UTM, Parallels) are sparse files. They report as 64 GB but only occupy the actually-written blocks (e.g., 9 GB). If you&apos;re running Docker, see how to [clean up Docker images and reclaim disk space](/cleanup-all-docker-things/).

- **Purgeable space:** macOS marks some files as purgeable (caches, local snapshots, iCloud-offloaded files). They count as &quot;used&quot; in `du` but macOS will auto-delete them when space is needed. You can check purgeable space with:
  ```bash
  diskutil info / | grep -i purgeable
  ```

&lt;Notice type=&quot;info&quot; title=&quot;df vs du: why the numbers don&apos;t match&quot;&gt;df shows real filesystem-level usage (what the kernel reports). du sums individual file sizes. On APFS, clones, sparse files, and purgeable space cause these numbers to diverge significantly. When in doubt, trust df for &quot;how much space is actually used&quot; and du for &quot;which files are the biggest.&quot;&lt;/Notice&gt;

If you&apos;re generating [locally AI images like Flux models](/ai-images-mac/), those model files can be 10-13 GB each — and DiffusionBee stores them in `~/.diffusionbee/downloaded_assets/`.

## What&apos;s safe to delete (and what never to touch)

The script finds large files but doesn&apos;t tell you what&apos;s safe to remove. Here&apos;s a practical guide organized by category.

&lt;Accordion label=&quot;AI models (Ollama, DiffusionBee)&quot; group=&quot;safe-delete&quot; expanded=&quot;true&quot;&gt;

**Ollama models** — stored in `~/.ollama/models/`. Each model is 2-13 GB. Use the proper CLI to remove them (raw `rm` leaves orphaned blobs):
```bash
ollama list              # see what&apos;s installed
ollama rm &lt;model-name&gt;   # remove a specific model
```
See the full [Ollama setup guide](/ollama-docker-install/) for managing models properly.

**DiffusionBee models** — stored in `~/.diffusionbee/downloaded_assets/`. These are the largest files on many Macs (13 GB each for FLUX models). You can delete the `.sqlite` files directly if you no longer use DiffusionBee.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Docker images and containers&quot; group=&quot;safe-delete&quot;&gt;

Docker&apos;s disk usage adds up fast. Check what&apos;s consuming space:
```bash
docker system df         # see Docker&apos;s disk usage breakdown
docker system prune -a   # remove all unused images, containers, networks (careful!)
docker image prune       # remove dangling images only (safer)
```

Docker&apos;s `Docker.raw` disk image (in `~/Library/Containers/com.docker.docker/`) can be 50+ GB as a sparse file. Its reported size vs. actual disk usage will differ. See the full [Docker cleanup guide](/cleanup-all-docker-things/) for a detailed walkthrough.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Developer tool caches&quot; group=&quot;safe-delete&quot;&gt;

These are all safe to delete — they regenerate on next use:

```bash
# Homebrew cache (old downloads)
brew cleanup -s

# npm cache
npm cache clean --force

# pip cache
pip cache purge

# yarn cache
yarn cache clean
```

Project-level directories safe to delete (they rebuild):
- `node_modules/` — run `npm install` to regenerate
- `.venv/` or `venv/` — run `python -m venv .venv` to recreate
- `.next/` — Next.js build cache, regenerates on `npm run build`
- `target/` — Rust build output, regenerates on `cargo build`
- `__pycache__/` — Python bytecode, regenerates automatically

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Xcode leftovers&quot; group=&quot;safe-delete&quot;&gt;

If you&apos;ve ever had Xcode installed, old device support files pile up:
```bash
# Old iOS/tvOS/watchOS device support (can be many GB)
rm -rf ~/Library/Developer/Xcode/iOS\ DeviceSupport/*

# Old Xcode caches
rm -rf ~/Library/Developer/Xcode/DerivedData/*

# Old simulator data
xcrun simctl delete unavailable
```

&lt;/Accordion&gt;

&lt;Notice type=&quot;error&quot; title=&quot;Never delete these&quot;&gt;Do NOT remove anything in these locations unless you know exactly what you&apos;re doing:&lt;br/&gt;&lt;br/&gt;
• &lt;code&gt;/System&lt;/code&gt; — macOS system files&lt;br/&gt;
• &lt;code&gt;/usr&lt;/code&gt; (except &lt;code&gt;/usr/local&lt;/code&gt;) — system binaries&lt;br/&gt;
• &lt;code&gt;/Library/Extensions&lt;/code&gt; — kernel extensions&lt;br/&gt;
• &lt;code&gt;/Library/PreferencePanes&lt;/code&gt; — system preference panes&lt;br/&gt;
• Any file you can&apos;t identify in a system directory&lt;br/&gt;&lt;br/&gt;
When in doubt, don&apos;t delete it. Move it to Trash first and see if anything breaks before emptying.&lt;/Notice&gt;

If you&apos;re unsure whether two directories contain the same content before deleting one, you can [compare folder contents in Terminal](/compare-folders-content-differences/) to check.

## Related terminal tools

If you&apos;re spending time in Terminal for disk cleanup and system management, these tools make the experience better:

- **[Ghostty](/ghostty-terminal/)** — modern, GPU-accelerated terminal emulator for macOS
- **[WezTerm](/install-wezterm-mac/)** — terminal with built-in tmux-like multiplexing
- **[tmux](/tmux-basics/)** — terminal multiplexer for long-running scans and multiple sessions
- **[Fish shell](/fish-shell-macos-setup/)** — better default shell with autosuggestions and syntax highlighting
- **[cmux](/cmux-terminal/)** — AI-assisted terminal sessions for power users

## Conclusion

You don&apos;t need paid apps to find and manage large files on your Mac. Between Finder search for quick checks, `mdfind` for instant results, the Bash script for comprehensive scans, and `dust`/`gdu`/`ncdu` for interactive exploration, you have everything you need.

The real disk hogs are usually AI models (Ollama, DiffusionBee, Stable Diffusion), Docker images, Time Machine local snapshots, and dev project caches. Run the script once a month or whenever you see the &quot;Your disk is almost full&quot; warning. Apple charges $0.99/month for 50 GB of iCloud storage — but cleaning up local files is free.

&lt;Button text=&quot;Back to Top&quot; link=&quot;#&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;sm&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;Why does df show different free space than du?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

`df` reports filesystem-level usage — what the kernel sees. `du` sums individual file sizes. On APFS, these diverge because of:

- **Clones** — copy-on-write duplicates that share blocks until modified
- **Sparse files** — files that report a larger size than their actual allocated blocks
- **Purgeable space** — files macOS marks as deletable under disk pressure

Trust `df` for &quot;how full is my disk&quot; and `du` for &quot;which files are biggest.&quot; They answer different questions.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need to grant Full Disk Access to Terminal?&quot; group=&quot;faq&quot;&gt;

Yes, since macOS Mojave (10.14). Without it, even `sudo` cannot access `~/Library`, Mail, Messages, Photos, and other protected directories. The script will silently skip these paths.

Go to **System Settings &gt; Privacy &amp; Security &gt; Full Disk Access**, add your terminal app (Terminal.app, Ghostty, WezTerm, etc.), then quit and reopen the terminal.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the fastest way to find large files?&quot; group=&quot;faq&quot;&gt;

Depends on what you need:

- **Just a few big files?** Use Finder search (`Cmd+F`, filter by File Size &gt; 1 GB). Zero setup.
- **Fast terminal search?** `mdfind &quot;kMDItemFSSize &gt; 1000000000&quot;` — queries the Spotlight index, near-instant.
- **Full system scan?** The Bash script in this guide — comprehensive but takes 1-5 minutes.
- **Interactive browsing?** Install `dust` or `gdu` via Homebrew — best for exploring and deleting interactively.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I delete Time Machine local snapshots?&quot; group=&quot;faq&quot;&gt;

Yes. Local snapshots are stored on your startup disk and are separate from your Time Machine backup drive. Deleting them doesn&apos;t affect your backups.

```bash
# List snapshots
tmutil listlocalsnapshots /

# Thin them (reclaim space)
sudo tmutil thinlocalsnapshots / 999999999999 4
```

macOS also auto-deletes local snapshots when disk pressure occurs, but manual thinning is safe if you need space immediately.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is the script safe to run on macOS Tahoe?&quot; group=&quot;faq&quot;&gt;

Yes. The script uses standard POSIX tools (`find`, `du`, `df`, `sort`) and the `-x` flag, all of which work on macOS Sequoia 15.x and the upcoming Tahoe (macOS 26). The exclusion paths and Full Disk Access requirement are the same across both versions.

&lt;/Accordion&gt;</content:encoded><category>tools</category><category>mac</category><category>bash</category><category>terminal</category></item><item><title>Best Self-Hosted Airtable Alternatives in 2026</title><link>https://www.bitdoze.com/self-hosted-airtable-alternatives/</link><guid isPermaLink="true">https://www.bitdoze.com/self-hosted-airtable-alternatives/</guid><description>Discover the best self-hosted Airtable alternatives in 2026. Compare NocoDB, Teable, Baserow, Grist, and more: licensing, features, and Docker setup guides included.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

Airtable costs $20–45 per seat per month in 2026. For a 10-person team on the Business plan, that&apos;s $5,400/year, and you still don&apos;t own your data. If you&apos;re evaluating self-hosted Airtable alternatives, this guide compares six tools you can deploy with Docker on a cheap VPS tonight: NocoDB, Teable, Baserow, Grist, undb, and Mathesar.

I&apos;ll cover licensing reality (NocoDB is no longer open source), feature differences, Docker commands, and operator notes on database backends and backups. No feature tourism. Everything here runs on a single VPS with Docker Compose.

If you want more self-hosted apps for your stack, check [best self-hosted apps for your business](https://www.bitdoze.com/docker-containers-business/).

## Overview of Airtable and its popularity

Airtable combines a spreadsheet interface with relational database power. Features like customizable views (Grid, Calendar, Kanban, Gallery), rich field types, automations, and integrations made it the default choice for non-technical teams who outgrew spreadsheets.

That popularity came with a pricing restructure in 2025–2026. The old Plus plan ($10/user/mo) was replaced by Team ($20/user/mo annual, $24 monthly). Business is now $45/user/mo annual. For a 50-person team, that&apos;s $27,000/year.

Other reasons teams move to self-hosted alternatives:

- **Data sovereignty**: your data lives on Airtable&apos;s servers
- **Vendor lock-in**: exporting linked records and automations is painful
- **Row/record limits**: Airtable caps records per base depending on your plan
- **Privacy**: sensitive data in a third-party SaaS raises compliance questions

## Understanding self-hosted database solutions

Self-hosted in this context means running these tools as Docker containers on infrastructure you control: a VPS, a mini PC, or a NAS. All six tools in this article deploy with a single `docker run` or `docker-compose up`.

Benefits of self-hosting:

1. **Data control**: you own the data, the backups, and the access policies
2. **Customization**: deeper configuration than any SaaS allows
3. **Cost control**: flat monthly VPS cost regardless of team size
4. **Privacy**: data never leaves your infrastructure
5. **Performance**: tune hardware and network for your workload

The tradeoff is you handle server management, security updates, and backup planning. If you&apos;re already running Docker containers, this is familiar territory.

## Where you can host Airtable alternatives

### VPS server with Hetzner, DigitalOcean, etc.

A VPS is the fastest path to a running instance. Providers like [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), [DigitalOcean](https://go.bitdoze.com/do), and [Vultr](https://go.bitdoze.com/vultr) offer scalable resources starting at a few euros per month.

&lt;Notice type=&quot;info&quot; title=&quot;Recommended VPS Specs&quot;&gt;
A 2 vCPU / 4 GB RAM VPS (e.g. Hetzner CX22) is enough for most teams under 10 users. Plan 4 GB+ if running NocoDB or Teable with PostgreSQL.
&lt;/Notice&gt;

If you want a web UI to manage your Docker deployments, look at [Dokploy for one-click Docker deploys](https://www.bitdoze.com/dokploy-install/) or a [self-hosted PaaS like Coolify](https://www.bitdoze.com/coolify-install-heroku-alternative/). For managing multiple servers, check the [self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) comparison.

&gt; To monitor CPU, memory, and disk on your VPS: [How to monitor server and Docker resources](https://www.bitdoze.com/sever-monitoring/)

### Home server

For complete hardware control with a one-time cost, a home server works well for personal or small-team use.

#### Mini PC

Mini PCs are compact, energy-efficient, and powerful enough for all the tools in this article. Expect to pay €200–400 for a capable unit. An [ASUS DC510 mini PC](https://go.bitdoze.com/asus-dc510) or similar Intel NUC-style device handles Docker workloads fine.

For buying guidance, see [best mini PCs for home servers](https://www.bitdoze.com/best-mini-pc-home-server/) and [Docker containers for home servers](https://www.bitdoze.com/docker-containers-home-server/).

#### NAS (Network Attached Storage)

NAS devices from Synology, QNAP, and Asustor can run Docker containers. Good for storage-heavy workloads but limited CPU. Fine for personal use, marginal for teams with concurrent access.

## Quick comparison: all tools at a glance

| Application | License | GitHub Stars | Complexity | Backend | Key Benefit | OSS? |
|---|---|---|---|---|---|---|
| NocoDB | Sustainable Use License | 64.4k | Medium | MySQL/PG/SQLite/MSSQL/MariaDB | Most Airtable-like UI, widest DB support | source-available |
| Teable | AGPL-3.0 (CE) | 21.6k | Medium | PostgreSQL | Closest Airtable clone, million-row | CE: yes |
| Baserow | MIT (core) | 5.5k | Medium | PostgreSQL | Best UX, AI assistant, Automations | core: yes |
| Grist | Apache 2.0 | 11.4k | Low-Med | SQLite | Python formulas, spreadsheet-native | yes |
| undb | AGPL-3.0 | 3.0k | Low | SQLite/PostgreSQL | Lightweight, private-first | yes |
| Mathesar | GPL-3.0 | 5.1k | Medium | PostgreSQL | Works on existing PG schemas | yes |

## Top self-hosted Airtable alternatives

&lt;Notice type=&quot;info&quot;&gt;
If you are interested in more free self-hosted apps, check [toolhunt.net self hosted section](https://toolhunt.net/sh/).
&lt;/Notice&gt;

### NocoDB: most feature-rich (source-available)

![nocodb ui](../../assets/images/24/11/nocodb.png)

[NocoDB](https://nocodb.com/) transforms any MySQL, PostgreSQL, SQLite, Microsoft SQL Server, or MariaDB into a spreadsheet-like interface. It has 64.4k GitHub stars and the most Airtable-like UI of any tool in this list.

&lt;Notice type=&quot;warning&quot; title=&quot;NocoDB License Change&quot;&gt;
NocoDB changed from AGPL-3.0 to a Sustainable Use License (Fair Code) in early 2026. It is no longer open source. You can still self-host for free, but offering NocoDB as a paid service or redistributing it commercially requires a separate license. Many advanced features are now gated behind paid tiers.
&lt;/Notice&gt;

Community Edition features (free):

- Grid, Gallery, Kanban, Form views
- REST and GraphQL APIs
- Webhooks
- Role-based access control
- Canvas Grid (spreadsheet-like)

Enterprise-only features (paid):

- Realtime Collaboration
- Dashboard Widgets (Bar, Line, iFrame, Markdown)
- Gantt View, Timeline View
- Calendar Sync (Google/Outlook/CalDAV)
- Interfaces (app builder)
- NocoDB Sync, Custom Sync
- White-Label, Oracle/SQL Server support

**Deploy with Docker:**

```bash
docker run -d --name nocodb \
  -p 8080:8080 \
  -e NC_DB=&quot;pg://host:5432?u=user&amp;p=password&amp;d=nocodb&quot; \
  -e NC_AUTH_JWT_SECRET=&quot;your-secret&quot; \
  nocodb/nocodb:latest
```

SQLite works for quick evaluation, but PostgreSQL is recommended for production. Set `NC_SITE_URL` for shared links and OAuth to work. NocoDB 2026.05.2+ enforces SSRF protection by default. If connecting to local/private databases, set `NC_ALLOW_LOCAL_EXTERNAL_DBS=true`.

**Verify:** `curl http://localhost:8080` should return the NocoDB login page.

For more details, visit the [NocoDB GitHub repository](https://github.com/nocodb/nocodb).

### Teable: closest Airtable clone (open source)

&lt;Notice type=&quot;success&quot; title=&quot;Why Teable?&quot;&gt;
Teable has more GitHub stars (21.6k) than Baserow and Grist combined. It&apos;s the closest visual clone to Airtable among genuinely open-source options, filling the gap left by NocoDB&apos;s license change.
&lt;/Notice&gt;

[Teable](https://teable.ai/) is PostgreSQL-native (Next.js + NestJS) and looks almost identical to Airtable. If your team evaluated NocoDB but licensing is a dealbreaker, Teable is worth a look.

Key features:

- Grid, Form, Kanban, Gallery, Calendar views
- Real-time collaboration
- Million-row scalability (Postgres-backed)
- AI fields and formula generation
- SQL query mode for developers
- Chart visualization
- Comments, history, undo/redo
- Plugin system

Enterprise features (advanced AI, automation, authority matrix) require a paid license, but the Community Edition covers core Airtable functionality: views, collaboration, fields, and APIs.

**Deploy with Docker:**

```bash
git clone https://github.com/teableio/teable.git
cd teable/dockers/examples/standalone/
docker compose up -d
```

**Verify:** `curl http://localhost:8080` should return the Teable login/registration page.

**Failure mode:** Teable requires PostgreSQL. The compose file handles this, but if you bring your own Postgres, ensure the `TEABLE_PG_DATABASE_URL` env var is set correctly. The container will crash-loop without it.

### Baserow: best UX with AI assistant

![baserow ui](../../assets/images/24/11/baserow.webp)

[Baserow](https://baserow.io/) has an MIT-licensed core (5.5k GitHub stars) and focuses on the best non-technical user experience. It migrated from GitLab to GitHub in late 2025.

Baserow 2.0 (late 2025) added a lot:

- **Kuma AI Assistant**: build tables, write formulas, configure automations via natural language. Self-hosted users can bring their own model/API key.
- **Automations Builder (beta)**: triggers, actions, router nodes, conditions, formulas, variables. AI steps inside automations.
- **AI field upgrades**: bulk-generate columns, auto-refresh values, multiple AI model support
- **Timeline view** with date dependencies (Gantt-style)
- **Two-factor authentication (2FA)**
- **Workspace-wide search**
- **Two-way PostgreSQL sync**
- **Dashboards** with summaries, bar charts, pie/doughnut charts
- **Application Builder improvements**: theme templates, custom CSS/JS, Send Email and HTTP Request actions

The unlicensed self-hosted version has unlimited rows, storage, and API requests, but lacks Kanban, Calendar, and Survey views. Premium features require a license.

&lt;Notice type=&quot;info&quot; title=&quot;Automations&quot;&gt;
Baserow&apos;s Automations Builder handles simple workflows (triggers, conditions, actions). For complex multi-step automations across multiple services, pair it with a dedicated automation platform. See [how to self-host n8n for advanced automations](https://www.bitdoze.com/n8n-self-host-workflow-automation/).
&lt;/Notice&gt;

**Deploy with Docker:**

```bash
docker run -d --name baserow \
  -p 80:80 \
  -v baserow_data:/baserow/data \
  -e BASEROW_PUBLIC_URL=https://baserow.example.com \
  baserow/baserow:latest
```

Set `BASEROW_PUBLIC_URL`. Without it, features like file uploads and shared links break.

**Verify:** `curl http://localhost:80` should return the Baserow login page.

**Failure mode:** If you see CSRF errors on login, `BASEROW_PUBLIC_URL` is missing or mismatched. For the full setup guide, visit the [Baserow documentation](https://baserow.io/docs/installation/install-with-docker).

### Grist: spreadsheet-native with Python scripting

![grist ui](../../assets/images/24/11/grist.png)

[Grist](https://www.getgrist.com/) is Apache 2.0 licensed (11.2k stars), truly open source. It&apos;s a spreadsheet-database hybrid where formulas are written in Python, not a proprietary formula language. If your team thinks in pandas and NumPy, Grist is the obvious pick.

Recent additions:

- **AI Formula Assistant**: supports OpenRouter (Claude, DeepSeek, Mistral, etc.), not just OpenAI
- **Grist Assistant** (full edition): AI help with building tables, dashboards, styling, access rules
- **`grist-oss` Docker image**: pure Apache 2.0, no proprietary code at all
- **`grist-desktop`**: native desktop app for Linux/macOS/Windows
- **`grist-static`**: fully in-browser build for static websites
- **SCIM support** for user/group provisioning
- **Service accounts** for fine-grained API access

&lt;Notice type=&quot;info&quot; title=&quot;Grist Editions&quot;&gt;
Grist offers two Docker images: `gristlabs/grist` (default, includes inert proprietary code) and `gristlabs/grist-oss` (pure Apache 2.0). For maximum open-source purity, use `grist-oss`.
&lt;/Notice&gt;

**Deploy with Docker:**

```bash
docker run -d --name grist \
  -p 8484:8484 \
  -v grist-data:/persist \
  gristlabs/grist
```

**Verify:** `curl http://localhost:8484` should return the Grist welcome page.

**Production note:** For production, set `GRIST_SANDBOX_FLAVOR=gvisor` (Linux) to sandbox Python formula execution. Without this, formulas run in the host environment. Fine for evaluation, not for production.

**Pricing:** Free self-hosted full edition for individuals and orgs with less than US $1M in total annual funding.

For detailed setup, visit the [Grist documentation](https://support.getgrist.com/self-managed/).

### undb: lightweight and private-first

![undb ui](../../assets/images/24/11/undb.png)

[undb](https://undb.io/) is AGPL-3.0 (3.0k stars) and focuses on being lightweight. It supports both SQLite and PostgreSQL backends, has a clean minimalist interface with Table and Kanban views, and runs on minimal resources.

&lt;Notice type=&quot;warning&quot; title=&quot;Development Activity&quot;&gt;
undb&apos;s last GitHub push was July 2025. The project is still functional, but consider this when evaluating long-term support and maintenance.
&lt;/Notice&gt;

**Deploy with Docker:**

```bash
docker run -d -p 3721:3721 ghcr.io/undb-io/undb
```

**Verify:** `curl http://localhost:3721` should return the undb interface.

undb is a good choice for personal use, small teams, or situations where you want a simple database UI without the overhead of PostgreSQL. For teams evaluating long-term viability, Teable or Grist have stronger community momentum.

### Mathesar: honorable mention (Postgres-native)

[Mathesar](https://mathesar.org/) is GPL-3.0 (5.1k stars), built with Python/Django + Svelte, and takes a different approach: it works directly on your existing PostgreSQL schemas. No abstraction layer, no migration needed. It respects your existing Postgres tables and access controls.

Key differentiators:

- Works directly on your Postgres database, no migration needed
- Respects existing Postgres access control
- 100% open source, no premium features, no paywalls
- Good for teams that already have Postgres and want a UI on top

Limitations: Postgres-only, smaller community, less polished UI than NocoDB or Teable.

**Deploy with Docker:**

```bash
docker run -d --name mathesar \
  -p 8000:8000 \
  -e DJANGO_DATABASE_URL=postgres://user:password@host:5432/dbname \
  mathesar/mathesar-prod:latest
```

**Verify:** `curl http://localhost:8000` should return the Mathesar interface.

Mathesar is best positioned for teams already running PostgreSQL who want a spreadsheet-like UI without touching their existing schema.

## Licensing and open source status comparison

This is the most important section in this 2026 update. Licensing has changed a lot, especially for NocoDB.

&quot;Open source&quot; has a specific meaning: an [OSI-approved license](https://opensource.org/licenses) (Apache 2.0, MIT, GPL, AGPL). &quot;Source-available&quot; or &quot;Fair Code&quot; licenses allow viewing and self-hosting but restrict commercial redistribution.

| Tool | License | Fully Open Source? | Free Self-Host? | Paid Tiers |
|---|---|---|---|---|
| NocoDB | Sustainable Use License | no | yes | Enterprise features gated |
| Teable | AGPL-3.0 (CE) | CE: yes | yes | Enterprise features |
| Baserow | MIT (core) | core: yes | yes | Premium/Advanced/Enterprise |
| Grist | Apache 2.0 | yes | yes | Free for &amp;lt;$1M orgs |
| undb | AGPL-3.0 | yes | yes | None currently |
| Mathesar | GPL-3.0 | yes | yes | None |

&lt;Notice type=&quot;info&quot; title=&quot;What Does &apos;Open Source&apos; Mean?&quot;&gt;
OSI-approved licenses (Apache 2.0, MIT, GPL, AGPL) meet the Open Source Definition. &quot;Source-available&quot; or &quot;Fair Code&quot; licenses like NocoDB&apos;s allow viewing and self-hosting but restrict commercial redistribution. If true open-source licensing matters to your team, Grist, undb, and Mathesar are the safest choices.
&lt;/Notice&gt;

**Why this matters for self-hosters:** If you plan to offer the tool as a service, redistribute it, or embed it in a product, licensing is critical. NocoDB&apos;s Sustainable Use License prohibits commercial redistribution. Teable&apos;s AGPL requires you to open-source modifications if you distribute. Grist&apos;s Apache 2.0 and Baserow&apos;s MIT core are the most permissive.

## Airtable pricing vs self-hosted costs

The math here is straightforward.

**Airtable 2026 pricing:**
- Team: $20/user/mo (annual) or $24 (monthly)
- Business: $45/user/mo (annual) or $54 (monthly)
- 10-person team on Business = **$5,400/year**
- 50-person team on Business = **$27,000/year**

**Self-hosted cost:**
- [Hetzner](https://go.bitdoze.com/hetzner) CX22 (2 vCPU, 4 GB RAM): €4.49/month (€54/year)
- [Hostinger](https://go.bitdoze.com/hostinger-vps) VPS: starting ~€5/month
- [DigitalOcean](https://go.bitdoze.com/do) Basic Droplet: $6/month
- [Vultr](https://go.bitdoze.com/vultr) Cloud Compute: $6/month

&lt;Notice type=&quot;success&quot; title=&quot;Cost Savings&quot;&gt;
A 10-person team on Airtable Business pays $5,400/year. A Hetzner CX22 VPS costs €54/year. Self-hosting any of these tools on that VPS gives you comparable functionality at ~1% of the cost.
&lt;/Notice&gt;

The caveat: self-hosting cost doesn&apos;t include your time for maintenance, updates, and backups. But if you&apos;re already running Docker containers, the marginal effort is small.

## Operator notes: database backends, backups, and resource planning

### Database backend comparison

The database backend determines resource requirements, backup strategy, and scalability ceiling:

- **PostgreSQL-only:** Teable, Baserow, Mathesar: more robust for concurrent access, requires more RAM
- **Multi-DB:** NocoDB: supports MySQL, PostgreSQL, SQLite, MSSQL, MariaDB. PostgreSQL recommended for production
- **SQLite-based:** Grist (SQLite by default), undb (SQLite or PostgreSQL option): simpler setup, single-writer limitation

PostgreSQL handles concurrent users and large datasets better. SQLite is simpler to back up and runs on fewer resources. For teams under 10 users with &amp;lt;100k rows, either works fine.

### Backup strategies

For SQLite-based tools (Grist, undb with SQLite):

```bash
# Stop the container, copy the data volume, restart
docker stop grist
cp -r /var/lib/docker/volumes/grist-data/_data /backup/grist-$(date +%F)
docker start grist
```

For PostgreSQL-based tools (Teable, Baserow, Mathesar, NocoDB with PG):

```bash
docker exec postgres_container pg_dump -U user dbname &gt; backup-$(date +%F).sql
```

&lt;Notice type=&quot;warning&quot; title=&quot;Test Your Backups&quot;&gt;
A backup you haven&apos;t tested is not a backup. After your first backup, restore it to a fresh container and verify the data is intact.
&lt;/Notice&gt;

Schedule daily backups. Store them off-site (S3-compatible storage). Test restores quarterly at minimum.

### Resource planning

Minimum VPS specs per tool for a small team (&amp;lt;10 users, &amp;lt;100k rows):

| Tool | Minimum RAM | Recommended |
|---|---|---|
| NocoDB (with PG) | 4 GB | 4 GB |
| Teable | 4 GB | 4 GB |
| Baserow | 4 GB | 4 GB |
| Grist | 2 GB | 4 GB |
| undb | 2 GB | 2 GB |
| Mathesar | 4 GB | 4 GB |

Monitor CPU and memory once deployed. [How to monitor server and Docker resources](https://www.bitdoze.com/sever-monitoring/).

## Factors to consider when choosing an alternative

When choosing a tool, think about what matters most for your setup:

1. **Ease of use and UI polish**: Teable and Baserow have the most polished interfaces. NocoDB is close. Grist has a learning curve if you&apos;re not comfortable with Python formulas.

2. **Scalability and performance**: PostgreSQL-backed tools (Teable, Baserow, Mathesar) handle concurrent access and large datasets better. SQLite tools (Grist, undb) are simpler but single-writer.

3. **Integration capabilities**: All tools offer REST APIs. NocoDB also has GraphQL. None match Airtable&apos;s automation depth natively. For complex workflows, pair your database tool with [self-hosted n8n for advanced automations](https://www.bitdoze.com/n8n-self-host-workflow-automation/).

4. **Community support and development activity**: NocoDB (64.4k stars) and Teable (21.6k) have the most active communities. Check GitHub issue activity and release cadence before committing.

5. **Licensing and long-term viability**: NocoDB&apos;s license change is a warning: projects can change terms. True OSI-approved licenses (Grist, undb, Mathesar) offer more predictability.

&lt;Accordion label=&quot;Which tool should I pick?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Need widest DB support and most features?** → NocoDB (but accept the Fair Code license)

**Need closest Airtable clone with OSS license?** → Teable

**Need best non-technical UX + AI assistant?** → Baserow

**Need spreadsheet-native with Python formulas?** → Grist

**Need lightweight / personal use?** → undb

**Need Postgres-native on existing DB?** → Mathesar

&lt;/Accordion&gt;

## Security considerations for self-hosted containers

When deploying these containers in production:

- **Reverse proxy with SSL**: don&apos;t expose raw HTTP ports. Use [Traefik](https://www.bitdoze.com/traefik-proxy-docker/) or Nginx Proxy Manager to front these services with HTTPS and automatic certificate renewal.
- **Regular container updates**: watch for security releases. Set up Watchtower or similar for automated updates, or at minimum check monthly.
- **Network segregation**: run database containers on an internal Docker network, not exposed to the host.
- **Access control and MFA**: enable 2FA where available (Baserow supports it). Use strong JWT secrets.
- **Monitoring and logging**: set up basic monitoring. [Secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) for intrusion detection.
- **DNS-level protection**: use [NextDNS](https://go.bitdoze.com/nextdns) for DNS-level privacy and ad/malware blocking on your server.
- **SSRF protection**: NocoDB 2026.05.2+ enforces SSRF protection by default. If you need to connect to local/private databases, set `NC_ALLOW_LOCAL_EXTERNAL_DBS=true`.

## Conclusion

The self-hosted Airtable space changed a lot since 2025. NocoDB is still the most feature-rich option but is no longer open source. Teable emerged as the closest Airtable clone with a genuine AGPL license. Baserow 2.0 added AI and automations. Grist remains the best choice for teams that think in Python. undb is the lightweight option. Mathesar fills a niche for Postgres-native teams.

The cost math is clear: self-hosting on a €5/month VPS gives you 90% of Airtable&apos;s functionality at 1% of the price. The tradeoff is you own the operations: backups, updates, security. If you&apos;re already running Docker, that&apos;s a trade worth making.

Start with Docker on a cheap VPS. Evaluate one or two tools with real data before committing. The migration path from Airtable is CSV export → import, with some manual cleanup of linked records.

&lt;Button text=&quot;Get Started with Hetzner VPS&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;Is NocoDB still free to self-host?&quot; group=&quot;faq&quot;&gt;
Yes. NocoDB&apos;s Community Edition is free to self-host with no row limits. However, the license changed from AGPL-3.0 to a Sustainable Use License (Fair Code) in early 2026, meaning you cannot offer it as a commercial managed service. Many advanced features (Gantt, Timeline, Realtime Collaboration, Dashboard Widgets) now require a paid Enterprise license.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the best open-source Airtable alternative?&quot; group=&quot;faq&quot;&gt;
If &quot;open source&quot; means OSI-approved license: Grist (Apache 2.0) is the most mature truly open-source option. Teable (AGPL-3.0 Community Edition) is the closest Airtable UI clone. undb and Mathesar are also fully open source. NocoDB is source-available (Fair Code), not open source.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I migrate from Airtable to a self-hosted alternative?&quot; group=&quot;faq&quot;&gt;
NocoDB has a built-in Airtable CSV/JSON import. Baserow supports CSV import. Teable supports CSV and Airtable base import. For all tools, export your Airtable data as CSV and import it. Expect some manual cleanup of linked records and formulas.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which tool needs the least resources?&quot; group=&quot;faq&quot;&gt;
Grist and undb are the lightest. Both run comfortably on 2 vCPU / 2 GB RAM with SQLite. NocoDB with SQLite also runs on minimal resources. PostgreSQL-based tools (Teable, Baserow, Mathesar) need at least 4 GB RAM.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do any of these match Airtable&apos;s automations?&quot; group=&quot;faq&quot;&gt;
No single tool matches Airtable&apos;s automation depth. Baserow 2.0&apos;s Automations Builder (triggers, actions, conditions) is the closest self-hosted option. For complex workflows, pair your database tool with a self-hosted automation platform like n8n. See our [n8n self-hosting guide](https://www.bitdoze.com/n8n-self-host-workflow-automation/).
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>docker</category><category>database</category><category>self-hosted</category></item><item><title>How to Monitor Server &amp; Docker Resources: CPU, Memory, Disk</title><link>https://www.bitdoze.com/sever-monitoring/</link><guid isPermaLink="true">https://www.bitdoze.com/sever-monitoring/</guid><description>Learn how to monitor server resources (CPU, memory, disk, network) and Docker containers with Beszel, Netdata, Dozzle &amp; Prometheus. Free self-hosted tools compared.</description><pubDate>Sat, 08 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;


If you run Docker on a VPS, you need to know when CPU spikes, disk fills, or a container dies, before your users notice. Server monitoring is the systematic process of tracking resource utilization, container health, and service availability so you can catch problems early. This guide compares four free, self-hosted monitoring tools (Beszel, Netdata, Dozzle, and Prometheus/Grafana), adds Uptime Kuma for availability checks, and gives you a clear default stack for 1–20 servers. Whether you&apos;re running [Docker containers for your home server](/docker-containers-home-server/) or managing production workloads on cheap VPS boxes, the monitoring stack you choose matters.

## Quick picks: which monitoring tool should you use?

&lt;Notice type=&quot;info&quot; title=&quot;TL;DR&quot;&gt;
For 90% of self-hosters with 1–20 servers, deploy **Beszel** (metrics) + **Dozzle** (logs) + **Uptime Kuma** (availability). Total RAM overhead: ~50–80 MB. You can have the whole stack running tonight.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/vG8fFm_lI-o&quot;
  label=&quot;How To Monitor Server and Docker Resources:CPU,Memory..&quot;
/&gt;
If you need per-second resolution and AI anomaly detection, go with **Netdata**, but budget 200-500 MB RAM.

If you&apos;re running an organization with 50+ targets and custom PromQL dashboards, use **Prometheus + Grafana**, but budget 1 GB+ RAM and a weekend for setup.
&lt;/Notice&gt;

&lt;ListCheck&gt;
**You need Beszel if you want:**
- A monitoring dashboard that runs in 5 minutes with ~10–25 MB RAM
- Built-in Docker container stats, GPU monitoring, and S.M.A.R.T. disk health
- OAuth/OIDC login and automatic S3 backups

**You need Netdata if you want:**
- Per-second metric collection with 800+ integrations
- AI-powered anomaly detection and blast radius analysis
- Deep production debugging at 3 am

**You need Prometheus + Grafana if you want:**
- Custom PromQL dashboards and long-term metric storage
- Enterprise-grade alerting with Alertmanager
- Multi-team observability with role-based access
&lt;/ListCheck&gt;

Here&apos;s what each tool actually costs you in RAM on a VPS:

| Tool | RAM (idle) | Best for |
|---|---|---|
| Beszel agent | ~10–15 MB | Every server |
| Beszel hub | ~12 MB | Central dashboard |
| Dozzle | ~10–18 MB | Log viewing + alerts |
| Uptime Kuma | ~30–50 MB | HTTP/TCP availability |
| Netdata | 200–500 MB | Deep metrics, AI |
| Prometheus + Grafana | 500 MB–1 GB+ | Enterprise observability |

Compare these numbers against [self-hosted server management panels](/best-self-hosted-panels/) to plan your total resource budget.

## Key areas in server monitoring

Effective server monitoring tracks four resource domains. The tools in this article cover all of them.

| Metric Type | Traditional Server | Container Environment | Cloud Infrastructure |
|---|---|---|---|
| CPU | Overall usage | Per container usage | Instance utilization |
| Memory | Physical/Swap | Container limits | Instance limits |
| Storage | Partition usage | Volume usage | Block storage |
| Network | Interface stats | Container networks | VPC metrics |

Before you deploy anything, it helps to [benchmark your cloud server](/benchmark-cloud-servers/) so you know your baseline.

### CPU monitoring

- Load averages (1, 5, 15 minute)
- Per-core utilization
- Process-level CPU consumption
- System/user time split

For a deeper dive on CPU alerting, see how to [monitor CPU usage with alerts](/monitor-cpu-usage-and-send-email-alerts-in-linux/).

### Memory monitoring

- Available RAM and swap usage
- Buffer/cache utilization
- Per-process memory consumption
- OOM kill events (Dozzle can alert on these)

### Disk &amp; filesystem monitoring

- Disk space usage (percentage and absolute)
- Inode utilization
- Read/write operations and I/O wait times
- S.M.A.R.T. disk health with failure alerts (Beszel v0.17+)

Disk fills up fast with Docker images and logs. If you&apos;re running low, [clean up Docker disk usage](/clean-docker-overlay2-dir/) to reclaim space.

### Network monitoring

- Bandwidth utilization per interface
- Packet loss and latency
- Connection states and socket counts
- Container-level network stats

## Importance of getting notified

Proactive alerting is the difference between catching a problem at 90% disk and waking up to a dead server at 100%. Every tool in this article supports notifications:

- **Beszel**: ntfy, email, webhook, Telegram, Gotify
- **Netdata**: 90+ notification channels including Slack, Discord, PagerDuty
- **Dozzle**: webhook delivery to Slack, Discord, ntfy, or custom endpoints
- **Prometheus**: Alertmanager with multi-channel routing

The key principles still apply:

1. **Early warning.** Detect issues before they become critical. Monitor trend changes, not just thresholds.
2. **Quick response.** Deliver notifications with diagnostic info and clear action items.
3. **Prevent alert fatigue.** Use intelligent thresholds, correlation, and proper priorities. If everything is critical, nothing is.

&lt;Notice type=&quot;info&quot; title=&quot;Alert routing pattern&quot;&gt;
Instead of wiring each monitoring tool to email separately, route all alerts through a single notification server like [ntfy](https://ntfy.sh/) or [Gotify](https://gotify.net/). Every tool in this article supports webhook delivery. Point them all at one endpoint, then manage subscriptions from a single place. One webhook target to maintain, one place to mute at 3 am.
&lt;/Notice&gt;

## Metrics monitoring: track CPU, memory, disk &amp; network

This section covers tools that watch system resources. Metrics tell you *what* is happening (CPU at 95%, memory exhausted, disk filling up). The log monitoring section below covers *why* it&apos;s happening (error messages, stack traces, crash loops).

### Beszel: lightweight server &amp; Docker monitoring

&lt;Notice type=&quot;success&quot;&gt;
Beszel is the recommended starting point for most readers. It uses ~10-25 MB total RAM, deploys in 5 minutes, and now includes GPU monitoring, S.M.A.R.T. disk health, systemd service monitoring, and OAuth/OIDC. Version 0.18.7, 22,700+ GitHub stars, MIT license.
&lt;/Notice&gt;

[Beszel](https://github.com/henrygd/beszel) is a lightweight monitoring hub with agents that report back via SSH or universal tokens. It&apos;s designed for small to medium fleets, the kind of setup where you have 1-20 VPS boxes and want a clean dashboard without the overhead of Netdata or Prometheus.

Main Beszel interface:
![Beszel hub dashboard showing CPU, memory, disk, and network metrics for multiple servers](../../assets/images/24/11/beszel1.jpeg)

Beszel graphs:
![Beszel system detail view with per-core CPU graphs, memory utilization, and Docker container stats](../../assets/images/24/11/beszel2.jpeg)

#### Beszel key features (v0.18)

What&apos;s new since the original article:

- **Universal tokens** (v0.12+). Create a token at `/settings/tokens` and skip per-system key setup. This simplifies multi-server deployment significantly.
- **OAuth2/OIDC.** Sign in via Google, GitHub, Authentik, Authelia. Password auth can be disabled entirely.
- **S.M.A.R.T. disk monitoring** with failure alerts. Catch a dying drive before it takes your data.
- **GPU monitoring.** NVIDIA (NVML/nvtop), AMD, Intel, experimental Apple Silicon.
- **systemd service monitoring.** See which services are running and get alerted on failures.
- **Automatic backups** to disk or S3-compatible storage.
- **Multi-user** with admin and read-only roles.

&lt;Tabs&gt;
&lt;Tab name=&quot;TOKEN auth (recommended)&quot;&gt;
Since v0.12.0, universal tokens are the recommended auth method. You create a token in the hub UI and paste it into the agent config. No SSH key juggling.

```yaml
services:
  beszel-agent:
    image: henrygd/beszel-agent:latest
    container_name: beszel-agent
    restart: unless-stopped
    network_mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      PORT: 45876
      TOKEN: &quot;your-universal-token-from-hub&quot;
```

&lt;/Tab&gt;
&lt;Tab name=&quot;KEY auth (legacy)&quot;&gt;
The original SSH key-based auth still works. Generate an ed25519 key pair, paste the public key in the hub, and the private key in the agent config.

```yaml
services:
  beszel-agent:
    image: henrygd/beszel-agent:latest
    container_name: beszel-agent
    restart: unless-stopped
    network_mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      PORT: 45876
      KEY: &quot;ssh-ed25519 AAAAC3...&quot;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

#### Deploy Beszel hub with Docker Compose

```yaml
services:
  beszel:
    image: henrygd/beszel:latest
    container_name: beszel
    restart: unless-stopped
    environment:
      APP_URL: http://localhost:8090   # change to your domain in production
    ports:
      - 8090:8090
    volumes:
      - ./beszel_data:/beszel_data
```

Save this as `docker-compose.yml`, then:

```bash
mkdir -p beszel &amp;&amp; cd beszel
# paste the compose file above
docker compose up -d
```

**Verify:** Open `http://your-ip:8090`. The setup wizard should appear. Create your admin account. If you get a blank page, check that `APP_URL` matches your access URL.

&lt;Notice type=&quot;info&quot; title=&quot;Run the hub on a separate box&quot;&gt;
If your only server goes down, your monitoring goes down with it. Beszel hub uses ~12 MB RAM — cheap enough to run on a separate [Hetzner VPS](https://go.bitdoze.com/hetzner) (CX22 at ~€4/month) or a [Hostinger VPS](https://go.bitdoze.com/hostinger-vps). Even a Raspberry Pi works. The agent stays on your production servers; the hub lives elsewhere.
&lt;/Notice&gt;

#### Deploy Beszel agent with Docker Compose

On each monitored server:

```yaml
services:
  beszel-agent:
    image: henrygd/beszel-agent:latest
    container_name: beszel-agent
    restart: unless-stopped
    network_mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      # monitor other disks/partitions by mounting into /extra-filesystems
      # - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
    environment:
      PORT: 45876
      TOKEN: &quot;your-universal-token-from-hub&quot;
```

To get your token: in the Beszel hub UI, go to **Settings → Tokens**, create a new universal token, and paste it into the agent&apos;s `TOKEN` environment variable.

**Verify:** The system row should flip green in the hub UI within 30 seconds. If red, check `docker logs beszel-agent` for connection errors.

&lt;Accordion label=&quot;Beszel agent shows red — common fixes&quot; group=&quot;beszel-failure&quot;&gt;
**Wrong TOKEN or KEY:** Regenerate the token in the hub and update the agent compose. Restart the agent.
**Firewall blocking port 45876:** The hub connects to the agent on port 45876. Make sure your firewall allows inbound TCP on that port from the hub&apos;s IP:
```bash
sudo ufw allow from HUB_IP to any port 45876
```
**Agent not running:** Check `docker ps | grep beszel-agent`. If it&apos;s not listed, check `docker logs beszel-agent` for startup errors.
**Docker socket permissions:** Ensure `/var/run/docker.sock` is readable. The `:ro` mount is sufficient — the agent only reads stats, it doesn&apos;t control containers.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Hub shows &apos;connection refused&apos;&quot; group=&quot;beszel-failure&quot;&gt;
**APP_URL mismatch:** The `APP_URL` env var must match how you access the hub. If you&apos;re using `http://localhost:8090` but accessing via a reverse proxy domain, update `APP_URL` to the domain.
**Port not exposed:** Verify with `docker port beszel` that 8090 is mapped.
**Reverse proxy misconfiguration:** If behind Caddy/Nginx/Traefik, ensure the proxy passes the `Host` header and forwards to port 8090.
&lt;/Accordion&gt;

**Ops notes:**
- **Backup:** The `/beszel_data` directory contains all configuration and history. Back it up, or configure S3 backup in hub settings.
- **Updates:** `docker compose pull &amp;&amp; docker compose up -d` — Beszel hub and agent update independently.
- See how to [pair Beszel with Uptime Kuma](/beszel-uptime-kuma/) for the complete setup with Dokploy.

### Netdata: deep real-time server monitoring

[Netdata](https://www.netdata.cloud/) gives you 2,000+ metrics at 1-second resolution with AI anomaly detection. That depth costs 200–500 MB of RAM. For &quot;is my CPU OK,&quot; that&apos;s overkill. For debugging a production incident at 3 am with per-second granularity, it&apos;s worth every megabyte.

Version 2.10.4 (July 2026), 79,600+ GitHub stars, GPL-3.0+.

![Netdata real-time dashboard with per-second CPU, memory, disk I/O, and network metrics](../../assets/images/24/11/netdata1.jpeg)

#### Netdata key features (v2.10)

- **800+ integrations** with auto-detection — Netdata discovers what&apos;s running and starts collecting metrics with zero config.
- **AI Co-Engineer** — built-in AI chat, AI reporting, and blast radius detection. The agent has self-learning anomaly detection built in.
- **Per-second metric collection** — no other free tool matches this granularity.
- **Parent streaming + HA clustering** — scale to thousands of nodes with parent-child architecture.
- **UI-based alert configuration** — define alerts in the Cloud dashboard, not just config files. Includes alert silencing, recurrence rules, and acknowledgement.

#### Netdata pricing: Community vs Homelab vs Business

The old &quot;Community vs Cloud&quot; split is gone. Current tiers (Aug 2026):

| Plan | Price | Nodes | Custom Dashboards | Retention |
|---|---|---|---|---|
| Community | Free | 5 | 1 | 3 GB disk, &gt;1 year |
| Homelab | $90/yr | Unlimited | Unlimited | Fair usage |
| Business | $4.50/node/mo | Unlimited | Unlimited | Full |
| Enterprise On-Premise | Contact sales | Unlimited | Unlimited | Full |

&lt;Notice type=&quot;info&quot;&gt;
If you have more than 5 servers, the **Homelab plan at $90/year** is the sweet spot. Unlimited nodes with fair usage — this is the tier designed for self-hosters. The free Community plan (5 nodes, 1 custom dashboard, 3 GB disk / &gt;1 year retention) is enough to evaluate.
&lt;/Notice&gt;

Compare: SaaS alternatives like Datadog charge $15–18/host/month. Self-hosting Netdata is free; the Cloud plan adds multi-node dashboards and AI features.

#### Deploy Netdata with Docker

```yaml
services:
  netdata:
    image: netdata/netdata:stable
    container_name: netdata
    restart: unless-stopped
    pid: host
    network_mode: host
    cap_add:
      - SYS_PTRACE
      - SYS_ADMIN
    security_opt:
      - apparmor:unconfined
    volumes:
      - netdataconfig:/etc/netdata
      - netdatalib:/var/lib/netdata
      - netdatacache:/var/cache/netdata
      - /etc/passwd:/host/etc/passwd:ro
      - /etc/group:/host/etc/group:ro
      - /etc/localtime:/etc/localtime:ro
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      - NETDATA_CLAIM_TOKEN=your-cloud-token  # optional: connect to Netdata Cloud

volumes:
  netdataconfig:
  netdatalib:
  netdatacache:
```

**Verify:** Access `http://your-ip:19999` — the dashboard should populate with live metrics within seconds. If you see an empty page, check the Docker socket mount and network mode.

&lt;Accordion label=&quot;Netdata agent not connecting to Cloud&quot; group=&quot;netdata-failure&quot;&gt;
**Token mismatch:** Regenerate the claim token from Netdata Cloud and update the `NETDATA_CLAIM_TOKEN` env var.
**Network egress blocked:** Netdata Cloud requires outbound HTTPS to `app.netdata.cloud`. Check your firewall rules.
**Firewall:** If you&apos;re not using Cloud, just access the agent directly at port 19999 — no outbound connection needed.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;High RAM usage on small VPS&quot; group=&quot;netdata-failure&quot;&gt;
On a 1 GB VPS, Netdata alone can take 200–500 MB — that&apos;s 20–50% of your total RAM. Verify with `docker stats` before committing.

To reduce memory:
- Disable unused collectors in `/etc/netdata/netdata.conf`
- Reduce metric retention periods
- If you don&apos;t need per-second resolution, consider switching to Beszel (~10–15 MB)
&lt;/Accordion&gt;

**Ops notes:**
- On a 1 GB VPS, Netdata alone takes 20–50% of RAM — verify with `docker stats` before committing alongside other services.
- **Backup:** `/etc/netdata` for configs, `/var/cache/netdata` for historical data.
- **Updates:** `docker compose pull &amp;&amp; docker compose up -d`

### Prometheus &amp; Grafana: enterprise server monitoring

&lt;Notice type=&quot;warning&quot; title=&quot;Resource warning&quot;&gt;
The full Prometheus + Grafana stack needs 500 MB–1 GB+ RAM. Don&apos;t run this on a 1 GB VPS alongside your applications. This is for dedicated monitoring nodes or organizations with real infrastructure budgets.
&lt;/Notice&gt;

Prometheus handles metrics collection and storage. Grafana provides visualization and alerting. Together they&apos;re the industry standard for enterprise monitoring — PromQL for queries, Alertmanager for routing, and custom dashboards for everything. Prometheus 3.x (Nov 2024) and Grafana 12.x (May 2025) are the current releases.

![Grafana 12 dashboard with Prometheus 3.x metrics showing server CPU, memory, and container resource usage](../../assets/images/24/11/prometheus_grafana.webp)

#### Prometheus/Grafana key features (Prometheus 3.x, Grafana 12.x)

**Prometheus 3.0 changes:**
- Native OTLP ingestion on `/api/v1/otlp/v1/metrics` — receive OpenTelemetry metrics directly without a collector.
- Remote Write 2.0 with improved compression.
- UTF-8 support in label values.
- New web UI.

**Grafana 12 changes:**
- Observability as code — Git Sync for dashboards.
- Dynamic dashboards with drilldown apps.
- Grafana Alloy as the modern single collector (replaces node_exporter + cAdvisor + Promtail).

**Prometheus capabilities:**
- Pull-based metrics collection with service discovery
- PromQL query language for complex aggregations
- Time-series database with efficient compression
- Customizable retention periods

**Grafana strengths:**
- Customizable dashboards with template variables
- Multiple data source support (Prometheus, Loki, InfluxDB, etc.)
- Multi-channel alerting with grouping and escalation

#### Deployment overview

The essential components: Prometheus for metrics, Grafana for visualization, Node Exporter for system metrics, and cAdvisor for container stats. For a Docker-based setup:

```yaml
services:
  prometheus:
    image: prom/prometheus:v3.13.0
    container_name: prometheus
    restart: unless-stopped
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    ports:
      - 9090:9090

  grafana:
    image: grafana/grafana:12.0.0
    container_name: grafana
    restart: unless-stopped
    ports:
      - 3000:3000
    volumes:
      - grafana_data:/var/lib/grafana

  node-exporter:
    image: prom/node-exporter:latest
    container_name: node-exporter
    restart: unless-stopped
    pid: host
    volumes:
      - /proc:/host/proc:ro
      - /sys:/host/sys:ro
      - /:/rootfs:ro

  cadvisor:
    image: gcr.io/cadvisor/cadvisor:latest
    container_name: cadvisor
    restart: unless-stopped
    volumes:
      - /:/rootfs:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /sys:/sys:ro
      - /var/lib/docker:/var/lib/docker:ro

volumes:
  prometheus_data:
  grafana_data:
```

For production, there are also Kubernetes Helm charts, Ansible automation, and managed solutions like AWS Managed Grafana or Google Cloud Managed Prometheus. The official docs cover these in detail: [Prometheus Installation](https://prometheus.io/docs/prometheus/latest/installation/) and [Grafana Installation](https://grafana.com/docs/grafana/latest/setup-grafana/installation/).

**Ops notes:**
- **Backup:** The Prometheus TSDB data directory, `prometheus.yml`, and Grafana dashboards (export as JSON).
- **Cost:** Grafana Cloud free tier gives you 10k series with 14-day retention. Pro: $19/month + $6.50/1k series after 10k. Self-hosting is free but costs you RAM.
- Check your [Docker firewall configuration](/docker-bypasses-firewall/) — Prometheus pulls metrics from exporters, so the network path matters.

## Log monitoring: track Docker container logs

Metrics tell you *what* is happening. Logs tell you *why*. When CPU spikes, you need to know which process is responsible. When a container crashes, you need the error message. This is where Dozzle comes in — it pairs with Beszel or Netdata for metrics to give you the complete picture.

### Dozzle: Docker log viewer with alerts

[Dozzle](https://dozzle.dev/) started as a lightweight log viewer. Since v10 (Feb 2026), it&apos;s grown into a full Docker operations tool with alerts, SQL analytics, container actions, and shell access. Version 10.6.15, 14,000+ GitHub stars, MIT license. Image size: ~7–10 MB.

![Dozzle v10 Docker log viewer showing real-time container log streams with search and filter](../../assets/images/24/11/dozzle1.png)

#### Dozzle key features (v10)

What&apos;s new since the original article:

- **Alerts &amp; webhooks** — log pattern matching (&quot;alert when any container logs contain &apos;FATAL&apos;&quot;), CPU/memory metric thresholds (&quot;alert when postgres exceeds 85% memory&quot;), and container lifecycle events (&quot;alert when any container gets OOM-killed&quot;). Delivers to Slack, Discord, ntfy, or custom endpoints.
- **DuckDB SQL analytics** — query your live log streams with SQL in the browser. Example: `SELECT * FROM logs WHERE message LIKE &apos;%Error%&apos;`. Useful for debugging patterns across containers.
- **Container actions** — stop, start, and restart containers from the browser. Disabled by default.
- **Shell access** — exec into containers from the browser. Disabled by default.
- **Built-in authentication** — file-based auth (`DOZZLE_AUTH_PROVIDER=simple`) and forward proxy auth (Authelia).
- **Kubernetes support** and multi-host agents with TLS.
- Command palette (Cmd+K), theme-aware ANSI colors.

&lt;Notice type=&quot;warning&quot; title=&quot;Privacy note&quot;&gt;
Dozzle sends anonymous usage analytics by default. Opt out with `DOZZLE_NO_ANALYTICS=true` in your compose environment. This is for usage telemetry only — it does not affect the DuckDB analytics feature.
&lt;/Notice&gt;

#### Deploy Dozzle with Docker Compose

```yaml
services:
  dozzle:
    image: amir20/dozzle:latest
    container_name: dozzle
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./dozzle_data:/data              # required: persists alert/notification configs
    ports:
      - 8080:8080
    environment:
      DOZZLE_NO_ANALYTICS: &quot;true&quot;        # opt out of anonymous analytics
      # DOZZLE_AUTH_PROVIDER: simple     # enable built-in file-based auth
      # DOZZLE_ENABLE_ACTIONS: true      # stop/start/restart containers from UI
      # DOZZLE_ENABLE_SHELL: true        # exec into containers from UI
```

**Verify:** Navigate to `http://your-ip:8080` — you should see your running containers listed in the sidebar. Click one to see live log streaming. If the list is empty, verify the Docker socket mount.

**Important:** The `/data` volume mount is **required** as of v10. Without it, your alert configurations and notification settings are lost on every container restart.

&lt;Accordion label=&quot;Dozzle shows no containers&quot; group=&quot;dozzle-failure&quot;&gt;
**Docker socket permissions:** Ensure `/var/run/docker.sock` is readable by the container user. The default Docker socket permissions (root:docker, mode 660) usually work, but some distros restrict this.
**SELinux/AppArmor:** On SELinux-enforced systems, you may need `:z` on the socket mount:
```yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock:z
```
**Wrong socket path:** On some systems (Docker rootless, Podman), the socket path is different. Check with `ls /var/run/docker.sock` or `ls $XDG_RUNTIME_DIR/docker.sock`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Alerts not firing&quot; group=&quot;dozzle-failure&quot;&gt;
**Missing /data volume:** Alert configurations are stored in `/data`. If you didn&apos;t mount `./dozzle_data:/data`, alert configs exist only in the container&apos;s ephemeral filesystem and are lost on restart.
**Webhook URL misconfigured:** Test the webhook endpoint manually with `curl` from the Dozzle container to verify network reachability.
**Rule syntax:** Double-check your alert rules in the Dozzle UI. Log patterns use substring matching, not regex (unless specified).
&lt;/Accordion&gt;

**Ops notes:**
- **Backup:** The `./dozzle_data` directory contains alert and notification configs.
- **Updates:** `docker compose pull &amp;&amp; docker compose up -d`
- **Log retention:** Dozzle streams logs from Docker — it doesn&apos;t store them. Configure Docker log rotation to prevent disk filling. See [essential Docker commands](/docker-commands/) for log management. If disk is already full, [clean up Docker disk usage](/clean-docker-overlay2-dir/).

## Availability monitoring: Uptime Kuma

Beszel watches resources from *inside* the server. Uptime Kuma checks HTTP, TCP, DNS, and ping reachability from *outside*. Different jobs, both needed. This is the standard small-fleet monitoring stack in 2026.

[Uptime Kuma](https://github.com/louislam/uptime-kuma) is a self-hosted uptime monitoring tool with a clean UI, 90+ notification channels, built-in status pages, SSL certificate monitoring, and multi-language support. ~30–50 MB RAM, MIT license.

&lt;Notice type=&quot;info&quot; title=&quot;The standard stack&quot;&gt;
For most self-hosters in 2026: **Beszel** (metrics) + **Dozzle** (logs) + **Uptime Kuma** (availability) covers everything you need. Total overhead: ~50–80 MB RAM.
&lt;/Notice&gt;

Quick deploy:

```yaml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:latest
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      - ./uptime-kuma-data:/app/data
    ports:
      - 3001:3001
```

**Verify:** Open `http://your-ip:3001`, create your admin account, and add an HTTP monitor for your own server. It should show &quot;Up&quot; within a few seconds.

For detailed setup guides, see [how to install Uptime Kuma](/install-uptime-kuma/) or [deploy Uptime Kuma with one click](/deploy-uptime-kuma/). For the complete Beszel + Uptime Kuma integration, see [pairing Beszel with Uptime Kuma](/beszel-uptime-kuma/).

## Tool comparison table

Updated August 2026. Version numbers and RAM figures based on current releases and independent benchmarks.

| Feature | Beszel v0.18 | Netdata v2.10 | Prometheus 3.x / Grafana 12.x | Dozzle v10 | Uptime Kuma |
|---|---|---|---|---|---|
| **Primary purpose** | Lightweight metrics | Deep real-time metrics | Enterprise metrics | Log viewing + alerts | Availability |
| **RAM usage** | ~10–25 MB | 200–500 MB | 500 MB–1 GB+ | ~10–18 MB | ~30–50 MB |
| **Setup complexity** | Very low | Low | High | Very low | Low |
| **Learning curve** | Gentle | Moderate | Steep | Gentle | Gentle |
| **Docker stats** | Built-in | Built-in | Via cAdvisor | N/A (logs only) | N/A |
| **GPU monitoring** | NVIDIA, AMD, Intel, Apple Silicon | Via go.d/nvidia_smi | Via custom exporters | N/A | N/A |
| **S.M.A.R.T. disks** | Built-in with failure alerts | Via collectors | Via custom exporters | N/A | N/A |
| **Alerts** | Built-in (ntfy, email, webhook) | Built-in, 90+ channels | Alertmanager | Webhooks | Built-in, 90+ channels |
| **OAuth/Multi-user** | Built-in (OAuth2/OIDC) | Cloud plans only | Grafana built-in | File-based auth | Built-in |
| **Open source** | MIT | GPL-3.0+ | Apache-2.0 / AGPLv3 | MIT | MIT |

## Security &amp; production hardening

Every monitoring tool in this article exposes a web interface and most need access to the Docker socket. Treat these like any other production service.

**Reverse proxy + HTTPS:** Every tool should sit behind Caddy, Nginx, or Traefik with TLS. A minimal Caddy example for Beszel:

```
monitor.example.com {
    reverse_proxy http://localhost:8090
}
```

**Authentication:** Beszel has OAuth/OIDC built-in. Dozzle supports `DOZZLE_AUTH_PROVIDER=simple` for file-based auth or Authelia for forward proxy auth. Grafana has built-in auth with RBAC. Netdata Cloud handles auth server-side.

**Docker socket security:** All these tools mount `/var/run/docker.sock`. This is root-equivalent access — anyone with the socket can control your containers. Always use read-only mounts (`:ro`) and consider a [Docker socket proxy](https://github.com/Tecnativa/docker-socket-proxy) for production to limit the API surface.

&lt;Notice type=&quot;error&quot; title=&quot;Don&apos;t monitor on the same box&quot;&gt;
If your only server dies, your monitoring dies with it. You&apos;ll have no metrics, no logs, and no idea what happened. Run the Beszel hub on a separate box — even a $3/month VPS works. The hub is ~12 MB RAM. Cheap options: [Hetzner VPS](https://go.bitdoze.com/hetzner) (CX22), [Vultr](https://go.bitdoze.com/vultr), or [DigitalOcean](https://go.bitdoze.com/do). For a home setup, a mini PC with [dedicated home server hardware](/best-mini-pc-home-server/) works too.
&lt;/Notice&gt;

Check your [Docker firewall configuration](/docker-bypasses-firewall/) — Docker can bypass UFW/iptables rules by default. And [secure your server](/secure-ssh-server-linux/) with proper SSH hardening before exposing any monitoring dashboards.

## Post-deploy checklist

After deploying your monitoring stack, run through this list:

&lt;ListCheck&gt;
- All agents reporting green in the dashboard
- Alerts configured with meaningful thresholds (not defaults)
- Alert tested — trigger one intentionally and confirm delivery to your notification channel
- Data directories backed up (Beszel: `/beszel_data`, Dozzle: `/data`, Netdata: config volumes)
- Reverse proxy + HTTPS configured for all web UIs
- Anonymous analytics disabled if desired (`DOZZLE_NO_ANALYTICS=true`)
- Firewall rules verified — only necessary ports exposed to the internet
- Docker socket mounts are read-only (`:ro`) where possible
&lt;/ListCheck&gt;

## Conclusions

The monitoring landscape in 2026 is generous to self-hosters. You don&apos;t need to pay Datadog $15/host/month. Here&apos;s the decision tree:

**For 90% of readers** (solo devs, 1–20 VPS boxes, Docker): deploy Beszel + Dozzle + Uptime Kuma. Total RAM: ~50–80 MB. Cost: free. Setup time: under an hour. This is the stack I&apos;d recommend to anyone starting fresh.

**If you need deep observability** (per-second metrics, AI anomaly detection, 800+ integrations): Netdata with the Homelab plan ($90/year for unlimited nodes). Budget 200–500 MB RAM. Worth it when you&apos;re debugging production issues that need sub-second resolution.

**If you&apos;re running an organization** with 50+ targets, custom dashboards, and PromQL queries: Prometheus + Grafana. Budget 1 GB+ RAM and a weekend for initial setup. The learning curve is real, but the flexibility is unmatched.

&lt;Notice type=&quot;success&quot; title=&quot;Start here&quot;&gt;
New to server monitoring? Deploy Beszel hub + agent on two servers. You&apos;ll have metrics in 5 minutes and it costs you nothing. Expand to Dozzle for logs and Uptime Kuma for availability as you grow.
&lt;/Notice&gt;

&lt;Button text=&quot;Deploy Beszel Now&quot; link=&quot;https://beszel.dev/guide/getting-started&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

For more tools to run alongside your monitoring stack, check out [Docker containers for your home server](/docker-containers-home-server/) and [self-hosted server management panels](/best-self-hosted-panels/).</content:encoded><category>self-hosting</category><category>linux</category><category>docker</category><category>server-monitoring</category></item><item><title>Wezterm Mac Setup: The Ultimate Terminal with Tmux &amp; Zoxide</title><link>https://www.bitdoze.com/install-wezterm-mac/</link><guid isPermaLink="true">https://www.bitdoze.com/install-wezterm-mac/</guid><description>Build the perfect Mac terminal with Wezterm, Starship prompt, tmux, and zoxide. Step-by-step guide covering installation, configuration, and productivity tips.</description><pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

[Wezterm](https://wezfurlong.org/wezterm/) is a modern terminal emulator written in Rust that I keep coming back to. It&apos;s GPU-accelerated, configurable via Lua scripts, and has a built-in multiplexer. All things that matter when you live in a terminal eight hours a day.

Key features:

1. **GPU acceleration** for smooth scrolling, even with heavy output.
2. **Lua configuration**: programmable, not just a static config file.
3. **Built-in multiplexer**: panes, tabs, and workspaces without tmux.
4. **Ligature support** for programming fonts.
5. **Cross-platform**: macOS, Windows, Linux with the same config.
6. **True color (24-bit)** and a large built-in color scheme library.
7. **Image rendering** directly in the terminal.

Ghostty 1.0 (released December 2024) is another solid macOS-native option if you want a Zig-based terminal with native look and feel. Wezterm still wins for me on Lua configurability, built-in multiplexing, and cross-platform config portability. If you want to explore Ghostty, check the [Ghostty terminal setup guide](/ghostty-terminal/).

&lt;Notice type=&quot;info&quot; title=&quot;Cost: Everything here is free&quot;&gt;
All tools in this guide (Wezterm, Starship, tmux, zoxide, Homebrew) are free and open source (MIT or similarly permissive licenses). No subscriptions, no paid tiers.
&lt;/Notice&gt;

&gt; If you are interested to see some free cool Mac Apps you can check [toolhunt.net mac apps section](https://toolhunt.net/mac/).

## Install and configure Wezterm on Mac

Everything below flows from Homebrew. Once that&apos;s installed, the rest is copy-paste commands.

### Install Homebrew

Open your terminal and run:

```sh
/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
```

Follow the on-screen prompts. You may need your system password. Verify with:

```sh
brew --version
```

&lt;Notice type=&quot;info&quot; title=&quot;Permission denied?&quot;&gt;
If you hit permission errors, run `xcode-select --install` first to get the Xcode command-line tools, then retry.
&lt;/Notice&gt;

### Install Wezterm

```sh
brew install --cask wezterm
```

Launch from Applications or Spotlight (Cmd+Space, type &quot;Wezterm&quot;).

### Install Git

```sh
brew install git
```

Verify:

```sh
git --version
```

### Install Meslo Nerd Font

Nerd Fonts add programming-related glyphs (icons) to your terminal. Meslo is clear, readable, and widely supported.

```sh
brew install font-meslo-lg-nerd-font
```

Restart Wezterm after installing the font. Wezterm now bundles Nerd Font Symbols v3.3.0 as a fallback, but installing Meslo explicitly gives the best experience with Powerlevel10k, Starship, or any prompt that uses icon glyphs.

### Setup Wezterm config file

#### Create the config file

```sh
touch ~/.wezterm.lua
```

Open it in your editor of choice:

```sh
vim ~/.wezterm.lua
```

#### Configure Wezterm

Paste this Lua config into `~/.wezterm.lua`:

```lua
-- Pull in the wezterm API
local wezterm = require(&quot;wezterm&quot;)

-- This will hold the configuration.
local config = wezterm.config_builder()

-- This is where you actually apply your config choices

config.font = wezterm.font(&quot;MesloLGS Nerd Font Mono&quot;)
config.font_size = 19

config.window_decorations = &quot;RESIZE|MACOS_USE_BACKGROUND_COLOR_AS_TITLEBAR_COLOR&quot;
config.window_background_opacity = 0.8
config.macos_window_background_blur = 10

-- my coolnight colorscheme:
config.colors = {
    foreground = &quot;#CBE0F0&quot;,
    background = &quot;#011423&quot;,
    cursor_bg = &quot;#47FF9C&quot;,
    cursor_border = &quot;#47FF9C&quot;,
    cursor_fg = &quot;#011423&quot;,
    selection_bg = &quot;#033259&quot;,
    selection_fg = &quot;#CBE0F0&quot;,
    ansi = { &quot;#214969&quot;, &quot;#E52E2E&quot;, &quot;#44FFB1&quot;, &quot;#FFE073&quot;, &quot;#0FC5ED&quot;, &quot;#a277ff&quot;, &quot;#24EAF7&quot;, &quot;#24EAF7&quot; },
    brights = { &quot;#214969&quot;, &quot;#E52E2E&quot;, &quot;#44FFB1&quot;, &quot;#FFE073&quot;, &quot;#A277FF&quot;, &quot;#a277ff&quot;, &quot;#24EAF7&quot;, &quot;#24EAF7&quot; },
}

-- and finally, return the configuration to wezterm
return config
```

What this does:

1. Sets the font to Meslo Nerd Font Mono (the one we just installed).
2. Font size 19. Adjust to your display and preference.
3. `window_decorations = &quot;RESIZE|MACOS_USE_BACKGROUND_COLOR_AS_TITLEBAR_COLOR&quot;` gives you a resizable window with a titlebar that matches your terminal background. Looks cleaner on macOS.
4. Background at 80% opacity with a blur effect behind it (macOS only).
5. Custom &quot;coolnight&quot; color scheme: dark blue background with bright, readable text colors.

After saving, Wezterm detects changes and shows an error window if there are deprecated or invalid fields. If everything looks right, the config applies automatically. You can also force a reload with `Ctrl+Shift+R`.

#### New macOS-specific Wezterm tweaks

These options only work on macOS:

&lt;Notice type=&quot;info&quot; title=&quot;macOS-only settings&quot;&gt;
These config options have no effect on Linux or Windows.
&lt;/Notice&gt;

```lua
-- Extend terminal content behind the MacBook notch in fullscreen
config.macos_fullscreen_extend_behind_notch = true

-- Remove rounded corners (sharp square look)
-- Add MACOS_FORCE_SQUARE_CORNERS to window_decorations:
-- config.window_decorations = &quot;RESIZE|MACOS_USE_BACKGROUND_COLOR_AS_TITLEBAR_COLOR|MACOS_FORCE_SQUARE_CORNERS&quot;

-- Center content when the window isn&apos;t an exact cell-multiple size
config.window_content_alignment = &quot;Center&quot;
```

The notch support is nice on newer MacBooks. You get a few extra rows of terminal content in fullscreen mode. The titlebar color matching makes Wezterm blend with its own background instead of having a contrasting macOS titlebar strip.

### ⚠️ Powerlevel10k status and prompt options

&lt;Notice type=&quot;warning&quot; title=&quot;Powerlevel10k is on life support&quot;&gt;
The maintainer has declared the project has &quot;very limited support, no new features, most bugs will go unfixed.&quot; It still works today, but consider Starship below for an actively maintained alternative.
&lt;/Notice&gt;

Powerlevel10k (p10k) is still the most popular Zsh prompt (54k+ GitHub stars) and it still works. But the project is effectively frozen. If you&apos;re setting up fresh, Starship is the better bet.

&lt;Tabs&gt;
&lt;Tab name=&quot;Starship (Recommended)&quot;&gt;

**Why Starship:** Cross-shell (zsh, fish, bash), written in Rust, actively maintained, simple TOML config. No zsh-specific lock-in. If you switch shells later (see the [Fish vs Zsh comparison](/fish-shell-vs-zsh/)), your prompt carries over.

Install and activate:

```sh
brew install starship
echo &apos;eval &quot;$(starship init zsh)&quot;&apos; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

Create a basic config at `~/.config/starship.toml`:

```toml
# See https://starship.rs/presets/ for ready-made presets
# Example: enable all default modules
format = &quot;$all&quot;

[git_status]
format = &apos;([$all_status$ahead_behind]($style) )&apos;

[nodejs]
format = &quot;via [🤖 $version](bold green) &quot;
```

Starship ships with preset themes you can browse with `starship preset`. The Catppuccin Powerline preset is popular if you want a powerline-style look without manual config.

For more on Starship, see the [Starship and Ghostty setup guide](/starship-ghostty-terminal/) or [Starship with Fish shell](/fish-shell-starship-prompt/) if you use Fish.

&lt;/Tab&gt;
&lt;Tab name=&quot;Powerlevel10k (Legacy)&quot;&gt;

p10k still works fine today. Install it if you want, but know that bugs won&apos;t be fixed.

```sh
brew install powerlevel10k
echo &quot;source $(brew --prefix)/share/powerlevel10k/powerlevel10k.zsh-theme&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

On first load, the p10k configuration wizard walks you through prompt style, segments (git status, time, etc.), colors, and icon choices. You can re-run it anytime with `p10k configure`.

Fine-tune later by editing `~/.p10k.zsh`.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Setup zsh-autosuggestions plugin

zsh-autosuggestions shows faded suggestions as you type, based on your command history. Press the right arrow key to accept a suggestion.

```sh
brew install zsh-autosuggestions
echo &quot;source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

For more details on zsh autocomplete behavior, see the [comprehensive zsh autocomplete guide](/enable-command-autocomplete-in-zsh/).

### Setup zsh-syntax-highlighting

zsh-syntax-highlighting colors commands as you type: valid commands in green, errors in red, existing file paths underlined. Catches typos before you hit enter.

```sh
brew install zsh-syntax-highlighting
echo &quot;source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

For more customization options, check the [full zsh syntax highlighting guide](/enable-syntax-highlighting-zsh/). You can also browse more useful plugins in the [best Zsh plugins for 2026](/best-oh-my-zsh-plugins/).

## Enhance Wezterm with tmux and zoxide

Wezterm is already capable on its own, but tmux and zoxide fill gaps that matter for real workflows: persistent sessions and fast directory jumping.

### What is tmux and how can it help

tmux is a terminal multiplexer. It lets you create multiple terminal sessions inside a single window, detach from them, and reattach later. This is critical for remote server work: you can SSH in, start a tmux session, disconnect, and pick up right where you left off.

&lt;Notice type=&quot;info&quot; title=&quot;tmux 3.7 is current&quot;&gt;
tmux 3.6+ added scrollbars and Mode 2031 theming. `brew install tmux` gets you the latest version.
&lt;/Notice&gt;

Install and start:

```sh
brew install tmux
tmux
```

For a hands-on introduction, see the [tmux basics guide](/tmux-basics/).

#### Wezterm built-in multiplexing vs standalone tmux

Wezterm already has its own multiplexer: panes, tabs, and workspaces built right in. For purely local workflows (splitting your terminal into panes, switching between tabs), Wezterm&apos;s built-in mux is enough. You don&apos;t need tmux at all.

Where tmux still wins:

- **Persistent remote sessions**: SSH into a server, start tmux, detach, come back later. Wezterm&apos;s mux doesn&apos;t persist across SSH disconnects.
- **Session sharing**: Two users can attach to the same tmux session (pair programming on a remote box).
- **Reboot survival**: tmux sessions survive local reboots if the remote server stays up.

If you only work locally, skip tmux and use Wezterm&apos;s built-in panes (`Ctrl+Shift+D` for horizontal split, `Ctrl+Shift+E` for vertical split).

#### Wezterm tmux -CC control mode

Wezterm now has &quot;very usable&quot; `tmux -CC` support. Running `tmux -CC` inside Wezterm starts tmux in control mode, where Wezterm acts as the GUI client. You get tmux&apos;s session persistence with Wezterm&apos;s rendering and keybindings. This is a hybrid option for power users who want the best of both.

### What is zoxide and how can it help

zoxide replaces `cd` with a smarter version that learns from your usage. After a few days, `z project` jumps to `~/code/my-project` without typing the full path. It&apos;s one of those tools that feels like a small thing until you try going back.

Install and activate:

```sh
brew install zoxide
echo &apos;eval &quot;$(zoxide init zsh)&quot;&apos; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

Usage: replace `cd` with `z`. That&apos;s it. The more you use it, the smarter it gets.

zoxide has a `doctor` subcommand that checks if your shell hooks are set up correctly. Run it if `z` doesn&apos;t seem to be working:

```sh
zoxide doctor
```

For a deeper dive, see the [full zoxide guide](/zoxide/).

## Verify and troubleshoot your setup

After completing all the steps above, run these checks to confirm everything works.

### Font verification

Run this inside Wezterm:

```sh
wezterm ls-fonts --text &quot;Test&quot;
```

It should list MesloLGS Nerd Font Mono. If it shows a fallback font, restart Wezterm or check that the font name in your config matches exactly.

### Config error detection

Wezterm now shows an error window if your `~/.wezterm.lua` has deprecated or invalid fields. If you see this window after editing your config:

1. Read the error message. It names the specific field.
2. Fix the Lua syntax or field name.
3. Press `Ctrl+Shift+R` to reload the config without restarting Wezterm.

`Ctrl+Shift+L` opens the debug overlay / Lua REPL, useful for inspecting errors or testing config values live.

### zoxide and prompt diagnostics

```sh
# Check zoxide shell integration
zoxide doctor

# Verify terminal type
echo $TERM
# Should show: xterm-256color

# Starship: browse available presets
starship preset

# Powerlevel10k: re-run configuration wizard
p10k configure
```

### Common failure modes

&lt;Accordion label=&quot;Permission denied on brew install&quot; group=&quot;troubleshooting&quot;&gt;
Run `xcode-select --install` first to get the Xcode command-line tools, then retry the `brew install` command.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Font not showing in Wezterm&quot; group=&quot;troubleshooting&quot;&gt;
Restart Wezterm after installing the font. Run `wezterm ls-fonts --text &quot;Test&quot;` to confirm which font is active. Make sure the font name in your `.wezterm.lua` matches what Homebrew installed. The name is case-sensitive.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;zoxide z command not found&quot; group=&quot;troubleshooting&quot;&gt;
Make sure `eval &quot;$(zoxide init zsh)&quot;` is in your `~/.zshrc`. Open a new terminal tab or run `source ~/.zshrc`. Run `zoxide doctor` to diagnose further.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Prompt looks broken or empty&quot; group=&quot;troubleshooting&quot;&gt;
For Starship: run `starship preset` to browse presets, or check that `eval &quot;$(starship init zsh)&quot;` is the last prompt-related line in your `~/.zshrc`. For Powerlevel10k: run `p10k configure` to re-run the wizard. If colors look wrong in tmux, add `set -g default-terminal &quot;tmux-256color&quot;` to `~/.tmux.conf`.
&lt;/Accordion&gt;

## Alternative: Ghostty terminal

Ghostty 1.0 (released December 2024) is a macOS-native terminal written in Zig by Mitchell Hashimoto. It&apos;s fast, GPU-accelerated, and feels native on macOS in a way that few cross-platform terminals do.

Where Wezterm still has the edge:

- **Lua configuration**: Wezterm&apos;s config is a full programming language. Conditional logic, functions, dynamic values. Ghostty uses a simpler key-value format.
- **Built-in multiplexer**: Wezterm&apos;s panes and workspaces work without tmux. Ghostty has no built-in mux.
- **Cross-platform**: Same `.wezterm.lua` works on macOS, Linux, and Windows. Ghostty is still catching up on Linux.

If you&apos;re curious about Ghostty, see the [Ghostty terminal setup guide](/ghostty-terminal/). For a prompt setup that works great with either terminal, check the [Starship and Ghostty setup guide](/starship-ghostty-terminal/).

## Conclusion

You now have a terminal stack built from free, open-source tools: Wezterm as the emulator, Starship (or p10k) for the prompt, tmux for persistent sessions, and zoxide for fast directory jumping. Everything here runs on macOS with Homebrew. No paid apps, no subscriptions.

Wezterm&apos;s Lua config makes it uniquely flexible among modern terminals. You can tweak colors, keybindings, window behavior, and conditional logic all in one file. Start with the config above, then experiment.

If you want to go deeper, explore the [Starship and Ghostty setup guide](/starship-ghostty-terminal/) or browse the [top Fish shell plugins](/best-fish-shell-plugins/) if you&apos;re considering switching shells.</content:encoded><category>tools</category><category>wezterm</category><category>tmux</category><category>zoxide</category></item><item><title>How to Identify Processes Using Swap Space in Linux</title><link>https://www.bitdoze.com/swap-usage-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/swap-usage-linux/</guid><description>Find which processes use swap space in Linux with smem, /proc/VmSwap, and shell scripts. Covers swappiness tuning, swap troubleshooting, and container swap tracking.</description><pubDate>Fri, 07 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

When a Linux server starts swapping, performance tanks. The problem: standard tools like `docker stats` and even `top` don&apos;t always show you the real swap picture. You need to know which processes are actually consuming swap space so you can fix the root cause, not just throw RAM at it.

This guide covers three reliable approaches: `smem` (the tool I reach for first), reading `/proc` directly (no dependencies needed), and `vmstat` for detecting active swap thrashing. You&apos;ll also get practical coverage of swappiness tuning, container swap tracking with cgroups v2, and modern compressed swap with zswap/zram. All of it applies to any Linux distro: Ubuntu, Debian, RHEL, Fedora, and the rest.

If you need a broader refresher on Linux administration, start with these [essential Linux commands](https://www.bitdoze.com/linux-commands/).

## Quick diagnostic: 30-second swap health check

Before diving into per-process details, run these three commands to gauge whether you even have a problem.

&lt;Notice type=&quot;info&quot; title=&quot;Run these first&quot;&gt;
If swap is under 20% and vmstat shows si/so near zero, you&apos;re fine. The rest of this guide is optional reading.
&lt;/Notice&gt;

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;free -h&lt;/code&gt;: Shows total/used/available swap at a glance&lt;/li&gt;
&lt;li&gt;&lt;code&gt;smem -s swap -r -k | head&lt;/code&gt;: Top swap-consuming processes (install smem first if needed)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;vmstat 1 5&lt;/code&gt;: Shows if swap is actively being read/written (si/so columns)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
# 1. System-wide memory overview
free -h

# 2. Top 10 swap consumers
smem -s swap -r -k | head -10

# 3. Is swap actively being used? (5 samples, 1s interval)
vmstat 1 5
```

**What &quot;healthy&quot; looks like:**
- `free -h`: Swap used under 20% of total, &quot;available&quot; column still has headroom
- `smem`: Most processes show 0 kB swap, top consumer under 50 MB
- `vmstat`: `si` and `so` columns at 0 or near-zero

**What &quot;concerning&quot; looks like:**
- `free -h`: Swap used climbing past 50%, &quot;available&quot; under 1 GB
- `vmstat`: `si`/`so` consistently in the hundreds of KB/s, the system is actively thrashing

For more on [monitoring system resources on Linux](https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/), including alerting when swap usage spikes.

## Understanding Linux swap space

Swap space acts as overflow for physical RAM. When the kernel runs low on memory, it moves less-frequently-used pages to swap, a partition or file on disk. This prevents the OOM killer from firing immediately, but at a cost: disk I/O is orders of magnitude slower than RAM.

Without any swap, hitting the memory ceiling means the OOM killer starts terminating processes. With swap, the system degrades gradually instead of crashing, but excessive swapping (&quot;thrashing&quot;) can make the system feel frozen.

### Swap partition vs swap file

| | Swap Partition | Swap File |
|---|---|---|
| Setup | Dedicated partition on disk | Regular file in a filesystem |
| Performance | Slightly better on spinning disks (avoids filesystem overhead) | On SSDs, the difference is negligible |
| Flexibility | Fixed size, requires repartitioning to resize | Easy to create, resize, or remove |
| Caveats | N/A | btrfs has restrictions on swap files (must be on a non-compressed, non-COW subvolume) |

On a typical VPS or home server with SSDs, swap files are the pragmatic default. No partitioning needed, and you can [set up shared storage on Linux](https://www.bitdoze.com/setup-nfs-linux/) alongside swap without worrying about partition layout.

### How much swap should you allocate?

| System RAM | Recommended Swap | Notes |
|---|---|---|
| 2 GB or less | 2x RAM | Desktops, small VPS |
| 2-8 GB | Equal to RAM | General-purpose servers |
| 8-64 GB | 0.5x RAM | High-memory servers |
| 64 GB+ | Minimum 4 GB | Large production servers |

These are rough guidelines. Modern RHEL documentation says swap sizing depends on workload and whether you use hibernation, not just RAM size.

&lt;Notice type=&quot;info&quot; title=&quot;VPS swap defaults&quot;&gt;
Most cloud providers ship VPS instances with zero swap. For a server running Docker containers or databases, a 1-2 GB swap file is a cheap safety net. If you&apos;re [setting up a home server](https://www.bitdoze.com/best-mini-pc-home-server/), the same logic applies. A small swap file prevents OOM kills during memory spikes.
&lt;/Notice&gt;

## Check per-process swap usage with smem

`smem` is the best tool for this job. It reads swap data from `/proc` but gives you sorting, filtering, per-user views, and human-readable output. It&apos;s packaged in every major distro.

### Installing smem

&lt;Tabs&gt;
&lt;Tab name=&quot;Debian / Ubuntu&quot;&gt;
```bash
sudo apt install smem
```
&lt;/Tab&gt;
&lt;Tab name=&quot;RHEL / Fedora&quot;&gt;
```bash
sudo dnf install smem
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Verify the installation:

```bash
smem --version
```

**Failure mode:** If you get &quot;command not found&quot; after installing, your distro may not package it. Fallback: `pip install smem` (requires Python 3).

### Top swap-consuming processes

```bash
smem -s swap -r -k
```

Flags: `-s swap` sort by swap column, `-r` reverse (descending), `-k` show units in KB/MB/GB.

Sample output:

```
  PID User     Command                         Swap      USS      PSS      RSS
 1315 root     /usr/bin/python litellm         60.1M    89.2M    91.4M    98.1M
1746  root     /usr/bin/python gunicorn       109.4M   112.3M   114.8M   120.2M
1588  root     node next-server (v14)          41.5M    52.1M    54.3M    60.8M
 780  root     /usr/bin/containerd             3.3M     18.7M    19.2M    25.4M
 875  root     /usr/bin/dockerd                6.4M     42.1M    43.8M    52.3M
```

The **Swap** column is what matters here. USS (Unique Set Size) and PSS (Proportional Set Size) show physical memory usage, useful context but separate from swap.

### Filtering by process name

```bash
# Only postgres processes
smem -P postgres -s swap -r

# Per-user view (useful for multi-tenant servers)
smem -u -s swap -r

# Show percentages instead of absolute values
smem -s swap -r -p | head -20
```

These are practical for Docker/Dokploy hosts where you want to quickly check if a specific database or application is the swap hog.

## Using /proc to find swap usage by process

When you can&apos;t install `smem` (minimal containers, restricted environments), the `/proc` filesystem has everything you need. This is what `smem` reads under the hood.

### Reading VmSwap from /proc/PID/status

The kernel tracks per-process swap in `/proc/&lt;PID&gt;/status` as the `VmSwap` field:

```bash
# Check swap for a specific PID
grep VmSwap /proc/1438/status
```

```
VmSwap:      512 kB
```

One-liner to list all processes with non-zero swap:

```bash
for f in /proc/[0-9]*/status; do
    awk &apos;/^Name:/{n=$2} /^Pid:/{p=$2} /^VmSwap:/{if($2&gt;0) print p, $2, n}&apos; &quot;$f&quot; 2&gt;/dev/null
done | sort -k2 -nr | head -20 | column -t
```

**Failure mode:** &quot;No such file or directory&quot; errors are normal. A process can exit between the moment you start iterating and the moment you read its `/proc` entry. Ignore them.

### The smaps_rollup method (kernel 4.14+)

For an efficient aggregate of all memory mappings, `smaps_rollup` is faster than reading the full `smaps` file (which can be thousands of lines per process):

```bash
# All processes with non-zero swap, sorted descending
grep -H &apos;Swap:&apos; /proc/*/smaps_rollup 2&gt;/dev/null | awk -F&apos;[: ]&apos; &apos;{print $1, $4}&apos; | sort -t: -k2 -nr | head -20
```

`/proc/*/smaps_rollup` has been available since kernel 4.14 (2017). Every currently supported distro has it.

```bash
# Verify your kernel version
uname -r
```

Note: Reading the full `/proc/&lt;PID&gt;/smaps` for every process is expensive. `htop` had performance issues with this (see [htop issue #1712](https://github.com/htop-dev/htop/issues/1712)). Use `smaps_rollup` instead.

### Clean awk script (no dependencies)

Save this as `swap-users.sh` for a reusable, readable script:

```bash
#!/bin/bash
# swap-users.sh - List processes using swap, sorted by usage
printf &apos;%-10s %12s  %s\n&apos; &quot;PID&quot; &quot;Swap&quot; &quot;Command&quot;
for s in /proc/[0-9]*/status; do
    awk &apos;/^Name:/{n=$2} /^Pid:/{p=$2} /^VmSwap:/{if($2&gt;0) printf &quot;%-10s %10s kB  %s\n&quot;,p,$2,n}&apos; &quot;$s&quot; 2&gt;/dev/null
done | sort -k2 -n -r
```

Make it executable and run:

```bash
chmod +x swap-users.sh
./swap-users.sh
```

Output:

```
PID             Swap  Command
1746         112000 kB  gunicorn
1387          12416 kB  gunicorn
449142        17536 kB  node
1588          42496 kB  next-server
1315          61568 kB  litellm
812            9344 kB  unattended-upgr
875            6528 kB  dockerd
780            3328 kB  containerd
```

**Verify:** Output should be sorted by swap usage descending. Empty output means no processes are using swap (which is fine).

For a quick inline version without saving a file:

```bash
for f in /proc/[0-9]*/status; do
    awk &apos;/^Name:/{n=$2}/^Pid:/{p=$2}/^VmSwap:/{if($2&gt;0)print p,$2,n}&apos; &quot;$f&quot; 2&gt;/dev/null
done | sort -k2 -nr | head -20 | column -t
```

## Other tools: top, htop, and vmstat

The original article mentioned `top` and `htop` as monitoring tools. There are important caveats.

### top&apos;s SWAP column: what it actually shows

&lt;Notice type=&quot;warning&quot; title=&quot;top SWAP column trap&quot;&gt;
If your &lt;code&gt;top&lt;/code&gt; shows processes using hundreds of MB of swap while &lt;code&gt;free -h&lt;/code&gt; shows only a few MB total, you&apos;re running the old &lt;code&gt;procps&lt;/code&gt; top that computes SWAP = VIRT - RES. This is not real swap usage. It includes memory-mapped files, video memory, and other virtual memory. Upgrade to &lt;code&gt;procps-ng&lt;/code&gt; (standard on Ubuntu 16.04+, RHEL 7+, Debian 9+) or use &lt;code&gt;smem&lt;/code&gt; instead.
&lt;/Notice&gt;

On modern distros, `top` from procps-ng (version 3.3.10+) reads `VmSwap` from `/proc` correctly. Check your version:

```bash
top -v
```

If it shows `procps-ng` and a version ≥3.3.10, the SWAP column is accurate. If it shows just `procps` (no `-ng`), treat the SWAP column as fiction.

Source: [Red Hat KB 237633](https://access.redhat.com/solutions/237633), [htop FAQ](https://hisham.hm/htop/index.php?page=faq)

### Why htop doesn&apos;t have a SWAP column

htop deliberately omits a per-process swap column. The reason: shared memory pages make per-process swap accounting unreliable. A shared library page can be &quot;charged&quot; to multiple processes, inflating the numbers. The htop developers decided it was better to show no swap column than a misleading one.

Use `smem` or the `/proc` methods above for per-process swap data.

### Detecting swap thrashing with vmstat

`vmstat` shows real-time swap activity. The `si` (swap in) and `so` (swap out) columns are the key indicators:

```bash
vmstat 1 5
```

```
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
 1  0 294000 678528  12032 5712832    0    0     2    15   89   12  3  1 96  0  0
 0  0 294000 678528  12032 5712832    0    0     0     0  102   15  2  1 97  0  0
 0  0 294000 678528  12032 5712832    0    0     0     0   98   12  1  1 98  0  0
```

&lt;Notice type=&quot;info&quot; title=&quot;Interpreting si/so&quot;&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;si/so near zero&lt;/strong&gt;: Normal. Swap space is allocated but not actively being read/written.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;si/so in the hundreds of KB/s&lt;/strong&gt;: Actively swapping. Investigate with smem.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;si/so in the thousands of KB/s&lt;/strong&gt;: Thrashing. The system is spending more time swapping than doing work. Take action immediately.&lt;/li&gt;
&lt;/ul&gt;
&lt;/Notice&gt;

With `sysstat` installed, you can also use:

```bash
# Swap activity report (10 samples, 1s interval)
sar -S 1 10

# Page fault and paging rate
sar -B 1 10
```

If swap thrashing is impacting your server performance, consider [benchmarking your cloud server](https://www.bitdoze.com/benchmark-cloud-servers/) to compare upgrade options.

## Swap and Docker containers

If you run Docker or Dokploy, this section is critical. Standard container monitoring doesn&apos;t show swap at all.

### Why docker stats doesn&apos;t show swap

`docker stats` shows a &quot;MEM USAGE&quot; column, but that&apos;s RSS + page cache. It does not include swap. A container could be swapping heavily and `docker stats` would show nothing unusual.

This is a common blind spot for anyone [monitoring server and Docker resources](https://www.bitdoze.com/sever-monitoring/). The memory numbers in `docker stats` look fine while the system is actually thrashing because a container&apos;s anonymous pages are piling up in swap.

For [Docker management commands](https://www.bitdoze.com/docker-commands/) beyond memory monitoring.

### Tracking container swap with cgroups v2

On systems with cgroups v2 (default on Ubuntu 22.04+, Fedora 31+, Debian 11+), you can read per-container swap usage directly:

```bash
# Check cgroups version (should show &quot;cgroup2fs&quot;)
stat -fc %T /sys/fs/cgroup/

# Swap used by a specific container
cat /sys/fs/cgroup/system.slice/docker-&lt;container-id&gt;.scope/memory.swap.current
```

The value is in bytes. Convert to MB: divide by 1048576.

Loop through all containers:

```bash
for cg in /sys/fs/cgroup/system.slice/docker-*.scope; do
    name=$(basename &quot;$cg&quot;)
    bytes=$(cat &quot;$cg/memory.swap.current&quot; 2&gt;/dev/null)
    if [ -n &quot;$bytes&quot; ] &amp;&amp; [ &quot;$bytes&quot; -gt 0 ]; then
        mb=$((bytes / 1048576))
        echo &quot;$name: ${mb} MB&quot;
    fi
done
```

&lt;Notice type=&quot;info&quot; title=&quot;Requires cgroups v2&quot;&gt;
This only works on cgroups v2. If &lt;code&gt;stat -fc %T /sys/fs/cgroup/&lt;/code&gt; returns &lt;code&gt;tmpfs&lt;/code&gt; instead of &lt;code&gt;cgroup2fs&lt;/code&gt;, you&apos;re on cgroups v1. Enabling cgroups v2 requires kernel boot parameters. Check your distro documentation.
&lt;/Notice&gt;

Also useful: if you&apos;re [reclaiming disk space from Docker](https://www.bitdoze.com/clean-docker-overlay2-dir/), remember that swap files also consume disk space.

## Tuning swap behavior with vm.swappiness

The existing swappiness guidance in most articles is oversimplified or outdated. Here&apos;s what actually matters.

### What swappiness actually controls

Swappiness is not &quot;how aggressively the kernel swaps.&quot; It controls the kernel&apos;s weighting between reclaiming two types of memory pages:

- **File-backed pages** (page cache) — reading them back from disk is fast (sequential I/O on the filesystem)
- **Anonymous pages** (application memory) — reading them back requires swap I/O (random I/O)

A higher swappiness value tells the kernel to prefer evicting anonymous pages to swap. A lower value tells it to prefer dropping page cache.

On SSDs, the cost of reading swap is similar to reading page cache, so higher swappiness values (100) are reasonable. On spinning disks, swap reads are expensive random I/O, so lower values make more sense.

Source: Chris Down, [In defence of swap](https://chrisdown.name/2018/01/02/in-defence-of-swap.html)

### Swappiness 0-200: kernel 5.8+ changes

Since kernel 5.8 (August 2020), `vm.swappiness` accepts values 0-200, not just 0-100. Values above 100 bias more heavily toward swapping anonymous pages.

Critical distinction:

| Value | Behavior |
|---|---|
| **0** | Never swap anonymous pages unless the system is near OOM (special semantics since kernel 3.5, 2012) |
| **1** | Lowest &quot;normal&quot; value — avoids swap but doesn&apos;t have the special-case behavior of 0 |
| **60** | Default on most distros. Balanced for general use |
| **100** | Treats file and anonymous pages equally. Good default for SSD-based systems |

### Persistent configuration with sysctl.d

The old approach of appending to `/etc/sysctl.conf` works but is messy. The modern method uses drop-in files:

```bash
# Check current value
sysctl vm.swappiness

# Apply temporarily (resets on reboot)
sudo sysctl -w vm.swappiness=60

# Apply permanently with a drop-in file
echo &apos;vm.swappiness=60&apos; | sudo tee /etc/sysctl.d/99-swap.conf
sudo sysctl --system
```

Verify the change took effect:

```bash
sysctl vm.swappiness
```

**Failure mode:** Value not persisting after reboot? Another file in `/etc/sysctl.d/` might be overriding it. Check with:

```bash
sudo sysctl --system 2&gt;&amp;1 | grep swappiness
```

## Modern swap technologies: zswap and zram

If you&apos;re running Linux 5.x+, there are better options than raw disk swap alone.

### zswap: compressed write-back cache

zswap sits between the kernel&apos;s memory allocator and disk swap. When the kernel wants to swap a page out, zswap compresses it first. If the compressed page fits in a RAM cache, no disk I/O happens. When the cache fills up, the least-recently-used pages get written to disk swap automatically.

This is the &quot;enable and forget&quot; option for most servers. On a VPS with SSD-backed swap, zswap reduces disk swap writes significantly.

&lt;Notice type=&quot;info&quot; title=&quot;Enable zswap on SSD-based servers&quot;&gt;
Add &lt;code&gt;zswap.enabled=1&lt;/code&gt; to your kernel command line (via GRUB or bootloader config). It&apos;s a free performance win — compressed pages stay in RAM when possible, and only hit disk when necessary.
&lt;/Notice&gt;

Check if zswap is active:

```bash
dmesg | grep zswap
cat /sys/module/zswap/parameters/enabled
```

### zram: compressed RAM block device

zram creates a compressed block device entirely in RAM and uses it as swap. No disk I/O at all. Everything stays in memory, just compressed. Fedora uses zram by default (paired with `systemd-oomd`).

The tradeoff: zram has a hard capacity limit (typically 50% of RAM) and can cause LRU inversion when used alongside disk swap. It&apos;s best for memory-constrained systems like laptops and containers, not general-purpose servers.

For most VPS/Dokploy setups, zswap with disk swap is the better choice.

## Swap troubleshooting and verification

### How to verify swap is active

```bash
# List all active swap devices with priorities
cat /proc/swaps

# Human-readable version
swapon --show

# Quick check
free -h
```

If `swapon --show` returns nothing, you have no swap configured. For most cloud VPS instances, this is the default — you need to [create a swap file](#how-much-swap-should-you-allocate) manually.

### When swap is normal vs. when it&apos;s a problem

&lt;Notice type=&quot;success&quot; title=&quot;Normal&quot;&gt;
Swap used 10–20%, vmstat si/so near zero. The kernel is keeping cold pages in swap to free up RAM for active workloads. This is expected behavior and not a problem.
&lt;/Notice&gt;

&lt;Notice type=&quot;error&quot; title=&quot;Problem&quot;&gt;
Swap growing steadily over time, vmstat si/so consistently in the hundreds of KB/s or higher. The system is thrashing — spending more time swapping pages than doing actual work. Add RAM, kill processes, or optimize memory-hungry applications.
&lt;/Notice&gt;

The distinction: swap *allocation* (pages sitting in swap) is fine. Swap *activity* (pages being constantly moved between RAM and swap) is the problem. `vmstat` si/so columns tell you which one you have.

### How to safely clear swap

To move all swap pages back to RAM:

```bash
sudo swapoff -a &amp;&amp; sudo swapon -a
```

&lt;Notice type=&quot;warning&quot; title=&quot;Check free RAM first&quot;&gt;
Before clearing swap, run &lt;code&gt;free -h&lt;/code&gt; and confirm that available RAM is greater than swap used. If there&apos;s not enough free RAM, &lt;code&gt;swapoff&lt;/code&gt; will hang or trigger the OOM killer. Stop memory-heavy services first if needed.
&lt;/Notice&gt;

Verify swap cleared:

```bash
free -h
# Swap used should drop to near zero
```

### Using systemd-oomd to prevent swap storms

On Ubuntu 22.04+ and Fedora, `systemd-oomd` is enabled by default. It uses PSI (Pressure Stall Information) to detect when the system is under memory pressure and kills memory-hungry processes before swap fills up completely.

Check if it&apos;s running:

```bash
systemctl status systemd-oomd
```

If it&apos;s not active and you&apos;re on Ubuntu 22.04+ or Fedora, enable it:

```bash
sudo systemctl enable --now systemd-oomd
```

For older distros, `earlyoom` is a simpler alternative available in most repos:

```bash
sudo apt install earlyoom    # Debian/Ubuntu
sudo dnf install earlyoom    # RHEL/Fedora
sudo systemctl enable --now earlyoom
```

## Conclusion

Three things to remember about swap on Linux:

1. **Use `smem` as your go-to tool.** `smem -s swap -r -k` gives you the clearest, most accurate per-process swap picture. Fall back to `/proc` scripts when you can&apos;t install packages.

2. **vmstat si/so is the real danger signal.** Swap allocation is normal. Swap activity (high si/so) means the system is thrashing and needs attention.

3. **Enable zswap on SSD-based servers.** It&apos;s a one-line kernel parameter that reduces disk swap writes for free.

If you&apos;re consistently hitting swap limits, the fix is usually more RAM — not more tuning. For affordable VPS upgrades, [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) starts at a few euros per month with SSD storage. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) and [Vultr](https://go.bitdoze.com/vultr) are solid alternatives with global datacenter coverage. [DigitalOcean](https://go.bitdoze.com/do) is another option if you want a developer-friendly platform.

For more Linux administration guides, check out our [essential Linux commands](https://www.bitdoze.com/linux-commands/) reference and the guide to [monitoring system resources on Linux](https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/) with email alerts.</content:encoded><category>linux</category><category>linux</category><category>swap</category><category>memory-management</category></item><item><title>Configure Postfix to Send Email Using External SMTP Servers</title><link>https://www.bitdoze.com/postfix-external-smtp/</link><guid isPermaLink="true">https://www.bitdoze.com/postfix-external-smtp/</guid><description>Configure Postfix to relay emails through external SMTP servers like Brevo, Mailgun, or Amazon SES. Step-by-step setup guide with an automated script.</description><pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

Postfix is a mail transfer agent (MTA) that handles sending, routing, and delivering email on Unix-like systems. Most VPS setups only need a send-only SMTP server. Something that takes outgoing mail from your applications and hands it off to a real provider. That&apos;s exactly what configuring Postfix to send email using external SMTP servers gives you: a lightweight local relay that forwards everything through Brevo, Mailgun, Amazon SES, or another provider you trust.

This postfix send-only SMTP server setup is useful for everything from cron job notifications to [sending email alerts from your Linux server](/monitor-cpu-usage-and-send-email-alerts-in-linux). Below you&apos;ll get the full configuration, an automated script, and the troubleshooting I wish I&apos;d had the first time around.

## Why use Postfix with an external SMTP service

Running your own mail server that delivers directly to recipients is a headache you don&apos;t want. IP reputation management, blacklist monitoring, DKIM key rotation. It&apos;s a full-time job. External SMTP providers handle all of that. Here&apos;s why the relay pattern works:

- **Better deliverability.** Providers like Brevo and Mailgun maintain relationships with Gmail, Outlook, and Yahoo. Your emails actually reach inboxes instead of spam folders.
- **Reduced server load.** Postfix hands off the message to the provider&apos;s servers. Your VPS doesn&apos;t maintain outbound SMTP connections to dozens of recipient domains.
- **Simplified maintenance.** No SPF/DKIM key management on your server, no IP blacklist monitoring, no reverse DNS configuration. The provider handles it.
- **Cost-effective.** Free tiers from Brevo (300/day) or SMTP2GO (1,000/month) cover most personal and small-business workloads.
- **Scalability.** Need to send 50,000 emails? Your VPS config doesn&apos;t change. You just upgrade your provider plan.
- **Compliance.** External providers help with GDPR, CAN-SPAM, and CASL requirements through built-in unsubscribe handling and consent tracking.

## SMTP relay services you can use with Postfix

Not all SMTP providers are equal on pricing, deliverability, and ease of setup. Here&apos;s a comparison of the ones worth considering, then details on each.

| Provider | Free Tier | Paid Starting Price | Notes |
|----------|-----------|-------------------|-------|
| **Brevo** | 300/day (~9,000/month) | $9/month (Starter) | Best free option, permanent free tier |
| **Mailgun** | 100/day (~3,000/month) | $15/month (Basic) | Permanent free tier, strong API |
| **Amazon SES** | 3,000 messages/month (12 months) | $0.10/1,000 emails | Cheapest at scale, best for AWS users |
| **mail.baby** | None | $1/month + $0.20/1,000 | Cheapest paid option, unlimited domains |
| **SMTP2GO** | 1,000/month | $15/month (Starter) | No credit card needed, good SendGrid replacement |
| **Resend** | 3,000/month (100/day) | $20/month (Pro) | Modern, API-first, developer-focused |
| **Zeptomail** | 10,000 emails (1-month trial) | Credit-based | Zoho ecosystem, pay-as-you-go after trial |
| **SendGrid** | 60-day trial only | $19.95/month (Essentials) | No longer has a permanent free tier |

&lt;Notice type=&quot;warning&quot; title=&quot;Pricing changes frequently&quot;&gt;
Verify current tiers on each provider&apos;s website before committing. Prices listed here are accurate as of July 2025.
&lt;/Notice&gt;

### Brevo free SMTP relay

[Brevo](https://www.brevo.com/) (formerly Sendinblue) remains the best free SMTP relay for Postfix. The permanent free tier gives you 300 emails per day (about 9,000 per month) with no credit card required.

- SMTP server: `smtp-relay.brevo.com:587`
- Good deliverability rates across major providers
- Email tracking and templating on the free plan
- API access if you need more than SMTP

For most personal projects and small VPS workloads, Brevo&apos;s free tier is enough. I default to it unless there&apos;s a reason not to.

### Mailgun SMTP relay

[Mailgun](https://www.mailgun.com/) updated its free tier. It&apos;s now **100 emails per day** on a permanent free plan (no expiration). Previously it was 5,000 emails for 3 months, which sounded better but had a time limit.

- Powerful API for developers who need more than basic SMTP
- Good documentation and support
- Email validation and domain authentication included
- Flexible pricing if you outgrow the free tier

### Amazon SES SMTP relay

Amazon SES pricing changed significantly. The old &quot;62,000 free emails from EC2&quot; tier was replaced in August 2023. The current model:

- **3,000 message charges per month free for the first 12 months** after you start using SES
- After that: $0.10 per 1,000 emails (plus data transfer)
- New AWS customers (as of July 2025) can get up to $200 in Free Tier credits across AWS services, valid for 12 months

SES is the cheapest option at scale but requires more setup (IAM policies, sandbox mode exit). Best if you&apos;re already running on AWS and need high volume.

### mail.baby SMTP relay

[mail.baby](https://www.mail.baby/) is the budget option: $1/month plus $0.20 per 1,000 emails. No free tier, but the pricing is hard to beat for low-volume paid email. Unlimited domains on all plans, simple setup, 24/7 support.

For more details, see the [detailed mail.baby review](/mail-baby-review).

### SMTP2GO

[SMTP2GO](https://www.smtp2go.com/) is a solid alternative now that SendGrid killed its free tier. The free forever plan includes 1,000 emails per month with no credit card required. Setup is straightforward, deliverability is good, and the dashboard is clean.

### Resend

[Resend](https://resend.com/) is a newer, API-first SMTP provider aimed at developers. Free tier: 3,000 emails/month (100/day). The modern developer experience and React Email integration make it appealing if you&apos;re building apps, though it works fine as a plain SMTP relay too.

### Zeptomail SMTP relay

[Zeptomail](https://www.zoho.com/zeptomail/) from Zoho no longer has a perpetual free tier. You get **one free credit (10,000 emails) valid for 1 month** as a trial. After that, it&apos;s credit-based pricing (1 credit = 10,000 emails, credits valid for 6 months). Pricing is not publicly listed in USD. You contact sales.

Good if you&apos;re in the Zoho ecosystem. For a [step-by-step ZeptoMail SMTP relay guide](/how-to-setup-smtp-relay-email-on-zeptomail), see the dedicated article.

### SendGrid SMTP (trial only)

&lt;Notice type=&quot;warning&quot; title=&quot;SendGrid retired its free plan in May 2025&quot;&gt;
New accounts get a 60-day free trial (100 emails/day), then paid plans start at $19.95/month (Essentials). There is no longer a permanent free tier. Consider SMTP2GO, Brevo, or Mailgun as free alternatives.
&lt;/Notice&gt;

[SendGrid](https://sendgrid.com/) is owned by Twilio. It&apos;s still a capable platform with high deliverability and solid APIs. But at $19.95/month minimum, it&apos;s no longer the go-to for hobby projects. I&apos;d pick Brevo or SMTP2GO for free-tier needs.

## Prerequisites

Before configuring Postfix, make sure you have:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A Linux VPS with Debian/Ubuntu (this guide uses apt commands)&lt;/li&gt;
&lt;li&gt;A domain with DNS records (SPF, DKIM, DMARC) configured with your SMTP provider&lt;/li&gt;
&lt;li&gt;An SMTP provider account with credentials ready&lt;/li&gt;
&lt;li&gt;Root or sudo access&lt;/li&gt;
&lt;li&gt;Outbound port 587 (STARTTLS) unblocked by your VPS provider&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Install required packages

```sh
sudo apt update &amp;&amp; sudo apt install -y postfix libsasl2-modules mailutils
```

&lt;Notice type=&quot;error&quot; title=&quot;The #1 failure: missing libsasl2-modules&quot;&gt;
Without `libsasl2-modules`, Postfix cannot authenticate with your SMTP provider. You&apos;ll get &quot;SASL authentication failure: No worthy mechs found&quot; in the mail log. This package is not installed by default on minimal Debian/Ubuntu images. Always include it.
&lt;/Notice&gt;

Also make sure CA certificates are installed (for TLS verification):

```sh
sudo apt install -y ca-certificates
```

**Verify:** Run `postconf -m | grep sasl`. You should see SASL mechanisms listed (LOGIN, PLAIN, etc.). If the command returns nothing, `libsasl2-modules` is missing.

### DNS records (SPF, DKIM, DMARC)

Your emails will land in spam regardless of your Postfix configuration if DNS records are missing. Your SMTP provider gives you the specific values to add. At minimum, you need:

- **SPF TXT record**: authorizes your provider to send on behalf of your domain (e.g., `v=spf1 include:brevo.com ~all`)
- **DKIM public key**: added as a TXT record so recipient servers can verify message signatures
- **DMARC policy**: tells receiving servers what to do with messages that fail SPF/DKIM checks

Each provider has its own documentation for these. Set them up before testing. Otherwise your test emails will go straight to spam.

### VPS port requirements

Many budget VPS providers block outbound ports 25, 587, and 465 by default to prevent spam. This includes [Hetzner](https://go.bitdoze.com/hetzner), OVH, Contabo, and Netcup. You&apos;ll need to open a support ticket asking them to unblock port 587 (STARTTLS) for your server.

To [check if your SMTP ports are reachable](/check-remote-port-in-linux-nc), use `nc`:

```sh
nc -zv smtp-relay.brevo.com 587
```

A successful connection shows `Connection to smtp-relay.brevo.com 587 port [tcp/submission] succeeded!`. If it hangs, your provider is blocking the port.

## Configure Postfix as an external SMTP relay

There are two paths: manual configuration or an automated script. Both produce the same result.

&lt;Tabs&gt;
&lt;Tab name=&quot;Manual Configuration&quot;&gt;

### Manual Postfix relayhost configuration

The manual approach takes 5-10 minutes. You&apos;ll edit a few files and restart Postfix. If you&apos;re comfortable with the [essential Linux commands](/linux-commands), this is straightforward.

**Step 1:** Install prerequisites (see the Prerequisites section above).

**Step 2:** Edit the main Postfix configuration file:

```sh
sudo nano /etc/postfix/main.cf
```

Add or modify these lines at the end of the file:

```sh
compatibility_level = 3.11
relayhost = [smtp-relay.brevo.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = lmdb:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = may
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
inet_interfaces = loopback-only
header_size_limit = 4096000
```

A few things to note about this config:

- **`smtp_sasl_security_options = noanonymous`**: this overrides the Postfix default of `noplaintext, noanonymous`. Without the override, PLAIN and LOGIN auth methods (which most providers use) would be rejected. We set `noanonymous` explicitly to allow PLAIN/LOGIN over TLS.
- **`lmdb:`**: on Postfix 3.11+ (current stable), `lmdb:` is the forward-compatible table type. Older setups use `hash:`. Both work today, but `lmdb:` is the direction Postfix is heading.
- **`inet_interfaces = loopback-only`**: for a send-only setup, this prevents Postfix from listening on public interfaces. Reduces attack surface.
- **`smtp_tls_CAfile`**: ensures TLS certificate verification works on minimal installs where CA certs might not be in the default path.

Replace `smtp-relay.brevo.com` with your provider&apos;s SMTP server.

**Step 3:** Create the SASL password file:

```sh
sudo nano /etc/postfix/sasl_passwd
```

Add this line:

```sh
[smtp-relay.brevo.com]:587 your_username:your_password
```

Replace with your actual SMTP credentials.

**Step 4:** Generate the lookup table:

```sh
# Postfix 3.11+ (lmdb):
sudo postmap lmdb:/etc/postfix/sasl_passwd

# Older Postfix (hash):
# sudo postmap hash:/etc/postfix/sasl_passwd
```

**Step 5:** Secure the credential files:

```sh
sudo chown root:root /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
sudo chmod 0600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
```

**Step 6:** (Optional) Rewrite the sender address for all outgoing mail. This is useful when your system sends mail as `root@hostname` but you want everything to come from a real address.

```sh
sudo nano /etc/postfix/sender_canonical
```

Add:

```sh
/.+/    your_email@example.com
```

Create the header checks file:

```sh
sudo nano /etc/postfix/smtp_header_checks
```

Add:

```sh
/From:.*/ REPLACE From: your_email@example.com
```

Enable the rewriting:

```sh
sudo postconf -e &apos;sender_canonical_classes = envelope_sender, header_sender&apos;
sudo postconf -e &apos;sender_canonical_maps = regexp:/etc/postfix/sender_canonical&apos;
sudo postconf -e &apos;smtp_header_checks = regexp:/etc/postfix/smtp_header_checks&apos;
```

**Step 7:** Set the mailname and restart:

```sh
echo &quot;yourdomain.com&quot; | sudo tee /etc/mailname
sudo systemctl restart postfix
```

**Verify:** Run `postfix check`. It should return no errors. Then `systemctl status postfix` should show `active (exiting)`.

&lt;/Tab&gt;
&lt;Tab name=&quot;Automated Script&quot;&gt;

### Using the automated Postfix setup script

The script handles everything in one go: package installation, configuration, credential setup, optional sender rewriting, and restart. I put it together for repeatable deployments across multiple VPS instances.

**Step 1:** Download the script:

```sh
curl -sSL https://utils.bitdoze.com/scripts/postfix-setup.sh -o postfix-setup.sh
```

**Step 2:** Make it executable and run:

```sh
chmod +x postfix-setup.sh
bash postfix-setup.sh
```

**Step 3:** Follow the prompts. The script will ask for:
- Your SMTP username
- Your SMTP password
- The domain you&apos;re using with your SMTP provider
- A sender email address (press Enter to skip)
- Your Postfix hostname
- Your SMTP server address and port (e.g., `[smtp-relay.brevo.com]:587`)

**Example output:**

```sh
root@cloud:/var/log# bash postfix-setup.sh
[2024-09-18 06:04:42] Step 1: Make sure you have already set up your domain with your SMTP provider and added any necessary DNS records (like SPF, DKIM, and CNAME).
Enter your SMTP Username: bitdoze1@gmail.com
Enter your SMTP Password:
Enter the domain you are using with your SMTP provider (e.g. example.com): bitdoze.ro
Enter the sender email address (optional, press Enter to skip):
Enter your Postfix hostname (e.g. yourdomain.com): bitdoze.ro
Enter your SMTP server with port (e.g. [smtp.provider.com]:587): [smtp-relay.brevo.com]:587
[2024-09-18 06:05:20] Step 2: Updating system and installing Postfix...
[2024-09-18 06:05:23] Step 3: Configuring Postfix...
[2024-09-18 06:05:23] Backed up /etc/postfix/main.cf to /etc/postfix/main.cf.bak
[2024-09-18 06:05:23] Step 4: Creating /etc/postfix/sasl_passwd file with SMTP credentials...
[2024-09-18 06:05:23] Backed up /etc/postfix/sasl_passwd to /etc/postfix/sasl_passwd.bak
[2024-09-18 06:05:23] Securing /etc/postfix/sasl_passwd and creating hash...
[2024-09-18 06:05:25] Step 5: Configuring sender address settings...
[2024-09-18 06:05:25] Resetting sender address configuration...
[2024-09-18 06:05:25] Step 6: Configuring /etc/mailname...
[2024-09-18 06:05:25] Backed up /etc/mailname to /etc/mailname.bak
[2024-09-18 06:05:25] Step 7: Restarting Postfix...
postfix/postfix-script: refreshing the Postfix mail system
[2024-09-18 06:05:27] All done! Postfix has been configured with your SMTP settings.
```

**Verify:** After the script completes, run `systemctl status postfix`. It should show `active`. Send a test email (see the testing section below) to confirm delivery.

### What the script does

The script automates these steps:

1. **Backs up existing config files**: creates `.bak` copies of `main.cf`, `sasl_passwd`, and `mailname` before touching them. If something goes wrong, you can roll back.
2. **Installs required packages**: runs `apt update` and installs `mailutils` (and `libsasl2-modules` if the script has been updated).
3. **Configures main.cf**: sets relayhost, SASL auth, TLS, and other relay settings.
4. **Creates SASL credentials**: writes the `sasl_passwd` file, sets permissions to `0600`, and generates the lookup table with `postmap`.
5. **Optional sender rewriting**: if you provide a sender email, sets up `sender_canonical` and `smtp_header_checks` so all outgoing mail uses that address.
6. **Restarts Postfix**: applies the new configuration.

Throughout the process, the script logs each step and checks for errors. If anything fails, it stops and reports the issue rather than continuing with a broken config.

&lt;/Tab&gt;
&lt;/Tabs&gt;

YouTube embed of the one-click setup walkthrough:

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/lEPuB0Eh8Sg&quot;
  label=&quot;Postfix 1 Click Setup&quot;
/&gt;

## Port 465 with implicit TLS (optional)

Port 587 with STARTTLS is the most common and well-tested setup. But many providers (including Brevo) also support **port 465 with implicit TLS** (sometimes called SMTPS or wrappermode). RFC 8314 now recommends port 465 for implicit TLS alongside 587 for STARTTLS.

To use port 465, change your relayhost and add one line to `main.cf`:

```sh
relayhost = [smtp-relay.brevo.com]:465
smtp_tls_wrappermode = yes
```

That&apos;s it. The rest of the config stays the same.

&lt;Notice type=&quot;info&quot; title=&quot;Stick with port 587 unless your provider requires 465&quot;&gt;
Port 587 with STARTTLS is the most widely tested combination. Port 465 works on modern Postfix versions, but some older distributions have quirks with wrappermode. Default to 587 unless your provider specifically requires 465.
&lt;/Notice&gt;

## Testing and verifying your Postfix SMTP relay

After configuration (whether manual or script), verify everything works before trusting it for real email.

### Send a test email

```sh
echo &quot;Postfix relay test&quot; | mail -s &quot;Test Email&quot; recipient@example.com
```

With a custom sender address:

```sh
echo &quot;Postfix relay test&quot; | mail -s &quot;Test Email&quot; -r sender@yourdomain.com recipient@example.com
```

Replace `recipient@example.com` with an address you can check.

### Check the mail log

Watch the log in real time while sending:

```sh
tail -f /var/log/mail.log
```

Or check recent entries:

```sh
tail -100 /var/log/mail.log
```

A successful delivery looks like:

```sh
Jul 17 10:30:45 hostname postfix/smtp[12345]: 1AB2C3D4E5F: to=&lt;recipient@example.com&gt;, relay=smtp-relay.brevo.com[1.2.3.4]:587, delay=0.8, delays=0.02/0.00/0.5/0.28, dsn=2.0.0, status=sent (250 2.0.0 OK 1234567890abcdef)
```

The key is `status=sent`. If you see `status=bounced`, `status=deferred`, or `status=failed`, read the error message that follows. It usually tells you exactly what&apos;s wrong.

You can also use `journalctl`:

```sh
journalctl -u postfix -f
```

### Verify deliverability

1. **Send to multiple domains**: test with Gmail, Outlook, and Yahoo. Each has different spam filters.
2. **Check spam folders**: even with correct config, new domains/IPs sometimes land in spam until reputation builds up.
3. **Use mail-tester.com**: send a test email to the address they give you, then check their score. They&apos;ll flag SPF/DKIM/DMARC issues, blacklists, and content problems.
4. **Check IP reputation**: use MXToolbox to verify your VPS IP isn&apos;t on any blacklists (this is separate from your SMTP provider&apos;s reputation).

## Troubleshooting common Postfix SMTP relay errors

This is the section I wish existed when I first set up Postfix relay. Here are the errors you&apos;re most likely to hit.

### &quot;No worthy mechs found&quot;: SASL authentication failure

**Symptom:** In `/var/log/mail.log`:

```sh
warning: SASL authentication failure: No worthy mechs found
```

**Cause:** `libsasl2-modules` is not installed. This is the most common failure for new Postfix relay setups.

**Fix:**

```sh
sudo apt install -y libsasl2-modules
sudo systemctl restart postfix
```

Verify SASL mechanisms are available: `postconf -m | grep sasl`

### &quot;Authentication failed&quot;: wrong credentials

**Symptom:** `SASL authentication failed` or `authentication failed` in the mail log.

**Cause:** Incorrect username or password in `/etc/postfix/sasl_passwd`. Some providers (Brevo, Gmail) require app-specific passwords or SMTP keys, not your regular login password.

**Fix:**

1. Verify your credentials by logging into your provider&apos;s dashboard
2. Check the sasl_passwd file: `sudo cat /etc/postfix/sasl_passwd`
3. Regenerate the lookup table: `sudo postmap lmdb:/etc/postfix/sasl_passwd`
4. Restart: `sudo systemctl restart postfix`

### &quot;Connection timed out&quot;: blocked SMTP ports

&lt;Notice type=&quot;warning&quot; title=&quot;Budget VPS providers often block SMTP ports&quot;&gt;
Hetzner, OVH, Contabo, and many other providers block outbound port 587 and 465 by default. You&apos;ll need to open a support ticket asking them to unblock port 587 for your server. Some providers require you to have a paid account for a certain period before they&apos;ll unblock SMTP.
&lt;/Notice&gt;

**Symptom:** `Connection timed out` or `Network is unreachable` in the mail log.

**Cause:** Your VPS provider is blocking outbound SMTP traffic.

**Fix:** Open a support ticket with your hosting provider asking to unblock port 587. To verify, test the connection: `nc -zv smtp-relay.brevo.com 587`

### &quot;Relay access denied&quot;

**Symptom:** `relay access denied` in the mail log.

**Cause:** The `relayhost` directive is missing or misconfigured, or SASL authentication isn&apos;t working (Postfix tries to deliver directly and gets rejected by the recipient server).

**Fix:** Verify `relayhost` is set in `/etc/postfix/main.cf`:

```sh
postconf relayhost
```

Should return something like `[smtp-relay.brevo.com]:587`. If empty, add it and restart Postfix.

### Emails landing in spam

**Symptom:** Emails are sent successfully (`status=sent` in the log) but recipients don&apos;t see them in their inbox.

**Cause:** Missing or incorrect SPF, DKIM, or DMARC DNS records. This is a DNS configuration issue, not a Postfix issue.

**Fix:**

1. Log into your SMTP provider&apos;s dashboard and check domain authentication status
2. Verify SPF, DKIM, and DMARC records exist in your domain&apos;s DNS zone
3. Use mail-tester.com to get a detailed deliverability report
4. Check MXToolbox for DNS and blacklist issues

## Conclusions

Configuring Postfix to relay through an external SMTP server is the reliable path for VPS email delivery. You get the flexibility of a local MTA without the pain of managing your own mail server reputation. Brevo remains my default recommendation for free-tier needs. 300 emails/day covers most workloads.

Always test thoroughly before relying on the setup for production email. Check the mail log, verify deliverability across multiple providers, and make sure your DNS records are solid.

If you also need to [send WordPress emails via SMTP](/send-emails-in-wordpress-zoho-smtp-fluentsmtp), the same SMTP provider credentials work with plugins like FluentSMTP. For next steps on [securing your server](/bsi-security-report-docker-ufw), see the Docker security guide, or [harden your Linux server](/secure-ssh-server-linux) with the SSH hardening walkthrough.

&lt;Button text=&quot;Explore More Linux Guides&quot; link=&quot;/linux-commands&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>linux</category><category>linux</category><category>postfix</category><category>smtp</category></item><item><title>Sink Install: Free Self-Hosted Link Shortener (2026 Guide)</title><link>https://www.bitdoze.com/sink-install/</link><guid isPermaLink="true">https://www.bitdoze.com/sink-install/</guid><description>Deploy Sink, a free open-source link shortener with analytics, on Cloudflare Workers. Step-by-step guide with D1 database, KV cache, and AI-powered slug generation.</description><pubDate>Thu, 06 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/09/ai-analytics.png&quot;;
import img2 from &quot;../../assets/images/24/09/sink-ui.png&quot;;

[Sink](https://github.com/miantiao-me/Sink) is an open-source, free self-hosted link shortener that runs entirely on Cloudflare&apos;s edge network. Originally created by ccbikai, the project has moved to the `miantiao-me` GitHub organization and now has around 7,000 stars. With v0.3.0, the storage layer switched from KV to Cloudflare D1 as the primary database, a breaking change that makes the old installation guide outdated. This article covers deploying Sink on Cloudflare Workers with the current D1-based architecture, updated for 2026.

## What is Sink?

Sink is a self-hosted link shortener that runs on Cloudflare Workers. It uses D1 (Cloudflare&apos;s SQL database) for permanent link storage and KV as a write-through read cache for fast redirects. The Cloudflare free tier is enough for personal and small-team use. No VPS, no Docker containers, no server to maintain.

The project is now at v0.3.0 (latest stable: v0.2.11 if you want the pre-D1 version). The repo lives at [github.com/miantiao-me/Sink](https://github.com/miantiao-me/Sink). It&apos;s licensed under AGPL-3.0.

## Why choose a free self-hosted link shortener?

Hosted link shorteners like Bitly start at $8/month. Dub.co has a free tier but limits features behind a paywall. Short.io caps you on the free plan. Sink costs nothing if you stay within Cloudflare&apos;s free tier limits.

The self-hosting angle matters: you own your data, there&apos;s no vendor lock-in, and you get analytics, AI-powered slug generation, and features like password protection and geo-based routing. All included, no upsells.

If you&apos;re building a home server with [Docker containers](/docker-containers-home-server), Sink is a lightweight addition to your self-hosted stack, though it runs on Cloudflare, not on your server. For business use, [Sink complements other self-hosted tools](/docker-containers-business) in your stack.

&lt;Notice type=&quot;info&quot; title=&quot;Don&apos;t want to self-host?&quot;&gt;
The same author offers [S.EE](https://s.ee), a managed link platform with 180,000+ users, A/B testing, link-in-bio pages, and commercial plans. The Sink README explicitly recommends S.EE for professional/business needs.
&lt;/Notice&gt;

## What&apos;s new in Sink (2024-2026)

Sink has added a lot since the original version. Here are the highlights:

- **D1 replaces KV as primary database** (v0.3.0): KV is now only a redirect cache
- **Link tags, filtering, and sorting** (v0.3.0)
- **Click webhooks with HMAC signatures** (v0.2.11): Dub-style webhook payloads
- **Cloudflare Access auth** (v0.2.11): alternative to `NUXT_SITE_TOKEN`
- **Duplicate URL detection** (v0.2.11)
- **Link check dashboard** (v0.2.10): batch broken-link detection with CSV export
- **Country/geo-based redirection** (v0.2.9): route visitors by Cloudflare-detected country
- **AI OpenGraph generation** (v0.2.9): generate OG metadata from page content
- **UTM parameter builder** (v0.2.8): built-in with live preview
- **Password protection and link cloaking** (v0.2.6): per-link password gate, full-screen iframe masking
- **Device-based redirection** (v0.2.3): route iOS/Android/Desktop differently
- **OpenGraph customization** (v0.2.3): per-link OG title, description, image
- **Real-time analytics** (v0.2.4+): 3D WebGL globe, 10-second polling event log
- **QR code generation**: built-in for all short links
- **Import/export**: JSON for links, CSV for analytics
- **Multi-language UI**: 10+ languages

The ecosystem has grown too: there&apos;s now a Chrome extension (&quot;Sink Quick Shorten&quot;), a Raycast extension, an iOS app, and Apple Shortcuts support.

## Key features

### AI-powered slug generation

Sink uses Workers AI with the `@cf/qwen/qwen3-30b-a3b-fp8` model by default. When creating a link, you can let AI suggest a relevant, catchy slug based on the destination URL. It saves time when you&apos;re shortening links in bulk.

### Password protection and link cloaking

Set a per-link password to gate access, useful for sharing internal docs or time-sensitive content. Link cloaking renders the destination inside a full-screen iframe, masking the target URL. Note: cloaking only works on sites that allow iframe embedding.

### Device and geo-based redirection

Route visitors to different URLs based on their device (iOS/Android/Desktop) or country (detected by Cloudflare). This is handy for app store links: send iPhone users to the App Store, Android users to Google Play, desktop users to a landing page.

### Advanced analytics and link health monitoring

Sink tracks clicks with geographic data, referrer information, and device breakdowns rendered on a 3D WebGL globe. The link check dashboard (v0.2.10) batch-verifies destination URL reachability and exports broken links as CSV. You can also export access analytics as CSV.

Sink includes built-in analytics, but if you need website-wide analytics beyond link clicks, [Plausible is a great self-hosted companion](/install-plausible-analytics).

### Webhooks, API, and integrations

Click webhooks (v0.2.11) send Dub-style payloads to a configured URL with optional HMAC signatures. Every Sink instance exposes OpenAPI docs at `/docs/scalar`, making it easy to integrate with automation tools and MCP proxies. The Chrome extension, Raycast extension, iOS app, and Apple Shortcuts integrations make Sink practical for daily use.

## Technologies powering Sink

Sink&apos;s tech stack has evolved since the original version:

| Component | Role |
|---|---|
| **Nuxt 4** | Frontend framework (upgraded from Nuxt.js) |
| **Cloudflare D1** | Primary database, stores all links (NEW in v0.3.0) |
| **Cloudflare Workers KV** | Write-through read cache for fast redirects |
| **Drizzle ORM** | Database access layer for D1 |
| **Cloudflare R2** | Optional: automatic backups and social images |
| **Shadcn-vue + Tailwind CSS** | UI components and styling (unchanged) |

&lt;Notice type=&quot;info&quot; title=&quot;How D1 and KV work together&quot;&gt;
D1 stores your links permanently. KV caches them for fast redirects. Both are required. When you create a link, it writes to D1 first, then to KV. Redirects read from KV (fast) and fall back to D1 if the cache misses. This is a different architecture from the original article where KV was the only storage.
&lt;/Notice&gt;

## Deploy Sink on Cloudflare Workers (recommended)

Cloudflare Workers is the recommended deployment method. Pages still works but is deprecated. This section walks through the full setup.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/MkU23U2VE9E&quot;
  label=&quot;Sink Install&quot;
/&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Video may be outdated&quot;&gt;
The video above was recorded before the D1 migration. Follow the written steps below for the current deployment process.
&lt;/Notice&gt;

### Prerequisites

&lt;ListCheck&gt;

- Cloudflare account (free tier is fine)
- GitHub account (to fork the repository)
- A domain added to Cloudflare (for custom short URLs)
- Cloudflare API token with Account &gt; Account Analytics &gt; Read permission (create at Profile &gt; API Tokens)

&lt;/ListCheck&gt;

### Step 1: Fork the repository

Go to [github.com/miantiao-me/Sink](https://github.com/miantiao-me/Sink) and click **Fork**. This creates your own copy under your GitHub account.

If you previously forked `ccbikai/Sink`, GitHub redirects still work, but update your bookmarks and any CI references to the new URL.

### Step 2: Create a Cloudflare D1 database

&lt;Notice type=&quot;warning&quot; title=&quot;This step is new since v0.3.0&quot;&gt;
Without a D1 database, Sink cannot store links. This didn&apos;t exist when the original article was written.
&lt;/Notice&gt;

1. Go to **Cloudflare Dashboard** &gt; **D1** &gt; **Create database**
2. Name it `sink`
3. Copy the **database ID** from the database details page. You&apos;ll need it later

### Step 3: Create a KV namespace

1. Go to **Cloudflare Dashboard** &gt; **Workers &amp; Pages** &gt; **KV** &gt; **Create a namespace**
2. Name it `sink`
3. Copy the **namespace ID**. You&apos;ll need it in the next step

### Step 4: Deploy with Workers Git integration

1. Go to **Cloudflare Dashboard** &gt; **Workers &amp; Pages** &gt; **Create**
2. Select **Workers** (not Pages)
3. Click **Connect to Git** and authorize GitHub
4. Select your forked Sink repository and the `master` branch
5. Set **Build command**: `pnpm build`
6. Set **Deploy command**: `pnpm deploy:worker`
7. Click **Save and Deploy**

### Step 5: Configure build variables and bindings

After the first deployment, configure two sets of settings.

**Build variables** (Settings &gt; Variables and Secrets):

| Variable | Value |
|---|---|
| `DEPLOY_D1_DATABASE_ID` | Your D1 database ID from Step 2 |
| `DEPLOY_KV_NAMESPACE_ID` | Your KV namespace ID from Step 3 |

**Runtime bindings** (Settings &gt; Bindings):

| Binding Name | Type | Resource |
|---|---|---|
| `DB` | D1 Database | `sink` |
| `KV` | KV Namespace | `sink` |
| `ANALYTICS` | Analytics Engine | Dataset: `sink` |
| `R2` | R2 Bucket | Optional: name: `sink` |
| `AI` | Workers AI | Optional: for AI features |

&lt;Notice type=&quot;warning&quot; title=&quot;The DB binding is required&quot;&gt;
Without the D1 `DB` binding, you&apos;ll get errors when creating links. This is the most common deployment mistake since v0.3.0.
&lt;/Notice&gt;

**Environment variables** (Settings &gt; Variables and Secrets):

| Variable | Value |
|---|---|
| `NUXT_SITE_TOKEN` | Your admin password (minimum 8 characters) |
| `NUXT_CF_ACCOUNT_ID` | Your Cloudflare account ID (visible in the dashboard URL) |
| `NUXT_CF_API_TOKEN` | API token with Account Analytics Read permission |

&lt;Picture src={img1} alt=&quot;Cloudflare Workers bindings configuration for Sink&quot; /&gt;

### Step 6: Complete storage setup (first run)

After deployment, you must initialize the database. This is a one-time step.

1. Open `https://your-domain/dashboard`
2. Log in with your `NUXT_SITE_TOKEN`
3. Navigate to **Links**

This triggers the storage initialization. Until you do this, the API returns HTTP 423 (Storage not ready). If you skip this step, link creation will fail.

### Step 7: Add your custom domain

1. Go to **Workers &amp; Pages** &gt; your project &gt; **Settings** &gt; **Domains &amp; Routes**
2. Click **Add custom domain**
3. Enter your domain (e.g., `go.yourdomain.com`)
4. Cloudflare handles DNS and TLS automatically

Verify the domain is active:

```bash
curl -I https://your-domain/test-slug
```

You should get a 301 or 302 redirect (even if the slug doesn&apos;t exist yet, the worker is responding).

&lt;Picture src={img2} alt=&quot;Sink dashboard showing the link management UI&quot; /&gt;

## Deploy Sink on Cloudflare Pages (deprecated)

&lt;Notice type=&quot;error&quot; title=&quot;Cloudflare Pages is deprecated&quot;&gt;
Use Workers (above) for new installations. The steps below are for existing Pages users who want to upgrade.
&lt;/Notice&gt;

Pages deployment still works but requires extra configuration:

1. Enable the `nodejs_compat` compatibility flag in your Pages project settings (this wasn&apos;t required before)
2. Add `CLOUDFLARE_API_TOKEN` with D1 Edit permission and `CLOUDFLARE_ACCOUNT_ID` as environment variables. The `postbuild` script runs D1 migration automatically on `master` branch builds
3. All other bindings (DB, KV, ANALYTICS, R2, AI) still apply

Already hosting an [Astro blog on Cloudflare](/deploy-astrojs-cloudflare)? Sink fits alongside your existing Cloudflare projects, though Workers is the simpler path now.

## Post-deployment: verification and troubleshooting

### Verify your deployment

&lt;ListCheck&gt;

- Open `https://your-domain/dashboard` — should show the login screen
- Log in with `NUXT_SITE_TOKEN`
- Create a test short link — verify the redirect works by visiting it in a browser
- Check the analytics page — visit your test link from a different device/browser, confirm the click appears
- Visit `/docs/scalar` — the API documentation should be accessible

&lt;/ListCheck&gt;

### Common issues and fixes

&lt;Accordion label=&quot;I can&apos;t create short links / HTTP 423 errors&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** Storage not initialized, or the `DB` and `KV` bindings are missing.

**Fix:** Open your Sink dashboard, navigate to Links once to trigger initialization. If that doesn&apos;t help, check that both `DB` (D1) and `KV` bindings are configured in Workers settings. Redeploy after adding bindings.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Analytics is empty&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** Missing `ANALYTICS` binding, wrong dataset name, or API token lacks the right permission.

**Fix:** Ensure the `ANALYTICS` binding exists with dataset name `sink`. Verify your `NUXT_CF_API_TOKEN` has &quot;Account &gt; Account Analytics &gt; Read&quot; permission. Redeploy after changes.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Custom slugs lose uppercase letters&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** Slugs are case-insensitive by default.

**Fix:** Set `NUXT_CASE_SENSITIVE=true` in environment variables and redeploy. Note that this means `Go/Link` and `go/link` will be different URLs.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloaked page shows blank&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** The target site blocks iframe embedding via `X-Frame-Options` or `Content-Security-Policy` headers.

**Fix:** Cloaking only works on sites that allow being embedded. Most major sites (Google, GitHub) block it. Test with a site you control.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Redirects show the old destination&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** Browser or CDN caching the redirect response.

**Fix:** Set `NUXT_REDIRECT_NO_STORE=true` in environment variables to prevent caching. You can also adjust `NUXT_LINK_CACHE_TTL` (default: 60 seconds) to control KV cache duration.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Migrating from pre-v0.3.0 (KV-only) to D1&quot; group=&quot;troubleshooting&quot;&gt;

**Cause:** Upgrading from a version that stored links only in KV.

**Fix:** Deploy v0.3.0 with your original KV namespace still bound. Then open Dashboard &gt; Links — this triggers the KV-to-D1 migration automatically. The API returns HTTP 423 until migration completes. Existing links continue working during migration as long as the original KV namespace stays bound.

&lt;/Accordion&gt;

## Advanced configuration options

Sink exposes many environment variables for fine-tuning behavior. Changing any of these requires a redeploy on Workers.

&lt;Accordion label=&quot;Environment variables reference&quot; group=&quot;config&quot;&gt;

| Variable | Default | Purpose |
|---|---|---|
| `NUXT_HOME_URL` | empty | Redirect `/` to a URL |
| `NUXT_NOT_FOUND_REDIRECT` | empty | Custom 404 destination |
| `NUXT_REDIRECT_STATUS_CODE` | `301` | Redirect HTTP code (301/302/307/308) |
| `NUXT_LINK_CACHE_TTL` | `60` | KV cache TTL in seconds |
| `NUXT_REDIRECT_WITH_QUERY` | `false` | Forward visitor query params to destination |
| `NUXT_CASE_SENSITIVE` | `false` | Preserve slug case |
| `NUXT_DISABLE_BOT_ACCESS_LOG` | `false` | Drop bot traffic from analytics |
| `NUXT_DISABLE_AUTO_BACKUP` | `false` | Disable scheduled R2 backups |
| `NUXT_AI_MODEL` | `@cf/qwen/qwen3-30b-a3b-fp8` | Workers AI model for slug suggestions |
| `NUXT_WEBHOOK_URL` | empty | Click webhook URL |
| `NUXT_WEBHOOK_SECRET` | empty | Webhook HMAC secret (`whsec_` prefix) |
| `NUXT_CF_ACCESS_TEAM_DOMAIN` | empty | Cloudflare Access team domain |
| `NUXT_CF_ACCESS_AUD` | empty | Cloudflare Access AUD |
| `NUXT_PUBLIC_SLUG_DEFAULT_LENGTH` | `6` | Auto-generated slug length |
| `NUXT_API_CORS` | empty | Enable CORS for `/api/**` |

Full reference: [docs.sink.cool/configuration](https://docs.sink.cool/configuration/)

To undo a change: remove the variable and redeploy.

&lt;/Accordion&gt;

## Backups and production notes

If you configured the R2 binding (Step 5), Sink runs automatic backups on a schedule. Disable with `NUXT_DISABLE_AUTO_BACKUP=true`. For broader R2 backup strategies, see how to [configure Dokploy backups with Cloudflare R2](/dokploy-backups-cloudflare-r2) — the R2 setup process is similar.

**Cloudflare free tier limits (relevant to Sink):**

| Product | Free limit | Notes |
|---|---|---|
| Workers requests | 100,000/day | Resets at 00:00 UTC |
| D1 rows read | 5 million/day | Resets at 00:00 UTC |
| D1 rows written | 100,000/day | Resets at 00:00 UTC |
| D1 storage | 5 GB total | Sum of all databases on your account |
| KV reads | 100,000/day | |
| KV writes | 1,000/day | |
| R2 storage | 10 GB | |

&lt;Notice type=&quot;info&quot; title=&quot;Cost summary&quot;&gt;
Sink runs free on Cloudflare&apos;s free tier for personal use. Heavy traffic (thousands of links, millions of clicks) may require the Workers Paid plan at $5/month, with D1 overage at $0.001 per million rows read. For most people shortening links for social media or email campaigns, the free tier is more than enough.
&lt;/Notice&gt;

Every Sink instance also exposes OpenAPI docs at `/docs/scalar`, which is useful for automation and [AI agent integration](/ai-docker-deploy-skill).

## Sink vs alternatives

| Feature | Sink | Dub.co | Bitly | Short.io |
|---|---|---|---|---|
| Self-hosted | Yes | Yes (open-core) | No | No |
| Cost | Free (Cloudflare) | Free tier + paid | From $8/mo | Free tier + paid |
| AI slug generation | Yes | No | No | No |
| Geo/device routing | Yes | Yes | Yes (paid) | Yes (paid) |
| Password protection | Yes | No | No | No |
| Cloudflare-native | Yes | No | No | No |

Sink&apos;s main advantage is that it runs entirely on Cloudflare&apos;s free tier with no server to manage. If you&apos;re managing a broader self-hosted setup, you can [compare the best server panels](/best-self-hosted-panels) or check out [Coolify](/coolify-install-heroku-alternative) as a self-hosted PaaS. If you use [Dokploy for self-hosting](/dokploy-install), Sink is one more service you can run alongside — though on Cloudflare rather than your VPS.

## Extending Sink: apps and integrations

Sink has several clients and integrations:

- **Chrome Extension**: &quot;Sink Quick Shorten&quot; lets you right-click any URL to shorten it (Chrome Web Store)
- **Raycast Extension**: &quot;Sink Short Links Manager&quot; for macOS power users (Raycast Store)
- **iOS App**: Native iPhone app on the App Store
- **Apple Shortcuts**: macOS and iOS automation integration
- **API**: OpenAPI docs at `/docs/scalar`, MCP support via OpenAPI proxy

These make Sink far more practical than a dashboard-only tool. You can shorten links from your browser, phone, or terminal without opening the dashboard.

## Conclusion

Sink started as a simple Cloudflare KV-based shortener and is now a full link management platform. The v0.3.0 D1 migration makes it a proper database-backed application while keeping the zero-server, zero-cost deployment model. Password protection, geo routing, webhooks, and the extension ecosystem (Chrome, Raycast, iOS, Apple Shortcuts) make it a solid choice for anyone who wants link shortening without monthly fees or vendor lock-in.

If you&apos;re building out your self-hosted stack, Sink pairs well with [Docker containers for your home server](/docker-containers-home-server) — one runs on your infrastructure, the other runs free on Cloudflare&apos;s edge.

&lt;Button text=&quot;View Sink on GitHub&quot; link=&quot;https://github.com/miantiao-me/Sink&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>cloudflare</category><category>link-shortener</category></item><item><title>BSI Security Report from Hetzner: How I Secured My Docker Server</title><link>https://www.bitdoze.com/bsi-security-report-docker-ufw/</link><guid isPermaLink="true">https://www.bitdoze.com/bsi-security-report-docker-ufw/</guid><description>Hetzner forwarded me a BSI security report for an exposed server. How I audited open Docker ports, enabled ufw without breaking my reverse proxy, and hardened SSH.</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;

A few days ago I got an email from [Hetzner](https://www.hetzner.com/) that started like this:

&gt; We have received a notification from the German Federal Office for Information Security (BSI) for (the IP address of) a server you have with us.

No, I hadn&apos;t been hacked. No, BSI wasn&apos;t accusing me of anything. This is routine: BSI runs security scanners across the German IP space, finds open ports and exposed services, and notifies the hosting provider. The provider forwards the report to the customer. The email even says you don&apos;t need to respond, and that the issue is &quot;usually fairly easy to secure.&quot;

There was one catch: the original report (with the exact port and CVE) was an attachment I had to dig out of the email chain. I didn&apos;t want to wait for it, so I audited the server myself. It took about 20 minutes, and it turned out the BSI scanner had picked a good target: the box was wide open.

Here&apos;s the full story: how I audited the server, the ufw rule set that fixed it, the gotcha that briefly broke my reverse proxy (and the single rule that fixed that), and the defense-in-depth steps I took after. If you run a VPS with Docker, run this audit today.

## What a BSI report is (and what it isn&apos;t)

The [BSI](https://www.bsi.bund.de/) is the German Federal Office for Information Security. Its computer emergency response team, CERT-Bund, scans German IP ranges for known vulnerabilities and exposed services: open databases, unauthenticated admin panels, outdated software with public CVEs. When they find something on an IP owned by a German provider, they send a report and the provider forwards it to you.

The important facts:

- It is not an accusation, a threat, or a takedown notice. No abuse was involved.
- You don&apos;t need to reply to BSI or your provider.
- The report names the exact service, port, and/or CVE. Fix that and you&apos;re done.
- The same scan runs against every German IP. The finding means someone out there verified your exposure was reachable.

My report boiled down to one theme: **services listening on `0.0.0.0` with no firewall at all**.

## Step 1: Audit what&apos;s actually listening

Three commands give you 95% of the picture.

**1. Listening ports and their processes:**

```bash
sudo ss -tulpn
```

**2. Docker containers and their published ports:**

```bash
docker ps --format &apos;table {{.Names}}\t{{.Ports}}\t{{.Image}}&apos;
```

**3. Firewall status:**

```bash
sudo ufw status verbose
```

Here&apos;s what the first two revealed on my server:

| Port | Service | Risk |
|---|---|---|
| 5432 | PostgreSQL (pgvector container) | 🔴 Exposed database |
| 5532 | PostgreSQL (second instance) | 🔴 Exposed database |
| 8055 | Directus CMS | 🔴 Exposed admin panel |
| 9999, 18888 | Hindsight app | 🔴 Exposed app |
| 6806 | Siyuan notes | 🔴 Exposed app |
| 3552 | Arcane | 🟡 Exposed app |
| 22 | SSH | 🟡 Root login + password auth |
| 80, 443 | Caddy reverse proxy | 🟢 Intended |

And the third command said `Status: inactive`. Nothing was filtering anything. Every published Docker port was reachable from the internet, and SSH was accepting root logins with passwords.

How does this happen? It&apos;s the classic Docker pattern: you publish a port during setup so you can reach the service, then you put a reverse proxy (Caddy, Traefik, nginx) in front of it, and you never remove the `ports:` block. The proxy works, the app works, and nobody notices the raw ports are still bound to `0.0.0.0` — directly reachable from the internet, bypassing your proxy, its rate limiting, and any auth you added there.

BSI notices. So does every botnet scanner that crawls German IP space. Within minutes of the firewall going up, I watched probes for exactly the flagged port get dropped.

## Step 2: Enable the firewall

The fastest fix, and the one that closed every port at once, is ufw. This is the complete rule set that works for a Docker server with a public reverse proxy:

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow from 172.16.0.0/12 comment &apos;Docker bridge networks -&gt; host services&apos;
sudo ufw allow in on tailscale0   # only if you use Tailscale
sudo ufw --force enable
```

Rule by rule:

- `default deny incoming` — drop every new inbound connection that isn&apos;t explicitly allowed. Established connections stay alive, so your current SSH session survives the enable.
- `22/tcp` — SSH. If you only ever SSH over Tailscale, skip this rule and let the `tailscale0` rule cover it.
- `80/tcp` and `443/tcp` — your reverse proxy. This is the only door public traffic should use.
- `from 172.16.0.0/12` — **do not skip this one**; it&apos;s the rule that keeps your Docker reverse proxy working. Explained in Step 3.
- `in on tailscale0` — if you use Tailscale, the mesh can reach everything (including admin ports) without opening them to the internet.
- `--force` — skip the &quot;this may disrupt existing ssh connections&quot; prompt.

Then verify:

```bash
sudo ufw status verbose
```

You want to see `Default: deny (incoming), allow (outgoing), deny (routed)` and your allow list below it.

&lt;Notice type=&quot;info&quot; title=&quot;The routed policy is your friend&quot;&gt;
Recent ufw versions add a third default policy: `deny (routed)`. It applies to traffic forwarded through the host — which is exactly how Docker published ports work. So once ufw is active, published ports like `5432` are dropped from the internet even though they&apos;re still in your compose files. If you&apos;re on an older ufw and Docker ports still slip through, the fix is the `DOCKER-USER` iptables chain, covered in [how to fix Docker bypassing the firewall](/docker-bypasses-firewall/).
&lt;/Notice&gt;

## Step 3: The gotcha — ufw killed my reverse proxy

Here&apos;s the part that will save you a panic attack.

My setup: an AI agent app running on the host on port 4111, with a Caddy container reverse-proxying a subdomain to it via `host.docker.internal:4111`. Standard pattern: the proxy lives in Docker, the backend lives on the host, and `host.docker.internal` (with `host-gateway` in `extra_hosts`) maps to the host&apos;s bridge gateway IP.

After `ufw enable`, the subdomain came back with **502 Bad Gateway**. The Caddy container couldn&apos;t reach the host anymore. My first thought was that I&apos;d misconfigured the firewall. I hadn&apos;t.

The reason: `default deny incoming` does not only block the internet-facing interface. It drops **any new inbound connection to a host port, from any interface — including your Docker bridges**. The Caddy container connecting from `172.20.0.6` to the host gateway `172.17.0.1:4111` is &quot;incoming&quot; traffic as far as the host is concerned. Denied. Result: Caddy gets a refused connection and returns 502.

The fix is the `172.16.0.0/12` rule from Step 2:

```bash
sudo ufw allow from 172.16.0.0/12 comment &apos;Docker bridge networks -&gt; host services&apos;
```

Docker creates its bridge networks in `172.17.0.0/16` (`docker0`), `172.18.x`, `172.19.x`, `172.20.x`, and so on. `172.16.0.0/12` covers all of them. This only lets *your own containers* reach host ports — the internet can&apos;t spoof a source address on your private bridges, so nothing is reopened to the outside.

Add the rule, re-check `ufw status verbose`, and your proxy is back. In my case the site returned 200 immediately after.

## Step 4: Verify — and watch the bots hit the wall

Three checks.

**1. Your public site still works** (run from the server itself so it goes out the public interface):

```bash
curl -sk -o /dev/null -w &apos;HTTP %{http_code}\n&apos; https://your-domain.com
```

Expect `200`.

**2. The firewall state:**

```bash
sudo ufw status verbose
```

**3. The satisfying part — the blocked probes:**

```bash
sudo journalctl -k --no-pager | grep &apos;UFW BLOCK&apos; | tail -10
```

Within minutes of enabling ufw, the same kind of scanners that got me reported hit the wall:

```
kernel: [UFW BLOCK] IN=eth0 ... SRC=186.236.254.56 ... DPT=5432 SYN
kernel: [UFW BLOCK] IN=eth0 ... SRC=178.128.214.243 ... DPT=23 SYN
kernel: [UFW BLOCK] IN=eth0 ... SRC=91.230.168.148 ... DPT=50050 SYN
```

Port 5432 — exactly what the BSI report flagged — plus Telnet (23) and random ports. All dropped. That&apos;s the whole point: the exposure that got me reported is now invisible from the internet.

## Step 5: Defense in depth (do this too)

The firewall closes the doors, but it&apos;s one `sudo ufw disable` away from opening them again. Make the exposure structural:

**1. Unpublish ports in your compose files.** If only other containers need to reach a service, delete the `ports:` block entirely — containers talk over the Docker bridge using the service name. If you need host-local access (say, a local `psql`), bind to loopback instead of `0.0.0.0`:

```yaml
ports:
  - &quot;127.0.0.1:5432:5432&quot;
```

**2. Harden SSH.** My server had `PermitRootLogin yes` and password authentication enabled. Since you have keys (confirm with `ls ~/.ssh/id_ed25519.pub`), add a drop-in config:

```bash
sudo tee /etc/ssh/sshd_config.d/99-hardening.conf &lt;&lt;&apos;EOF&apos;
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
```

Test before restarting, and keep a second SSH session open:

```bash
sudo sshd -t &amp;&amp; sudo systemctl restart ssh
```

A full walkthrough of key-based auth, fail2ban, and the rest is in the [SSH server hardening guide](/secure-ssh-server-linux/).

**3. Put auth on anything that doesn&apos;t have it.** Admin panels and dev tools without their own login should sit behind basic auth at the proxy — same idea as [Traefik basic authentication](/traefik-basic-authentication/).

**4. Add a behavior layer.** A firewall is static; tools like [CrowdSec](/crowdsec-secure-server/) watch logs and block offending IPs dynamically. Worth having on top.

## Checklist

Here&apos;s the exact sequence I&apos;d run on a fresh, previously-exposed server:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Audit with &lt;code&gt;sudo ss -tulpn&lt;/code&gt;, &lt;code&gt;docker ps&lt;/code&gt;, and &lt;code&gt;sudo ufw status verbose&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Write down which ports are supposed to be public (usually only 80 and 443)&lt;/li&gt;
&lt;li&gt;Add the ufw rules from Step 2 &lt;strong&gt;before&lt;/strong&gt; enabling — including the &lt;code&gt;172.16.0.0/12&lt;/code&gt; Docker bridge rule&lt;/li&gt;
&lt;li&gt;Enable with &lt;code&gt;sudo ufw --force enable&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Verify your site returns 200 and check &lt;code&gt;journalctl -k | grep &apos;UFW BLOCK&apos;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Remove or loopback-bind published ports in every compose file&lt;/li&gt;
&lt;li&gt;Harden SSH (drop-in config, &lt;code&gt;sudo sshd -t&lt;/code&gt;, restart, keep a second session)&lt;/li&gt;
&lt;li&gt;Re-check from an external network that only 22, 80, 443 respond&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## FAQ

&lt;Accordion label=&quot;Do I have to reply to BSI or my hosting provider?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
No. The notification is informational. Hetzner&apos;s forwarding email says so explicitly: &quot;You do not need to send us, or the BSI, a response.&quot; If you want to confirm the exact finding, look for the ticket number (`CB-Report#...`) in the original report and use it if you ever contact [certbund@bsi.bund.de](mailto:certbund@bsi.bund.de). Do not reply to the sender address of the report email — it&apos;s unmonitored.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does ufw actually block Docker published ports?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
On modern versions, yes. ufw&apos;s `deny (routed)` default policy applies to forwarded traffic, which is how Docker published ports reach containers, so they get dropped from the internet. On older ufw versions Docker&apos;s own iptables rules were evaluated before the firewall and could bypass it entirely — the `DOCKER-USER` chain fix is documented in [how to fix Docker bypassing the firewall](/docker-bypasses-firewall/). Either way, the belt-and-suspenders approach is to unpublish the ports in compose.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why did my reverse proxy return 502 after enabling ufw?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Because `default deny incoming` also blocks container-to-host traffic over the Docker bridge. If your proxy container reaches a host service via `host.docker.internal:PORT`, the connection lands on the host&apos;s INPUT chain as incoming traffic and gets dropped. Allow the Docker bridge range with `sudo ufw allow from 172.16.0.0/12` and the proxy connects again.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I still fix the ports if the firewall blocks them?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Yes. The firewall is one command away from being disabled (accidentally or during troubleshooting), and then every published port is public again. Removing or loopback-binding `ports:` in compose makes the exposure structural instead of incidental. It also removes the attack surface from the container&apos;s perspective — fewer reachable sockets, fewer CVEs that matter.
&lt;/Accordion&gt;

## The takeaway

The BSI notification wasn&apos;t a threat — it was a free vulnerability scan, courtesy of the German government. The exposed services were my own doing: published Docker ports left behind after setup, no host firewall, and weak SSH defaults. Twenty minutes of auditing plus one firewall rule set closed all of it, and the one real surprise (the Docker bridge vs. ufw gotcha) is now a one-line rule in my standard server setup.

If you&apos;re self-hosting on a VPS, spend those 20 minutes today. The scanners certainly will.</content:encoded><category>self-hosting</category><category>ufw</category><category>docker</category><category>hetzner</category></item><item><title>How To Deploy A Docker Compose App in Dokploy</title><link>https://www.bitdoze.com/dokploy-docker-compose-app/</link><guid isPermaLink="true">https://www.bitdoze.com/dokploy-docker-compose-app/</guid><description>Deploy any Docker Compose app in Dokploy with this step-by-step guide. Covers the Domains tab, Traefik labels, environment variables, and troubleshooting.</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/08/dns-a-rec.png&quot;;
import img2 from &quot;../../assets/images/24/08/dokploy-compose.png&quot;;
import img3 from &quot;../../assets/images/24/08/dokploy-env.png&quot;;
import img4 from &quot;../../assets/images/24/08/dokploy-domain-add.png&quot;;

[Dokploy](https://dokploy.com/) is an open-source, self-hostable Platform as a Service (PaaS) for deploying and managing applications with Docker and Traefik. It has 36,300+ GitHub stars and regular releases (currently at v0.29.x), and it&apos;s a free alternative to Vercel, Heroku, and Netlify. For comparisons, see [how Dokploy stacks up against Coolify and Kamal 2](/coolify-vs-dokploy-vs-kamal-2/) or our [self-hosted server panels roundup](/best-self-hosted-panels/).

This guide walks through deploying any Docker Compose app in Dokploy. I&apos;ll use a Flowise AI + PostgreSQL stack as the running example, but the same workflow applies to any compose file.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/mJY4lXbXsPM&quot;
  label=&quot;How To Deploy A Docker Compose App in Dokploy&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Video note&quot;&gt;
The video was recorded with an earlier Dokploy version. The workflow is the same, but the UI may look slightly different. The Domains tab is now the recommended method for configuring domains.
&lt;/Notice&gt;

## Prerequisites

Before you start, make sure you have everything in place:

&lt;ListCheck&gt;
- A VPS running Linux (Ubuntu 22.04+ or Debian 12+). A Hetzner CX22 at ~€4.49/mo works well. You can get an [affordable VPS from Hetzner](https://go.bitdoze.com/hetzner) or a [budget VPS from Hostinger](https://go.bitdoze.com/hostinger-vps)
- Dokploy installed and accessible at its dashboard URL. See our guide to [install and configure Dokploy on your VPS](/dokploy-install/)
- Docker and Docker Compose v2 installed (the Dokploy installer handles this)
- Ports 80 and 443 open on the VPS firewall (required for Traefik and Let&apos;s Encrypt)
- A domain name with DNS access (to create an A record)
- The Docker Compose file for the app you want to deploy
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Ports 80 and 443&quot;&gt;
If these ports are blocked by your firewall or cloud provider, Let&apos;s Encrypt certificate issuance will fail silently. Double-check before proceeding.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Don&apos;t want to self-host?&quot;&gt;
Dokploy Cloud is available starting at $4.50/mo per server if you&apos;d rather skip managing the infrastructure yourself.
&lt;/Notice&gt;

## Flowise example: the Docker Compose file we&apos;ll deploy

Here&apos;s the compose file we&apos;ll use throughout this guide. It runs Flowise AI with a PostgreSQL backend. This is a **clean version**: no Traefik labels and no `dokploy-network`. Dokploy handles networking and routing through the Domains tab or Isolated Deployments.

```yml
services:
  flowise-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - flowise-db-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  flowise:
    image: flowiseai/flowise:latest
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:${PORT}
    volumes:
      - flowiseai:/root/.flowise
    environment:
      DEBUG: false
      PORT: ${PORT}
      FLOWISE_USERNAME: ${FLOWISE_USERNAME}
      FLOWISE_PASSWORD: ${FLOWISE_PASSWORD}
      APIKEY_PATH: /root/.flowise
      SECRETKEY_PATH: /root/.flowise
      LOG_LEVEL: info
      LOG_PATH: /root/.flowise/logs
      DATABASE_TYPE: postgres
      DATABASE_PORT: 5432
      DATABASE_HOST: flowise-db
      DATABASE_NAME: ${POSTGRES_DB}
      DATABASE_USER: ${POSTGRES_USER}
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
    restart: on-failure:5
    depends_on:
      flowise-db:
        condition: service_healthy
    entrypoint: /bin/sh -c &quot;sleep 3; flowise start&quot;

volumes:
  flowiseai:
    driver: local
  flowise-db-data:
    driver: local
```

A few things to note:

- **No `networks:` section**. Dokploy handles networking for you (via the Domains tab or Isolated Deployments).
- **No `labels:` block**. Traefik routing is configured through the Domains tab, not in the compose file.
- **No `container_name`**. Setting this in Dokploy causes issues with logs and metrics. Dokploy generates its own container names.
- **No `version:` key**. Docker Compose v2 ignores it and may warn about it. If you&apos;re bringing your own compose file with `version: &quot;3.8&quot;`, just remove that line.
- **`${VAR_NAME}` syntax**. Environment variables are referenced this way because Dokploy writes UI-defined vars to a `.env` file. More on this in the [environment variables section](#configuring-environment-variables-in-dokploy).

For a refresher on compose syntax, see [essential Docker commands](/docker-commands/). The official docs have more examples at the [Dokploy Docker Compose docs](https://docs.dokploy.com/docs/core/docker-compose) and the [official example](https://docs.dokploy.com/docs/core/docker-compose/example).

## Point the domain to your server (DNS)

Before deploying, point your domain or subdomain to the Dokploy server:

- Add an A record in your DNS settings:
  - **Name**: the subdomain you want (e.g., `flowise` for `flowise.yourdomain.com`)
  - **Value**: your server&apos;s IP address

&lt;Picture src={img1} alt=&quot;DNS A Record&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;DNS validation&quot;&gt;
Since Dokploy v0.22.0, the Domains tab includes built-in DNS validation. It checks whether your domain is correctly pointing to your server before you deploy.
&lt;/Notice&gt;

## Method 1: using the Domains tab (recommended)

This is Dokploy&apos;s recommended approach. You paste your compose file as-is, then configure the domain and port through the UI. Dokploy injects the Traefik labels and networking config automatically.

For details on how Traefik works under the hood, see [how Traefik works as a reverse proxy in Docker](/traefik-proxy-docker/).

### Step 1: create the compose service

1. In the Dokploy dashboard, go to **Projects** and either create a new project or select an existing one.
2. Click **Create Service** → **Compose**.
3. Give it a name (e.g., `flowise`).
4. In **General**, select **Raw** as the source.
5. Paste the clean compose file from above.
6. Click **Save**.

&lt;Picture src={img2} alt=&quot;Dokploy Compose&quot; /&gt;

### Step 2: add a domain

1. Go to the **Domains** tab.
2. Click **Add Domain**.
3. Fill in the fields:
   - **Domain**: enter your domain (e.g., `flowise.yourdomain.com`)
   - **Service Name**: select the service from the dropdown (e.g., `flowise`. This is the key from your compose file)
   - **Internal Port**: the port your app listens on inside the container (3000 for Flowise)
   - **Certificate Provider**: select **Let&apos;s Encrypt** for automatic HTTPS
4. Click **Save**.

&lt;Picture src={img4} alt=&quot;Dokploy Domain add&quot; /&gt;

Dokploy will inject the required Traefik labels into the final compose file. You don&apos;t need to add them manually.

### Step 3: verify with Preview Compose

After configuring your domain, click the **Preview Compose** button. This shows you the final compose file Dokploy will actually execute, with all injected Traefik labels, network config, and domain settings. This is a critical verification step. Check that:

- The Traefik labels include your domain and the correct port
- The `dokploy-network` has been injected (or an isolated network if you enabled that)
- No unexpected changes were made to your services

&lt;Notice type=&quot;success&quot; title=&quot;Ready to deploy&quot;&gt;
If Preview Compose shows the Traefik labels and your domain correctly, you&apos;re ready to deploy.
&lt;/Notice&gt;

## Method 2: manual Traefik labels (advanced)

For most users, Method 1 is sufficient. Use manual Traefik labels only when you need custom configuration: middleware (auth, rate limiting), multiple host rules, custom entrypoints, or non-standard TLS config.

### When to use manual labels

- You need Traefik middleware (basic auth, rate limiting, custom headers)
- You&apos;re routing multiple domains to a single service
- You need custom entrypoints or TLS settings beyond what the Domains tab offers

If none of these apply, stick with the Domains tab.

### Adding dokploy-network and Traefik labels

With this approach, you modify the compose file to include the `dokploy-network` and Traefik labels:

```yml
services:
  flowise:
    image: flowiseai/flowise:latest
    networks:
      - dokploy-network
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.flowiseai.rule=Host(`flowise.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.flowiseai.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.flowiseai.tls.certResolver=letsencrypt&quot;
      - &quot;traefik.http.services.flowiseai.loadbalancer.server.port=3000&quot;
    # ... rest of your service config

networks:
  dokploy-network:
    external: true
```

The Traefik label format:

```yml
labels:
  - &quot;traefik.enable=true&quot;
  - &quot;traefik.http.routers.&lt;unique-name&gt;.entrypoints=websecure&quot;
  - &quot;traefik.http.routers.&lt;unique-name&gt;.tls.certResolver=letsencrypt&quot;
  - &quot;traefik.http.routers.&lt;unique-name&gt;.rule=Host(`app.yourdomain.com`)&quot;
  - &quot;traefik.http.services.&lt;unique-name&gt;.loadbalancer.server.port=3000&quot;
```

Replace `&lt;unique-name&gt;` with a unique identifier for your service, set the `Host()` to your domain, and set the port to whatever your app listens on internally.

Every service that needs to communicate (e.g., the app and its database) must be on `dokploy-network`. For more on Traefik, see [Traefik FREE Let&apos;s Encrypt Wildcard Certificate](/traefik-wildcard-certificate/).

## Isolated Deployments (Advanced tab)

Dokploy&apos;s Isolated Deployments feature is a third option for networking. When enabled, Dokploy:

- Creates a **per-app isolated network** automatically
- Connects Traefik to that isolated network
- Eliminates the need to manually add `dokploy-network` to every service
- Prevents service name conflicts when running multiple instances of the same app

To enable it: go to the **Advanced** tab for your compose service and toggle **Isolated Deployments**.

&lt;Notice type=&quot;info&quot; title=&quot;Zero modifications needed&quot;&gt;
When using Isolated Deployments together with the Domains tab, your compose file can be used as-is. No `networks:` section, no labels, no modifications at all. Just paste and deploy.
&lt;/Notice&gt;

This is available for both Raw and Git provider compose services. See the [Utilities docs](https://docs.dokploy.com/docs/core/docker-compose/utilities) for details.

## Configuring Environment Variables in Dokploy

The Flowise example uses `${VAR_NAME}` syntax throughout. Here&apos;s how that works in Dokploy.

### Understanding the .env file mechanism

When you set environment variables in Dokploy&apos;s **Environment** tab, they are written to a `.env` file on the server. Your compose file must reference them with `${VAR_NAME}` syntax (or use `env_file: - .env`). This is different from application services, where vars are injected directly into containers.

For the Flowise example, add these in the **Environment** tab:

```sh
PORT=3000
POSTGRES_USER=&apos;user&apos;
POSTGRES_PASSWORD=&apos;pass&apos;
POSTGRES_DB=&apos;flowise&apos;
FLOWISE_USERNAME=bitdoze
FLOWISE_PASSWORD=bitdoze
```

&lt;Picture src={img3} alt=&quot;Dokploy env&quot; /&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Variables are not auto-injected&quot;&gt;
Environment variables set in the UI are NOT automatically injected into containers. You must reference them as `${VAR_NAME}` in your compose file or add `env_file: - .env` to your service definition.
&lt;/Notice&gt;

A few additional notes:

- **Encryption at rest**: since v0.29.12, all environment variables are encrypted using AES-256-GCM.
- **Shared variables**: Dokploy supports project-level (`${{project.VAR_NAME}}`) and environment-level (`${{environment.VAR_NAME}}`) shared variables. Useful when multiple services in the same project share credentials. See the [Variables docs](https://docs.dokploy.com/docs/core/variables).

For a deeper dive on Docker environment variables, see [how Docker environment variables work with ARG and ENV](/docker-env-vars/).

## Deploy and verify your app

After configuring your compose file, environment variables, and domain, it&apos;s time to deploy:

1. Go to **General** and click **Deploy**.
2. Switch to the **Deployments** tab to watch the build logs.
3. Wait for a &quot;success&quot; status.

### Checking deployment logs

The Deployments tab shows real-time logs. A successful deploy ends with the containers running and Traefik routing traffic. If you see errors, the logs will point to the specific issue (wrong image tag, missing env var, port conflict, etc.).

### Verifying the app is running

Once the deploy succeeds, verify it:

```sh
curl -I https://flowise.yourdomain.com
```

You should get an `HTTP/2 200` response. The SSL certificate should be valid. Traefik issues it automatically via Let&apos;s Encrypt.

&lt;Notice type=&quot;info&quot; title=&quot;SSL timing&quot;&gt;
It can take up to 10 seconds for Traefik to issue a Let&apos;s Encrypt certificate after the first deployment. If you get a certificate error immediately, wait a moment and try again.
&lt;/Notice&gt;

## Backups for Docker Compose Apps

Since v0.22.0 (May 2025), Dokploy supports database backups and volume backups for Docker Compose services. If you&apos;re running anything with persistent data, set this up early.

### Database backups (PostgreSQL)

The Flowise example uses PostgreSQL. Dokploy can back up PostgreSQL, MariaDB, MySQL, and MongoDB databases within Docker Compose services. You configure this in the service&apos;s backup settings and point it to an S3-compatible destination.

To set up Cloudflare R2 as your backup destination, see how to [configure Dokploy backups with Cloudflare R2](/dokploy-backups-cloudflare-r2/).

### Volume backups

Dokploy can back up Docker named volumes to S3-compatible storage. This covers the `flowiseai` and `flowise-db-data` volumes in our example.

&lt;Notice type=&quot;warning&quot; title=&quot;Named volumes only&quot;&gt;
Volume backups only work with named volumes (like `flowiseai:`), NOT bind mounts (like `./data:/app/data`). If your compose file uses bind mounts, you&apos;ll need a different backup strategy.
&lt;/Notice&gt;

See the [Volume Backups docs](https://docs.dokploy.com/docs/core/volume-backups) for configuration details.

## Troubleshooting common issues

&lt;Accordion label=&quot;DNS not propagated&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;
**Symptom**: browser shows &quot;server not found&quot; or Dokploy&apos;s DNS validation fails in the Domains tab.

**Cause**: the A record isn&apos;t set, or the TTL hasn&apos;t expired yet.

**Fix**: check your DNS record:
```sh
dig flowise.yourdomain.com +short
```
This should return your server&apos;s IP. If it doesn&apos;t, verify the A record in your DNS provider. DNS propagation can take a few minutes to a few hours depending on your provider.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Port mismatch (502 Bad Gateway)&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: you get a 502 Bad Gateway or connection refused after deploying.

**Cause**: the port configured in the Domains tab (or Traefik labels) doesn&apos;t match the port the app actually listens on inside the container.

**Fix**: check your app&apos;s documentation for its default port. For Flowise, it&apos;s `3000` (controlled by the `PORT` env var). Make sure the **Internal Port** in the Domains tab matches.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Services can&apos;t communicate&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: the app starts but can&apos;t connect to its database (e.g., Flowise can&apos;t reach PostgreSQL).

**Cause**: services are not on the same network.

**Fix**: if you&apos;re using the Domains tab without Isolated Deployments, make sure all services that need to communicate are on `dokploy-network`. Or enable **Isolated Deployments** in the Advanced tab. Dokploy handles networking for you. Also verify that the service name in your connection string matches the key in the compose file (e.g., `flowise-db`, not `localhost`).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Container name issues&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: logs or metrics not showing in the Dokploy dashboard.

**Cause**: `container_name` is set in the compose file.

**Fix**: remove the `container_name` directive. Dokploy generates its own container names for tracking logs, metrics, and deployments. Setting it manually breaks this.
&lt;/Accordion&gt;

For more debugging commands, see [essential Docker commands](/docker-commands/). To monitor your server after deployment, [set up server monitoring with Beszel and Uptime Kuma](/beszel-uptime-kuma/).

## Updating and maintaining your compose app

After the initial deployment, you&apos;ll eventually need to update images, change environment variables, or modify the compose file. Dokploy makes this straightforward. Edit the config and redeploy.

For the full update workflow (including how to handle image tags, rollbacks, and redeployments), see our guide on [updating your deployed Docker Compose apps in Dokploy](/dokploy-update-docker-compose/).

## Conclusions

Deploying a Docker Compose app in Dokploy comes down to two approaches:

1. **Domains tab (recommended)**: paste your compose file as-is, configure the domain and port in the UI, and Dokploy handles the rest. No label editing, no network config.
2. **Manual Traefik labels (advanced)**: for when you need custom routing, middleware, or non-standard TLS setup.

With Isolated Deployments enabled, you can take the cleanest path: zero modifications to your compose file. Paste, set environment variables, add a domain, and deploy.

Don&apos;t skip backups. Set up database backups for any stateful service and volume backups for persistent data. It takes five minutes and saves you from data loss.

&lt;Button text=&quot;How to Update Docker Compose Apps in Dokploy&quot; link=&quot;/dokploy-update-docker-compose/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&gt; If you are interested in more self-hosted apps, you can [discover more self-hosted Docker containers](/docker-containers-home-server/) or check [toolhunt.net self hosted section](https://toolhunt.net/sh/).</content:encoded><category>self-hosting</category><category>dokploy</category><category>docker-compose</category><category>self-hosted</category></item><item><title>Nexterm Docker Install: Self-Hosted SSH, VNC &amp; RDP Management</title><link>https://www.bitdoze.com/nexterm-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/nexterm-docker-install/</guid><description>Install Nexterm with Docker Compose for self-hosted SSH, VNC &amp; RDP management. Step-by-step guide with Traefik reverse proxy, encryption setup, and security tips.</description><pubDate>Wed, 05 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/09/nexterm-interface.png&quot;;
import imag2 from &quot;../../assets/images/24/09/nexterm-add-server.png&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Nexterm](https://nexterm.dev/) is an open-source, self-hosted server management platform for SSH, VNC, RDP, Telnet, SFTP, and FTP connections. At v1.2.2-BETA with nearly 5,000 GitHub stars and an MIT license, it&apos;s past the experimental stage. Production-capable, as long as you back up your data and pin versions. This guide covers a complete Nexterm Docker install using Docker Compose with Traefik reverse proxy, mandatory encryption key setup, and the security hardening you need before opening it to the network.

## What is Nexterm?

Nexterm is a web-based server management dashboard that consolidates SSH, VNC, RDP, Telnet, SFTP, and FTP into a single interface. You deploy it as a Docker container, point your browser at it, and manage all your remote connections from one place. It supports OIDC SSO, LDAP, passkeys, 2FA, audit logging, session recordings, and role-based permissions (added in v1.2.2). There are desktop clients for Windows, macOS, and Linux, a mobile app for Android and iOS, and a CLI client (`nt`) for terminal purists.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/O4NmTxLXfrE&quot;
  label=&quot;Nexterm install docker&quot;
/&gt;
If you&apos;re comparing [self-hosted server management panels](https://www.bitdoze.com/best-self-hosted-panels/), Nexterm covers more protocols than most alternatives with a smaller resource footprint. It&apos;s also worth looking at [alternative SSH management tools like Termix](https://www.bitdoze.com/termix-self-host/) if you want something with a different architecture.

## Nexterm features

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Multi-protocol:** SSH, VNC, RDP, Telnet, SFTP, FTP, all from one dashboard&lt;/li&gt;
&lt;li&gt;**Security:** 2FA (TOTP), Passkeys, OIDC SSO, LDAP, server-side encryption at rest, audit logging, role-based permissions, API keys&lt;/li&gt;
&lt;li&gt;**Session management:** Split view, session recordings, session popout/live sharing, port tunneling, jump hosts&lt;/li&gt;
&lt;li&gt;**Dynamic snippets and scripts:** Quick commands, scripts, SSH config import (fully implemented since v1.0.3)&lt;/li&gt;
&lt;li&gt;**Desktop and mobile apps:** Desktop connector (Win/Mac/Linux), mobile app (Android/iOS) for VNC/RDP/SFTP, CLI client (`nt`)&lt;/li&gt;
&lt;li&gt;**Server monitoring and AI:** Built-in server monitoring (v1.0.4+), AI integration for terminal assistance&lt;/li&gt;
&lt;li&gt;**Infrastructure:** Organizations, tags, fuzzy search, internationalization, customizable themes, Nerd Fonts, Proxmox LXC/QEMU management&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re building out a home lab, check [other self-hosted Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/) for more ideas.

## How to install Nexterm with Docker Compose

This uses the official `nexterm/aio` image (all-in-one: server + web client + engine). The old `germannewsmaker/nexterm` image is deprecated. If you&apos;re migrating from it, see the troubleshooting section below.

### Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**VPS or home server:** Linux (Ubuntu 22.04+ / Debian 12+). Minimum 1 vCPU, 512 MB RAM, 2 GB disk. Use [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), or [Vultr](https://go.bitdoze.com/vultr) for a cheap VPS, or a [mini PC as a home server](https://www.bitdoze.com/best-mini-pc-home-server/)&lt;/li&gt;
&lt;li&gt;**Docker and Docker Compose** installed. Use [Dockge for managing Docker Compose stacks](https://www.bitdoze.com/dockge-install/) if you want a web UI for your compose files&lt;/li&gt;
&lt;li&gt;**Traefik (optional but recommended):** For HTTPS reverse proxy with automatic Let&apos;s Encrypt certificates. [Set up Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) or configure [Traefik with a free Let&apos;s Encrypt wildcard certificate](https://www.bitdoze.com/traefik-wildcard-certificate/)&lt;/li&gt;
&lt;li&gt;**openssl:** required to generate the encryption key. Pre-installed on most Linux distros&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Lightweight resource usage&quot;&gt;
Nexterm is lightweight. A 1 vCPU / 512 MB VPS is enough for managing dozens of connections. The AIO image is ~95 MB compressed. You do not need 8 CPUs and 16 GB RAM. That was for something else entirely.
&lt;/Notice&gt;

### Step 1: Generate your encryption key

Since v1.0.3, Nexterm requires server-side encryption of stored passwords and SSH private keys. The `ENCRYPTION_KEY` environment variable is **mandatory**. The container won&apos;t start without it.

```bash
openssl rand -hex 32
```

Example output:

```
a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2
```

Copy this key. You&apos;ll paste it into the docker-compose file.

&lt;Notice type=&quot;warning&quot; title=&quot;Store this key safely&quot;&gt;
If you lose the ENCRYPTION_KEY, all stored credentials become **unrecoverable**. Save it in a password manager. Never commit it to git. Docker secrets are also supported via `/run/secrets/encryption_key`. See how to [manage secrets securely with Docker Compose](https://www.bitdoze.com/docker-compose-secrets/).
&lt;/Notice&gt;

### Step 2: Docker Compose configuration

Create a `docker-compose.yml` file:

```yaml
services:
  nexterm:
    image: nexterm/aio:latest
    environment:
      ENCRYPTION_KEY: &quot;your-generated-key-here&quot;
    volumes:
      - ./nexterm:/app/data
    networks:
      - traefik-net
    restart: always
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.nexterm.rule=Host(`nexterm.domain.com`)&quot;
      - &quot;traefik.http.routers.nexterm.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.nexterm.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.nexterm.loadbalancer.server.port=6989&quot;

networks:
  traefik-net:
    external: true
```

Replace `nexterm.domain.com` with your actual domain. The `traefik-net` network must already exist (create it with `docker network create traefik-net` if needed).

The image is `nexterm/aio:latest`, the all-in-one package (server + web client + C-based engine) that replaces the deprecated `germannewsmaker/nexterm`. Paste the key from Step 1 into the `ENCRYPTION_KEY` variable. The volume `./nexterm:/app/data` persists all configuration, connections, and credentials. The Traefik labels route `nexterm.domain.com` traffic through Traefik with automatic Let&apos;s Encrypt TLS on port 6989.

&lt;Notice type=&quot;info&quot; title=&quot;Docker images&quot;&gt;
Nexterm distributes three images: `nexterm/aio` (all-in-one, recommended for most users), `nexterm/server` (server + web client only, needs external engine), and `nexterm/engine` (engine only, the C-based connection service). For 95% of setups, `aio` is the right choice. See the [official installation docs](https://docs.nexterm.dev/installation).
&lt;/Notice&gt;

### Step 3: Start the container

```bash
docker compose up -d
```

Verify it&apos;s running:

```bash
docker compose ps
docker compose logs nexterm
```

&lt;Notice type=&quot;success&quot; title=&quot;Verify startup&quot;&gt;
Run `docker compose logs nexterm` and look for the startup message confirming the server is listening on port 6989. If using Traefik, check the Traefik dashboard. The `nexterm` route should appear under your entrypoints. `docker compose ps` should show &quot;Up&quot; status.
&lt;/Notice&gt;

If the container exits immediately, the most common cause is a missing or malformed `ENCRYPTION_KEY`. Check the troubleshooting section below.

### Step 4: Access Nexterm and add your first connections

Open `https://nexterm.domain.com` in your browser (or `http://your-server-ip:6989` if you skipped the reverse proxy). The first user you create becomes the admin. After logging in, add connections:

1. Click **Servers** in the sidebar
2. Create a folder (e.g., &quot;Production&quot; or &quot;Home Lab&quot;)
3. Click the **+** button to add a new connection
4. Select the protocol (SSH, RDP, VNC, etc.), enter the host, port, and credentials
5. Save and double-click to connect

&lt;Picture src={imag1} alt=&quot;Nexterm web interface dashboard showing server connections&quot; /&gt;
&lt;Picture src={imag2} alt=&quot;Nexterm adding a new SSH server connection&quot; /&gt;

## Host network vs bridge network: which should you use?

The official Nexterm docs recommend `network_mode: host` because it enables Wake-on-LAN and localhost connectivity to the Docker host. But host networking means Traefik Docker provider labels **won&apos;t work**. You&apos;d need a file provider or a different reverse proxy.

For most bitdoze readers with an existing Traefik stack, **bridge network (the setup above) is the practical choice**. Use host network only if you need Wake-on-LAN or localhost server access and don&apos;t use Traefik&apos;s Docker provider.

&lt;Tabs&gt;
&lt;Tab name=&quot;Bridge Network + Traefik&quot;&gt;
**What you get:** Works with your existing Traefik Docker provider. Automatic TLS via Let&apos;s Encrypt labels. Clean integration with `traefik-net`.

**What you lose:** No Wake-on-LAN support. No direct `localhost` connectivity to the Docker host from inside the container. Target servers must be reachable from the Docker bridge network.

This is the setup in Step 2 above.
&lt;/Tab&gt;
&lt;Tab name=&quot;Host Network&quot;&gt;
**What you get:** Full access to the host&apos;s network stack. Wake-on-LAN works. Connect to `localhost` services on the host directly.

**What you lose:** Traefik Docker labels don&apos;t function. You&apos;ll need to configure the reverse proxy via file provider, or use Nginx/Caddy with static config. Port 6989 is exposed on all host interfaces by default.

```yaml
services:
  nexterm:
    image: nexterm/aio:latest
    environment:
      ENCRYPTION_KEY: &quot;your-generated-key-here&quot;
    network_mode: host
    restart: always
    volumes:
      - ./nexterm:/app/data
```

If using host network on a public VPS, restrict port 6989 with UFW/iptables or put it behind a VPN.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Cloudflare Tunnel users&quot;&gt;
If you&apos;re running Nexterm behind a Cloudflare Tunnel, ensure WebSocket proxying is enabled. Nexterm uses WebSocket for terminal sessions. Without it, you&apos;ll get a connection that immediately drops. See the [official reverse proxy docs](https://docs.nexterm.dev/reverse-proxy).
&lt;/Notice&gt;

## Hardening and production notes

&lt;Notice type=&quot;warning&quot; title=&quot;Beta software&quot;&gt;
Nexterm is still in beta (v1.2.x). Always back up your data directory before upgrading. Breaking changes between versions are possible.
&lt;/Notice&gt;

**Back up before upgrades.** Nexterm is beta. Data loss is possible between major version jumps. Always back up the `./nexterm` data directory before pulling a new image:

```bash
docker compose down
cp -r ./nexterm ./nexterm-backup-$(date +%Y%m%d)
docker compose pull
docker compose up -d
```

**ENCRYPTION_KEY management.** Store the key in a password manager. Never commit it to git or paste it in public forums. If you lose it, all stored credentials become unrecoverable. Docker secrets are supported. Mount at `/run/secrets/encryption_key` instead of using the environment variable. Learn more about how to [manage secrets securely with Docker Compose](https://www.bitdoze.com/docker-compose-secrets/).

**Firewall.** If you&apos;re using host network mode on a public VPS, port 6989 is exposed directly on all interfaces. Use UFW or iptables to restrict access, or put Nexterm behind a reverse proxy. If you&apos;re running on Hetzner, read how to [secure your Docker server](https://www.bitdoze.com/bsi-security-report-docker-ufw/). For broader VPS hardening, [secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/).

**Resource usage.** Nexterm typically runs under 200 MB RAM. The AIO image is ~95 MB compressed. It doesn&apos;t need much. A small VPS handles dozens of connections.

**Regular updates.** Check [GitHub releases](https://github.com/gnmyt/Nexterm/releases) periodically. Use `nexterm/aio:latest` to auto-update on pull, or pin to a specific version like `nexterm/aio:1.2.2-BETA` for stability.

## Troubleshooting common issues

&lt;Accordion label=&quot;Container won&apos;t start (missing ENCRYPTION_KEY)&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;
**Symptom:** Container exits immediately after `docker compose up -d`.

**Fix:** Ensure `ENCRYPTION_KEY` is set in the `environment` section of your docker-compose file. Check logs:

```bash
docker compose logs nexterm
```

If you see encryption-related errors, regenerate the key with `openssl rand -hex 32` and restart.

**Verify:** `docker compose ps` should show &quot;Up&quot; status after fixing.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can&apos;t connect via SSH from Nexterm&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Connection timeout or refused when trying to SSH to a target server.

**Fix:** If using bridge network, verify the target is reachable from the container:

```bash
docker compose exec nexterm ping target-ip
```

Check firewall rules on the target server. If the target is on a different network, the Docker bridge may not have a route to it.

**Verify:** Check Nexterm logs for engine connection errors.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;RDP/VNC shows a black screen&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Connects to the remote server but displays a black screen.

**Fix:** For RDP, try adjusting the security method. Since v1.2.2, Nexterm supports NLA and Kerberos configuration. For VNC, verify the VNC server is running and accepting connections on the target. Some VNC servers only accept connections from localhost by default.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Reverse proxy WebSocket errors&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Terminal connects but immediately disconnects.

**Fix:** Nexterm requires WebSocket support. If using Nginx as your reverse proxy, add these headers:

```nginx
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection &quot;upgrade&quot;;
```

For Cloudflare, enable WebSocket proxying in the dashboard. See the [official reverse proxy docs](https://docs.nexterm.dev/reverse-proxy).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Migrating from the old germannewsmaker/nexterm image&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Switching to `nexterm/aio:latest` fails or data seems incompatible.

**Fix:** Back up your `./nexterm` data directory first. Swap the image in docker-compose:

```yaml
# old (deprecated)
image: germannewsmaker/nexterm:1.0.1-OPEN-PREVIEW
# new
image: nexterm/aio:latest
```

If the container fails to start after the swap (encryption migration issues from pre-1.0.3 data), start fresh: delete the `./nexterm` directory and re-add your connections manually. The v1.2.1-BETA release notes warn that migration from very old versions may be unstable.
&lt;/Accordion&gt;

## Conclusion

Nexterm at v1.2.2-BETA is a capable, lightweight, self-hosted server management platform. The Nexterm Docker install with Docker Compose gives you SSH, VNC, RDP, Telnet, and SFTP from a single web dashboard, with proper encryption at rest, 2FA/passkey support, and session recording. It runs comfortably on a small VPS and integrates well with Traefik for HTTPS access.

If you&apos;re exploring the space, check [self-hosted server management panels](https://www.bitdoze.com/best-self-hosted-panels/) for a broader comparison, or look at [alternative SSH management tools like Termix](https://www.bitdoze.com/termix-self-host/) for different trade-offs.

&lt;Button text=&quot;Visit Nexterm Docs&quot; link=&quot;https://docs.nexterm.dev&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>server-management</category></item><item><title>How to Add a Floating Menu to a Carrd Website</title><link>https://www.bitdoze.com/carrd-floating-menu/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-floating-menu/</guid><description>How to add a floating hamburger menu to a Carrd website with custom HTML/CSS/JS. Includes accessibility fixes, troubleshooting, and styling tips.</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Carrd.co](https://go.bitdoze.com/carrd) is a solid platform for one-page websites, but when your page has many sections, visitors lose their place fast. A floating hamburger menu fixes that. It gives your Carrd site always-visible navigation that sits in the bottom-right corner, right where a thumb naturally rests on mobile.

Carrd&apos;s native elements don&apos;t include a floating nav component, so you need custom code via the Embed element. The good news: one embed block, one paste, and you&apos;re done. The code below includes built-in accessibility (ARIA attributes, keyboard support, reduced-motion handling) and won&apos;t break your existing Carrd styling.

If you&apos;re evaluating the platform, check our [Carrd.co review](/carrd-review/) first.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

## Why a floating hamburger menu improves your Carrd site

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Navigation stays accessible without cluttering the main content area, especially on mobile screens&lt;/li&gt;
&lt;li&gt;The hamburger icon is universally recognized. Users know to tap it for a menu&lt;/li&gt;
&lt;li&gt;Bottom-right placement matches the natural thumb position for one-handed mobile use&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;position: fixed&lt;/code&gt;, the menu stays visible as visitors scroll through your entire page&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/0RPYtau6PxI&quot;
  label=&quot;How to Add a Floating Menu to an Carrd Website&quot;
/&gt;
## Prerequisites

Before you start, make sure you have:

- **A Carrd Pro Standard ($19/yr) or Pro Plus ($49/yr) plan.** Custom code Embeds are NOT available on the Free plan or Pro Lite ($9/yr). This is the number one reason the floating menu &quot;doesn&apos;t work&quot; for people. Their plan doesn&apos;t support Embeds. See [Carrd&apos;s plan comparison](https://carrd.co/docs/pro/plans) for details.
- **A Carrd site with sections you want to link to.** Each menu item points to an anchor ID (like `#about`, `#contact`). If you haven&apos;t set up section IDs yet, do that first.
- **The ability to publish your site.** Custom code Embeds do NOT preview in the Carrd builder. You must publish to see the floating menu in action.

&lt;Notice type=&quot;warning&quot; title=&quot;Pro plan required&quot;&gt;
The Embed element requires Carrd Pro Standard ($19/yr) or higher. Free and Pro Lite ($9/yr) plans cannot use custom code embeds. If you&apos;re on Pro Lite, you&apos;ll need to upgrade before this works.
&lt;/Notice&gt;

If you&apos;ve already added a [back to top button on Carrd](/carrd-back-to-top-button/), you&apos;ve used the same Embed workflow. For smooth scrolling between sections, see our guide on [smooth scroll and anchor links in Carrd](/carrd-smooth-scroll/). Once your menu is working, consider [adding a custom domain to Carrd](/carrd-add-domain/) for a professional URL.

## How to add the Carrd floating hamburger menu

Three steps: add an embed, paste the code, customize your links.

### 1. Add an Embed element to your Carrd site

In the Carrd editor, click the `+` button and add an Embed element anywhere on the page. Configure it as:

- **Type:** Code
- **Style:** Hidden, Head

&quot;Hidden, Head&quot; means the code gets injected into the site&apos;s `&lt;head&gt;`. It won&apos;t show up as a visible content block on your page. The embed is invisible in the builder preview. You&apos;ll only see the menu after publishing.

&lt;Picture src={imag1} alt=&quot;Carrd embed element settings showing Type: Code and Style: Hidden, Head&quot; /&gt;

### 2. Add the HTML, CSS, and JavaScript code

Paste this complete code into the Embed element:

```html
&lt;style&gt;
  :root {
    --primary-color-ha: rgba(0, 112, 15, 0.58);
    --secondary-color-ha: #fff;
    --font-size-base-ha: 16px;
    --floating-hamburger-size: 50px;
    --floating-font: inherit;
  }

  /* Scoped box-sizing: won&apos;t break Carrd&apos;s native styling */
  .floating-hamburger,
  .floating-hamburger *,
  .floating-menu,
  .floating-menu * {
    box-sizing: border-box;
  }

  .floating-hamburger {
    position: fixed;
    bottom: 20px;
    right: 20px;
    width: var(--floating-hamburger-size);
    height: var(--floating-hamburger-size);
    background-color: var(--primary-color-ha);
    border-radius: 50%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    cursor: pointer;
    z-index: 1000;
  }

  .floating-hamburger:focus-visible {
    outline: 2px solid var(--secondary-color-ha);
    outline-offset: 2px;
  }

  .floating-hamburger .floating-bar {
    width: 30px;
    height: 3px;
    background-color: var(--secondary-color-ha);
    margin: 3px 0;
  }

  @media (prefers-reduced-motion: no-preference) {
    .floating-hamburger .floating-bar {
      transition: 0.4s;
    }
  }

  #floating-menu-toggle {
    display: none;
  }

  .floating-menu {
    position: fixed;
    bottom: calc(var(--floating-hamburger-size) + 30px);
    right: 20px;
    background-color: var(--primary-color-ha);
    padding: 20px;
    border-radius: 10px;
    transform: scale(0);
    transform-origin: bottom right;
    z-index: 999;
    font-family: var(--floating-font);
    font-size: var(--font-size-base-ha);
  }

  @media (prefers-reduced-motion: no-preference) {
    .floating-menu {
      transition: transform 0.3s ease-in-out;
    }
  }

  #floating-menu-toggle:checked ~ .floating-menu {
    transform: scale(1);
  }

  .floating-menu ul {
    list-style: none;
    margin: 0;
    padding: 0;
  }

  .floating-menu li {
    margin: 15px 0;
  }

  .floating-menu li a {
    color: var(--secondary-color-ha);
    text-decoration: none;
    font-size: 1em;
  }

  @media (prefers-reduced-motion: no-preference) {
    .floating-menu li a {
      transition: font-size 0.3s ease;
    }
  }

  .floating-menu li a:hover {
    font-size: 1.1em;
  }

  .floating-menu a:focus-visible {
    outline: 2px solid var(--secondary-color-ha);
    outline-offset: 2px;
  }

  .floating-close-button {
    position: absolute;
    top: 10px;
    right: 10px;
    background: none;
    border: none;
    color: var(--secondary-color-ha);
    font-size: 1.2em;
    cursor: pointer;
    font-family: var(--floating-font);
  }
&lt;/style&gt;

&lt;input type=&quot;checkbox&quot; id=&quot;floating-menu-toggle&quot; /&gt;
&lt;label for=&quot;floating-menu-toggle&quot; class=&quot;floating-hamburger&quot;
       aria-label=&quot;Open navigation menu&quot; tabindex=&quot;0&quot; role=&quot;button&quot;&gt;
  &lt;span class=&quot;floating-bar&quot;&gt;&lt;/span&gt;
  &lt;span class=&quot;floating-bar&quot;&gt;&lt;/span&gt;
  &lt;span class=&quot;floating-bar&quot;&gt;&lt;/span&gt;
&lt;/label&gt;
&lt;nav class=&quot;floating-menu&quot; role=&quot;navigation&quot;&gt;
  &lt;button class=&quot;floating-close-button&quot; aria-label=&quot;Close menu&quot;&gt;&amp;#x2715;&lt;/button&gt;
  &lt;ul&gt;
    &lt;li&gt;&lt;a href=&quot;#&quot;&gt;Home&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#about&quot;&gt;About&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#testimonials&quot;&gt;Testimonials&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#contact&quot;&gt;Contact&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;
&lt;/nav&gt;

&lt;script&gt;
  const toggle = document.getElementById(&quot;floating-menu-toggle&quot;);
  const label = document.querySelector(&quot;.floating-hamburger&quot;);

  // Toggle aria-expanded for screen readers
  toggle.addEventListener(&quot;change&quot;, function () {
    label.setAttribute(&quot;aria-expanded&quot;, this.checked);
  });
  label.setAttribute(&quot;aria-expanded&quot;, &quot;false&quot;);

  // Close button
  document.querySelector(&quot;.floating-close-button&quot;).addEventListener(&quot;click&quot;, function () {
    toggle.checked = false;
    label.setAttribute(&quot;aria-expanded&quot;, &quot;false&quot;);
    label.focus();
  });

  // Close on link click
  document.querySelectorAll(&quot;.floating-menu a&quot;).forEach(function (link) {
    link.addEventListener(&quot;click&quot;, function () {
      toggle.checked = false;
      label.setAttribute(&quot;aria-expanded&quot;, &quot;false&quot;);
    });
  });

  // Escape key closes menu, returns focus to hamburger
  document.addEventListener(&quot;keydown&quot;, function (e) {
    if (e.key === &quot;Escape&quot; &amp;&amp; toggle.checked) {
      toggle.checked = false;
      label.setAttribute(&quot;aria-expanded&quot;, &quot;false&quot;);
      label.focus();
    }
  });
&lt;/script&gt;
```

&lt;Notice type=&quot;info&quot; title=&quot;What changed from the old code&quot;&gt;
Two critical fixes for returning readers: (1) The global `* { margin: 0; padding: 0; box-sizing: border-box; }` reset has been removed. It was destroying Carrd&apos;s native element spacing. (2) The `body { font-size; font-family }` override has been removed. It was hijacking all text on the page. Both are replaced with scoped selectors that only affect the floating menu itself.
&lt;/Notice&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Accessibility built in&quot;&gt;
This code includes ARIA attributes (`aria-label`, `aria-expanded`, `role=&quot;navigation&quot;`), visible focus outlines for keyboard users, `@media (prefers-reduced-motion: no-preference)` wrapping all animations, and Escape key support to close the menu. This addresses the European Accessibility Act (effective June 28, 2025) requirements for interactive UI elements.
&lt;/Notice&gt;

#### CSS custom properties explained

These variables at the top of the `&lt;style&gt;` block control the menu&apos;s appearance. Change them to match your site&apos;s design:

1. **`--primary-color-ha`**: Background color of the hamburger button and menu panel. Uses `rgba()` for transparency so visitors can still see content behind the menu. Change the RGB values and alpha to match your brand. Use [https://rgbacolorpicker.com/](https://rgbacolorpicker.com/) to pick a color.

2. **`--secondary-color-ha`**: Color of the hamburger bars, menu text, and close button. Default is white (`#fff`).

3. **`--font-size-base-ha`**: Base font size for menu items. Default `16px` is a good baseline for readability.

4. **`--floating-hamburger-size`**: Diameter of the circular hamburger button in pixels. Default `50px` is comfortable to tap on mobile.

5. **`--floating-font`**: Font family for menu text. Default `inherit` uses your Carrd site&apos;s font. Replace with a specific font stack if you want a different look (e.g., `&quot;Inter&quot;, sans-serif`).

6. **`.floating-hamburger .floating-bar` `width: 30px`**: Controls the width of the three hamburger lines. Adjust if you want narrower or wider bars inside the button.

### 3. Customize the menu items and links

The menu items are in the `&lt;ul&gt;` inside the `&lt;nav&gt;`:

```html
&lt;ul&gt;
  &lt;li&gt;&lt;a href=&quot;#&quot;&gt;Home&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#about&quot;&gt;About&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#testimonials&quot;&gt;Testimonials&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#contact&quot;&gt;Contact&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
```

Add, remove, or rename items to match your Carrd site&apos;s sections. Each `href` should point to the section&apos;s anchor ID in your Carrd editor (e.g., `#about` links to the section with ID `about`). Using `href=&quot;#&quot;` scrolls back to the top of the page.

For the best experience, pair this menu with [smooth scroll and anchor links in Carrd](/carrd-smooth-scroll/) so clicking a menu item scrolls smoothly instead of jumping.

## Verify your floating menu works

After publishing your Carrd site, run through this checklist:

1. **Publish the site.** Embeds don&apos;t work in the builder preview. This is the most common &quot;it doesn&apos;t work&quot; mistake.
2. **Open the published URL in an incognito/private window.** Clears any cached state.
3. **Click the hamburger button.** The menu should scale in from the bottom-right corner.
4. **Click a menu link.** The page scrolls to that section and the menu closes automatically.
5. **Click the close (✕) button.** The menu closes and focus returns to the hamburger button.
6. **Test on a real mobile device** (or Chrome DevTools device emulator). The button should be easy to tap with a thumb.
7. **Keyboard test.** Tab to the hamburger button, press Enter/Space to open, Tab through menu items, press Escape to close. Focus should return to the hamburger.
8. **Check for overlaps.** The menu shouldn&apos;t cover other fixed elements like cookie notices or back-to-top buttons. If you have a [WhatsApp button on Carrd](/carrd-whatsapp-button/) or similar floating elements, adjust `z-index` values to prevent stacking conflicts.
9. **Reduced motion test.** Enable your OS &quot;Reduce motion&quot; setting and verify the menu opens/closes instantly without animation.

&lt;Notice type=&quot;warning&quot; title=&quot;Embeds don&apos;t preview in the builder&quot;&gt;
You MUST publish your Carrd site to see the floating menu. The Carrd builder does not render custom code embeds in its preview. If you can&apos;t see the menu, publish first, then check the live site.
&lt;/Notice&gt;

## Troubleshooting common issues

&lt;Accordion label=&quot;Menu doesn&apos;t appear at all&quot; group=&quot;troubleshooting&quot;&gt;
Three things to check in order:

1. **Your plan.** Embeds require Carrd Pro Standard ($19/yr) or Pro Plus ($49/yr). Free and Pro Lite ($9/yr) plans don&apos;t support custom code. Check your plan at [carrd.co/docs/pro/plans](https://carrd.co/docs/pro/plans).
2. **Embed settings.** The embed must be set to Type: Code, Style: Hidden, Head. If Style is set to something else, the code won&apos;t inject into `&lt;head&gt;`.
3. **You&apos;re looking at the builder, not the published site.** Publish first, then open the live URL.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu breaks other elements on the page&quot; group=&quot;troubleshooting&quot;&gt;
If you&apos;re using older code that includes `* { margin: 0; padding: 0; box-sizing: border-box; }`, that global reset is the problem. It wipes out Carrd&apos;s native spacing on every element. Replace it with the scoped version in the code above. It only applies `box-sizing` to the floating menu&apos;s own elements.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu appears behind other elements&quot; group=&quot;troubleshooting&quot;&gt;
Increase the `z-index` on `.floating-hamburger` (currently `1000`) and `.floating-menu` (currently `999`). The element with the higher `z-index` value appears on top. If you have other custom elements with high z-index values, you may need to go higher. If you&apos;re using CSS custom properties for other Carrd customizations like a [dark mode toggle](/carrd-dark-mode-toggle/), make sure your variable names don&apos;t conflict.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Hamburger doesn&apos;t respond to click&quot; group=&quot;troubleshooting&quot;&gt;
Another fixed-position element is likely overlapping the hamburger button. Right-click the hamburger area, choose &quot;Inspect,&quot; and look at the z-index of nearby elements. A cookie banner or chat widget with a higher z-index will block clicks. Adjust z-index values so the hamburger sits on top.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;JavaScript doesn&apos;t work&quot; group=&quot;troubleshooting&quot;&gt;
Open your browser console (F12) and check for errors. If you have multiple Carrd embeds with `&lt;script&gt;` blocks, they can conflict, especially if they use the same variable names. Make sure each embed uses unique IDs and variable names. The checkbox hack used here is the same pattern as a [popup modal on Carrd](/carrd-popup-modal/). If you have both, ensure each uses a unique checkbox ID.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Code won&apos;t save in the embed&quot; group=&quot;troubleshooting&quot;&gt;
Carrd embeds have a 16,384 character limit. The code in this article is well under that, but if you&apos;ve added a lot of custom CSS or extra menu items, you might hit it. Split the code into two embeds: put the `&lt;style&gt;` block in a &quot;Hidden, Head&quot; embed and the HTML + `&lt;script&gt;` in a &quot;Hidden, Body End&quot; embed.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu works on desktop but not mobile&quot; group=&quot;troubleshooting&quot;&gt;
Carrd has responsive visibility settings per element. Check that your Embed element isn&apos;t hidden on mobile breakpoints. In the Carrd editor, select the embed and look for visibility toggles. Make sure it&apos;s visible on all device sizes.
&lt;/Accordion&gt;

## Alternative navigation approaches for Carrd

The floating hamburger menu is one way to handle Carrd navigation. Here are other patterns depending on your needs:

- **Sticky header.** A fixed header bar with navigation links that stays at the top of the viewport. Different visual pattern from a floating button. See our [sticky header in Carrd](/add-stickey-header-carrd/) tutorial.
- **Sidebar menu.** A slide-in panel from the edge of the screen, good for sites with many navigation items. Our [sidebar menu for Carrd](/carrd-sidebar-menu/) guide covers this.
- **Mobile responsive navbar.** A hamburger menu that integrates into the page header rather than floating. See [Carrd mobile responsive navbar](/carrd-mobile-navbar/) for three different methods.
- **Third-party plugins.** Pre-built Carrd widgets that handle navigation for you. Options include the [Tabs plugin](https://go.carrdme.com/tabs) and [Accordion plugin](https://go.carrdme.com/accordion) from CarrdMe, or browse [Plugin Kitchen](https://plugin-kitchen.carrd.co/) and [Jason&apos;s Plugins](https://plugins.carrd.co/) for more.

&lt;Accordion label=&quot;What about the native Popover API?&quot; group=&quot;alternatives&quot;&gt;
The HTML `popover` attribute (Baseline 2025, supported in all modern browsers) provides light-dismiss behavior, top-layer rendering, and keyboard handling natively with no JavaScript. In theory, it could simplify the floating menu significantly. In practice, Carrd&apos;s embed constraints may strip or interfere with `popover` attributes. I haven&apos;t tested it inside a Carrd embed yet. If you try it and it works, that would be a cleaner approach than the checkbox hack used here.
&lt;/Accordion&gt;

## Conclusion

A floating hamburger menu gives your Carrd one-page site always-accessible, mobile-first navigation without cluttering the content area. The updated code is scoped (no global CSS resets that break Carrd&apos;s styling), accessible (ARIA attributes, keyboard support, reduced-motion handling), and customizable through CSS custom properties.

Test on real devices after publishing, tweak the colors and sizing to match your brand, and you&apos;ll have a navigation component that works for every visitor.</content:encoded><category>web-development</category><category>carrd</category><category>floating-menu</category><category>hamburger-menu</category></item><item><title>How to Install Memos with Docker Compose: EASY STEPS!</title><link>https://www.bitdoze.com/memos-install/</link><guid isPermaLink="true">https://www.bitdoze.com/memos-install/</guid><description>Step-by-step guide to install Memos with Docker Compose using SQLite or PostgreSQL. Self-host a privacy-first note-taking app with Traefik reverse proxy in minutes.</description><pubDate>Tue, 04 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import imag1 from &quot;../../assets/images/24/08/memos1.png&quot;;
import imag2 from &quot;../../assets/images/24/08/memos2.jpeg&quot;;

[Memos](https://www.usememos.com/) is a self-hosted note-taking app for personal knowledge management. It&apos;s open source (MIT license), runs on Go + React, and has over 60,000 GitHub stars as of v0.30.0 (July 2025). It uses SQLite by default, supports Markdown out of the box, and runs comfortably on 1 CPU / 1 GB RAM. This guide walks through installing Memos with Docker Compose using either SQLite or PostgreSQL, behind a Traefik reverse proxy with automatic TLS.

## What is Memos?

Memos is a privacy-first, self-hosted note-taking application. All data stays on your server. There&apos;s no cloud dependency, no subscription, no tracking. The interface is clean: you type Markdown, it renders live, and everything syncs across your devices through the web UI.

Core features:

- **Markdown support** with a new CodeMirror 6 editor and formatting toolbar (v0.30.0)
- **Lightweight architecture**: the Go backend and React frontend keep memory usage low
- **Customizable UI**: light/dark themes, server name, icon, description
- **Multi-device access** through any browser
- **Attachments stored on filesystem** by default since v0.27.0 (not inside the database)
- **Open source** under MIT license. Contribute or fork as you like.

If you need something heavier for team documentation, check [How to Install Outline Wiki on Docker](https://www.bitdoze.com/outline-install/) or [Docmost Docker Compose Install](https://www.bitdoze.com/docmost-docker-install/).

Since the original publish, Memos has added several features worth knowing about:

- **Web Clipper** browser extension for [Chrome](https://chromewebstore.google.com/detail/memos-web-clipper/nebaoebnljalfegiidibihhkebeiklbl) and [Firefox](https://addons.mozilla.org/en-US/firefox/addon/memos-web-clipper/)
- **Voice notes with AI transcription** via OpenAI or Gemini providers
- **MCP server** at `/mcp` for AI client integration (Model Context Protocol)
- **CodeMirror 6 editor** with WYSIWYG-style formatting toolbar
- **Multi-column feed layouts**: 1, 2, or 3 columns plus auto-fit
- **SSE live refresh**: real-time updates without polling
- **Standard Webhooks** with HMAC-SHA256 signing
- **Deployment-managed configuration** via `/etc/secrets` JSON files for GitOps workflows

## Install Memos with Docker Compose

Below are two complete setups: SQLite (simplest, recommended for personal use) and PostgreSQL (for when you already run Postgres or want `pg_dump` backups).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/vBa4ogLNF14&quot;
  label=&quot;Memos Installation&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Monitor your server&quot;&gt;
After deploying Memos, set up server monitoring to keep an eye on CPU, memory, and disk. See [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/) or set up [Beszel &amp; Uptime Kuma](https://www.bitdoze.com/beszel-uptime-kuma/) for a lightweight dashboard.
&lt;/Notice&gt;

### Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;A VPS or home server. Memos runs on 1 CPU / 1 GB RAM without issues. A [Hetzner](https://go.bitdoze.com/hetzner) CX22, [Hostinger](https://go.bitdoze.com/hostinger-vps) KVM VPS, or [Vultr](https://go.bitdoze.com/vultr) instance all work fine. You can also use a [Mini PC as a Home Server](https://www.bitdoze.com/best-mini-pc-home-server/).&lt;/li&gt;
  &lt;li&gt;Docker and Docker Compose installed&lt;/li&gt;
  &lt;li&gt;A reverse proxy with TLS. Traefik is recommended. See [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) and [Traefik FREE Let&apos;s Encrypt Wildcard Certificate](https://www.bitdoze.com/traefik-wildcard-certificate/). You can also use Nginx, Caddy, or Cloudflare Tunnel.&lt;/li&gt;
  &lt;li&gt;A domain name pointed to your server&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

You can manage Docker Compose stacks through [Dockge](https://www.bitdoze.com/dockge-install/) or any of the [Best Self-Hosted Server Panels](https://www.bitdoze.com/best-self-hosted-panels/).

### Quick start: Memos with SQLite

SQLite is the default backend and the simplest way to get running. One container, one volume, no database service to manage.

```yaml
services:
  memos:
    image: neosmemo/memos:stable
    container_name: memos
    restart: unless-stopped
    networks:
      - traefik-net
    volumes:
      - ./memos:/var/opt/memos
    environment:
      MEMOS_DRIVER: sqlite
      MEMOS_PORT: 5230
      # Set this to your public URL to enable public mode.
      # Leave empty for private-only (no anonymous access, no RSS, no Explore).
      MEMOS_INSTANCE_URL: https://memos.yourdomain.com
      # MEMOS_LOG_LEVEL: debug  # Uncomment for troubleshooting
    labels:
      - traefik.enable=true
      - traefik.http.routers.memos.rule=Host(`memos.yourdomain.com`)
      - traefik.http.routers.memos.entrypoints=websecure
      - traefik.http.routers.memos.tls.certresolver=letsencrypt
      - traefik.http.services.memos.loadbalancer.server.port=5230

networks:
  traefik-net:
    external: true
```

&lt;Notice type=&quot;warning&quot; title=&quot;v0.30.0 breaking change: private mode default&quot;&gt;
Since Memos v0.30.0, leaving `MEMOS_INSTANCE_URL` empty puts the instance in private-only mode. Anonymous visitors get redirected to sign-in, RSS is disabled, and Explore is hidden. Set `MEMOS_INSTANCE_URL` to your public URL (e.g., `https://memos.yourdomain.com`) if you want public memos and RSS feeds.
&lt;/Notice&gt;

**What this does:**

- `neosmemo/memos:stable` pulls the latest stable release (currently v0.30.0)
- The volume `./memos:/var/opt/memos` persists all data (database + attachments) on the host
- Traefik labels route `memos.yourdomain.com` to port 5230 with automatic TLS via your cert resolver
- The container runs as non-root by default (UID 10001), no need for `user: root`

If you&apos;re not using Traefik (e.g., Cloudflare Tunnel or direct access), replace the `labels` block with a port mapping:

```yaml
ports:
  - 5230:5230
```

Remove the `networks` section if you&apos;re not using an external Traefik network.

### Memos with PostgreSQL

Use PostgreSQL if you already run it for other services, want `pg_dump` backup support, or prefer a managed database backend. MySQL is also supported. See the [Memos database docs](https://usememos.com/docs/configuration/database) for connection strings.

&lt;Tabs&gt;
&lt;Tab name=&quot;When to use SQLite&quot;&gt;
- Zero configuration, just a file on disk
- Ideal for personal, single-node use
- Backup = copy the data directory
- Runs anywhere, no database service to maintain
- Default and recommended for most users
&lt;/Tab&gt;
&lt;Tab name=&quot;When to use PostgreSQL&quot;&gt;
- You already run PostgreSQL for other services
- You want `pg_dump` for structured backups
- You prefer operational control (replication, monitoring)
- You need better concurrency handling for many attachments
- See [Multiple PostgreSQL Databases in ONE Service](https://www.bitdoze.com/multiple-postgres-databases-docker/) to share a single Postgres container
&lt;/Tab&gt;
&lt;/Tabs&gt;

```yaml
services:
  memos:
    image: neosmemo/memos:stable
    container_name: memos
    restart: unless-stopped
    networks:
      - traefik-net
    depends_on:
      memos-db:
        condition: service_healthy
    volumes:
      - ./memos:/var/opt/memos
    environment:
      MEMOS_DRIVER: postgres
      MEMOS_DSN: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@memos-db:5432/${POSTGRES_DB}?sslmode=disable
      MEMOS_PORT: 5230
      MEMOS_INSTANCE_URL: https://memos.yourdomain.com
    labels:
      - traefik.enable=true
      - traefik.http.routers.memos.rule=Host(`memos.yourdomain.com`)
      - traefik.http.routers.memos.entrypoints=websecure
      - traefik.http.routers.memos.tls.certresolver=letsencrypt
      - traefik.http.services.memos.loadbalancer.server.port=5230

  memos-db:
    image: postgres:16-alpine
    restart: unless-stopped
    networks:
      - traefik-net
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - ./memos-db:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

networks:
  traefik-net:
    external: true
```

The `depends_on` with `condition: service_healthy` ensures Memos doesn&apos;t start until PostgreSQL is accepting connections. The PostgreSQL container uses `postgres:16-alpine` for a small image footprint.

### Create the `.env` file

The SQLite setup doesn&apos;t need an `.env` file. For PostgreSQL, create a `.env` file in the same directory as your `docker-compose.yml`:

```sh
POSTGRES_DB=memos
POSTGRES_USER=memos
POSTGRES_PASSWORD=change-me-to-a-strong-random-password
```

&lt;Notice type=&quot;warning&quot; title=&quot;Change the default password&quot;&gt;
Generate a random password before deploying. You can use `openssl rand -base64 32` to create one. Don&apos;t run PostgreSQL with a guessable password, even on a private network.
&lt;/Notice&gt;

### Start Memos

```sh
docker compose up -d
```

This pulls the images, creates the containers, and starts everything in detached mode.

### Verify it works

After `docker compose up -d`, confirm everything is healthy:

```sh
# Check container status — both should be &quot;Up&quot; and (for postgres) &quot;healthy&quot;
docker compose ps

# Check Memos logs for startup confirmation
docker compose logs memos
# Look for: &quot;Server started at http://localhost:5230&quot;

# Quick HTTP check
curl -I http://localhost:5230
# Expect: HTTP/1.1 200 OK
```

If the container is restarting, check the logs with `docker compose logs memos --tail 50` for error messages.

### Access the Memos UI

Open `https://memos.yourdomain.com` in your browser. The first user you create becomes the admin. You&apos;ll see the main memo feed:

&lt;Picture src={imag1} alt=&quot;Memos self-hosted note-taking app dashboard&quot; /&gt;

From the Settings page you can switch between light and dark themes, manage users, configure SSO/OAuth2 (callback URL: `https://&lt;instance&gt;/auth/callback`), and set up storage backends:

&lt;Picture src={imag2} alt=&quot;Memos Docker Compose settings page&quot; /&gt;

## Environment variables reference

| Variable | Purpose | Default |
|---|---|---|
| `MEMOS_PORT` | HTTP listen port | `5230` |
| `MEMOS_DRIVER` | Database backend: `sqlite`, `postgres`, `mysql` | `sqlite` |
| `MEMOS_DSN` | Database connection string | Auto for SQLite |
| `MEMOS_INSTANCE_URL` | Public URL; empty = private mode | _(empty)_ |
| `MEMOS_UID` / `MEMOS_GID` | Override container UID/GID | `10001` / `10001` |
| `MEMOS_LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` |

See the [full environment variables docs](https://usememos.com/docs/configuration/environment-variables) for all options.

## Backup and upgrade Memos

### Backup

Backups depend on your database backend. Don&apos;t skip this, especially before upgrades.

**SQLite:**

```sh
# Stop Memos for a consistent snapshot
docker compose stop memos
tar -czf memos-backup-$(date +%Y%m%d).tar.gz ./memos/
docker compose start memos
```

**PostgreSQL:**

```sh
# No downtime needed — pg_dump is consistent
docker compose exec memos-db pg_dump -U memos memos &gt; memos-dump-$(date +%Y%m%d).sql
```

&lt;Notice type=&quot;info&quot; title=&quot;Automate backups&quot;&gt;
Set up a cron job to run these commands daily. For offsite safety, push the backup files to S3-compatible storage (MinIO, Backblaze B2, Bunny Storage). A missed backup is worse than a failed upgrade. At least with a backup you can roll back.
&lt;/Notice&gt;

### Upgrade

Memos releases frequently. Always back up first and check the [changelog](https://usememos.com/changelog) for breaking changes.

```sh
# 1. Backup (see above)
# 2. Pull the new image
docker compose pull
# 3. Recreate containers
docker compose up -d
# 4. Verify
docker compose logs memos
# Look for: &quot;Server started at http://localhost:5230&quot;
```

&lt;Notice type=&quot;warning&quot; title=&quot;Upgrading from pre-v0.30.0?&quot;&gt;
Memos v0.30.0 introduced private-mode-by-default. If your instance was publicly accessible before, set `MEMOS_INSTANCE_URL` to your public URL in the compose file **before** upgrading. Otherwise you&apos;ll lose anonymous access, RSS, and Explore after the restart. See the [changelog](https://usememos.com/changelog) for v0.28.0 SSO identity re-linking requirements as well.
&lt;/Notice&gt;

After upgrading, clean up old Docker images to free disk space. See [How To Clean All Docker Images](https://www.bitdoze.com/cleanup-all-docker-things/).

## Troubleshooting

&lt;Accordion label=&quot;Permission denied on volume (UID/GID mismatch)&quot; group=&quot;troubleshooting&quot;&gt;
Memos v0.30.0 runs as UID 10001 by default. If you see permission errors in the logs, the host volume directory may be owned by a different user.

Fix options:
1. Set environment variables to override: `MEMOS_UID=1000` and `MEMOS_GID=1000` (match your host user)
2. Or fix ownership: `chown -R 10001:10001 ./memos/`

See [How to Add Users to a Docker Container](https://www.bitdoze.com/add-users-to-docker-container/) for more on container user management.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Container won&apos;t start after upgrade&quot; group=&quot;troubleshooting&quot;&gt;
Check the logs for migration errors:

```sh
docker compose logs memos --tail 100
```

Common causes:
- Database migration failed. Restore from your pre-upgrade backup and check the changelog for breaking changes.
- `MEMOS_INSTANCE_URL` not set after upgrading to v0.30.0 (shouldn&apos;t prevent startup, but check)
- PostgreSQL not ready. Ensure the healthcheck passes (`docker compose ps` should show &quot;healthy&quot; for `memos-db`).

If you need to roll back: `docker compose down`, restore the backup, pin the image to a specific version tag (e.g., `neosmemo/memos:0.29.0`), and `docker compose up -d`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can&apos;t see public memos / RSS not working&quot; group=&quot;troubleshooting&quot;&gt;
Since v0.30.0, an empty `MEMOS_INSTANCE_URL` puts the instance in private mode. Anonymous visitors get redirected to sign-in, RSS feeds are unavailable, and Explore is hidden.

Fix: set `MEMOS_INSTANCE_URL` to your full public URL in the compose environment:

```yaml
environment:
  MEMOS_INSTANCE_URL: https://memos.yourdomain.com
```

Then restart: `docker compose up -d`
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Database connection refused (PostgreSQL)&quot; group=&quot;troubleshooting&quot;&gt;
Make sure the `memos-db` container is healthy before Memos starts. The `depends_on: condition: service_healthy` directive handles this, but check:

```sh
docker compose ps
# memos-db should show &quot;healthy&quot; in the STATUS column
docker compose logs memos-db
# Look for: &quot;database system is ready to accept connections&quot;
```

Also verify that your `.env` credentials match between the Memos `MEMOS_DSN` and the PostgreSQL `POSTGRES_*` variables.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Memos is slow or unresponsive&quot; group=&quot;troubleshooting&quot;&gt;
Memos is lightweight. It runs on a Raspberry Pi with 1 GB RAM. If it&apos;s slow, the issue is almost certainly not Memos itself.

Check:
- Server resources: `docker stats memos` for CPU/memory usage
- Disk space: `df -h`. A full disk will cause write failures.
- Container resource limits: if you set `mem_limit` or `cpus` in compose, they may be too low
- Attachment storage: if you have many large attachments on a slow disk, uploads will lag

See [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/) for detailed monitoring setup.
&lt;/Accordion&gt;

## Security and production hardening

Memos v0.30.0 runs as UID 10001 by default. Don&apos;t override with `user: root`, there&apos;s no reason to. Always put Memos behind a reverse proxy with TLS. Traefik + Let&apos;s Encrypt is the recommended path. Never expose port 5230 directly to the internet.

Only expose ports 80 and 443. Block direct access to 5230 from outside. For public instances, consider adding Traefik rate-limit middleware to prevent abuse.

Memos releases frequently. Subscribe to [GitHub releases](https://github.com/usememos/memos/releases) and update monthly. Set up automated daily backups to S3-compatible storage. A local backup on the same disk is better than nothing, but it won&apos;t save you from a disk failure.

&lt;Notice type=&quot;info&quot; title=&quot;Harden your server too&quot;&gt;
Memos is only as secure as the server it runs on. Secure your VPS with [CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) and add [Traefik Basic Authentication](https://www.bitdoze.com/traefik-basic-authentication/) as an extra layer for private instances. Set up [Beszel &amp; Uptime Kuma](https://www.bitdoze.com/beszel-uptime-kuma/) to get alerted if Memos goes down.
&lt;/Notice&gt;

## What&apos;s new in Memos v0.27 to v0.30

If you installed Memos before August 2024, here&apos;s what changed:

| Version | Key features |
|---|---|
| **v0.27.0** | Voice notes, AI transcription (OpenAI/Gemini), MCP server at `/mcp`, SSE live refresh, `@username` mentions, filesystem attachment storage (new default) |
| **v0.28.0** | SSO identity re-linking (**breaking change**: existing SSO users must re-link after upgrade) |
| **v0.29.0** | Link preview cards, SMTP notification email settings, dedicated shortcuts page, configurable log level, instance statistics APIs |
| **v0.30.0** | Web Clipper (Chrome + Firefox), CodeMirror 6 editor with toolbar, multi-column layouts, Standard Webhooks with HMAC signing, deployment-managed config via `/etc/secrets` JSON, private mode default |

The Web Clipper and MCP server are the standout additions. The clipper lets you save web pages directly to Memos from your browser. The MCP endpoint makes Memos accessible to AI clients that support the Model Context Protocol.

## Conclusions

Setting up Memos with Docker Compose takes under 10 minutes. SQLite is the default for a reason: zero config, one volume, and you&apos;re done. PostgreSQL adds operational controls if you need them. Either way, back up your data and check the changelog before upgrading.

&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

For more self-hosted tools and Docker setups, check out the [Best Self-Hosted Server Panels](https://www.bitdoze.com/best-self-hosted-panels/) or explore the full list below.

&lt;Button text=&quot;Explore More Docker Containers&quot; link=&quot;/docker-containers-home-server/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>docker-compose</category></item><item><title>Ghostty Terminal: A Complete Setup Guide for Modern Mac Development</title><link>https://www.bitdoze.com/ghostty-terminal/</link><guid isPermaLink="true">https://www.bitdoze.com/ghostty-terminal/</guid><description>Install and configure Ghostty terminal on Mac, then explore the libghostty ecosystem — cmux and 100+ terminals built on Ghostty&apos;s engine — plus prompts, zsh plugins, and tmux.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;

[Ghostty](https://ghostty.org/) is a modern, GPU-accelerated terminal emulator that’s quickly gaining popularity among developers and power users. It’s fast, native-feeling on macOS, and straightforward to configure. Its rendering engine, **libghostty**, is also powering a fast-growing ecosystem of new terminals — cmux and 100+ others.

In this guide you’ll:
- Install Ghostty and a Nerd Font (so icons render correctly)
- Set up a clean `~/.config/ghostty/config`
- Learn the standout Ghostty features (themes, inspector, shaders, image support)
- Understand the libghostty ecosystem: cmux and 100+ other terminals built on Ghostty&apos;s engine
- (Optional) Layer in a modern prompt and productivity tools like tmux and zoxide

If you specifically want **Starship + Ghostty** with presets and a complete prompt setup, see my companion guide: **Turbocharge Your Mac Terminal: The Ultimate Starship and Ghostty Setup Guide**:
https://www.bitdoze.com/starship-ghostty-terminal/

## Key Features of Ghostty

### GPU Acceleration

Ghostty renders using your GPU, which means fast scrolling and smooth performance. Complex terminal output stays responsive, and working with large files doesn&apos;t slow things down.

### Native UI

Ghostty doesn&apos;t try to be a one-size-fits-all UI. On macOS it uses native Swift and AppKit, on Linux it uses GTK4. Because of this, Ghostty actually feels like part of your system instead of a foreign tool.

### Simple Configuration File (Key-Value)

Config is straightforward: `~/.config/ghostty/config` uses simple key-value pairs. No scripting language, no complex syntax. Change fonts, pick a theme, adjust opacity.

### Built-in Multiplexer

Ghostty has built-in multiplexing so you can split panes and manage multiple sessions without reaching for tmux. If you want to take that idea further for AI workflows, with agent notifications, vertical workspaces, and a built-in browser, see my guide to [cmux terminal](/cmux-terminal/).

### Rich Color Support

Supports true color and comes with over 100 themes built-in. You can also add custom ones if you prefer something specific.

### Image Support

Ghostty supports the Kitty Graphics Protocol, so you can render images directly in the terminal. Useful if you work with preview tools or want visual output inline.

## What&apos;s New in Ghostty 1.3 (March 2026)

Ghostty 1.3.0 shipped on March 9, 2026, with 6 months of work and 180 contributors. The current release is **1.3.1**. Here are the headline features:

- **Scrollback search** — `Cmd+F` on macOS or `Ctrl+Shift+F` on GTK to search your terminal history. All matches highlighted, navigate with arrow keys. The search bar can be dragged to any corner on macOS.
- **Native scrollbars** — scrollbars now look and behave like your OS expects. They appear during scrolling and fade out when idle.
- **Click to move cursor** — if your shell supports OSC 133 prompt detection, you can click to position the cursor in your prompt. Works with zsh, fish, and bash with the right prompt setup.
- **Key overlay for leader keys** — a visual overlay appears after pressing a leader key showing available bindings. Useful if you use custom keybindings or tmux prefix keys.
- **Auto-update on macOS** — Ghostty can now update itself without needing Homebrew or a manual download. Includes delta patching to keep updates small.
- **Text shaping improvements** — better handling of complex scripts (Arabic, Devanagari, Thai) and ligatures.
- **Security fix** — CVE-2026-26982 patched an issue where control characters in pasted text could execute commands. Update to 1.3.1 if you haven&apos;t already.

To update if you installed via Homebrew:

```sh
brew upgrade --cask ghostty
```

Ghostty is on a 6-month release cadence, so the next minor release, **1.4.0, is planned for September 2026**. The project is now backed by a non-profit organization, and the macOS app sees roughly a million downloads a week. There are no signs of it slowing down.

## Performance

Ghostty&apos;s written in Zig, which shows in the performance. Benchmarks consistently put it among the fastest terminals for handling large Unicode files.

Keep in mind: benchmarks depend heavily on your setup (font, shaders, opacity) and the workload being tested. Real-world differences are smaller than numbers suggest.

| Terminal | Version | Speed |
|----------|---------|-------|
| Ghostty  | 1.3     | 73ms  |
| Alacritty| 0.13    | 66ms  |
| WezTerm  | 20240203| 140ms |

## Unique Features

### Terminal Inspector

The Terminal Inspector is one of Ghostty&apos;s best features. It shows you real-time debugging info—keystrokes, render timings, everything happening under the hood. Handy if you&apos;re troubleshooting or just curious about what&apos;s going on.

### Shaders

You can write custom shaders to apply visual effects—think glow effects, CRT filters, or whatever else you want to try. Fun if you like tweaking things visually.

## The libghostty Ecosystem: A Terminal Library, Not Just a Terminal

Everything above describes Ghostty the app. Since late 2025 there&apos;s a second, arguably bigger story: **libghostty**. It&apos;s the cross-platform, C-ABI-compatible library (written in Zig) that powers Ghostty itself — terminal emulation, font handling, and rendering — and any application can embed it. Think of it like WebKit for terminals: the Ghostty GUI is just one consumer of the engine.

Mitchell Hashimoto announced libghostty in September 2025, starting with `libghostty-vt`, a zero-dependency library that parses terminal sequences and maintains terminal state. The ecosystem grew faster than anyone expected.

&lt;Notice type=&quot;info&quot; title=&quot;Mitchell Hashimoto, creator of Ghostty&quot;&gt;
&quot;I suspect by the middle of 2027, the number of people using Ghostty via libghostty will dwarf the number of users that actually use the Ghostty GUI.&quot;
&lt;/Notice&gt;

The [awesome-libghostty list](https://github.com/Uzaaft/awesome-libghostty) already tracks more than 100 projects built on the engine. The most interesting ones:

| Project | Platform | What it adds |
| --- | --- | --- |
| **cmux** | macOS | Vertical tabs, notification rings when agents need input, in-app scriptable browser, CLI/socket API |
| **blink, Zentty, Forge, in0, Supacode** | macOS | Agent-centric workspaces: live session status, worktrees, orchestration |
| **Muxy, ykmx, it-shell3** | macOS | Terminal multiplexers on the libghostty core |
| **Enso, macterm, justty** | macOS | Different takes on a lightweight native Ghostty terminal |
| **Echo, Geistty, Spectty** | iOS/iPadOS | SSH/Mosh clients with Ghostty rendering |
| **Umbra** | Android | GPU-accelerated Android terminal |
| **mightty, phantty** | Windows | Experimental Windows ports and renderers |
| **deepin-terminal-ghostty, Husk, forgetty** | Linux | GTK/Wayland terminals on the Ghostty core |
| **OrbStack** | macOS | Ghostty-powered terminal inside its Docker/Linux VM app |
| **vscode-bootty** | VS Code | libghostty terminal inside VS Code |
| **obsidian-ghostty-terminal** | Obsidian | Real terminal in your notes |
| **Godotty** | Godot Engine | Terminal emulator for the game engine |
| **ghostty-web, browstty** | Browser | Ghostty compiled to WebAssembly, running in a web page |

There are also bindings for Rust, Go, .NET, Python, TypeScript, Flutter/Dart, Elixir, Odin, C++, MoonBit, and HarmonyOS — building a custom terminal is now a weekend project instead of a multi-year one.

### Why this matters to you

Your Ghostty config is portable. **cmux**, by far the most popular libghostty app (18k+ GitHub stars), reads your existing `~/.config/ghostty/config` for themes, fonts, and colors — you get a completely different app with zero reconfiguration. I have a full guide here: [cmux Terminal: A Practical Guide for AI Coding Agents on macOS](/cmux-terminal/).

More importantly, terminals are no longer forced to be generic. Keep Ghostty as your daily driver and run a specialized app (an agent workspace, an SSH client, an embedded terminal) when the job calls for it — without relearning rendering quirks, config syntax, or fonts. Every libghostty app inherits the same correctness and performance.

**Further reading:** [ghostty.org/docs/about](https://ghostty.org/docs/about) · [libghostty announcement](https://mitchellh.com/writing/libghostty-is-coming) · [awesome-libghostty](https://github.com/Uzaaft/awesome-libghostty)

## Installation and Configuration

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NR25BdXR6mE&quot;
  label=&quot;Ghostty Terminal: A Complete Setup Guide for Modern Mac Development&quot;
/&gt;

&gt; If you are interested to see some free cool Mac Apps you can check [toolhunt.net mac apps section](https://toolhunt.net/mac/).

### Quickstart (TL;DR)

Quick setup if you just want Ghostty running:

```sh
brew install --cask ghostty
brew install font-meslo-lg-nerd-font
mkdir -p ~/.config/ghostty
```

Then create `~/.config/ghostty/config` with the basic config below and restart Ghostty.

### Ghostty Install

[Download Ghostty](https://ghostty.org/download) directly or use Homebrew:

```sh
brew install --cask ghostty
```

### Ghostty on Linux

Ghostty is packaged in Ubuntu 26.04 LTS (universe repo, amd64 + arm64) as a preview:

```sh
sudo apt install ghostty
```

The packaged version tracks upstream (1.3.x), and GPU acceleration requires OpenGL 4.3+. On machines without it — older GPUs, some Raspberry Pi setups — Ghostty falls back to software rendering:

```sh
LIBGL_ALWAYS_SOFTWARE=true ghostty
```

libghostty itself isn&apos;t packaged yet, but the Ubuntu maintainers plan to ship it as a separate binary package.

### Install Meslo Nerd Font

Nerd Fonts include programming icons and symbols. Install Meslo LG via Homebrew:

```sh
brew install font-meslo-lg-nerd-font
```

The font installs automatically. Verify it in Font Book if needed. You may need to restart Ghostty after installation.

### Setup Ghostty Config File

Create the config file and add basic settings:

```bash
mkdir -p ~/.config/ghostty
vim ~/.config/ghostty/config
```

Add this to the config:

```
font-family = MesloLGS Nerd Font Mono
font-size = 18
background-opacity = 0.9
theme = Argonaut
```

Find available fonts and themes:

```sh
ghostty +list-fonts
ghostty +list-themes
```

Restart Ghostty after saving. If icons look broken, verify that Ghostty is using the Nerd Font you installed.

**Tip:** Keep your Ghostty config minimal—put heavy customization in your shell rc files or prompt setup instead. This makes it easier to sync across machines.

### More settings worth trying

The basic config gets you running. These options cover the most-requested tweaks:

| Option | Example | What it does |
| --- | --- | --- |
| `window-padding-x` / `window-padding-y` | `window-padding-x = 12` | Breathing room around your text |
| `cursor-style` | `cursor-style = bar` | Bar, block, or underline cursor |
| `copy-on-select` | `copy-on-select = true` | Copy immediately when you select text |
| `mouse-hide-while-typing` | `mouse-hide-while-typing = true` | Hide the mouse cursor while you type |
| `background-blur-radius` | `background-blur-radius = 20` | Blur behind transparency (macOS) |
| `confirm-close-surface` | `confirm-close-surface = false` | Skip the close confirmation dialog |
| `shell-integration-features` | `shell-integration-features = true` | OSC 133 marks: enables click-to-move-cursor and prompt jumping |
| `keybind` | `keybind = global:cmd+backquote=toggle_quick_terminal` | Drop-down &quot;Quake&quot; terminal from anywhere |

### Essential shortcuts (macOS defaults)

| Action | Shortcut |
| --- | --- |
| New tab | `Cmd+T` |
| New split (right / down) | `Cmd+D` / `Cmd+Shift+D` |
| Close surface | `Cmd+W` |
| Toggle fullscreen | `Cmd+Enter` or `Cmd+Ctrl+F` |
| Scrollback search | `Cmd+F` |
| Terminal inspector | `Cmd+Option+I` |
| Increase / decrease font size | `Cmd+=` / `Cmd+-` |
| Open / reload config | `Cmd+,` / `Cmd+Shift+,` |

All of these are rebindable with `keybind` lines — run `ghostty +list-keybinds --default` to see your current defaults.

## Prompt Options (Pick One)

Now you need a prompt. Pick one:

- **Starship**: Works with any shell, has ready-made themes, and is fast. I have a full guide for Starship + Ghostty:
  https://www.bitdoze.com/starship-ghostty-terminal/
- **Powerlevel10k**: Built for zsh, highly configurable, popular with zsh users.

### Install powerlevel10k theme

Install Powerlevel10k:

```sh
brew install powerlevel10k
echo &quot;source $(brew --prefix)/share/powerlevel10k/powerlevel10k.zsh-theme&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

The first run shows a configuration wizard. Walk through it to customize your prompt—choose what segments to display, colors, icons, etc. You can always rerun `p10k configure` later to tweak it.

The result is a prompt that shows git status, command execution time, and more, without sacrificing speed. Edit `~/.p10k.zsh` if you want to fine-tune it further.


### Setup zsh-autosuggestions plugin

zsh-autosuggestions suggests commands as you type, based on your history. Get it working:

```sh
brew install zsh-autosuggestions
echo &quot;source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

Now when you type, you&apos;ll see faded suggestions. Press the right arrow or End to accept them. You can customize the suggestions in `.zshrc`—change colors, tweak keybindings, etc.

See also: [Enable Command Autocomplete in Zsh](https://www.bitdoze.com/enable-command-autocomplete-in-zsh/)

### Setup zsh-syntax-highlighting

zsh-syntax-highlighting colors your commands as you type—green for valid commands, red for errors. Install it:

```sh
brew install zsh-syntax-highlighting
echo &quot;source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh&quot; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

It highlights valid commands and options, underlines existing file paths, and matches bracket pairs. Catch typos before you hit enter.

See also: [Enable Syntax Highlighting in Zsh](https://www.bitdoze.com/enable-syntax-highlighting-zsh/)

## Enhance Ghostty with tmux and zoxide

Ghostty has built-in multiplexing, but **tmux** is worth adding if you need:
- Remote attach/detach workflows over SSH
- Session sharing across machines or teams
- A larger ecosystem and documentation

If your workflow is more local and agent-heavy than remote, `cmux` is another route worth looking at — it&apos;s the flagship of the libghostty ecosystem I covered above. I have a full setup guide here: [cmux Terminal: A Practical Guide for AI Coding Agents on macOS](/cmux-terminal/).

**zoxide** makes jumping between directories faster.

### What is tmux and how can it help

tmux lets you manage multiple terminal sessions in one window. Useful for running background processes, sharing sessions with others, or keeping your setup across reboots.

```sh
brew install tmux
```

Then just type `tmux` to start a new session. More details: [Tmux Basics](https://www.bitdoze.com/tmux-basics/)

### What is zoxide and how can it help

zoxide replaces `cd` with smarter navigation. It tracks your most-used directories so you can jump to them quickly with fuzzy matching.

```sh
brew install zoxide
echo &apos;eval &quot;$(zoxide init zsh)&quot;&apos; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

Now use `z` instead of `cd` to jump around. See: [Zoxide Guide](https://www.bitdoze.com/zoxide/)

With tmux, zoxide, and Ghostty together, you&apos;ve got a solid terminal workflow—multiplexing, fast navigation, and GPU-powered rendering.

## Known Ghostty Errors


### &apos;xterm-ghostty&apos;: unknown terminal type

On some remote Linux machines, tools may not recognize Ghostty’s terminal type and you can see errors like: `&apos;xterm-ghostty&apos;: unknown terminal type.`

Quick fix for the current session:

```sh
export TERM=xterm-256color
```

Permanent fix on that machine:

```sh
echo &quot;export TERM=xterm-256color&quot; &gt;&gt; ~/.bashrc
source ~/.bashrc
```

If it only happens over SSH, set `TERM` conditionally in your shell config instead.


## Conclusion

Ghostty feels like a terminal built for 2026. GPU rendering, native UI, the Inspector—it&apos;s a step above what most terminals offer. And with libghostty, it&apos;s quietly becoming the engine for the next generation of terminals: specialized, agent-aware, and embedded everywhere. Ghostty 1.4 lands in September 2026, and Hashimoto&apos;s prediction is that by mid-2027 more people will use Ghostty through libghostty apps than the GUI itself.

If you spend time in the terminal, give Ghostty a try — and if you run AI coding agents, cmux is worth adding on top. The setup is straightforward, and once configured, you&apos;ve got a fast, responsive terminal that doesn&apos;t get in your way.

Ghostty works great with [Fish Shell](/install-fish-shell-ubuntu/) too. If you&apos;re looking for a shell upgrade, see my [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) comparison or [set up Starship prompt with Fish](/fish-shell-starship-prompt/).</content:encoded><category>linux</category><category>ghostty</category><category>libghostty</category><category>cmux</category></item><item><title>Kie.ai Video Generation Guide: Veo 3.1, Kling 3.0 &amp; Seedance API</title><link>https://www.bitdoze.com/kie-ai-video-generation/</link><guid isPermaLink="true">https://www.bitdoze.com/kie-ai-video-generation/</guid><description>Generate AI videos with Kie.ai: Veo 3.1, Kling 3.0, Seedance 2.0, Wan and more behind one API. Async tasks, webhooks, image-to-video, per-second pricing, and a working download script.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Images are the easy part of AI media. Video is where it gets annoying: Veo needs a Google key, Kling wants its own account, Seedance has its own quota, and every one of them generates asynchronously — you create a task, wait minutes, grab a URL that expires, and hope the whole pipeline did not break in between.

[Kie.ai](https://go.bitdoze.com/kie-ai) solves the integration side: one API key, one credit wallet, and 30+ text-to-video models behind the same `createTask` flow — Veo 3.1, Kling 3.0, Seedance 2.0, Wan, Hailuo, Grok Imagine, PixVerse V6 and more. I already use Kie as the generation backend for blog covers and YouTube thumbnails ([Add an AI Image Agent to Mastra with Kie.ai](/mastra-image-agent-kie-ai/)). This guide covers the video side: which models exist, what they cost per second, how the async jobs work, and a copy-paste script that generates a video and downloads it to disk.

&lt;Button text=&quot;Try Kie.ai (Get API Key)&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Kie.ai Review 2026&quot; link=&quot;/kie-ai-review/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Which video models Kie.ai exposes and what each is best at&lt;/li&gt;
&lt;li&gt;Verified pricing examples (per video and per second)&lt;/li&gt;
&lt;li&gt;The async flow: create task → poll or webhook → download&lt;/li&gt;
&lt;li&gt;The Veo 3.1 dedicated endpoint vs the Market `createTask` API&lt;/li&gt;
&lt;li&gt;Image-to-video with uploaded reference images&lt;/li&gt;
&lt;li&gt;A working Node script that generates and downloads an MP4&lt;/li&gt;
&lt;li&gt;Cost control and when to skip Kie for video&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Affiliate disclosure&quot;&gt;
Some Kie.ai links in this article are affiliate links (`https://go.bitdoze.com/kie-ai`). I use the product for media generation in my own stack. Model availability, ids, and prices change fast — always verify against the [Kie pricing page](https://kie.ai/pricing) and [docs](https://docs.kie.ai/) before you build on a specific number.
&lt;/Notice&gt;

## Why use Kie.ai for video

The pitch is the same as for images: one integration surface instead of one SDK per provider. Video makes it hurt more, because every provider has a different payload, different polling story, and different billing unit (per video, per second, per resolution tier).

What the Market looks like right now (checked August 2026, verify live):

| Model | Provider | What it is good at | Example price on Kie |
|---|---|---|---|
| Veo 3.1 Quality / Fast / Lite | Google | Cinematic 1080p, native audio, native 9:16 | Quality 1080p ~$1.28/video |
| Veo 3 Fast | Google | Cheap quick clips with audio | ~$0.30–$0.40 per 8s |
| Veo 3 Quality | Google | Premium cinematic output | ~$2.00 per 8s |
| Kling 3.0 (std / pro / 4K) | Kuaishou | Multi-shot storytelling, 3–15s, element refs | $0.07/s (std, no audio) |
| Kling 3.0 Turbo | Kuaishou | Faster, cheaper Kling 3.0 | Market price |
| Kling 2.6 | Kuaishou | Native audio + speech | $0.28 per 5s HD |
| Seedance 2.0 | ByteDance | Fast realistic generation (~5 min/job) | ~$0.057/s |
| Seedance 2.0 Mini | ByteDance | Budget batch generation | Market price |
| Wan 2.6 / 2.7 | Alibaba | Multi-shot 1080p, T2V/I2V/R2V | Market price |
| Hailuo 2.3 | MiniMax | Expressive characters, complex motion | $0.15 per 6s |
| MiniMax H3 (Hailuo-03) | MiniMax | 2K video, native stereo sound | Market price |
| Grok Imagine | xAI | Realistic motion + native audio | $0.10 per 6s |
| PixVerse V6, HappyHorse, Gemini Omni, Sora2, Runway Aleph | various | Newer / niche workflows | Market price |

Platform rules that matter for video budgets:

- **1 credit = $0.005**. A video task typically costs **100–500 credits** (docs range), which is why a single clip runs from cents to a couple of dollars.
- **Failed tasks are not charged** (platform claim) — friendly when you batch-generate and retry.
- **Credits never expire**; new accounts get free trial credits plus a Playground to test prompts before writing code.
- Video URLs are **temporary** — you must download results yourself.

I still keep the [full pricing and competitor comparison in the Kie.ai Review](/kie-ai-review/). This guide assumes you already decided Kie is worth a try and want the video workflow.

## How Kie video generation works

All Market video models share one async pattern:

```text
1. POST https://api.kie.ai/api/v1/jobs/createTask
   Authorization: Bearer YOUR_API_KEY
   body: { model, input, callBackUrl? }

2. 200 + taskId   → task accepted, NOT finished

3a. Poll GET /api/v1/jobs/recordInfo?taskId=...
    until state = success | fail
    (states: waiting | queuing | generating | success | fail)
 OR
3b. Wait for your callBackUrl webhook

4. Parse resultUrls from resultJson
5. Download the MP4 yourself — URLs expire fast
```

Two practical facts I hit constantly:

- **Result URLs age out quickly.** The docs&apos; best practice says to download immediately because generated content URLs typically expire after ~24 hours. Your account keeps task logs (~2 months) and media (~14 days), but treat the URL as short-lived and pull the file into your own storage the moment the task succeeds.
- **Rate limits are real.** About 20 new requests per 10 seconds by default; rejected creates do not queue, so back off on HTTP 429.

## Text-to-video: Kling 3.0 via the Market API

Here is a verified `createTask` example using the Kling 3.0 model id (`kling-3.0/video`), straight from [docs.kie.ai](https://docs.kie.ai/market/kling/kling-3-0):

```bash
curl -X POST https://api.kie.ai/api/v1/jobs/createTask \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;model&quot;: &quot;kling-3.0/video&quot;,
    &quot;input&quot;: {
      &quot;prompt&quot;: &quot;A red fox trots through a snowy pine forest at dawn, low tracking camera, soft volumetric light, cinematic&quot;,
      &quot;duration&quot;: &quot;5&quot;,
      &quot;aspect_ratio&quot;: &quot;16:9&quot;,
      &quot;mode&quot;: &quot;std&quot;,
      &quot;sound&quot;: true,
      &quot;multi_shots&quot;: false
    }
  }&apos;
```

Response — a task id, not a video:

```json
{
  &quot;code&quot;: 200,
  &quot;msg&quot;: &quot;success&quot;,
  &quot;data&quot;: {
    &quot;taskId&quot;: &quot;task_kling-3.0_1765187774173&quot;
  }
}
```

Poll until it finishes:

```bash
curl &quot;https://api.kie.ai/api/v1/jobs/recordInfo?taskId=task_kling-3.0_1765187774173&quot; \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot;
```

On success you get `state: &quot;success&quot;` and the download URL inside `resultJson`:

```json
{
  &quot;code&quot;: 505,
  &quot;msg&quot;: &quot;success&quot;,
  &quot;data&quot;: {
    &quot;taskId&quot;: &quot;task_kling-3.0_1765187774173&quot;,
    &quot;model&quot;: &quot;kling-3.0/video&quot;,
    &quot;state&quot;: &quot;success&quot;,
    &quot;resultJson&quot;: &quot;{\&quot;resultUrls\&quot;:[\&quot;https://example.com/generated-video.mp4\&quot;]}&quot;,
    &quot;progress&quot;: 100,
    &quot;creditsConsumed&quot;: 35
  }
}
```

Download it:

```bash
curl -L -o fox.mp4 &quot;https://example.com/generated-video.mp4&quot;
```

Kling 3.0 specific knobs worth knowing:

- `mode`: `std`, `pro`, or `4K`. Resolutions map from `aspect_ratio` (16:9 → 1280x720 in std, etc.). 4K costs more and takes longer.
- `multi_shots: true` switches to a `multi_prompt` array, each shot with its own prompt and duration (1–12s, max 500 chars per shot, total up to 15s).
- `sound: true` generates native audio (dialogue, effects) — roughly doubles the price per second.
- First/last frame images via `image_urls`; when you provide images, `aspect_ratio` becomes optional (auto-adapts).

## Veo 3.1: the dedicated endpoint

Veo 3.1 is not a Market `createTask` model — Kie gives it its own endpoint with extra reliability tooling at roughly 25% of Google&apos;s direct pricing:

```bash
curl -X POST https://api.kie.ai/api/v1/veo/generate \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;prompt&quot;: &quot;Aerial drone shot over a misty mountain lake at sunrise, cinematic 1080p&quot;,
    &quot;model&quot;: &quot;veo3_fast&quot;,
    &quot;aspect_ratio&quot;: &quot;9:16&quot;,
    &quot;generationType&quot;: &quot;TEXT_2_VIDEO&quot;,
    &quot;callBackUrl&quot;: &quot;https://your-app.example/kie/callback&quot;
  }&apos;
```

Details from the [Veo 3.1 docs](https://docs.kie.ai/veo3-api/generate-veo-3-video):

- Models: **Veo 3.1 Quality** (flagship), **Veo 3.1 Fast** (cost-efficient), **Veo 3.1 Lite** (highest volume). Example id in the docs: `veo3_fast`.
- Generation modes: `TEXT_2_VIDEO`, `FIRST_AND_LAST_FRAMES_2_VIDEO` (transition between two images), `REFERENCE_2_VIDEO` (material-based, Fast/Lite only).
- Native **9:16 and 16:9** at **1080p or 4K**. 4K goes through a separate endpoint and costs ~2x a Fast video.
- All videos ship with a background audio track by default.
- Optional `watermark` and `enableTranslation` fields.

The response also returns a `taskId` (e.g. `veo_task_abcdef123456`); poll the same `recordInfo` endpoint or use the callback to learn when it is done.

## Image-to-video with references

Most video models accept a starting image, and Kling 3.0 also supports start + end frames. First upload your local image to Kie&apos;s file host, then reference the returned URL in the task.

Upload (base64 pattern I use in the [Mastra image agent](/mastra-image-agent-kie-ai/)):

```bash
curl -X POST https://kieai.redpandaai.co/api/file-base64-upload \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;base64Data&quot;: &quot;data:image/png;base64,iVBORw0KGgo...&quot;,
    &quot;uploadPath&quot;: &quot;images&quot;,
    &quot;fileName&quot;: &quot;fox.png&quot;
  }&apos;
```

The response contains `downloadUrl` / `fileUrl` — use it as `image_urls[0]` in a Kling task:

```json
{
  &quot;model&quot;: &quot;kling-3.0/video&quot;,
  &quot;input&quot;: {
    &quot;prompt&quot;: &quot;The fox from the image turns its head and starts running through the snow&quot;,
    &quot;image_urls&quot;: [&quot;https://kieai.redpandaai.co/.../fox.png&quot;],
    &quot;duration&quot;: &quot;5&quot;,
    &quot;sound&quot;: true,
    &quot;multi_shots&quot;: false
  }
}
```

For Veo 3.1, the same upload URL goes into `imageUrls` with `generationType: &quot;REFERENCE_2_VIDEO&quot;` (or `FIRST_AND_LAST_FRAMES_2_VIDEO` with two images). Uploaded files are temporary on Kie&apos;s host — pass them into a task right away.

## Webhooks instead of polling

Long video jobs are exactly where you want `callBackUrl`. Add it to any task and Kie notifies your endpoint when the task finishes, so nothing sits in a busy-wait loop. The exact payload keys vary by model — treat the callback as a trigger and always fetch `recordInfo` afterward for the canonical state and `resultUrls`.

Minimal receiver in Node:

```js
import { createServer } from &quot;node:http&quot;;

createServer(async (req, res) =&gt; {
  let body = &quot;&quot;;
  for await (const chunk of req) body += chunk;
  const payload = JSON.parse(body || &quot;{}&quot;);
  console.log(&quot;Kie callback:&quot;, payload);
  // Then: fetch recordInfo?taskId=... to get resultUrls and download.
  res.writeHead(200);
  res.end(&quot;ok&quot;);
}).listen(8080, () =&gt; console.log(&quot;webhook on :8080&quot;));
```

## One script that generates and downloads

Here is a dependency-free script (Node 22+ or Bun) that creates a Market video task, polls with backoff, and saves the MP4 into `./videos/`:

```js
#!/usr/bin/env node
// generate-video.mjs — Kie.ai text-to-video (Market API) + download
import { mkdirSync, writeFileSync } from &quot;node:fs&quot;;
import { join } from &quot;node:path&quot;;

const KIE_API = &quot;https://api.kie.ai&quot;;
const KEY = process.env.KIE_API_KEY;
if (!KEY) {
  console.error(&quot;Set KIE_API_KEY first (from https://kie.ai/api-key)&quot;);
  process.exit(1);
}

async function createTask(model, input, callBackUrl) {
  const res = await fetch(`${KIE_API}/api/v1/jobs/createTask`, {
    method: &quot;POST&quot;,
    headers: {
      Authorization: `Bearer ${KEY}`,
      &quot;Content-Type&quot;: &quot;application/json&quot;,
    },
    body: JSON.stringify({ model, input, callBackUrl }),
  });
  const json = await res.json();
  if (json?.code !== 200) {
    throw new Error(json?.msg || `createTask failed (HTTP ${res.status})`);
  }
  return json.data.taskId;
}

async function waitForVideo(taskId, { timeoutMs = 15 * 60 * 1000, intervalMs = 5000 } = {}) {
  const start = Date.now();
  let delay = intervalMs;
  while (Date.now() - start &lt; timeoutMs) {
    const res = await fetch(
      `${KIE_API}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`,
      { headers: { Authorization: `Bearer ${KEY}` } },
    );
    const json = await res.json();
    const data = json?.data;
    if (!data) throw new Error(json?.msg || &quot;No task data&quot;);
    if (data.state === &quot;success&quot;) {
      const parsed = JSON.parse(data.resultJson || &quot;{}&quot;);
      const urls = parsed.resultUrls ?? [];
      if (!urls.length) throw new Error(&quot;Task succeeded but no resultUrls&quot;);
      return { urls, credits: data.creditsConsumed };
    }
    if (data.state === &quot;fail&quot;) {
      throw new Error(data.failMsg || &quot;Task failed&quot;);
    }
    console.log(`  ${data.state} (progress ${data.progress ?? &quot;?&quot;}%)...`);
    await new Promise((r) =&gt; setTimeout(r, delay));
    delay = Math.min(delay * 1.25, 15000);
  }
  throw new Error(`Timed out waiting for ${taskId}`);
}

async function main() {
  const model = process.env.KIE_MODEL || &quot;kling-3.0/video&quot;;
  const prompt = process.argv[2] || &quot;A red fox trots through a snowy pine forest at dawn, cinematic&quot;;
  const outDir = join(process.cwd(), &quot;videos&quot;);
  mkdirSync(outDir, { recursive: true });

  console.log(`Creating video task (${model})...`);
  const taskId = await createTask(
    model,
    { prompt, duration: &quot;5&quot;, aspect_ratio: &quot;16:9&quot;, mode: &quot;std&quot;, sound: true, multi_shots: false },
    process.env.KIE_CALLBACK_URL,
  );
  console.log(`taskId: ${taskId}`);

  const { urls, credits } = await waitForVideo(taskId);
  const safeName = taskId.replace(/[^a-zA-Z0-9._-]/g, &quot;_&quot;);
  const outPath = join(outDir, `${safeName}.mp4`);

  console.log(`Downloading ${urls[0]}`);
  const res = await fetch(urls[0]);
  if (!res.ok) throw new Error(`Download failed HTTP ${res.status}`);
  writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));

  console.log(`Saved ${outPath} (${credits ?? &quot;?&quot;} credits consumed)`);
}

main().catch((err) =&gt; {
  console.error(err.message);
  process.exit(1);
});
```

Run it:

```bash
export KIE_API_KEY=your-key
node generate-video.mjs &quot;A slow pan over a neon city street at night, rain reflections, 9:16&quot;
# KIE_MODEL=kling-3.0/video KIE_CALLBACK_URL=https://your-app.example/kie node generate-video.mjs &quot;...&quot;
```

The script handles the three things that bite most people: task creation, polling with exponential backoff, and saving the file before the URL expires. If you prefer a typed version, the [Mastra image agent article](/mastra-image-agent-kie-ai/) shows the same pattern as TypeScript tools (`createTask` → `pollTaskUntilDone` → `downloadToGenerated`).

## Which model should you pick

| Goal | Pick |
|---|---|
| YouTube Shorts / TikTok 9:16 with audio | Veo 3.1 Fast (native 9:16) or Kling 3.0 std 9:16 |
| Cinematic product/brand clip | Veo 3.1 Quality or Kling 3.0 pro |
| Budget batch generation | Seedance 2.0 Mini, Grok Imagine, Veo 3 Fast |
| Multi-shot story (3+ shots, one video) | Kling 3.0 with `multi_shots: true` |
| Start image → motion (I2V) | Kling 3.0 `image_urls`, Veo `REFERENCE_2_VIDEO` |
| Character/element consistency | Kling 3.0 `kling_elements` + `@element_name` in prompt |

Prompt structure matters more than the model: subject, action, scene, camera movement, lighting, and ratio. Test in the free Playground before spending credits in a loop.

## Cost control

Model the budget per clip before you automate:

| Job | Rough cost |
|---|---|
| Veo 3 Fast, 8s with audio | ~$0.30–$0.40 |
| Veo 3 Quality, 8s | ~$2.00 |
| Veo 3.1 Quality, 1080p | ~$1.28 |
| Kling 3.0 std, 5s, no audio | ~$0.35 |
| Kling 3.0 pro, 5s, with audio | ~$0.68 |
| Seedance 2.0 | ~$0.057/s |
| Grok Imagine, 6s | ~$0.10 |
| Hailuo 2.3, 6s | ~$0.15 |

Check [kie.ai/pricing](https://kie.ai/pricing) for live numbers. My rules: test prompts in the Playground, set a credit budget in the wallet, download immediately, and watch [task logs](https://kie.ai/logs) for `creditsConsumed` after the first few runs. At $0.10–$0.40 per Short, a batch of 10 is still less than one coffee — but a 15s 4K job with audio can cross a couple of dollars, so don&apos;t loop blindly.

## Video agent in Mastra

The image agent pattern transfers directly: an LLM picks a model, calls a `generate_kie_video` tool that wraps `createTask` + poll + download, a `get_kie_video_task` tool for status, and `get_kie_credits` before batch runs. That is how I plan to wire video into the [Mastra assistant](/build-ai-agent-mastra/) for @webdoze clips — same tools, different input schema per model.

## Pros and cons

### Pros

- One key and one wallet for 30+ video models; switch models without new SDKs
- Aggressive pricing vs fal.ai / Replicate on Veo (up to ~60–70% listed)
- Failed tasks not charged (platform claim) — safe for retry loops
- Credits never expire; free trial credits + Playground
- Consistent async shape across Market models; `callBackUrl` webhooks supported
- Media retention and logs give you a paper trail

### Cons

- Result URLs expire fast (~24h per docs) — your pipeline must download immediately
- Jobs take minutes; not a synchronous API
- Model ids and input fields differ per model — read each model&apos;s docs
- Middleman risk: upstream provider outages hit you too
- Rate limits (~20 new requests/10s) bite bulk automation without backoff
- Per-second pricing adds up fast on long clips; 4K and audio multiply cost

## FAQ

&lt;Accordion label=&quot;Is there a free trial for video generation?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
New Kie accounts get trial credits, and the Playground lets you test video prompts before writing code. Video jobs consume more credits than images (docs: typically 100–500 credits per generation), so the trial covers a handful of clips — enough to compare models.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which model should I use for 9:16 Shorts?&quot; group=&quot;faq&quot;&gt;
Veo 3.1 is the cleanest choice because 9:16 is a native output ratio (Fast for volume, Quality for polish). Kling 3.0 also supports 9:16 in std/pro modes if you want multi-shot storytelling or element references.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Veo 3.1 vs Kling 3.0 — which is cheaper?&quot; group=&quot;faq&quot;&gt;
On Kie&apos;s listed prices, Veo 3.1 Quality 1080p is around $1.28 per video, while Kling 3.0 std runs $0.07/s without audio ($0.35 for 5s) and pro runs $0.09/s. For an 8s 1080p clip Kling std is cheaper; for cinematic quality with audio, compare Veo 3.1 Quality vs Kling pro on the live pricing page — the answer changes with duration and resolution.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I generate a video from my own image?&quot; group=&quot;faq&quot;&gt;
Yes. Upload the image to Kie&apos;s file host (base64 upload → `fileUrl`), then pass it as `image_urls[0]` for Kling (start frame, or start + end frames) or `imageUrls` with `generationType: &quot;REFERENCE_2_VIDEO&quot;` for Veo 3.1.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How long do generated video URLs stay valid?&quot; group=&quot;faq&quot;&gt;
Short. The docs recommend downloading immediately because generated content URLs typically expire after ~24 hours. Task logs stick around ~2 months and media ~14 days in your account, but your pipeline should pull the file to your own storage on success.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do failed video tasks cost credits?&quot; group=&quot;faq&quot;&gt;
The platform states failed tasks are not charged. My advice: still verify `creditsConsumed` in the task record after the first failures, and check the fail reason in `failMsg` / the logs page before retrying the same prompt.
&lt;/Accordion&gt;

## Bottom line

Kie.ai makes video generation practical for automation: one API for Veo 3.1, Kling 3.0, Seedance 2.0, Wan and the rest, async jobs with webhooks, and prices that undercut the official endpoints. The trade-offs are the ones you can code around — expiring URLs, minutes-long jobs, and per-model input differences.

Start with the free credits, generate one clip in the Playground, then run the script above to make sure your download step works before you build the pipeline.

&lt;Button text=&quot;Get a Kie.ai API Key&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Kie.ai Image Agent Guide&quot; link=&quot;/mastra-image-agent-kie-ai/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>kie-ai</category><category>video-generation</category><category>ai-tools</category></item><item><title>OpenCode Go Review 2026: 18 AI Models for $10/Month (GPT 5.6 Luna, Kimi K3)</title><link>https://www.bitdoze.com/opencode-go-plan/</link><guid isPermaLink="true">https://www.bitdoze.com/opencode-go-plan/</guid><description>OpenCode Go review after 3 weeks of daily use. One API key, 18 models including GPT 5.6 Luna, Kimi K3, and DeepSeek V4 Pro. Works with Hermes, OpenClaw, Pi Agent. $5 first month.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

I have been juggling API keys for coding agents for months. DeepSeek here, MiniMax there, OpenRouter balance to top up, three billing pages I forget which password goes to. It works. It is still annoying.

OpenCode Go is basically one $10/month key ($5 the first month) for 18 models: GPT 5.6 Luna, Grok 4.5, Kimi K3, GLM-5.2, Qwen3.8 Max, DeepSeek V4, MiniMax M3, and a pile of cheaper options. It plugs into OpenCode, Hermes, OpenClaw, Pi, or anything that speaks OpenAI-compatible APIs.

I almost skipped it. Another subscription. Another thing to cancel later. At $10 I figured I would burn a week and move on if it sucked. Three weeks later I am still on it. Here is the honest version.

&lt;Button text=&quot;Get $5 in Free Credits&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## What OpenCode Go is

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/XGQIBn1i1uw&quot;
  label=&quot;Stop Paying $20/mo! Best Unlimited AI Coding Plan for $5?&quot;
/&gt;

OpenCode Go is a flat subscription from the OpenCode team: one API key, 18 models, $10/month after a $5 first month. No per-token math. No separate accounts at every model provider.

Models are served from the US, EU, and Singapore. Most providers claim zero retention, so your code is not supposed to train anyone&apos;s next model. The exceptions are Grok 4.5 and GPT 5.6 Luna, which keep logs for 30 days. That matters if you ship proprietary work.

You do not need OpenCode the IDE to use Go. Point any OpenAI-compatible client at the endpoint: [Hermes Agent](/hermes-agent-setup-guide/), [OpenClaw](/clawdbot-setup-guide/), [Pi Agent](/pi-coding-agent-setup-guide/), [Mastra](/build-ai-agent-mastra/), Agno, Codex, whatever you already run.

&lt;Button text=&quot;Try OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Which models are included

The current list (18 models):

| Model | Notes |
|-------|-------|
| **GPT 5.6 Luna** | Newest arrival; OpenAI&apos;s cheapest tier, strong on hard tasks |
| **Grok 4.5** | Strong on hard tasks, but the quota burns fast (use sparingly) |
| **Kimi K3** | New Moonshot flagship; good at code, few requests per cap |
| **GLM-5.2** | Zhipu&apos;s current flagship for coding agents |
| GLM-5.1 | Previous GLM, same usage tier as 5.2 |
| Kimi K2.7 Code | Coding-tuned Kimi with way more requests than K3 |
| Kimi K2.6 | Fine daily driver if you do not need K3 |
| MiMo-V2.5-Pro | Stronger Xiaomi coding model |
| MiMo-V2.5 | Cheap and high request count |
| MiniMax M3 | Solid agentic workhorse |
| MiniMax M2.7 | Still my default for day-to-day agent work |
| **Qwen3.8 Max** | Newest Qwen tier, very few requests per cap |
| Qwen3.7 Max | Previous Qwen top tier |
| Qwen3.7 Plus | Middle Qwen 3.7 option |
| Qwen3.6 Plus | Older Qwen, still usable |
| DeepSeek V4 Pro | Strong general coding, long context |
| DeepSeek V4 Flash | Fast and cheap for quick jobs |
| Hy3 | High request count open coding model |

&lt;Notice type=&quot;info&quot; title=&quot;GPT 5.6 Luna and Qwen3.8 Max are live&quot;&gt;
The two newest additions are GPT 5.6 Luna (OpenAI&apos;s budget tier) and Qwen3.8 Max. They join Grok 4.5 and Kimi K3 on the $15 tier — about $15 of included usage per month instead of $60 like most of the roster. You will feel that on the 5-hour cap. I keep them for the hard problems and burn MiniMax, DeepSeek Flash, or MiMo when I am grinding through volume. Run `/models` in OpenCode if the list has shifted again.
&lt;/Notice&gt;

OpenCode does not dump every new model onto Go. They test for coding-agent use first, then add what holds up. The list will keep moving.

## Usage limits

Go is not unlimited. It uses dollar-value limits, not request counts. Your actual number of requests depends on which model you pick.

| Limit | Cap |
|-------|-----|
| Per 5 hours | $12 |
| Per week | $30 |
| Per month | $60 |

If you have credits on your OpenCode Zen balance, you can enable the &quot;Use balance&quot; option in the console. When Go limits run out, it falls back to your Zen balance instead of blocking requests.

Cheaper models stretch further. Estimated request counts from the [official Go docs](https://opencode.ai/docs/go/):

| Model | Requests per 5 hours | Requests per week | Requests per month |
|-------|---------------------|-------------------|-------------------|
| DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 |
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
| Hy3 | 4,300 | 10,750 | 21,500 |
| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 |
| DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 |
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 |
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
| GLM-5.2 | 880 | 2,150 | 4,300 |
| GLM-5.1 | 880 | 2,150 | 4,300 |
| Qwen3.7 Max | 340 | 840 | 1,690 |
| Qwen3.8 Max | 160 | 400 | 810 |
| Grok 4.5 | 120 | 300 | 600 |
| Kimi K3 | 110 | 250 | 490 |

Lean hard on Grok 4.5, GPT 5.6 Luna, or Kimi K3 and you will hit the 5-hour wall. Stick to DeepSeek V4 Flash, MiMo-V2.5, or MiniMax M2.7 and I doubt you hit anything unless agents run all day.

## Setting it up

The setup takes about two minutes.

1. Go to [opencode.ai/auth](https://go.bitdoze.com/opencode-go) and create an account
2. Subscribe to Go and copy your API key
3. In OpenCode, run `/connect`
4. Select `OpenCode Go` and paste your key
5. Run `/models` to see the available models

The base URL for the API is:

```
https://opencode.ai/zen/go/v1/chat/completions
```

### Using Go with other agents

Since the endpoint is OpenAI-compatible, you can use Go with any tool that supports custom API endpoints:

&lt;Tabs&gt;
&lt;Tab name=&quot;Pi Agent&quot;&gt;
```bash
export OPENCODE_API_KEY=your-go-key
pi
# /model, select opencode-go provider
```
See the [Pi setup guide](/pi-coding-agent-setup-guide/) for full instructions.
&lt;/Tab&gt;
&lt;Tab name=&quot;Hermes Agent&quot;&gt;
```bash
echo &quot;OPENAI_BASE_URL=https://opencode.ai/zen/go/v1/chat/completions&quot; &gt;&gt; ~/.hermes/.env
echo &quot;OPENAI_API_KEY=your-go-key&quot; &gt;&gt; ~/.hermes/.env
hermes config set model opencode-go/glm-5.1
```
See the [Hermes setup guide](/hermes-agent-setup-guide/) for full instructions.
&lt;/Tab&gt;
&lt;Tab name=&quot;Mastra&quot;&gt;
```typescript
import { createOpenAI } from &apos;@ai-sdk/openai&apos;;

const opencodeGo = createOpenAI({
  baseURL: &apos;https://opencode.ai/zen/go/v1&apos;,
  apiKey: process.env.OPENCODE_API_KEY,
});

const agent = new Agent({
  name: &apos;assistant&apos;,
  model: opencodeGo(&apos;glm-5.1&apos;),
});
```
See the [Mastra guide](/build-ai-agent-mastra/) for a full walkthrough.
&lt;/Tab&gt;
&lt;Tab name=&quot;Agno&quot;&gt;
```python
from agno.agent import Agent
from agno.models.openai import OpenAI

agent = Agent(
    model=OpenAI(
        id=&quot;glm-5.1&quot;,
        api_key=os.environ[&quot;OPENCODE_API_KEY&quot;],
        base_url=&quot;https://opencode.ai/zen/go/v1&quot;,
    ),
    markdown=True,
)
agent.print_response(&quot;Your task here&quot;)
```
&lt;/Tab&gt;
&lt;Tab name=&quot;OpenClaw&quot;&gt;
Set the base URL to `https://opencode.ai/zen/go/v1/chat/completions` and use your Go API key in the OpenClaw config.
&lt;/Tab&gt;
&lt;Tab name=&quot;Codex App&quot;&gt;
Add to `~/.codex/config.toml`:
```toml
model = &quot;glm-5.1&quot;
model_provider = &quot;opencode-go&quot;

[model_providers.opencode-go]
name = &quot;OpenCode Go&quot;
base_url = &quot;https://opencode.ai/zen/go/v1&quot;
env_key = &quot;OPENCODE_API_KEY&quot;
wire_api = &quot;chat&quot;
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Who this is for

Go makes sense if:

- You do not want to manage multiple API keys and billing dashboards
- You are based outside the US and want low-latency access to models (the Singapore and EU endpoints help)
- You want a predictable monthly cost instead of per-token billing
- You use multiple agents (OpenCode + Pi + Hermes + Mastra + Agno) and want one key for all of them

Go does not make sense if:

- You only use one or two models and already have cheap API access
- You need models not on the list (like Claude or GPT-4)
- You are doing heavy production work that needs unlimited or very high limits

## How it compares to managing your own keys

I ran the numbers for my own usage. I typically use MiniMax M2.7 and DeepSeek V4 Pro through their direct APIs. My monthly cost with direct API keys was around $15-20 depending on how much I used agents that month.

With Go at $10/month, I get access to those same models plus others I would not have bothered setting up. The limits are generous enough that I have not hit them once in three weeks of daily use.

The trade-off is control. With direct API keys, I can switch providers if one goes down. With Go, I am relying on the OpenCode team to handle failover. In practice, I have not had any downtime issues, but it is worth noting.

&lt;Accordion label=&quot;How does Go compare to OpenRouter?&quot; group=&quot;faq&quot;&gt;
OpenRouter is a pay-per-token aggregator. You load credits and pay for what you use. Go is a flat monthly subscription with usage caps. If you use a lot of tokens, Go is cheaper. If you use very few tokens, OpenRouter might be cheaper. Go also includes models from providers that are not on OpenRouter.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Go with tools other than OpenCode?&quot; group=&quot;faq&quot;&gt;
Yes. The Go endpoint is OpenAI-compatible. Any tool that supports custom OpenAI-compatible endpoints can use it. This includes Hermes Agent, OpenClaw, Pi Agent, Mastra, and others. Set the base URL to `https://opencode.ai/zen/go/v1/chat/completions` and use your Go API key.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if I hit the usage limits?&quot; group=&quot;faq&quot;&gt;
Requests get blocked until the limit resets. The 5-hour limit resets on a rolling basis. If you have credits on your Zen balance, you can enable the &quot;Use balance&quot; option to keep going after hitting Go limits.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is my code used for training?&quot; group=&quot;faq&quot;&gt;
OpenCode says providers on Go do not train on your prompts. Most models keep zero retention; Grok 4.5 and GPT 5.6 Luna retain logs for 30 days. That is their policy statement; I have not independently audited it.
&lt;/Accordion&gt;

## My take after three weeks

If you already have cheap direct keys and only use one model, Go is optional. Nice, not life-changing. For me the win is simplicity: one invoice, one key, and it works in every agent I already run.

If you are still pasting five API keys around, start with the $5 first month. I leave Grok 4.5, GPT 5.6 Luna, and Kimi K3 for the hard stuff, MiniMax M3 or GLM-5.2 for normal agent sessions, and DeepSeek Flash or MiMo when I just need volume.

&lt;Button text=&quot;Get $5 in Free Credits&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Related articles

- [Build Your Own AI Agent with Mastra](/build-ai-agent-mastra/) — full guide using OpenCode Go with Mastra
- [How to Use the Codex App with Any Model](/codex-app-any-model/) — run Go models inside the Codex app
- [OpenCode Setup Guide: Install and Configure on a VPS](/opencode-setup-guide/) — full installation walkthrough
- [OpenCode vs Pi Agent: Which Terminal Coding Agent to Use](/opencode-vs-pi-agent/) — side-by-side comparison
- [Best Cheap Models for AI Coding Agents](/best-cheap-models-hermes-agent/) — model pricing and benchmarks
- [GitHub Copilot Alternatives After the June 2026 Pricing Change](/github-copilot-alternatives-2026/) — what to switch to</content:encoded><category>ai</category><category>ai-tools</category><category>opencode</category><category>llm</category></item><item><title>Get DeepSeek V4 Flash, GPT 5.6 Luna, Qwen 3.8 Max and Kimi K3 for Cheap: OpenCode Go vs ClinePass</title><link>https://www.bitdoze.com/opencode-go-vs-clinepass-cheap-models/</link><guid isPermaLink="true">https://www.bitdoze.com/opencode-go-vs-clinepass-cheap-models/</guid><description>How to get DeepSeek V4 Flash, GPT 5.6 Luna, Qwen 3.8 Max and Kimi K3 for $10/month. OpenCode Go and ClinePass compared: rosters, limits, API access, and which one fits your coding agents.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

DeepSeek V4 Flash. GPT 5.6 Luna. Qwen 3.8 Max. Kimi K3. A year ago, running this lineup meant juggling four API keys, four billing dashboards, and a monthly bill that wandered between $20 and $80. Today, two flat subscriptions get you there for about $10/month each: [OpenCode Go](https://go.bitdoze.com/opencode-go) and ClinePass.

I have been testing both since they launched. Same pitch, different execution: pay a flat fee, get a curated roster of open coding models, no per-token math. The catch is the details — which models each one actually ships, how the quotas feel, and whether you can plug them into your existing agents.

This guide breaks down what each plan gives you, model by model, so you can pick the one that fits.

&lt;Button text=&quot;Try OpenCode Go ($5 first month)&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## The four models everyone wants

Before comparing subscriptions, here is why these four models are worth paying for at all:

| Model | What it is | Why you want it |
|-------|-----------|-----------------|
| **DeepSeek V4 Flash** | DeepSeek&apos;s fast budget tier | ~31,650 requests per 5 hours on Go. The volume workhorse: grinds through boring tasks all day without touching your quota |
| **GPT 5.6 Luna** | OpenAI&apos;s cheapest GPT 5.6 tier | Real OpenAI frontier reasoning on a $10 plan. 30-day retention on logs, so keep secrets out of prompts |
| **Qwen 3.8 Max** | Alibaba&apos;s newest flagship | The newest Qwen tier, announced at WAIC in July. Few requests per cap, but strong reasoning for hard problems |
| **Kimi K3** | Moonshot&apos;s 2.8T open-weight flagship | The largest open-weight model ever released, leads SWE Marathon and the Frontend Code Arena. Weights are open since July 27 |

These are exactly the models people keep asking about on [@webdoze](https://youtube.com/@webdoze) and in the comments. The good news: you no longer need a $200 Claude Max plan or direct DeepSeek API keys to run them.

## OpenCode Go: the full roster, one key

OpenCode Go is a subscription from the OpenCode team: **$5 for the first month, then $10/month**. One API key, **18 models**, and a dollar-value quota system — $12 per 5 hours, $30 per week, $60 per month.

It is the only one of the two that includes **all four** models in this article: GPT 5.6 Luna, DeepSeek V4 Flash, Qwen 3.8 Max, and Kimi K3. The full current roster (verified against the [official docs](https://opencode.ai/docs/go/) and the live `/zen/go/v1/models` endpoint):

| Model | Notes |
|-------|-------|
| **GPT 5.6 Luna** | OpenAI&apos;s budget tier, strong on hard tasks |
| **Grok 4.5** | Strong, but quota burns fast |
| **Kimi K3** | Moonshot flagship, few requests per cap |
| **GLM-5.2** | Zhipu&apos;s current coding flagship |
| GLM-5.1 | Previous GLM, same tier |
| Kimi K2.7 Code | Coding-tuned Kimi, more requests than K3 |
| Kimi K2.6 | Daily driver |
| MiMo-V2.5-Pro | Stronger Xiaomi model |
| MiMo-V2.5 | Cheap, high request count |
| MiniMax M3 | Solid agentic workhorse |
| MiniMax M2.7 | My default for day-to-day work |
| **Qwen 3.8 Max** | Newest Qwen tier, few requests |
| Qwen 3.7 Max | Previous Qwen top tier |
| Qwen 3.7 Plus | Middle option |
| Qwen 3.6 Plus | Older, still usable |
| DeepSeek V4 Pro | Strong general coding |
| DeepSeek V4 Flash | Fast and cheap for volume |
| Hy3 | High request count |

Estimated request counts per 5 hours, from the Go docs:

| Model | Requests per 5h | Requests per week | Requests per month |
|-------|----------------|------------------|-------------------|
| DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 |
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
| Qwen 3.8 Max | 160 | 400 | 810 |
| Kimi K3 | 110 | 250 | 490 |

&lt;Notice type=&quot;info&quot; title=&quot;The $15 tier will surprise you&quot;&gt;
Grok 4.5, GPT 5.6 Luna, Kimi K3, Qwen 3.8 Max, MiMo-V2.5-Pro, and DeepSeek V4 Pro only get about $15 of included usage per month instead of $60 like most of the roster. You feel that on the 5-hour cap — Qwen 3.8 Max gives you only 160 requests per 5 hours. Keep those for hard problems and let DeepSeek V4 Flash or MiMo do the grinding.
&lt;/Notice&gt;

The API is OpenAI-compatible at `https://opencode.ai/zen/go/v1`, so it plugs into OpenCode, Hermes, OpenClaw, Pi Agent, Mastra, Agno, Codex, or anything that speaks that protocol. Model IDs use the `opencode-go/&lt;model-id&gt;` format (e.g. `opencode-go/kimi-k3`).

&lt;Button text=&quot;Get GPT 5.6 Luna + Qwen 3.8 Max + Kimi K3&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## ClinePass: the Cline-native subscription

ClinePass is Cline&apos;s answer: **$4.99 for the first month, then $9.99/month** ($1.99 first month if you sign up through the Cline CLI). It gives you **2-5x the usage** on popular open coding models compared to standard API rates, with no separate provider setup or API keys inside Cline.

The current roster (11 models, from the [ClinePass docs](https://docs.cline.bot/getting-started/clinepass)):

| Model | Model ID |
|-------|----------|
| GLM-5.2 | `cline-pass/glm-5.2` |
| Kimi K3 | `cline-pass/kimi-k3` |
| Kimi K2.7 Code | `cline-pass/kimi-k2.7-code` |
| Kimi K2.6 | `cline-pass/kimi-k2.6` |
| DeepSeek V4 Pro | `cline-pass/deepseek-v4-pro` |
| DeepSeek V4 Flash | `cline-pass/deepseek-v4-flash` |
| MiMo-V2.5 | `cline-pass/mimo-v2.5` |
| MiMo-V2.5-Pro | `cline-pass/mimo-v2.5-pro` |
| MiniMax M3 | `cline-pass/minimax-m3` |
| Qwen 3.7 Max | `cline-pass/qwen3.7-max` |
| Qwen 3.7 Plus | `cline-pass/qwen3.7-plus` |

&lt;Notice type=&quot;warning&quot; title=&quot;ClinePass does not have GPT 5.6 Luna or Qwen 3.8 Max&quot;&gt;
ClinePass includes DeepSeek V4 Flash and Kimi K3, but its newest Qwen tier is 3.7 Max — not 3.8 Max — and there is no OpenAI model on the roster. If you specifically want GPT 5.6 Luna or Qwen 3.8 Max, OpenCode Go is the only one of the two that has them right now.
&lt;/Notice&gt;

What ClinePass gets right: it is one click inside Cline (IDE or CLI), the quotas are generous for agentic loops, and the reference pricing shows you are getting 2-5x your money&apos;s worth in usage. It also works outside Cline via the OpenAI-compatible Cline API at `https://api.cline.bot/api/v1/chat/completions` — create an API key under Settings, then use the full `cline-pass/&lt;model-id&gt;` slug.

## Side by side

| | OpenCode Go | ClinePass |
|---|---|---|
| Price | $5 first month, then $10/mo | $4.99 first month, then $9.99/mo |
| Models | 18 | 11 |
| GPT 5.6 Luna | ✅ | ❌ |
| DeepSeek V4 Flash | ✅ | ✅ |
| Qwen 3.8 Max | ✅ | ❌ (3.7 Max max) |
| Kimi K3 | ✅ | ✅ |
| Quota system | $12/5h, $30/wk, $60/mo | 2-5x standard API rate |
| API | OpenAI-compatible `opencode.ai/zen/go/v1` | OpenAI-compatible `api.cline.bot` |
| Works with other agents | OpenCode, Hermes, OpenClaw, Pi, Mastra, Agno, Codex | Cline IDE/CLI + API for anything else |
| Zero retention | Most models; Grok 4.5 + GPT 5.6 Luna keep 30-day logs | — |

## Which one should you pick?

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Pick OpenCode Go&lt;/strong&gt; if you want GPT 5.6 Luna and Qwen 3.8 Max, use multiple agents (Hermes, OpenClaw, Pi, Mastra), or want the widest model roster in one key.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pick ClinePass&lt;/strong&gt; if you live inside Cline, want the easiest setup, and care more about DeepSeek V4 Flash / Kimi K3 quotas than OpenAI or Qwen 3.8 access.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Run both&lt;/strong&gt; if your workflow mixes Cline with other agents. $20/month total gets you 29 models across two ecosystems — still less than one Claude Max or Codex plan.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

My honest setup right now: OpenCode Go is the workhorse (all my agents point at it), and I grab ClinePass when I am doing a long Cline session that needs 2-5x quota headroom on DeepSeek V4 Flash.

## Getting started

&lt;Accordion label=&quot;How do I set up OpenCode Go?&quot; group=&quot;setup&quot;&gt;
1. Go to [opencode.ai/auth](https://go.bitdoze.com/opencode-go) and create an account
2. Subscribe to Go ($5 first month) and copy your API key
3. In OpenCode, run `/connect`, select OpenCode Go, paste the key
4. Run `/models` to pick any of the 18 models
5. For other agents, point them at `https://opencode.ai/zen/go/v1` with the same key
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I set up ClinePass?&quot; group=&quot;setup&quot;&gt;
1. Subscribe at [cline.bot/cline-pass](https://cline.bot/cline-pass) ($4.99 first month, or $1.99 via the Cline CLI)
2. In the Cline IDE extension settings, set API Provider to ClinePass and sign in
3. In Cline CLI, go to `/settings` and select ClinePass
4. To use it outside Cline, create an API key at Settings &gt; API Keys in app.cline.bot and call `https://api.cline.bot/api/v1/chat/completions`
&lt;/Accordion&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;OpenCode Go + Hermes&quot;&gt;
```bash
echo &quot;OPENAI_BASE_URL=https://opencode.ai/zen/go/v1/chat/completions&quot; &gt;&gt; ~/.hermes/.env
echo &quot;OPENAI_API_KEY=your-go-key&quot; &gt;&gt; ~/.hermes/.env
hermes config set model opencode-go/deepseek-v4-flash
```
&lt;/Tab&gt;
&lt;Tab name=&quot;ClinePass API (curl)&quot;&gt;
```bash
export CLINE_API_KEY=&quot;your_key&quot;
curl -X POST https://api.cline.bot/api/v1/chat/completions \
  -H &quot;Authorization: Bearer $CLINE_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;model&quot;: &quot;cline-pass/kimi-k3&quot;,
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Refactor this function&quot;}]
  }&apos;
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## FAQ

&lt;Accordion label=&quot;Are these really open models?&quot; group=&quot;faq&quot;&gt;
Kimi K3, DeepSeek V4, Qwen 3.7/3.8, GLM, MiMo, MiniMax and Hy3 are open-weight. GPT 5.6 Luna and Grok 4.5 are proprietary — OpenCode Go serves them at a discount anyway, which is why they sit on the lower $15 usage tier.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I cancel anytime?&quot; group=&quot;faq&quot;&gt;
Both are month-to-month. Cancel before renewal and you keep access through the end of the current term.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is my code used for training?&quot; group=&quot;faq&quot;&gt;
Both providers state zero retention and no training on your prompts for most models. OpenCode Go&apos;s exceptions: Grok 4.5 and GPT 5.6 Luna keep logs for 30 days. If you ship proprietary code, avoid those two or scrub the prompts.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens when I hit the limits?&quot; group=&quot;faq&quot;&gt;
OpenCode Go blocks requests until the 5-hour window resets (or falls back to your Zen balance if you enable &quot;Use balance&quot;). ClinePass measures 5-hour, weekly and monthly windows against your quota. On both, DeepSeek V4 Flash and the budget tiers stretch furthest.
&lt;/Accordion&gt;

## The bottom line

You do not need frontier-priced plans to run frontier-quality open models anymore. DeepSeek V4 Flash gives you effectively unlimited volume, Kimi K3 is the biggest open-weight brain in town, and GPT 5.6 Luna + Qwen 3.8 Max bring OpenAI and Alibaba&apos;s newest reasoning to a $10 subscription.

Start with [OpenCode Go](https://go.bitdoze.com/opencode-go) at $5 — it is the only one covering all four models in this article with a single key. Add ClinePass only if you live in Cline and want the 2-5x quota on Kimi K3 and DeepSeek V4 Flash.

&lt;Button text=&quot;Get $5 in Free Credits&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Related articles

- [OpenCode Go Review 2026: 18 AI Models for $10/Month](/opencode-go-plan/) — full review of the plan
- [Best Cheap Models for AI Coding Agents](/best-cheap-models-hermes-agent/) — model pricing and benchmarks
- [OpenCode Setup Guide: Install and Configure on a VPS](/opencode-setup-guide/) — full installation walkthrough
- [GitHub Copilot Alternatives After the June 2026 Pricing Change](/github-copilot-alternatives-2026/) — what to switch to</content:encoded><category>ai</category><category>ai-tools</category><category>opencode</category><category>cline</category></item><item><title>Traefik Basic Authentication: Secure Your Docker Services</title><link>https://www.bitdoze.com/traefik-basic-authentication/</link><guid isPermaLink="true">https://www.bitdoze.com/traefik-basic-authentication/</guid><description>Learn how to add Traefik Basic Authentication to protect your Docker services. Step-by-step guide with bcrypt hashing, Docker Compose labels, and troubleshooting tips.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you&apos;re running self-hosted Docker services through Traefik, you probably have a few endpoints that don&apos;t have their own authentication: the Traefik dashboard, a whoami container, maybe some internal dev tools. Exposing those to the internet without any access control is a risk you don&apos;t need to take.

Traefik&apos;s Basic Authentication middleware adds a username/password prompt in front of any service with just a couple of Docker labels. It&apos;s not a replacement for proper app-level auth, but for dev tools and internal services it&apos;s a quick first layer.

This guide covers the full setup: generating secure bcrypt passwords, adding auth labels in Docker Compose, using `usersFile` for multi-service setups, customizing the login prompt, chaining middlewares for defense in depth, and protecting the Traefik dashboard itself. Everything targets Traefik v3.x (the current stable line).

If you&apos;re looking for more Docker containers to protect, check the [best self-hosted Docker containers for home server](/docker-containers-home-server/).

![Traefik Basic Authentication middleware protecting Docker services with a login prompt](../../assets/images/24/08/traefik-basic-authentication.jpeg)

## Understanding Traefik basic authentication middleware

Traefik middleware sits between the client and your backend service. When a request arrives, Traefik routes it through the middleware chain before forwarding it to the backend. Basic Authentication is one of many middleware types. It intercepts the request, checks for valid credentials, and either passes the request through or returns a `401 Unauthorized` response.

The flow looks like this:

1. **Client sends request** to `https://myapp.example.com`
2. **Traefik checks middleware:** finds Basic Auth middleware on this route
3. **No credentials?** Traefik returns `401 Unauthorized` with a `WWW-Authenticate` header
4. **Browser shows login prompt:** the user enters username and password
5. **Client resends request** with `Authorization: Basic &lt;base64-encoded&gt;` header
6. **Traefik verifies the hash** against the configured `htpasswd` entries
7. **Valid credentials?** Traefik forwards the request to the backend
8. **Invalid credentials?** Traefik returns `401` again

Basic Auth credentials are Base64-encoded, not encrypted. That means anyone intercepting the traffic (without HTTPS) can decode them trivially. Always use Basic Auth over HTTPS. You should already have TLS configured if your services are behind Traefik.

&lt;Notice type=&quot;info&quot; title=&quot;When NOT to use basic auth&quot;&gt;
If your app has its own authentication (Grafana, Portainer, Nextcloud), prefer the app&apos;s native auth instead of double-wrapping with Traefik Basic Auth. Basic Auth is best for dev tools without built-in auth: the Traefik dashboard, whoami containers, simple internal utilities. There&apos;s no logout mechanism. The browser caches credentials until you close the tab.
&lt;/Notice&gt;

## How to set up Traefik basic auth in Docker Compose

This assumes Traefik is already running in Docker with the Docker provider enabled. If you haven&apos;t set that up yet, see the [Traefik reverse proxy in Docker](/traefik-proxy-docker/) guide first.

### Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Traefik v3.x running in Docker with the Docker provider enabled&lt;/li&gt;
&lt;li&gt;Docker Compose v2 installed&lt;/li&gt;
&lt;li&gt;A service already exposed via Traefik with a working HTTPS route&lt;/li&gt;
&lt;li&gt;A domain name with DNS pointing to your VPS&lt;/li&gt;
&lt;li&gt;Let&apos;s Encrypt or Cloudflare certificates configured. See [Traefik wildcard certificates](/traefik-wildcard-certificate/)&lt;/li&gt;
&lt;li&gt;HTTP to HTTPS redirect configured. See [Traefik HTTP to HTTPS redirect](/traefik-redirect-http-https/)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;apache2-utils&lt;/code&gt; (Debian/Ubuntu) or &lt;code&gt;httpd-tools&lt;/code&gt; (RHEL/Fedora) installed for &lt;code&gt;htpasswd&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Step 1: Generate bcrypt passwords with htpasswd

&lt;Notice type=&quot;warning&quot; title=&quot;Use bcrypt, not MD5&quot;&gt;
The older approach used `htpasswd -nb` which generates APR1/MD5 hashes. These are cryptographically weak. Always use `htpasswd -nB` for bcrypt (`$2y$` prefix). The official Traefik docs now recommend bcrypt.
&lt;/Notice&gt;

Install the `htpasswd` utility:

```sh
# Debian/Ubuntu
sudo apt update &amp;&amp; sudo apt install apache2-utils

# RHEL/Fedora
# sudo dnf install httpd-tools
```

Generate a bcrypt hash:

&lt;Tabs&gt;
&lt;Tab name=&quot;Interactive (recommended)&quot;&gt;
```sh
# The interactive prompt keeps the password out of your shell history
echo $(htpasswd -nB admin) | sed -e &apos;s/\$/\$\$/g&apos;
```

You&apos;ll be prompted to type the password. Output looks like:

```
admin:$$2y$$05$$...
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Quick one-liner&quot;&gt;
```sh
# Fine for testing. Avoid in production, password ends up in shell history.
echo $(htpasswd -nBC 12 admin MySecurePass) | sed -e &apos;s/\$/\$\$/g&apos;
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

What&apos;s happening here:

- **`-nB`:** `-n` outputs to stdout instead of a file, `-B` uses bcrypt
- **`-C 12`:** optional cost factor (2^12 rounds). Default is usually 5. Higher means slower but more resistant to brute force. 12 is a reasonable default for production.
- **`sed -e &apos;s/\$/\$\$/g&apos;`:** doubles every `$` because Docker Compose interprets `$VAR` as environment variable substitution. Without this, the hash gets mangled at startup.

&lt;Notice type=&quot;info&quot; title=&quot;When you don&apos;t need the $$ escaping&quot;&gt;
The `$$` doubling is only needed inside `docker-compose.yml` labels. If you use a `usersFile` (plain text file, covered in Step 4), you write the hash with single `$` because the file isn&apos;t YAML-evaluated. Tools like Ansible&apos;s `docker_container` module also don&apos;t need the doubling. See [environment variables in Docker Compose](/docker-env-vars/) for more on this.
&lt;/Notice&gt;

### Step 2: Add basic auth labels to your Docker service

You need two labels per service: one to reference the middleware, and one to define it.

```yaml
services:
  nginx:
    image: nginx:latest
    restart: unless-stopped
    env_file: .env
    networks:
      - traefik-net
    labels:
      - traefik.enable=true
      - traefik.http.routers.nginx.rule=Host(`nginx.example.com`)
      - traefik.http.routers.nginx.entrypoints=https
      - traefik.http.services.nginx.loadbalancer.server.port=80
      # Basic auth middleware
      - traefik.http.routers.nginx.middlewares=nginx-auth
      - traefik.http.middlewares.nginx-auth.basicauth.users=${TRAEFIK_USER_PASS}
      - traefik.http.middlewares.nginx-auth.basicauth.realm=Restricted Area
      - traefik.http.middlewares.nginx-auth.basicauth.removeheader=true
networks:
  traefik-net:
    external: true
```

Key points:

- **Middleware name must match** between the router reference (`nginx-auth`) and the middleware definition (`nginx-auth`). This is the #1 source of &quot;auth doesn&apos;t work&quot; bugs. A typo here means Traefik silently ignores the middleware.
- **Use unique middleware names per service.** If two services use the same middleware name with different user lists, one will silently override the other.
- **Quote the label values.** Without quotes, YAML can choke on special characters.

**Verify it works:**

```sh
# Should return 401 Unauthorized
curl -I https://nginx.example.com

# Should return 200 with correct credentials
curl -I -u admin:yourpassword https://nginx.example.com
```

### Step 3: Store credentials in an .env file

Hardcoding the hashed password directly in `docker-compose.yml` works, but it puts credentials into version control and makes rotation painful. Move them to a `.env` file instead.

&lt;Notice type=&quot;info&quot; title=&quot;Why .env over hardcoded?&quot;&gt;
Keeps credentials out of version control, makes rotation easier, and separates config from secrets. For production environments, consider [Docker Compose secrets](/docker-compose-secrets/) or a vault for even tighter control.
&lt;/Notice&gt;

```sh
# .env (in the same directory as docker-compose.yml)
TRAEFIK_USER_PASS=admin:$$2y$$05$$KJ3RixvQ.Zabc123...rest_of_hash
```

Note the `$$` doubling. This file is still evaluated by Docker Compose as environment variable substitution. Your `docker-compose.yml` references it with `${TRAEFIK_USER_PASS}` as shown in Step 2.

### Step 4: Using usersFile for multiple services

When you have several services sharing the same credentials, inline `users` labels get repetitive. The `usersFile` approach lets you maintain one plain-text file that Traefik reads at startup.

Create the users file:

```sh
# ./traefik-data/users.txt
# Each line is username:hash, standard htpasswd format
# NO $$ doubling needed here, this is a plain text file, not YAML
admin:$2y$05$KJ3RixvQ.Zabc123...
viewer:$2y$05$AnotherHash...
```

Mount it into the **Traefik** container (not the service container):

```yaml
# In Traefik&apos;s docker-compose.yml
services:
  traefik:
    image: traefik:v3.3
    volumes:
      - ./traefik-data/users.txt:/etc/traefik/users.txt:ro
      # ... other volumes
```

Reference it in service labels:

```yaml
# In any service&apos;s docker-compose.yml
labels:
  - traefik.http.routers.myapp.middlewares=myapp-auth
  - traefik.http.middlewares.myapp-auth.basicauth.usersfile=/etc/traefik/users.txt
  - traefik.http.middlewares.myapp-auth.basicauth.realm=My App
```

&lt;Notice type=&quot;warning&quot; title=&quot;Mount into the Traefik container&quot;&gt;
The #1 community gotcha: `usersFile` must be readable by the **Traefik** container, not your service container. Traefik reads the file at startup. If you mount it into the wrong container, you&apos;ll get a silent config error and auth won&apos;t be applied.

Verify the file is mounted correctly:

```sh
docker exec traefik cat /etc/traefik/users.txt
```
&lt;/Notice&gt;

**Precedence note:** If you set both `users` and `usersFile` on the same middleware, the values in `users` take precedence over `usersFile` in current Traefik v3.x. In v3.1 through v3.3, `usersFile` had priority — the flip happened in a later v3.x release, so the behavior depends on your version.

### Step 5: Customizing the login prompt with `realm`

By default, the browser&apos;s basic auth dialog shows &quot;traefik&quot; as the realm name. You can customize it with the `realm` option:

```yaml
labels:
  - &quot;traefik.http.middlewares.my-auth.basicauth.realm=My App - Restricted Access&quot;
```

This is a small UX improvement, but it makes the prompt look intentional rather than default. Users see &quot;My App - Restricted Access&quot; instead of &quot;traefik&quot; in the login dialog.

### Step 6: Remove the Authorization header for security

By default, Traefik forwards the `Authorization` header (containing the Base64-encoded credentials) to your backend service. If your backend doesn&apos;t need it, strip it:

```yaml
labels:
  - &quot;traefik.http.middlewares.my-auth.basicauth.removeheader=true&quot;
```

Why this matters: if your backend service logs request headers (and most do), you don&apos;t want credentials sitting in those logs. Defense in depth — strip what you don&apos;t need.

### Step 7: Pass the authenticated username to backends

If your backend needs to know who authenticated, use `headerField` to set a custom header with the username:

```yaml
labels:
  - &quot;traefik.http.middlewares.my-auth.basicauth.headerField=X-WebAuth-User&quot;
```

This sets `X-WebAuth-User: &lt;username&gt;` on proxied requests. Useful for logging, per-user behavior, or simple access control in apps that don&apos;t have their own auth.

## Chaining Traefik middlewares for defense in depth

Basic auth alone is a single layer. For services exposed to the internet, chain it with rate limiting and IP allowlisting.

&lt;Notice type=&quot;info&quot; title=&quot;Middleware chaining syntax&quot;&gt;
Use comma-separated middleware references: `my-auth@docker,rate-limit@docker`. Order matters. Traefik applies them left to right. Put the fastest-failing middleware first (rate limit, IP allowlist) before the more expensive ones (auth).
&lt;/Notice&gt;

### Basic auth + rate limiting

```yaml
labels:
  - traefik.enable=true
  - traefik.http.routers.myapp.rule=Host(`myapp.example.com`)
  - traefik.http.routers.myapp.entrypoints=https
  - traefik.http.routers.myapp.middlewares=rate-limit@docker,my-auth@docker
  # Rate limiting
  - traefik.http.middlewares.rate-limit.ratelimit.average=100
  - traefik.http.middlewares.rate-limit.ratelimit.burst=50
  # Basic auth
  - traefik.http.middlewares.my-auth.basicauth.users=${TRAEFIK_USER_PASS}
  - traefik.http.middlewares.my-auth.basicauth.removeheader=true
  - traefik.http.middlewares.my-auth.basicauth.realm=My App
```

This limits the service to 100 requests/second average with bursts up to 50, and requires authentication.

### Basic auth + IP allowlist

```yaml
labels:
  - traefik.http.routers.myapp.middlewares=local-only@docker,my-auth@docker
  # Allow only private networks
  - traefik.http.middlewares.local-only.ipallowlist.sourcerange=10.0.0.0/8,192.168.0.0/16
  # Basic auth
  - traefik.http.middlewares.my-auth.basicauth.users=${TRAEFIK_USER_PASS}
```

Note: `ipWhiteList` was renamed to `ipAllowList` in Traefik v3. If you&apos;re migrating from v2, update your label names.

For broader server security including CrowdSec integration, see [how to secure a VPS with CrowdSec](/crowdsec-secure-server/).

### Protecting specific paths only

Use case: protect `/admin` but leave `/public` open. Define two routers on the same host:

```yaml
labels:
  # Public router — no auth
  - traefik.http.routers.myapp.rule=Host(`myapp.example.com`)
  - traefik.http.routers.myapp.entrypoints=https
  # Admin router — with auth, higher priority
  - traefik.http.routers.myapp-admin.rule=Host(`myapp.example.com`) &amp;&amp; PathPrefix(`/admin`)
  - traefik.http.routers.myapp-admin.entrypoints=https
  - traefik.http.routers.myapp-admin.priority=10
  - traefik.http.routers.myapp-admin.middlewares=admin-auth
  - traefik.http.middlewares.admin-auth.basicauth.users=${TRAEFIK_USER_PASS}
```

The more specific router (`/admin`) needs a higher priority value to match first. Traefik&apos;s default priority is calculated from rule length, but explicit values are more reliable.

## Protecting the Traefik dashboard with basic auth

One of the most common use cases — securing the Traefik dashboard itself. Add these labels to the **Traefik** container:

```yaml
services:
  traefik:
    image: traefik:v3.3
    command:
      - --api.dashboard=true
      - --providers.docker=true
      # ... other args
    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`traefik.example.com`)
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.entrypoints=https
      - traefik.http.routers.dashboard.middlewares=dashboard-auth
      - traefik.http.middlewares.dashboard-auth.basicauth.users=${TRAEFIK_DASHBOARD_USERS}
      - traefik.http.middlewares.dashboard-auth.basicauth.realm=Traefik Dashboard
      - traefik.http.middlewares.dashboard-auth.basicauth.removeheader=true
    networks:
      - traefik-net
```

Use a separate env variable for the dashboard credentials (`TRAEFIK_DASHBOARD_USERS`) — don&apos;t share the same password across all your services.

**Verify:** Navigate to `https://traefik.example.com` in your browser. You should see a login prompt. After entering credentials, the Traefik dashboard should load.

## Common issues and troubleshooting

&lt;Accordion label=&quot;401 Unauthorized — credentials not working&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
**Symptom:** Login prompt appears but credentials are always rejected.

**Common causes:**
- Hash generated with `htpasswd -nb` (MD5) instead of `htpasswd -nB` (bcrypt). Regenerate with bcrypt.
- Password mismatch — the hash in `.env` doesn&apos;t match what you&apos;re typing.
- `$$` not doubled in `.env` file — Docker Compose stripped the `$` characters from the hash.
- Wrong hash format — Traefik expects `username:hash`, not just the hash.

**Fix:** Regenerate and verify:

```sh
# Generate a fresh bcrypt hash
echo $(htpasswd -nBC 12 admin) | sed -e &apos;s/\$/\$\$/g&apos;

# Test with curl (should return 200)
curl -I -u admin:yourpassword https://myapp.example.com
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;404 Not Found — auth prompt doesn&apos;t appear&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
**Symptom:** No login prompt, just a 404.

**Common causes:**
- Middleware name mismatch between the router reference and middleware definition. This is the most common error.

```yaml
# WRONG — names don&apos;t match:
- &quot;traefik.http.routers.myapp.middlewares=traefik-auth&quot;
- &quot;traefik.http.middlewares.traefiknas-auth.basicauth.users=...&quot;

# RIGHT — names match exactly:
- &quot;traefik.http.routers.myapp.middlewares=myapp-auth&quot;
- &quot;traefik.http.middlewares.myapp-auth.basicauth.users=...&quot;
```

- Labels on the wrong container — the labels must be on the service container, not the Traefik container (unless you&apos;re configuring dashboard auth).
- The router isn&apos;t matching the request — check `Host()` rule and `entrypoints`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;usersFile not loading&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
**Symptom:** Auth doesn&apos;t work when using `usersFile`, but works with inline `users`.

**Cause:** The file is mounted into the service container instead of the Traefik container. Traefik reads the file, not your backend.

**Fix:** Verify the file is readable inside Traefik:

```sh
docker exec traefik cat /etc/traefik/users.txt
```

If the file isn&apos;t there, mount it in Traefik&apos;s `docker-compose.yml`:

```yaml
volumes:
  - ./traefik-data/users.txt:/etc/traefik/users.txt:ro
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Configuration errors — middleware not applied&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
**Symptom:** Auth works on one service but not another, or middleware seems ignored.

**Common causes:**
- Duplicate middleware names across services — if two services define a middleware called `auth`, one silently overrides the other. Use unique names: `nginx-auth`, `grafana-auth`, etc.
- Labels on the wrong container — labels must be on the service that needs auth, not on a shared proxy container.
- Typos in label keys — `basicauth` not `basicAuth`, `usersfile` not `usersFile` (case-insensitive in labels but be consistent).

**Verify:** Check Traefik&apos;s discovered configuration:

```sh
docker logs traefik 2&gt;&amp;1 | grep -i &quot;middleware&quot;
```

Or check the dashboard (once you can access it) under the Middleware tab.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;bcrypt hash incompatible — auth always fails&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
**Symptom:** Hash looks correct but auth always rejects.

**Cause:** The `httpd:alpine` Docker image can produce incompatible bcrypt hashes in some versions.

**Fix:** Generate the hash using `apache2-utils` on the host or a full Alpine container:

```sh
# On the host (Debian/Ubuntu)
sudo apt install apache2-utils
echo $(htpasswd -nBC 12 admin) | sed -e &apos;s/\$/\$\$/g&apos;

# Or via Docker
docker run --rm -it alpine:latest sh -c &quot;apk add --no-cache apache2-utils &amp;&amp; htpasswd -nBC 12 admin&quot;
```
&lt;/Accordion&gt;

### Quick verification checklist

After setting up auth, run these commands to confirm everything works:

```sh
# Should return 401 Unauthorized (no credentials)
curl -I https://myapp.example.com

# Should return 200 OK (with correct credentials)
curl -I -u admin:yourpassword https://myapp.example.com

# Check Traefik logs for auth-related entries
docker logs traefik 2&gt;&amp;1 | grep -i &quot;authentication&quot;
```

## Limitations of basic authentication

&lt;Notice type=&quot;warning&quot; title=&quot;Basic auth is a first layer, not a complete solution&quot;&gt;
Basic Auth has real limitations. Know them before relying on it for anything sensitive:

- **No logout mechanism** — the browser caches credentials until you close the tab
- **No MFA support** — single-factor password only
- **No session management** — credentials are sent on every request
- **No granular permissions** — it&apos;s all-or-nothing access
- **Credentials are Base64-encoded, not encrypted** — without HTTPS, they&apos;re trivially readable

For anything truly sensitive (production dashboards, user-facing apps), use a proper auth proxy like Authelia or Authentik, or the app&apos;s native authentication. Basic Auth is a good first layer for dev tools, internal utilities, and services without their own auth.
&lt;/Notice&gt;

## Conclusion

Traefik&apos;s Basic Authentication middleware is a quick way to add access control to Docker services that don&apos;t have their own auth. With bcrypt password hashing, the `usersFile` approach for shared credentials, and middleware chaining with rate limiting or IP allowlisting, you get a solid first layer of protection.

Here&apos;s what we covered:

- Generating secure bcrypt passwords with `htpasswd -nB`
- Adding auth labels to Docker Compose services
- Using `.env` files and `usersFile` for credential management
- Customizing the login prompt with `realm`
- Stripping the `Authorization` header with `removeHeader`
- Passing the authenticated username to backends with `headerField`
- Chaining middlewares for defense in depth
- Protecting the Traefik dashboard

If you&apos;re still setting up Traefik, start with the [Traefik reverse proxy in Docker](/traefik-proxy-docker/) guide and [configure wildcard certificates](/traefik-wildcard-certificate/). For more services to protect behind auth, see the [best Docker containers for home server](/docker-containers-home-server/). If you want a managed deployment platform, check out [self-hosting with Dokploy](/dokploy-install/).

Need a reliable VPS to run all this on? [Hetzner](https://go.bitdoze.com/hetzner) offers affordable VPS hosting with solid performance for self-hosted Docker stacks.</content:encoded><category>self-hosting</category><category>traefik</category><category>docker</category><category>security</category></item><item><title>Traefik HTTP to HTTPS Redirect: Complete v3 Setup Guide</title><link>https://www.bitdoze.com/traefik-redirect-http-https/</link><guid isPermaLink="true">https://www.bitdoze.com/traefik-redirect-http-https/</guid><description>Configure Traefik HTTP to HTTPS redirect using entrypoints or middleware. Step-by-step Traefik v3 guide with Docker Compose, global and per-service redirect options.</description><pubDate>Mon, 03 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Every Traefik setup should redirect HTTP to HTTPS. This guide covers the two ways to do it: global entrypoint-level redirect (recommended for most setups) and per-service middleware redirect (for when you need granular control). All config snippets target Traefik v3.x and use the `web`/`websecure` entrypoint naming convention.

&lt;Notice type=&quot;success&quot; title=&quot;Tested with Traefik v3.7&quot;&gt;
This guide targets Traefik v3.x. All config snippets use the &lt;code&gt;web&lt;/code&gt;/&lt;code&gt;websecure&lt;/code&gt; entrypoint naming convention. Traefik v2.11 reached end-of-life in February 2026 : upgrade if you haven&apos;t already.
&lt;/Notice&gt;

## Benefits of redirecting HTTP to HTTPS

### Security and data integrity

HTTPS encrypts everything between the browser and your server. Login credentials, API tokens, form data, none of it travels in plaintext. Without HTTPS, anyone on the network path (ISP, coffee shop Wi-Fi, compromised router) can read and modify traffic. HTTPS also ensures the data transferred arrives unaltered.

### SEO and browser trust

HTTPS has been a confirmed Google ranking factor since 2014. HTTP-only sites get penalized. Chrome, Firefox, and Safari all flag HTTP sites as &quot;Not Secure,&quot; and users bounce when they see that. Once you&apos;re fully HTTPS, you stop fighting mixed-content warnings that break layouts and scripts.

### Performance

No browser supports HTTP/2 over plaintext. If you want multiplexed connections and header compression, you need TLS. Traefik v3 also has stable HTTP/3 (QUIC) support. QUIC runs over UDP and improves performance on high-latency connections. Covered later in this article.

## Prerequisites

If you need a VPS to run Traefik, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers affordable VPS instances starting from €4.50/month with solid network performance.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Traefik v3.x running in Docker : use image tag &lt;code&gt;traefik:v3.7&lt;/code&gt; (or &lt;code&gt;traefik:v3&lt;/code&gt; for minor auto-updates). Avoid &lt;code&gt;:latest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose installed. If you&apos;re new to Docker, brush up on &lt;a href=&quot;https://www.bitdoze.com/docker-commands/&quot;&gt;essential Docker commands&lt;/a&gt; first.&lt;/li&gt;
&lt;li&gt;A domain name with a DNS A record (and/or AAAA for IPv6) pointing to your server&lt;/li&gt;
&lt;li&gt;Ports 80 (TCP) and 443 (TCP) open on the server firewall. If you plan to enable HTTP/3 later, also open 443/UDP.&lt;/li&gt;
&lt;li&gt;TLS certificate provisioned or about to be provisioned (Let&apos;s Encrypt / ACME)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you haven&apos;t set up Traefik yet, follow our [Traefik reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) guide first. For wildcard certificates, see our [free Let&apos;s Encrypt wildcard certificate with Traefik](https://www.bitdoze.com/traefik-wildcard-certificate/) guide.

## Step-by-step: Traefik HTTP to HTTPS redirects

### Setting up Traefik entrypoints

Traefik uses entrypoints to define the ports it listens on. You need at least two: one for HTTP (port 80) and one for HTTPS (port 443).

The standard naming convention in Traefik v3 (and the Helm chart defaults) is `web` for HTTP and `websecure` for HTTPS. The old `http`/`https` names still work, but `web`/`websecure` is what the official docs use now.

In your `traefik.yml` static config file:

```yaml
entryPoints:
  web:
    address: &quot;:80&quot;
  websecure:
    address: &quot;:443&quot;
```

Or as CLI commands in your Docker Compose file:

```yml
command:
  - &quot;--entrypoints.web.address=:80&quot;
  - &quot;--entrypoints.websecure.address=:443&quot;
```

These entrypoints are the foundation. The redirect config in the next sections tells Traefik what to do with traffic hitting each one.

### Global HTTP to HTTPS redirect

This is the recommended approach for most setups. All HTTP traffic gets redirected to HTTPS at the entrypoint level : no per-service configuration needed.

#### Using a traefik.yml static config file

Add a `redirections` block under the `web` entrypoint. The `websecure` entrypoint gets TLS enabled. I also disable access logs on the HTTP entrypoint since it only serves redirects, which reduces noise.

```yaml
entryPoints:
  web:
    address: &quot;:80&quot;
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
    observability:
      accessLogs: false   # reduce noise : this entrypoint only serves redirects

  websecure:
    address: &quot;:443&quot;
    http:
      tls: {}
    observability:
      accessLogs: true
```

#### Using CLI commands in Docker Compose

Same config as CLI flags. I include the `permanent` flag explicitly even though it defaults to `true` in v3 : explicit is better when someone else reads your compose file six months from now.

```yml
command:
  - &quot;--entrypoints.web.address=:80&quot;
  - &quot;--entrypoints.web.http.redirections.entrypoint.to=websecure&quot;
  - &quot;--entrypoints.web.http.redirections.entrypoint.scheme=https&quot;
  - &quot;--entrypoints.web.http.redirections.entrypoint.permanent=true&quot;
  - &quot;--entrypoints.websecure.address=:443&quot;
```

&lt;Notice type=&quot;info&quot; title=&quot;308 vs 301: Why it matters&quot;&gt;
Entrypoint-level redirects return &lt;strong&gt;308 Permanent Redirect&lt;/strong&gt;, which preserves the HTTP method (POST stays POST). The per-service middleware with &lt;code&gt;permanent: true&lt;/code&gt; returns &lt;strong&gt;301 Moved Permanently&lt;/strong&gt; for GET/HEAD requests, which can cause browsers to change POST to GET (losing the request body). For APIs or forms, the entrypoint approach is safer.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;ACME HTTP Challenge Compatibility&quot;&gt;
Entrypoint-level redirects automatically pass through &lt;code&gt;/.well-known/acme-challenge/&lt;/code&gt; paths for Let&apos;s Encrypt HTTP-01 challenges. The per-service middleware approach does NOT : it will redirect the challenge request and break certificate renewal. If you use HTTP-01 challenges, prefer the entrypoint method.
&lt;/Notice&gt;

### Per-service HTTPS redirect with labels

If you need some services to stay on HTTP (internal-only services, specific ACME setups), you can configure redirect on a per-service basis using Docker labels.

Here&apos;s a complete example with both the HTTP redirect router and the HTTPS router:

```yaml
labels:
  # HTTP router - redirects to HTTPS
  - &quot;traefik.http.routers.myapp-http.entrypoints=web&quot;
  - &quot;traefik.http.routers.myapp-http.rule=Host(`your-domain.com`)&quot;
  - &quot;traefik.http.routers.myapp-http.middlewares=https-redirect@docker&quot;
  - &quot;traefik.http.middlewares.https-redirect.redirectscheme.scheme=https&quot;
  - &quot;traefik.http.middlewares.https-redirect.redirectscheme.permanent=true&quot;
  # HTTPS router - serves the actual content
  - &quot;traefik.http.routers.myapp.entrypoints=websecure&quot;
  - &quot;traefik.http.routers.myapp.rule=Host(`your-domain.com`)&quot;
  - &quot;traefik.http.routers.myapp.tls=true&quot;
```

What each label does:

1. `myapp-http.entrypoints=web` tells the HTTP router to listen on the `web` (port 80) entrypoint.
2. `myapp-http.rule=Host(...)` matches requests for your domain.
3. `myapp-http.middlewares=https-redirect@docker` applies the redirect middleware. The `@docker` suffix is the provider name, which avoids ambiguity in multi-provider setups.
4. `https-redirect.redirectscheme.scheme=https` sets the redirect target scheme.
5. `https-redirect.redirectscheme.permanent=true` makes it a permanent redirect (301 for GET/HEAD).
6-8. The HTTPS router listens on `websecure`, matches the same domain, and enables TLS.

Once HTTPS is enforced, you may also want to add [Traefik basic authentication](https://www.bitdoze.com/traefik-basic-authentication/) to protect your services.

### Global vs per-service: Which should you use?

&lt;Tabs&gt;
&lt;Tab name=&quot;Global (Recommended)&quot;&gt;
Use the entrypoint-level global redirect when all your services need HTTPS. It&apos;s simpler (one place to configure), handles ACME challenge passthrough automatically, and returns 308 (preserving POST method). This is the right default for most setups.
&lt;/Tab&gt;
&lt;Tab name=&quot;Per-Service&quot;&gt;
Use per-service middleware labels only when some services must stay on HTTP : for example, internal-only services that don&apos;t need TLS, or specific ACME HTTP challenge handlers. More configuration overhead, and you need to handle ACME passthrough yourself.
&lt;/Tab&gt;
&lt;/Tabs&gt;

| Scenario | Method |
|---|---|
| All services need HTTPS | Global entrypoint redirect (recommended) |
| Some services must stay on HTTP | Per-service middleware |
| Using HTTP-01 ACME challenges | Global entrypoint (auto-handles ACME passthrough) |
| API with POST requests | Global entrypoint (returns 308, preserves method) |

## Verifying the redirect

After applying your config, test from the command line. No browser needed : this works over SSH.

```bash
# Test the redirect
curl -I http://your-domain.com

# Expected output:
# HTTP/1.1 308 Permanent Redirect
# Location: https://your-domain.com/
```

```bash
# Verify the HTTPS endpoint is alive
curl -I https://your-domain.com

# Expected: HTTP/2 200 (or your app&apos;s response code)
```

If `curl` returns `000` or connection refused, port 80 is blocked or Traefik isn&apos;t listening. Jump to the troubleshooting section below.

You can also check the Traefik dashboard (`http://localhost:8080/dashboard/` or your configured dashboard URL) to confirm the routers and middlewares are registered correctly. The `web` entrypoint should show the redirect middleware, and `websecure` should show your TLS routers.

## Troubleshooting common issues

### Redirect not working (no 308 response)

- Check Traefik logs: `docker logs traefik --tail 50`
- Verify entrypoint names match between your config and labels (`web` vs `http` : a common typo)
- Confirm Traefik is listening on port 80: `ss -tlnp | grep :80`

### ERR_CONNECTION_REFUSED or timeout

- Verify ports 80 and 443 are open on the server firewall (`ufw`, `iptables`, `nftables`)
- Check your cloud provider&apos;s firewall : Hetzner Cloud Firewall, OCI security lists, and similar all have separate rules
- Docker may bypass your firewall rules : check our guide on [Docker bypassing your firewall rules](https://www.bitdoze.com/docker-bypasses-firewall/) if traffic seems blocked despite firewall rules being open

### Certificate errors after redirect

- Verify your ACME/TLS configuration in Traefik
- If using HTTP-01 challenge: confirm the redirect isn&apos;t intercepting `/.well-known/acme-challenge/` (the middleware approach can break this : use the entrypoint method instead)
- Check certificate resolver logs: `docker logs traefik 2&gt;&amp;1 | grep -i acme`

### Wrong Location header (http:// instead of https://)

- If Traefik sits behind another proxy (Cloudflare, HAProxy), configure `forwardedHeaders.trustedIPs` on the entrypoint so Traefik reads the correct protocol from `X-Forwarded-Proto` headers
- See the Cloudflare section below for the exact config

### Typos and syntax errors

- Double-check YAML indentation : a misplaced space breaks the whole config
- In Traefik v3, rule syntax defaults to `v3`. Ensure `Host()` matchers use backticks: `` Host(`example.com`) ``
- CLI flags use `=` for values, not `:`. Wrong: `--entrypoints.web.address: :80`. Right: `--entrypoints.web.address=:80`

## Cloudflare and reverse proxy considerations

If you use Cloudflare&apos;s proxy (orange cloud), the HTTP to HTTPS redirect may happen at Cloudflare&apos;s edge before traffic ever reaches your Traefik instance. Your Traefik redirect config still works as a safety net : keep it.

If you use Cloudflare Tunnels, port 80 may not be exposed at all. The redirect config won&apos;t trigger but won&apos;t break anything either.

When Traefik sits behind any reverse proxy or CDN, you need `forwardedHeaders.trustedIPs` so Traefik correctly reads `X-Forwarded-Proto`:

```yaml
entryPoints:
  web:
    address: &quot;:80&quot;
    http:
      redirections:
        entryPoint:
          to: websecure
          scheme: https
          permanent: true
    forwardedHeaders:
      trustedIPs:
        - &quot;173.245.48.0/20&quot;
        - &quot;103.21.244.0/22&quot;
        - &quot;103.22.200.0/22&quot;
        - &quot;103.31.4.0/22&quot;
        - &quot;141.101.64.0/18&quot;
        - &quot;108.162.192.0/18&quot;
        - &quot;190.93.240.0/20&quot;
        - &quot;188.114.96.0/20&quot;
        - &quot;197.234.240.0/22&quot;
        - &quot;198.41.128.0/17&quot;
        - &quot;162.158.0.0/15&quot;
        - &quot;104.16.0.0/13&quot;
        - &quot;104.24.0.0/14&quot;
        - &quot;172.64.0.0/13&quot;
        - &quot;131.0.72.0/22&quot;
```

Get the current list from [Cloudflare&apos;s IP ranges page](https://www.cloudflare.com/ips/). Update these periodically : Cloudflare does add new ranges.

&lt;Notice type=&quot;warning&quot; title=&quot;Cloudflare Users&quot;&gt;
If you use Cloudflare&apos;s proxy (orange cloud), the HTTP to HTTPS redirect may happen at Cloudflare&apos;s edge before reaching Traefik. Your Traefik config still works as a safety net, but ensure &lt;code&gt;forwardedHeaders.trustedIPs&lt;/code&gt; includes &lt;a href=&quot;https://www.cloudflare.com/ips/&quot;&gt;Cloudflare&apos;s IP ranges&lt;/a&gt; so Traefik reads the correct protocol from headers.
&lt;/Notice&gt;

## Dokploy users: Redirects are pre-configured

If you use [Dokploy](https://www.bitdoze.com/dokploy-install/), Traefik&apos;s HTTPS redirect is already configured. Dokploy manages Traefik&apos;s static config internally and sets up a `redirect-to-https@file` middleware. You typically don&apos;t need to configure redirects manually.

Custom redirect config should be additive : don&apos;t create conflicting rules. Check the [Dokploy domains docs](https://docs.dokploy.com/docs/core/troubleshooting/domains) first.

For alternatives to Dokploy, see our comparison of [self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/).

&lt;Notice type=&quot;info&quot; title=&quot;Dokploy Users&quot;&gt;
Dokploy manages Traefik&apos;s configuration internally and already sets up HTTPS redirects. If you use Dokploy, you typically don&apos;t need to configure redirects manually. Check the &lt;a href=&quot;https://docs.dokploy.com/docs/core/troubleshooting/domains&quot;&gt;Dokploy domains docs&lt;/a&gt; first.
&lt;/Notice&gt;

## Bonus: Enable HTTP/3 (QUIC) for faster HTTPS

HTTP/3 is stable in Traefik v3 : it&apos;s no longer experimental. If your users are on high-latency connections (mobile, remote locations), HTTP/3 can noticeably improve load times thanks to QUIC&apos;s 0-RTT connection establishment.

To enable it, add `http3: {}` to your `websecure` entrypoint:

```yaml
entryPoints:
  websecure:
    address: &quot;:443&quot;
    http:
      tls: {}
    http3: {}
```

Or via CLI flag:

```
--entrypoints.websecure.http3
```

HTTP/3 requires UDP port 443. Update your Docker Compose ports:

```yaml
ports:
  - &quot;80:80&quot;
  - &quot;443:443/tcp&quot;
  - &quot;443:443/udp&quot;
```

Make sure your cloud provider firewall and any host-level firewall allow UDP 443.

To verify HTTP/3 is working, open your site in Chrome or Firefox, then check DevTools → Network tab → Protocol column. You should see `h3` for requests served over HTTP/3.

## Advanced: `asDefault` entrypoint and observability

Two Traefik v3 features worth knowing about for power users.

**`asDefault: true`** on `websecure` makes all routers automatically attach to the HTTPS entrypoint unless they explicitly specify otherwise. Convenient when every service is HTTPS-only:

```yaml
entryPoints:
  websecure:
    address: &quot;:443&quot;
    asDefault: true
```

**Per-entrypoint observability** lets you control access logs, metrics, and tracing per entrypoint. The example config earlier already uses this : disabling access logs on the `web` entrypoint (which only serves redirects) keeps your logs focused on real traffic:

```yaml
entryPoints:
  web:
    address: &quot;:80&quot;
    observability:
      accessLogs: false
      metrics: false
      tracing: false
  websecure:
    address: &quot;:443&quot;
    observability:
      accessLogs: true
      metrics: true
      tracing: false
```

## Keep Traefik updated

Pin Traefik to a specific minor version tag (e.g., `traefik:v3.7`). Avoid `:latest` : you want to know exactly which version is running when something breaks.

Recent CVEs like [CVE-2025-32431](https://nvd.nist.gov/vuln/detail/CVE-2025-32431) (a path traversal vulnerability in PathPrefix/Path/PathRegex matchers, patched in v3.3.6+) show why version pinning matters. You can&apos;t patch what you can&apos;t identify.

Check the [Traefik releases page](https://github.com/traefik/traefik/releases) periodically and update when security patches land. Test on a staging instance first if you have one.

For additional server security, consider CrowdSec to [secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) against brute-force attacks.

&lt;Notice type=&quot;warning&quot; title=&quot;Security: Pin Your Traefik Version&quot;&gt;
Always pin Traefik to a specific minor version tag (e.g., &lt;code&gt;traefik:v3.7&lt;/code&gt;). Avoid &lt;code&gt;:latest&lt;/code&gt;. Recent CVEs like &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2025-32431&quot;&gt;CVE-2025-32431&lt;/a&gt; (path traversal) were patched in specific versions. You need to know which version you&apos;re running.
&lt;/Notice&gt;

## Conclusion

Use the global entrypoint-level redirect as your default. It&apos;s one config block, handles ACME challenge passthrough automatically, and returns 308 (preserving HTTP methods). Only reach for per-service middleware labels when you genuinely need some services to stay on HTTP.

After configuring the redirect, verify with `curl -I http://your-domain.com` : you should see a 308 response pointing to HTTPS. Keep Traefik pinned to a known version, and test after every update.</content:encoded><category>self-hosting</category><category>traefik</category><category>docker</category><category>https</category></item><item><title>Traefik Reverse Proxy in Docker: Complete Setup Guide</title><link>https://www.bitdoze.com/traefik-proxy-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/traefik-proxy-docker/</guid><description>Set up Traefik as a Docker reverse proxy with automatic Let&apos;s Encrypt TLS certificates. Step-by-step guide covering v3.7, dashboard security, and app deployment.</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/07/traefik-diagram.jpeg&quot;;

If you&apos;re running Docker on a VPS and need a reverse proxy that handles TLS certificates automatically, Traefik is the best option. I&apos;ve been using Cloudflare Tunnels for most of my setups, but Traefik gives you full control. No third-party tunnel dependency, automatic Let&apos;s Encrypt certificates, and native Docker integration. In this guide, we&apos;ll set up Traefik as a reverse proxy in Docker from scratch: VPS creation, Docker install, Traefik v3.7 configuration, dashboard security, and your first app behind the proxy.

&lt;Notice type=&quot;error&quot; title=&quot;Security Warning&quot;&gt;
The original version of this guide used Traefik v3.1, which reached end of life on October 28, 2024 and has known critical vulnerabilities (CVE-2024-45410, CVSS 7.5 HIGH per NVD / 9.8 per GitHub&apos;s advisory). This guide has been updated to Traefik v3.7. If you are running v3.1, upgrade immediately.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot;&gt;
If you need a Let&apos;s Encrypt wildcard certificate with Cloudflare DNS challenge, see: &lt;a href=&quot;https://www.bitdoze.com/traefik-wildcard-certificate/&quot;&gt;Traefik FREE Let&apos;s Encrypt Wildcard Certificate With Cloudflare Provider&lt;/a&gt;
&lt;/Notice&gt;

## What is Traefik?

[Traefik](https://traefik.io/traefik/) is a modern reverse proxy and load balancer designed for containerized environments. It routes traffic to your microservices and applications by automatically discovering and configuring routes based on your infrastructure.

Traefik&apos;s Docker integration is what sets it apart. It detects new containers and updates its routing config automatically. You don&apos;t have to touch a config file every time you add a service.

Main features:

1. Automatic service discovery and configuration
2. Support for multiple protocols (HTTP, HTTPS, TCP, UDP)
3. Built-in monitoring dashboard
4. Built-in Let&apos;s Encrypt integration for automatic SSL/TLS certificate management
5. Support for various load balancing algorithms
6. Middleware for adding extra functionality like authentication or rate limiting

&lt;Picture src={img1} alt=&quot;Traefik architecture diagram showing EntryPoints, Routers, and Middlewares flow&quot; /&gt;

## Traefik&apos;s architecture

Traefik&apos;s architecture is built around three main components: EntryPoints, Routers, and Middlewares.

### EntryPoints

EntryPoints are the network entry points into Traefik. They define the ports and protocols on which Traefik listens for incoming traffic.

What EntryPoints do:

- Define listening ports for HTTP, HTTPS, or UDP traffic
- Can be configured for TCP and UDP protocols
- Support for multiple EntryPoints (e.g., separate ones for HTTP and HTTPS)
- Can be associated with specific IP addresses

### Routers

Routers are responsible for connecting incoming requests to the services that can handle them. They analyze the requests using rules and route them accordingly.

What Routers do:

- Use rules to determine which requests they should handle
- Can be associated with specific EntryPoints
- Support priority settings to manage overlapping rules
- Can be configured for HTTP, TCP, or UDP traffic

### Middlewares

Middlewares tweak the requests before they are sent to your service (or the responses before they are sent back to the clients). They can be attached to routers and provide a way to apply modifications to requests or responses.

What Middlewares do:

- Can modify requests and responses
- Chainable (multiple middlewares can be applied in sequence)
- Provide functionality such as authentication, rate limiting, headers manipulation, etc.
- Can be reused across multiple routers

Traefik has three core components: EntryPoints (where it listens), Routers (how it handles requests), and Middlewares (request/response modifications). Together they form a flexible routing system for containerized environments.

## How to setup Traefik as a reverse proxy for your Docker apps

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/vce3EEkvuZ4&quot;
  label=&quot;How to Use Traefik as A Reverse Proxy in Docker&quot;
/&gt;

&lt;Notice type=&quot;info&quot;&gt;
Want to monitor server resources like CPU, memory, and disk space? See: &lt;a href=&quot;https://www.bitdoze.com/sever-monitoring/&quot;&gt;How To Monitor Server and Docker Resources&lt;/a&gt;
&lt;/Notice&gt;

After we have seen what Traefik is, we are going to go through all the steps needed: create and configure a VPS, install Docker, configure DNS, set up Traefik with the dashboard, and deploy some applications.

### 1. Create a VPS server

You need a VPS with ports 22, 80, and 443 open. I use [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps) for most setups. Use your provider&apos;s cloud firewall if available (e.g., Hetzner Cloud Firewall). It&apos;s more reliable than UFW alone because of how Docker interacts with iptables (more on that in Step 4).

&lt;Notice type=&quot;info&quot;&gt;
For hardening your VPS beyond basic firewall rules, consider &lt;a href=&quot;https://www.bitdoze.com/crowdsec-secure-server/&quot;&gt;securing your VPS with CrowdSec&lt;/a&gt;. It works well alongside Traefik.
&lt;/Notice&gt;

### 2. Add SWAP

Most VPS servers don&apos;t have swap by default. Add it with:

```sh
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | sudo tee -a /etc/fstab
```

### 3. Install Docker

The next step is installing Docker and Docker Compose v2. The commands below auto-detect your distro codename (works for both Ubuntu and Debian):

```sh
# Add Docker&apos;s official GPG key
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

# Add the repository (auto-detects Ubuntu/Debian codename)
echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release &amp;&amp; echo &quot;$VERSION_CODENAME&quot;) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```

Verify the install worked:

```sh
docker --version
docker compose version
```

You should see version output for both. If `docker compose version` errors, the Compose plugin wasn&apos;t installed correctly.

### 4. Configure firewall and update OS

Update the OS first:

```sh
sudo apt update &amp;&amp; sudo apt upgrade -y
```

If you&apos;re using UFW, open the required ports:

```sh
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status
```

&lt;Notice type=&quot;warning&quot; title=&quot;Docker bypasses UFW&quot;&gt;
Docker manipulates iptables directly, which can bypass UFW rules. Containers with published ports may be accessible even if UFW blocks that port. Consider using your cloud provider&apos;s firewall (e.g., Hetzner Cloud Firewall) or the DOCKER-USER iptables chain. See: &lt;a href=&quot;https://www.bitdoze.com/docker-bypasses-firewall/&quot;&gt;Docker Bypasses UFW Firewall Rules&lt;/a&gt;
&lt;/Notice&gt;

Reboot after updates:

```sh
reboot
```

### 5. Create the Docker network

Create the external network that Traefik and your apps will share:

```sh
docker network create traefik-net
```

Verify:

```sh
docker network ls | grep traefik-net
```

You should see `traefik-net` listed with bridge driver.

### 6. Create the Traefik reverse proxy Docker Compose file

Create the directory and navigate to it:

```sh
mkdir -p /opt/stacks/traefik &amp;&amp; cd /opt/stacks/traefik
```

&lt;Notice type=&quot;info&quot;&gt;
This guide uses the TLS-ALPN-01 challenge, which requires port 443 to be reachable from the internet. If you need wildcard certificates or can&apos;t open port 443, use DNS challenge instead. See: &lt;a href=&quot;https://www.bitdoze.com/traefik-wildcard-certificate/&quot;&gt;Traefik Let&apos;s Encrypt Wildcard Certificate&lt;/a&gt;
&lt;/Notice&gt;

The recommended setup uses a Docker socket proxy to limit Traefik&apos;s access to the Docker API. If you want the simpler direct-socket version, use the second tab below.

&lt;Tabs&gt;
&lt;Tab name=&quot;Recommended: Socket Proxy&quot;&gt;

Create a `compose.yml` file:

```yaml
services:
  socket-proxy:
    image: tecnativa/docker-socket-proxy:latest
    container_name: socket-proxy
    restart: unless-stopped
    networks:
      - traefik-net
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      CONTAINERS: 1
      SERVICES: 1
      TASKS: 1
      NETWORKS: 1
    security_opt:
      - no-new-privileges:true

  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    command:
      #- --log.level=DEBUG
      - --api.dashboard=true
      - --ping=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.endpoint=tcp://socket-proxy:2375
      - --providers.docker.network=traefik-net
      - --entrypoints.http.address=:80
      - --entrypoints.http.http.redirections.entrypoint.to=https
      - --entrypoints.http.http.redirections.entrypoint.scheme=https
      - --entrypoints.https.address=:443
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      #- --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
      - --certificatesresolvers.letsencrypt.acme.email=you@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik-net
    ports:
      - 80:80
      - 443:443
    healthcheck:
      test: [&quot;CMD&quot;, &quot;traefik&quot;, &quot;healthcheck&quot;, &quot;--ping&quot;]
      interval: 30s
      timeout: 5s
      retries: 3
    env_file: .env
    volumes:
      - ./letsencrypt:/letsencrypt
    labels:
      - traefik.enable=true
      - traefik.http.routers.traefik-secure.rule=Host(`traefik.yourdomain.com`)
      - traefik.http.routers.traefik-secure.entrypoints=https
      - traefik.http.routers.traefik-secure.service=api@internal
      - traefik.http.routers.traefik-secure.tls.certresolver=letsencrypt
      - traefik.http.routers.traefik-secure.middlewares=traefik-auth
      - traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}
      - traefik.http.routers.traefik-secure.tls=true

networks:
  traefik-net:
    external: true
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Simple: Direct Socket&quot;&gt;

If you prefer the simpler setup without a socket proxy, create a `compose.yml` file:

```yaml
services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    command:
      #- --log.level=DEBUG
      - --api.dashboard=true
      - --ping=true
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=traefik-net
      - --entrypoints.http.address=:80
      - --entrypoints.http.http.redirections.entrypoint.to=https
      - --entrypoints.http.http.redirections.entrypoint.scheme=https
      - --entrypoints.https.address=:443
      - --certificatesresolvers.letsencrypt.acme.tlschallenge=true
      #- --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
      - --certificatesresolvers.letsencrypt.acme.email=you@example.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik-net
    ports:
      - 80:80
      - 443:443
    healthcheck:
      test: [&quot;CMD&quot;, &quot;traefik&quot;, &quot;healthcheck&quot;, &quot;--ping&quot;]
      interval: 30s
      timeout: 5s
      retries: 3
    env_file: .env
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    labels:
      - traefik.enable=true
      - traefik.http.routers.traefik-secure.rule=Host(`traefik.yourdomain.com`)
      - traefik.http.routers.traefik-secure.entrypoints=https
      - traefik.http.routers.traefik-secure.service=api@internal
      - traefik.http.routers.traefik-secure.tls.certresolver=letsencrypt
      - traefik.http.routers.traefik-secure.middlewares=traefik-auth
      - traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}
      - traefik.http.routers.traefik-secure.tls=true

networks:
  traefik-net:
    external: true
```

This mounts the Docker socket read-only directly into the Traefik container. It works fine for single-user setups, but the socket proxy version above limits blast radius if Traefik is compromised.

&lt;/Tab&gt;
&lt;/Tabs&gt;

**Command options explained:**

1. `--api.dashboard=true`: Enables the Traefik web dashboard.
2. `--providers.docker=true`: Enables Docker as a provider for automatic service discovery.
3. `--providers.docker.exposedbydefault=false`: Prevents Traefik from automatically exposing all containers. You must explicitly enable each one.
4. `--providers.docker.endpoint=tcp://socket-proxy:2375`: Connects to the Docker API through the socket proxy instead of the raw socket. (Omitted in the direct-socket version.)
5. `--providers.docker.network=traefik-net`: Tells Traefik which Docker network to use for routing traffic to containers.
6. `--entrypoints.http.address=:80`: HTTP entrypoint on port 80.
7. `--entrypoints.http.http.redirections.entrypoint.to=https`: Redirect all HTTP traffic to HTTPS.
8. `--entrypoints.http.http.redirections.entrypoint.scheme=https`: Ensures the redirect uses the HTTPS scheme. For a deeper dive on HTTP to HTTPS redirects, see [Traefik HTTP to HTTPS redirect](https://www.bitdoze.com/traefik-redirect-http-https/).
9. `--entrypoints.https.address=:443`: HTTPS entrypoint on port 443.
10. `--certificatesresolvers.letsencrypt.acme.tlschallenge=true`: Enables the TLS-ALPN-01 challenge for Let&apos;s Encrypt certificate acquisition.
11. `--certificatesresolvers.letsencrypt.acme.email=you@example.com`: Your email for Let&apos;s Encrypt registration and expiry notifications.
12. `--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json`: Where to store certificates. This file needs specific permissions (covered in Step 9).
13. `--ping=true`: Enables the healthcheck endpoint used by Docker healthcheck.

**Dashboard labels explained:**

1. `traefik.enable=true`: Enables Traefik for this container.
2. `traefik.http.routers.traefik-secure.rule=Host(`traefik.yourdomain.com`)`: Routes requests for this hostname to the dashboard.
3. `traefik.http.routers.traefik-secure.entrypoints=https`: Uses the HTTPS entrypoint.
4. `traefik.http.routers.traefik-secure.service=api@internal`: Routes to Traefik&apos;s internal dashboard API.
5. `traefik.http.routers.traefik-secure.tls.certresolver=letsencrypt`: Uses Let&apos;s Encrypt for TLS certificates.
6. `traefik.http.routers.traefik-secure.middlewares=traefik-auth`: Applies the auth middleware.
7. `traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}`: Sets up Basic Auth. Credentials come from the `.env` file. More on [Traefik Basic Authentication](https://www.bitdoze.com/traefik-basic-authentication/).
8. `traefik.http.routers.traefik-secure.tls=true`: Enables TLS for this router.

Replace `traefik.yourdomain.com` with your actual domain.

### 7. Create the `.env` file for Traefik dashboard credentials

Install htpasswd:

```sh
sudo apt install apache2-utils -y
```

Generate a bcrypt hash and write it to `.env` with the variable name:

```sh
echo &quot;TRAEFIK_DASHBOARD_CREDENTIALS=$(htpasswd -nbB admin &apos;YourPassword123&apos; | sed &apos;s/\$/$$/g&apos;)&quot; | sudo tee -a .env
```

The `-B` flag uses bcrypt. The `sed &apos;s/\$/$$/g&apos;` doubles every `$`: Docker Compose interpolates `$VAR` inside `.env` files, so a single-`$` hash would get mangled (the letters after `$` are eaten as an undefined variable) and auth would silently fail with 401. With `$$` the container receives the correct single-`$` hash.

Verify the `.env` contains the doubled hash:

```sh
cat .env
# TRAEFIK_DASHBOARD_CREDENTIALS=admin:$$2y$$05$$...hash...
```

Replace with the actual output from the htpasswd command.

&lt;Notice type=&quot;info&quot;&gt;
For production, consider using &lt;a href=&quot;https://www.bitdoze.com/docker-compose-secrets/&quot;&gt;Docker Compose secrets&lt;/a&gt; instead of .env files for sensitive credentials.
&lt;/Notice&gt;

### 8. Point the domain to your server IP

Create an A record for your domain pointing to the server IP. For subdomains, you can either:

- Create individual A records for each subdomain (e.g., `traefik.yourdomain.com`, `flowise.yourdomain.com`)
- Create a wildcard A record (`*.yourdomain.com`) pointing to the server

Individual records are more explicit and easier to debug. If you&apos;re not using a wildcard, make sure the `traefik.yourdomain.com` record is created before proceeding.

Verify DNS propagation:

```sh
dig traefik.yourdomain.com +short
```

Should return your server IP. If it returns nothing, wait a few more minutes. The TLS challenge requires DNS to resolve correctly.

### 9. Start Traefik and verify Let&apos;s Encrypt TLS certificates

Before starting, create the `acme.json` file with correct permissions:

```sh
mkdir -p ./letsencrypt
touch ./letsencrypt/acme.json
chmod 600 ./letsencrypt/acme.json
```

&lt;Notice type=&quot;warning&quot; title=&quot;acme.json permissions&quot;&gt;
If acme.json does not have 600 permissions, Traefik will refuse to start or fail to store certificates. This is the #1 beginner issue. Verify with `ls -la ./letsencrypt/acme.json`: the output should show `-rw-------`.
&lt;/Notice&gt;

Start Traefik:

```sh
docker compose up -d
```

**Verify it works:**

1. Check container status: `docker ps`: the traefik container should show &quot;healthy&quot; (the healthcheck takes ~30 seconds).

2. Check logs for cert issuance:
```sh
docker logs traefik
```
Look for a line about certificate being obtained. No errors about `acme.json` or permissions.

3. Test HTTP→HTTPS redirect:
```sh
curl -I http://traefik.yourdomain.com
```
Expect a `301 Moved Permanently` redirecting to HTTPS.

4. Test TLS certificate:
```sh
curl -vI https://traefik.yourdomain.com 2&gt;&amp;1 | grep -i &quot;issuer&quot;
```
Should show `issuer: CN=R3, O=Let&apos;s Encrypt, C=US` (or similar). If you see a self-signed cert, DNS isn&apos;t propagated or port 443 isn&apos;t reachable.

5. Access the dashboard: Open `https://traefik.yourdomain.com` in your browser. You should get a Basic Auth prompt, then see the Traefik dashboard.

&lt;Notice type=&quot;info&quot;&gt;
During testing, uncomment the staging CA server line in your compose file to avoid hitting Let&apos;s Encrypt rate limits. Switch back to the production server once everything works. For more essential Docker debugging commands, see: &lt;a href=&quot;https://www.bitdoze.com/docker-commands/&quot;&gt;essential Docker commands&lt;/a&gt;
&lt;/Notice&gt;

**Let&apos;s Encrypt certificate note:** Certificates are valid for 90 days currently. Traefik auto-renews them (starting 30 days before expiry). Let&apos;s Encrypt is [transitioning to 45-day certificates by February 2028](https://letsencrypt.org/2025/12/02/from-90-to-45). Traefik&apos;s auto-renewal will handle this transparently, but `acme.json` must remain writable and the container must stay running for renewals to succeed.

### 10. Deploy your first app behind Traefik reverse proxy

Now that Traefik is running, we can add applications. Here&apos;s a FlowiseAI example with a PostgreSQL database. Previously I used [FlowiseAI with Docker Compose](https://www.bitdoze.com/flowiseai-install/) behind Cloudflare Tunnels. Now we&apos;re using Traefik labels instead:

```yaml
services:
  flowise-db:
    image: postgres:16-alpine
    hostname: flowise-db
    networks:
      - traefik-net
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./flowise-db-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  flowise:
    image: flowiseai/flowise:latest
    container_name: flowiseai
    hostname: flowise
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:${PORT}
    volumes:
      - ./flowiseai:/root/.flowise
    environment:
      DEBUG: false
      PORT: ${PORT}
      FLOWISE_USERNAME: ${FLOWISE_USERNAME}
      FLOWISE_PASSWORD: ${FLOWISE_PASSWORD}
      APIKEY_PATH: /root/.flowise
      SECRETKEY_PATH: /root/.flowise
      LOG_LEVEL: info
      LOG_PATH: /root/.flowise/logs
      DATABASE_TYPE: postgres
      DATABASE_PORT: 5432
      DATABASE_HOST: flowise-db
      DATABASE_NAME: ${POSTGRES_DB}
      DATABASE_USER: ${POSTGRES_USER}
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
    restart: on-failure:5
    networks:
      - traefik-net
    depends_on:
      flowise-db:
        condition: service_healthy
    entrypoint: /bin/sh -c &quot;sleep 3; flowise start&quot;
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.flowise.rule=Host(`flowise.domain.com`)&quot;
      - &quot;traefik.http.routers.flowise.entrypoints=https&quot;
      - &quot;traefik.http.routers.flowise.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.flowise.loadbalancer.server.port=${PORT}&quot;

networks:
  traefik-net:
    external: true
```

&lt;Notice type=&quot;warning&quot;&gt;
The `flowiseai/flowise:latest` tag always pulls the newest image. For stability, check the &lt;a href=&quot;https://github.com/FlowiseAI/Flowise/releases&quot;&gt;Flowise releases&lt;/a&gt; and pin to a specific version instead.
&lt;/Notice&gt;

A few things to note about this config:

Both `flowise-db` and `flowise` are on `traefik-net`. The DB and Flowise can also share a separate internal network for database traffic. They just both need `traefik-net` for Traefik to route external traffic.

The Traefik-specific labels tell Traefik to route traffic for `flowise.domain.com` to this container on the HTTPS entrypoint, using Let&apos;s Encrypt for TLS.

We don&apos;t publish any host ports. Traefik routes traffic through the Docker network internally. The `loadbalancer.server.port` label tells Traefik which port the app listens on inside the container.

Replace `flowise.domain.com` with your actual domain.

Create the `.env` file for Flowise:

```sh
PORT=3000
POSTGRES_USER=&apos;user&apos;
POSTGRES_PASSWORD=&apos;pass&apos;
POSTGRES_DB=&apos;flowise&apos;
FLOWISE_USERNAME=bitdoze
FLOWISE_PASSWORD=bitdoze
```

If the subdomain isn&apos;t using a wildcard, make sure `flowise.domain.com` A record points to your server IP first. Then:

```sh
source .env
docker compose up -d
```

Verify: `docker logs flowiseai` and open `https://flowise.domain.com` in your browser.

For more Docker container ideas to deploy behind Traefik, check out [Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/).

### 11. Install Dockge to manage your Docker Compose files

Dockge is a lightweight Docker Compose manager with a web UI. I&apos;ve written a detailed [Dockge Docker Compose manager](https://www.bitdoze.com/dockge-install/) install guide. Here&apos;s how to run it behind Traefik.

Create a directory:

```sh
mkdir /opt/dockge
cd /opt/dockge
```

Create a `compose.yml` file:

```yaml
services:
  dockge:
    image: louislam/dockge:1
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/stacks:/opt/stacks
    environment:
      - DOCKGE_STACKS_DIR=/opt/stacks
    networks:
      - traefik-net
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.dockge.rule=Host(`dockge.domain.com`)&quot;
      - &quot;traefik.http.routers.dockge.entrypoints=https&quot;
      - &quot;traefik.http.routers.dockge.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.dockge.loadbalancer.server.port=5001&quot;

networks:
  traefik-net:
    external: true
```

The `louislam/dockge:1` tag follows the major version, which is the correct practice for stability. Dockge 1.5.0+ disables the built-in terminal/console by default for security. If you need terminal access, add `DOCKGE_ENABLE_CONSOLE=true` to the environment section.

If you&apos;re not using a wildcard for subdomains, make sure `dockge.domain.com` A record is pointing to your server IP. Then:

```sh
docker compose up -d
```

Verify: `docker logs dockge` and open `https://dockge.domain.com` in your browser.

For a broader comparison of self-hosted management tools, see [self-hosted server management panels](https://www.bitdoze.com/best-self-hosted-panels/).

## Troubleshooting common problems

&lt;Accordion label=&quot;Traefik won&apos;t start / acme.json errors&quot; group=&quot;troubleshooting&quot;&gt;
The most common cause is wrong permissions on `acme.json`. Traefik requires `600` permissions on this file.

```sh
ls -la ./letsencrypt/acme.json
# Should show: -rw------- 1 root root ...
```

If permissions are wrong:

```sh
chmod 600 ./letsencrypt/acme.json
docker compose restart traefik
```

Also check `docker logs traefik` for other errors (port conflicts, network issues).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;No certificate / self-signed cert warning&quot; group=&quot;troubleshooting&quot;&gt;
This means the TLS challenge failed. Common causes:

1. **DNS not propagated:** Verify with `dig traefik.yourdomain.com +short`: it must return your server IP.
2. **Port 443 not reachable:** The TLS-ALPN-01 challenge requires inbound connections on port 443. Check your firewall and cloud provider security groups.
3. **Cloudflare proxy enabled:** If using Cloudflare with orange cloud (proxy), the TLS challenge may fail. Try grey cloud (DNS only) during setup.

Use the staging server while debugging to avoid rate limits. Uncomment the `caserver` line in your compose file.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Too many certificates already issued&quot; group=&quot;troubleshooting&quot;&gt;
Let&apos;s Encrypt rate limits: 5 duplicate certificates per domain per week. If you&apos;ve been debugging, you may have hit this.

Fix: Switch to the staging server while testing:

```yaml
- --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
```

Staging certs show browser warnings but let you verify the setup works. Switch back to production once everything is confirmed.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Docker containers not discovered by Traefik&quot; group=&quot;troubleshooting&quot;&gt;
Check two things:

1. The container is on the `traefik-net` network:
```sh
docker network inspect traefik-net
```
Your container should appear in the output.

2. The container has the `traefik.enable=true` label:
```sh
docker inspect your-container | grep -i traefik.enable
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Dashboard shows 401 / auth not working&quot; group=&quot;troubleshooting&quot;&gt;
The most common cause is a single-`$` hash in `.env` getting mangled by Docker Compose interpolation. Regenerate with the `$$` escaping from Step 7:

```sh
echo &quot;TRAEFIK_DASHBOARD_CREDENTIALS=$(htpasswd -nbB admin &apos;YourPassword123&apos; | sed &apos;s/\$/$$/g&apos;)&quot; | sudo tee .env
```

Make sure the `.env` is in the same directory as the compose file and that the `env_file: .env` directive is present. Also verify the middleware label references `traefik-auth` correctly.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;HTTP redirect loop&quot; group=&quot;troubleshooting&quot;&gt;
If you&apos;re behind Cloudflare, the most common cause is the SSL/TLS mode set to &quot;Flexible&quot; instead of &quot;Full&quot; (or &quot;Full (strict)&quot;). Cloudflare &quot;Flexible&quot; connects to your origin over HTTP, Traefik redirects to HTTPS, Cloudflare connects over HTTP again, creating an infinite loop.

Fix: In Cloudflare dashboard, go to SSL/TLS and set it to **Full** (or **Full (strict)** if you have a valid cert).
&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot;&gt;
Use the Let&apos;s Encrypt staging server while testing to avoid hitting rate limits. Uncomment the caserver line in your compose file, and switch back to production once everything works.
&lt;/Notice&gt;

## Hardening and production notes

Once your basic setup works, here&apos;s what to do for production:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Socket proxy** (already in recommended setup). It limits Traefik&apos;s Docker API access to read-only container/network/service info&lt;/li&gt;
&lt;li&gt;**Security headers middleware**: add HSTS, content-type sniffing protection, XSS filter&lt;/li&gt;
&lt;li&gt;**Rate limiting middleware**: protect the dashboard and apps from abuse&lt;/li&gt;
&lt;li&gt;**Backup `acme.json`**: losing it means re-issuing all certificates (and hitting rate limits). Back it up periodically or use a Docker volume to persistent storage&lt;/li&gt;
&lt;li&gt;**Use Docker secrets** for credentials instead of `.env` files in production&lt;/li&gt;
&lt;li&gt;**Keep Traefik updated**. `traefik:v3.7` tracks the v3.7.x patch releases. Check for new minor versions periodically&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Security headers middleware**: add these labels to your app containers:

```yaml
- traefik.http.middlewares.sec-headers.headers.sslredirect=true
- traefik.http.middlewares.sec-headers.headers.stsseconds=63072000
- traefik.http.middlewares.sec-headers.headers.stsincludeSubdomains=true
- traefik.http.middlewares.sec-headers.headers.stspreload=true
- traefik.http.middlewares.sec-headers.headers.contentTypeNosniff=true
- traefik.http.middlewares.sec-headers.headers.browserXssFilter=true
```

**Rate limiting middleware**: add to your router:

```yaml
- traefik.http.middlewares.rate-limit.ratelimit.average=100
- traefik.http.middlewares.rate-limit.ratelimit.burst=50
- traefik.http.routers.traefik-secure.middlewares=traefik-auth,rate-limit
```

&lt;Notice type=&quot;info&quot;&gt;
Explore community plugins at &lt;a href=&quot;https://plugins.traefik.io/&quot;&gt;plugins.traefik.io&lt;/a&gt; for CrowdSec integration, geo filtering, and more. For broader VPS hardening, see: &lt;a href=&quot;https://www.bitdoze.com/crowdsec-secure-server/&quot;&gt;secure your VPS with CrowdSec&lt;/a&gt;
&lt;/Notice&gt;

## Conclusions

Setting up Traefik as a docker reverse proxy for your self-hosted apps is straightforward once you go through the steps. We covered VPS creation, Docker install, Traefik v3.7 with automatic Let&apos;s Encrypt TLS certificates, dashboard security with bcrypt auth, a socket proxy for reduced blast radius, and deploying your first app with Traefik labels.

The key improvements over a bare-bones setup: socket proxy limits Docker API exposure, `acme.json` with proper permissions prevents the most common beginner failure, healthchecks catch problems early, and bcrypt auth with the `$$` escaping handled in Step 7 keeps the dashboard locked down.

Once Traefik is running, adding new services is just a matter of adding labels to a Docker container and putting it on `traefik-net`. Dockge makes managing those compose files even easier from a web UI.

&lt;Button text=&quot;Best Docker Containers for Your Home Server&quot; link=&quot;/docker-containers-home-server/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>traefik</category><category>docker</category><category>reverse-proxy</category></item><item><title>Traefik Wildcard Certificate: Free Let&apos;s Encrypt + Cloudflare</title><link>https://www.bitdoze.com/traefik-wildcard-certificate/</link><guid isPermaLink="true">https://www.bitdoze.com/traefik-wildcard-certificate/</guid><description>Set up Traefik with a free Let&apos;s Encrypt wildcard SSL certificate using Cloudflare DNS challenge. Docker Compose guide with auto-renewal.</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/07/traefik-diagram.jpeg&quot;;
import img2 from &quot;../../assets/images/24/08/cloudflare-api.png&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;

A wildcard certificate covers `*.domain.com` with a single cert, so every subdomain gets HTTPS without requesting individual certificates from Let&apos;s Encrypt. For self-hosters running multiple services behind Traefik, this means less rate-limit risk, zero per-service certificate management, and automatic HTTPS for any new subdomain the moment you add it.

This guide walks through setting up Traefik v3.7 as a reverse proxy with a free Let&apos;s Encrypt wildcard certificate using the Cloudflare DNS challenge. Everything runs in Docker Compose with auto-renewal handled by Traefik internally. If you need the foundational Traefik setup first, check [our Traefik reverse proxy guide](https://www.bitdoze.com/traefik-proxy-docker/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/E3g-rZChzyw&quot;
  label=&quot;Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider&quot;
/&gt;

## Why use a wildcard certificate with Traefik?

With per-subdomain certificates, every new service you deploy triggers a separate Let&apos;s Encrypt request. Hit 50 certs per domain per week and you&apos;re rate-limited, stuck waiting. A wildcard certificate sidesteps this entirely. One certificate covers every subdomain you&apos;ll ever add.

Wildcard certs also solve a problem HTTP-01 challenges can&apos;t: services that don&apos;t expose HTTP. Databases, TCP proxies, internal APIs. None of them can respond to an HTTP challenge. DNS-01 challenge (required for wildcards) works by creating a TXT record via the Cloudflare API, so the service itself never needs to be web-accessible.

Traefik matches the wildcard certificate to any `Host(&apos;sub.domain.com&apos;)` route automatically. You add a container with the right labels, and HTTPS works. No certificate request, no wait.

## Prerequisites for Traefik wildcard SSL setup

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;A domain name managed by Cloudflare (free plan works)&lt;/li&gt;
  &lt;li&gt;A Linux VPS (Ubuntu 22.04/24.04 or Debian 12), a &lt;a href=&quot;https://go.bitdoze.com/hetzner&quot;&gt;Hetzner&lt;/a&gt; CX22 at ~€4/mo or &lt;a href=&quot;https://go.bitdoze.com/hostinger-vps&quot;&gt;Hostinger&lt;/a&gt; KVM1 is sufficient&lt;/li&gt;
  &lt;li&gt;SSH access to the VPS&lt;/li&gt;
  &lt;li&gt;Ports 80 and 443 open (see the Security section for the Docker firewall caveat)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Create a Cloudflare API token for DNS challenge

Log in to Cloudflare, go to your **Profile** → **API Tokens** → **Create Token**.

Use the **&quot;Edit zone DNS&quot;** template. It pre-configures the right permissions. Then adjust:

- **Permissions:** Zone / Zone / Read + Zone / DNS / Edit
- **Zone Resources:** Specific zone → your domain
- **Client IP Filtering (optional):** Is in → your server&apos;s public IP (defense in depth)

&lt;Picture src={img2} alt=&quot;Cloudflare API token creation with Zone:DNS:Edit permissions for Traefik DNS challenge&quot; /&gt;

Copy the token immediately. Cloudflare only shows it once. You&apos;ll store it as a Docker secret later.

&lt;Notice type=&quot;info&quot; title=&quot;CF_API_EMAIL is not required&quot;&gt;
When using API tokens (as this guide does), `CF_API_EMAIL` is not needed. The older Global API Key required email, but token-based auth is simpler and more secure. We&apos;ll skip the email secret entirely.
&lt;/Notice&gt;

### Install Docker and Docker Compose

Update the OS first, then install Docker with the official repository. These commands auto-detect your distro codename (works on Ubuntu 22.04, 24.04, and Debian 12):

&lt;Tabs&gt;
&lt;Tab name=&quot;Ubuntu&quot;&gt;
```sh
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release &amp;&amp; echo &quot;$VERSION_CODENAME&quot;) stable&quot; | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Debian&quot;&gt;
```sh
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release &amp;&amp; echo &quot;$VERSION_CODENAME&quot;) stable&quot; | \
  sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null

sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

If you&apos;re running on ARM (Raspberry Pi, Oracle ARM), see [how to install Docker on Ubuntu ARM](https://www.bitdoze.com/install-docker-ubuntu-arm/).

Add SWAP if your VPS doesn&apos;t have any (common on cheap VPS plans):

```sh
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | sudo tee -a /etc/fstab
```

Reboot after install:

```sh
sudo apt update &amp;&amp; sudo apt upgrade -y
reboot
```

## Traefik Docker Compose configuration for wildcard certificates

Traefik runs as a Docker container, discovers other containers via Docker labels, obtains wildcard certs through the Cloudflare DNS challenge, and terminates TLS. You can configure it with CLI arguments in the Docker Compose file or with a static `traefik.yml` file. Both approaches are shown below.

&lt;Picture src={img1} alt=&quot;Traefik reverse proxy architecture diagram showing wildcard SSL certificate flow&quot; /&gt;

### Configure Let&apos;s Encrypt DNS challenge with Cloudflare

Create the project directory:

```sh
sudo mkdir -p /opt/stacks/traefik
cd /opt/stacks/traefik
```

&lt;Notice type=&quot;warning&quot; title=&quot;Always test with staging first&quot;&gt;
Uncomment the `caServer` line below to use the Let&apos;s Encrypt staging server for your first run. This avoids hitting rate limits (5 failures per host per hour on production). Once you see certs obtained in the logs, comment it out and restart to get real certificates.
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker Compose (CLI args)&quot;&gt;
Create `docker-compose.yml`:

```yml
secrets:
  cloudflare-token:
    file: &quot;./secrets/cloudflare-token.secret&quot;

services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    command:
      # - --log.level=DEBUG
      - --providers.docker=true
      - --api.dashboard=true
      - --providers.docker.exposedbydefault=false
      # Let&apos;s Encrypt DNS challenge with Cloudflare
      - --certificatesresolvers.letsencrypt.acme.dnschallenge=true
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
      - --certificatesResolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,1.0.0.1:53
      - --certificatesresolvers.letsencrypt.acme.dnschallenge.propagation.delayBeforeChecks=20
      - --certificatesresolvers.letsencrypt.acme.email=email@domain.com
      - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
      - --certificatesresolvers.letsencrypt.acme.certificatesDuration=2160
      # staging environment, uncomment for first run, then remove
      #- --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
      # Entrypoints
      - --entrypoints.http.address=:80
      - --entrypoints.http.http.redirections.entrypoint.to=https
      - --entrypoints.http.http.redirections.entrypoint.scheme=https
      - --entryPoints.https.address=:443
      # TLS
      - --entrypoints.https.http.tls=true
      - --entrypoints.https.http.tls.certResolver=letsencrypt
      - --entrypoints.https.http.tls.domains[0].main=domain.com
      - --entrypoints.https.http.tls.domains[0].sans=*.domain.com
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik-net
    ports:
      - 80:80
      - 443:443
    environment:
      TRAEFIK_DASHBOARD_CREDENTIALS: ${TRAEFIK_DASHBOARD_CREDENTIALS}
      CF_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-token
    volumes:
      - ./letsencrypt:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
    secrets:
      - cloudflare-token
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.traefik-secure.rule=Host(`traefik.domain.com`)&quot;
      - &quot;traefik.http.routers.traefik-secure.entrypoints=https&quot;
      - &quot;traefik.http.routers.traefik-secure.service=api@internal&quot;
      - &quot;traefik.http.routers.traefik-secure.middlewares=traefik-auth&quot;
      - &quot;traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}&quot;

networks:
  traefik-net:
    external: true
```
&lt;/Tab&gt;
&lt;Tab name=&quot;traefik.yml (static config)&quot;&gt;
Create `traefik.yml`:

```yml
entryPoints:
  http:
    address: &quot;:80&quot;
    http:
      redirections:
        entryPoint:
          to: https
          scheme: https
  https:
    address: &quot;:443&quot;
    http:
      tls:
        certResolver: letsencrypt
        domains:
          - main: domain.com
            sans:
              - &quot;*.domain.com&quot;

providers:
  docker:
    exposedByDefault: false

api:
  dashboard: true

certificatesResolvers:
  letsencrypt:
    acme:
      email: email@domain.com
      storage: /letsencrypt/acme.json
      certificatesDuration: 2160  # 90 days. Change to 1536 for 64-day, 1080 for 45-day
      #caServer: https://acme-staging-v02.api.letsencrypt.org/directory # staging
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - &quot;1.1.1.1:53&quot;
          - &quot;1.0.0.1:53&quot;
        propagation:
          delayBeforeChecks: 20
# Uncomment for debugging:
# log:
#   level: DEBUG
#   filePath: /letsencrypt/traefik.log
```

Then create a minimal `docker-compose.yml` that mounts the static config:

```yml
secrets:
  cloudflare-token:
    file: &quot;./secrets/cloudflare-token.secret&quot;

services:
  traefik:
    image: traefik:v3.7
    container_name: traefik
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    networks:
      - traefik-net
    ports:
      - 80:80
      - 443:443
    environment:
      TRAEFIK_DASHBOARD_CREDENTIALS: ${TRAEFIK_DASHBOARD_CREDENTIALS}
      CF_DNS_API_TOKEN_FILE: /run/secrets/cloudflare-token
    volumes:
      - ./letsencrypt:/letsencrypt
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.yml:/traefik.yml:ro
    secrets:
      - cloudflare-token
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.traefik-secure.rule=Host(`traefik.domain.com`)&quot;
      - &quot;traefik.http.routers.traefik-secure.entrypoints=https&quot;
      - &quot;traefik.http.routers.traefik-secure.service=api@internal&quot;
      - &quot;traefik.http.routers.traefik-secure.middlewares=traefik-auth&quot;
      - &quot;traefik.http.middlewares.traefik-auth.basicauth.users=${TRAEFIK_DASHBOARD_CREDENTIALS}&quot;

networks:
  traefik-net:
    external: true
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Replace `domain.com` with your actual domain. Replace `email@domain.com` with your email (used for Let&apos;s Encrypt expiry notices).

&lt;Notice type=&quot;info&quot; title=&quot;delayBeforeCheck syntax changed in Traefik v3.3&quot;&gt;
The old `delayBeforeCheck=20` is deprecated. Use `propagation.delayBeforeChecks=20` instead. The old form still works but emits deprecation warnings and will be removed in a future version.
&lt;/Notice&gt;

**What this config does:**

- **DNS challenge:** Traefik uses lego (ACME client) to create `_acme-challenge.domain.com` TXT records via the Cloudflare API, then tells Let&apos;s Encrypt to verify them. This is the only way to get wildcard certificates.
- **Wildcard domain:** `domains[0].main=domain.com` + `domains[0].sans=*.domain.com` requests a cert covering both the apex and all subdomains.
- **Cert duration:** `certificatesDuration=2160` matches the current 90-day Let&apos;s Encrypt default. See the Let&apos;s Encrypt changes section below for the upcoming 45-day transition.
- **Docker secrets:** The Cloudflare API token is stored as a file-based Docker secret, not an environment variable. If someone compromises the container, they can&apos;t read the secret directly.

### Set up HTTP-to-HTTPS redirect in Traefik

The entrypoint configuration handles this automatically. Traefik defines two entrypoints: `http` on port 80 and `https` on port 443. The redirect directive sends all HTTP traffic to HTTPS:

```yml
- --entrypoints.http.address=:80
- --entrypoints.http.http.redirections.entrypoint.to=https
- --entrypoints.http.http.redirections.entrypoint.scheme=https
- --entryPoints.https.address=:443
```

Any request hitting port 80 gets a 301 redirect to the same URL over HTTPS. No nginx, no extra containers. For more details on redirect options, see how to [add Traefik HTTP to HTTPS redirect](https://www.bitdoze.com/traefik-redirect-http-https/).

### Secure the Traefik dashboard with basic authentication

The Traefik dashboard is exposed at `traefik.domain.com` with basic auth middleware. To generate the credentials:

Install `htpasswd`:

```sh
sudo apt update
sudo apt install apache2-utils
```

Generate a bcrypt hash (same as the basic auth guide — `-nB` gives bcrypt, not the weak APR1/MD5):

```sh
echo $(htpasswd -nB user) | sed -e s/\\$/\\$\\$/g
```

You&apos;ll be prompted to type the password. The output looks like:

```
user:$$2y$$05$$KJ3RixvQ.Zabc123...rest_of_hash
```

Create the `.env` file:

```sh
vi .env
```

Add the credentials:

```
TRAEFIK_DASHBOARD_CREDENTIALS=user:$$2y$$05$$KJ3RixvQ.Zabc123...rest_of_hash
```

The double `$$` is required. Docker Compose treats `$$` as an escaped `$`. For more on dashboard auth, see [how to add basic authentication to Traefik](https://www.bitdoze.com/traefik-basic-authentication/).

## Deploy Traefik and verify your wildcard certificate

### 1. Create the Docker network

```sh
docker network create traefik-net
```

The `traefik-net` network is external so that other Docker Compose stacks can join it without depending on the Traefik compose file.

### 2. Create the Cloudflare secret

```sh
mkdir -p secrets
echo &quot;YOUR_CLOUDFLARE_API_TOKEN&quot; &gt; secrets/cloudflare-token.secret
chmod 600 secrets/cloudflare-token.secret
```

Replace `YOUR_CLOUDFLARE_API_TOKEN` with the token you copied from Cloudflare.

### 3. Start Traefik

```sh
docker compose up -d
```

### 4. Verify it&apos;s working

Check the container is running:

```sh
docker ps | grep traefik
```

Expected: `traefik` container shows `Up` status.

Check logs for certificate activity:

```sh
docker logs traefik 2&gt;&amp;1 | grep -i acme
```

Expected output includes lines like:

```
msg=&quot;Certificate obtained&quot; domain=&quot;domain.com&quot;
msg=&quot;Certificate obtained&quot; domain=&quot;*.domain.com&quot;
```

If you used the staging CA server first, you&apos;ll see staging certificates. Comment out the `caServer` line, delete the `letsencrypt/` directory, and restart to get production certificates.

Verify the wildcard certificate:

```sh
echo | openssl s_client -servername test.domain.com -connect domain.com:443 2&gt;/dev/null | openssl x509 -noout -subject -dates -issuer
```

Expected: the certificate subject should include `*.domain.com`, and the issuer should be &quot;Let&apos;s Encrypt&quot;.

Check the stored certificates:

```sh
cat letsencrypt/acme.json | jq &apos;.letsencrypt.Certificates[].domain&apos;
```

Expected: shows both `domain.com` and `*.domain.com` entries.

&lt;Notice type=&quot;success&quot; title=&quot;Wildcard certificate working&quot;&gt;
If you see the certificate with both `domain.com` and `*.domain.com` in the SAN (Subject Alternative Names), your wildcard certificate is working. Any subdomain you add will be covered automatically.
&lt;/Notice&gt;

Test the dashboard:

```sh
curl -I https://traefik.domain.com
```

Expected: HTTP 401 (basic auth is protecting it). Pass your credentials to access the dashboard UI.

## How Traefik handles automatic certificate renewal

Traefik renews certificates automatically 30 days before expiry. With the current 90-day Let&apos;s Encrypt certificates, renewal happens around day 60. No cron job needed. Traefik checks certificate expiry on its own schedule.

Traefik also supports ACME Renewal Information (ARI), which Let&apos;s Encrypt provides to tell clients exactly when to renew. This means Traefik can react to CA-side changes (like early revocations) without manual intervention.

To check your current certificate expiry:

```sh
echo | openssl s_client -servername domain.com -connect domain.com:443 2&gt;/dev/null | openssl x509 -noout -dates
```

Or inspect `acme.json`:

```sh
cat letsencrypt/acme.json | jq &apos;.letsencrypt.Certificates[].domain&apos;
```

For proactive monitoring, set up TLS expiry alerts with [Uptime Kuma or similar tools](https://www.bitdoze.com/sever-monitoring/).

&lt;Notice type=&quot;info&quot; title=&quot;45-day certificates are coming&quot;&gt;
Let&apos;s Encrypt is transitioning to shorter certificate lifetimes. By February 2028, the default will be 45-day certificates. Traefik&apos;s 30-day-before-expiry renewal window works fine with 45-day certs (renewal at day 15), but you&apos;ll need to update `certificatesDuration` when the CA switches. See the Let&apos;s Encrypt changes section below for the full timeline.
&lt;/Notice&gt;

## Adding services behind Traefik (example with Dockge)

Once Traefik is running with the wildcard certificate, adding a new service is just Docker Compose labels. Here&apos;s an example with [Dockge, a Docker Compose manager](https://www.bitdoze.com/dockge-install/):

```sh
mkdir /opt/dockge
cd /opt/dockge
```

Create `docker-compose.yml`:

```yaml
services:
  dockge:
    image: louislam/dockge:1
    restart: unless-stopped
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/stacks:/opt/stacks
    environment:
      - DOCKGE_STACKS_DIR=/opt/stacks
    networks:
      - traefik-net
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.dockge.rule=Host(`dockge.domain.com`)&quot;
      - &quot;traefik.http.routers.dockge.entrypoints=https&quot;
      - &quot;traefik.http.services.dockge.loadbalancer.server.port=5001&quot;

networks:
  traefik-net:
    external: true
```

Start it:

```sh
docker compose up -d
```

Access at `https://dockge.domain.com`. The wildcard certificate covers it automatically. No port exposure needed. Traefik discovers the container via the shared `traefik-net` network and routes traffic based on the labels.

The key labels:

- `traefik.enable=true`: tells Traefik to route traffic to this container
- `traefik.http.routers.dockge.rule=Host(&apos;dockge.domain.com&apos;)`: matches the subdomain
- `traefik.http.routers.dockge.entrypoints=https`: uses the HTTPS entrypoint
- `traefik.http.services.dockge.loadbalancer.server.port=5001`: the port Dockge listens on inside the container

For more service ideas, check [Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/) or browse [self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/).

## Security considerations for your Traefik setup

### Docker bypasses UFW (critical)

Docker manipulates iptables directly, bypassing UFW. Containers with exposed ports are accessible from the internet regardless of your firewall rules. This is a common surprise for people who configure UFW to allow only ports 22, 80, and 443. Docker&apos;s port mappings create iptables rules that jump ahead of UFW.

&lt;Notice type=&quot;warning&quot; title=&quot;Docker bypasses your firewall&quot;&gt;
Docker&apos;s port mappings (`ports: - 80:80`) create iptables ACCEPT rules that bypass UFW. Use a cloud provider firewall (like [Hetzner](https://go.bitdoze.com/hetzner) firewall rules) as a first layer of defense. For internal services, bind to `127.0.0.1` instead of exposing ports. See [Docker bypassing firewall rules](https://www.bitdoze.com/docker-bypasses-firewall/) for detailed mitigation.
&lt;/Notice&gt;

For this Traefik setup, ports 80 and 443 are intentionally exposed. Use a cloud firewall to restrict other ports.

### Keep Traefik updated

&lt;Notice type=&quot;warning&quot; title=&quot;CVE-2024-45410 affected Traefik v3.1&quot;&gt;
`traefik:v3.1` had CVE-2024-45410 (CVSS 7.5 HIGH per NVD, 9.8 per GitHub&apos;s advisory). HTTP headers like X-Forwarded-Host could be manipulated via the Connection header in HTTP/1.1. Multiple additional CVEs have been fixed since then. Use `traefik:v3.7` (as this guide specifies) or `traefik:v3` for automatic minor version updates. Subscribe to [Traefik security announcements](https://github.com/traefik/traefik/releases) to stay informed.
&lt;/Notice&gt;

### Back up acme.json

If `letsencrypt/acme.json` is lost, Traefik must re-request all certificates from Let&apos;s Encrypt. This counts against rate limits (50 certs per domain per week). Back up the entire `letsencrypt/` directory regularly. Include it in your existing backup workflow or set up a cron job.

```sh
# Example: back up to /opt/backups
cp /opt/stacks/traefik/letsencrypt/acme.json /opt/backups/acme-$(date +%Y%m%d).json
```

### File permissions

Set restrictive permissions on sensitive files:

```sh
chmod 600 letsencrypt/acme.json
chmod 600 secrets/cloudflare-token.secret
```

### Consider CrowdSec for additional protection

For threat detection and automated blocking of malicious traffic, [secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/). It integrates with Traefik and provides community-driven IP reputation.

## Let&apos;s Encrypt certificate lifetime changes

Let&apos;s Encrypt is transitioning to shorter certificate lifetimes. This affects how you configure Traefik&apos;s renewal settings.

**Timeline:**

| Date | Change | Traefik action |
|------|--------|----------------|
| May 13, 2026 | `tlsserver` profile → 45-day certs (opt-in) | Set `certificatesDuration: 1080` if you opt in |
| Feb 10, 2027 | Default `classic` → 64-day certs | Set `certificatesDuration: 1536` |
| Feb 16, 2028 | Default → 45-day certs | Set `certificatesDuration: 1080` |

**What to do:**

Right now (mid-2026), the default is still 90-day certificates. The `certificatesDuration: 2160` in this guide matches. When Let&apos;s Encrypt switches the default to 64-day certs in February 2027, update your config:

```yaml
certificatesDuration: 1536  # 64 days in hours
```

Or via CLI:

```
--certificatesresolvers.letsencrypt.acme.certificatesDuration=1536
```

If you want to test 45-day certificates early, you can opt in now:

```yaml
certificatesDuration: 1080
profile: tlsserver
```

Traefik&apos;s renewal logic (renew 30 days before expiry) works fine with shorter certs. With 45-day certs, renewal happens at day 15 -- still plenty of buffer.

## Troubleshooting common wildcard certificate issues

&lt;Accordion label=&quot;DNS propagation delay, ACME challenge fails&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Logs show `propagation: timeout` or `NXDOMAIN` errors.

**Fix:** Increase `delayBeforeChecks` to 30 or 60 seconds:

```yaml
propagation:
  delayBeforeChecks: 60
```

Check if the TXT record was created:

```sh
dig _acme-challenge.domain.com TXT
```

Cloudflare is usually fast (&amp;lt;5 seconds) but can be slow for new zones. If the record doesn&apos;t appear, verify your API token has DNS:Edit permissions.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Too many certificates already issued (rate limited)&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** `too many certificates already issued for domain.com`

**Fix:** Let&apos;s Encrypt allows 5 failed validations per host per hour. Always test with the staging CA server first:

```yaml
caServer: https://acme-staging-v02.api.letsencrypt.org/directory
```

Once staging works, remove the `caServer` line and delete `letsencrypt/` before restarting. If you&apos;re already rate-limited, wait 1 hour before retrying.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloudflare proxy (orange cloud) interfering&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** DNS challenge works but certificate validation fails.

**Fix:** Ensure the `_acme-challenge` DNS record is DNS-only (gray cloud), not proxied (orange cloud). Traefik creates TXT records automatically -- the Cloudflare proxy shouldn&apos;t affect them, but some configurations can cause issues. The `_acme-challenge` record must be a TXT record that Let&apos;s Encrypt can read directly.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission denied on acme.json&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Traefik can&apos;t read or write to `acme.json`.

**Fix:**

```sh
chmod 600 letsencrypt/acme.json
```

Make sure the `letsencrypt/` directory exists and is writable by the container. If you created it with `sudo`, the container might not have access.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Container can&apos;t reach Cloudflare API&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** `unable to generate a certificate` with network errors in logs.

**Fix:** Test DNS resolution inside the container:

```sh
docker exec traefik nslookup api.cloudflare.com
```

If this fails, check your VPS outbound connectivity and DNS settings. Some VPS providers block outbound DNS on port 53 -- the resolver config (`1.1.1.1:53`) should handle this, but verify.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Wildcard cert not matching subdomains&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** A subdomain gets a different certificate or Traefik&apos;s default self-signed cert.

**Fix:** Verify your config includes the wildcard SAN:

```yaml
domains[0].main=domain.com
domains[0].sans=*.domain.com
```

And your router rules use `Host(&apos;sub.domain.com&apos;)`, not regex patterns. Traefik v3.7 supports `Host(&apos;*.example.com&apos;)` as a wildcard matcher, but individual `Host()` rules per subdomain are the standard pattern.

If you need to reset completely, [clean up Docker resources](https://www.bitdoze.com/cleanup-all-docker-things/) and start fresh:

```sh
docker compose down
rm -rf letsencrypt/
docker compose up -d
```
&lt;/Accordion&gt;

## Conclusion

Traefik with Cloudflare DNS challenge gives you free wildcard SSL certificates with automatic renewal -- no cron jobs, no manual certificate management. One certificate covers every subdomain you&apos;ll ever deploy.

Keep these three things in mind: test with the Let&apos;s Encrypt staging server first, back up your `acme.json` file, and keep Traefik updated for security patches. The upcoming shift to 45-day certificates means you&apos;ll need to update `certificatesDuration` in your config when the time comes, but Traefik&apos;s renewal logic handles it fine.

For the full Traefik reverse proxy setup (per-subdomain certs, middleware, load balancing), see the complete guide:

&lt;Button text=&quot;Learn More About Traefik Reverse Proxy&quot; link=&quot;https://www.bitdoze.com/traefik-proxy-docker/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>traefik</category><category>lets-encrypt</category><category>docker</category></item><item><title>FileBrowser Quantum Docker Setup: Self-Hosted File Manager</title><link>https://www.bitdoze.com/deploy-filebrowser-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-filebrowser-docker/</guid><description>Deploy FileBrowser Quantum with Docker Compose: the actively maintained FileBrowser fork. Multiple sources, share links with expiry, 2FA, and how I use it with my Mastra AI assistant.</description><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;

# FileBrowser Quantum Docker Setup: Self-Hosted File Manager

&lt;Notice type=&quot;warning&quot; title=&quot;Why Quantum and not FileBrowser&quot;&gt;
The original FileBrowser project is being **archived on 2026-09-01**. The final release (v2.63.23) shipped with no further security patches planned, and known issues like command execution vulnerabilities ([#5199](https://github.com/filebrowser/filebrowser/issues/5199)) and non-revocable JWT sessions ([#5216](https://github.com/filebrowser/filebrowser/issues/5216)) will stay unpatched.

**FileBrowser Quantum** is the actively maintained fork that took over: 7.6k+ GitHub stars, a stable release track, Apache-2.0 license, and it removed the shell command feature entirely. This guide deploys Quantum from scratch — if you&apos;re already running the original, the [migration notes](#switching-from-the-original-filebrowser) section covers what changes.
&lt;/Notice&gt;

[FileBrowser Quantum](https://github.com/gtsteffaniak/filebrowser) is a web-based file manager for self-hosted servers. It gives you a browser UI for uploading, downloading, previewing, renaming, and editing files — with the polish of a modern SaaS product and none of the subscriptions. It&apos;s the fork of FileBrowser that keeps getting updates, and it&apos;s what I run on my own server today.

This guide covers the full FileBrowser Quantum Docker setup: Docker Compose deployment, multiple file sources, share links with expiration, reverse proxy access, security hardening, and how I use it as the file window into my AI agent setup (a self-hosted Mastra assistant).

## What Makes FileBrowser Quantum Different

If you&apos;ve used the original FileBrowser, these are the changes that matter:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Multiple sources&lt;/strong&gt; — you can mount several directories (workspace, projects, media) with include/exclude rules, instead of one root&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Real-time indexed search&lt;/strong&gt; — SQLite-backed, searches filenames, contents, and sizes as you type&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Modern authentication&lt;/strong&gt; — OIDC, LDAP, JWT, password + 2FA, and proxy auth&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Share links with expiry&lt;/strong&gt; — anonymous public links with expiration, permissions (view/edit/upload), and even custom themes&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OnlyOffice integration&lt;/strong&gt; — edit office documents right in the browser&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;API tokens + Swagger docs&lt;/strong&gt; — long-lived API tokens and a documented API at &lt;code&gt;/swagger&lt;/code&gt;, useful for automation&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No shell commands&lt;/strong&gt; — the command runner was removed completely. This is the single biggest security improvement over the original&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Config format changed&quot;&gt;
Quantum uses a `config.yaml` instead of the original `settings.json`, and the database schema is different. You can&apos;t swap the Docker image on an existing original install — the old `filebrowser.db` is not compatible. Migration means reconfiguring from scratch (see below).
&lt;/Notice&gt;

If you&apos;re comparing other options, [Cloudreve is another solid self-hosted file manager](/cloudreve-docker-setup/) worth a look.

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS or home server — [Hetzner](https://go.bitdoze.com/hetzner) (from ~€4/mo) or [Hostinger](https://go.bitdoze.com/hostinger-vps) work well, or a [Mini PC as a home server](/best-mini-pc-home-server/)&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose v2 (the `docker compose` plugin; `docker-compose` v1 was removed in April 2025)&lt;/li&gt;
&lt;li&gt;A reverse proxy (Caddy, Traefik, Nginx) or Cloudflare Tunnel for TLS — never expose FileBrowser directly to the internet&lt;/li&gt;
&lt;li&gt;A domain or subdomain pointed at your server&lt;/li&gt;
&lt;li&gt;Optionally, [Dockge](/dockge-install/) or another [self-hosted management panel](/best-self-hosted-panels/) to manage containers via UI&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&gt; You can also use [Traefik as a reverse proxy for Docker](/traefik-proxy-docker/) — I have a full tutorial with Dockge.

## Deploy FileBrowser Quantum with Docker Compose

### Step 1: Create the base directory

Quantum stores its config, database, and cache in one data directory. Inside the container that&apos;s `/home/filebrowser/data`.

```sh
mkdir -p filebrowser/data &amp;&amp; cd filebrowser
```

### Step 2: Create the config

&lt;Notice type=&quot;info&quot; title=&quot;The config key is server.sources&quot;&gt;
Quantum&apos;s Docker docs had a typo at some point showing a top-level `sources` key. The correct schema nests sources under `server` — a config without `server.sources` fails startup validation with `Settings.Server.Sources required`.
&lt;/Notice&gt;

Create `data/config.yaml`:

```yaml
server:
  cacheDir: /home/filebrowser/data/tmp # inside the data volume so it persists across restarts
  sources:
    - path: /srv/workspace
      name: &quot;Agent Workspace&quot;
      config:
        defaultEnabled: true
    - path: /srv/projects
      name: &quot;Projects&quot;
      config:
        defaultEnabled: true
```

Notes on the config:

- **`path`** is from the **container&apos;s** point of view — it must match the right side of your volume mounts.
- **`name`** is the display name shown in the UI sidebar.
- **`defaultEnabled: true`** makes a source available to new users by default. Without it, users won&apos;t see the source until you grant access.
- **`cacheDir`** should live in the data volume so the search index and thumbnails survive restarts.
- Don&apos;t use a root `/` directory or include `/var` as a source — Quantum warns against both.

### Step 3: Create the Docker Compose file

```yaml
services:
  filebrowser:
    image: gtstef/filebrowser:stable
    container_name: filebrowser
    restart: unless-stopped
    volumes:
      - /opt/workspace:/srv/workspace
      - /opt/projects:/srv/projects
      - ./data:/home/filebrowser/data
    ports:
      - &quot;8080:80&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:80/health&quot;]
      interval: 30s
      timeout: 3s
      start_period: 10s
      retries: 3
```

What&apos;s going on here:

- **Image tags**: `stable` (60MB, includes FFmpeg + document preview) or `stable-slim` (15MB, core only). Use `stable` if you want media previews and thumbnails. Also available on GHCR as `ghcr.io/gtsteffaniak/filebrowser`.
- **`./data:/home/filebrowser/data`** — persists `config.yaml`, `database.db`, and the `tmp` cache. This volume is the whole state of the app.
- **`/opt/workspace:/srv/workspace`** — your first source. Change the host path to wherever your files actually live.
- **`8080:80`** — host port : container port. The container listens on 80 by default (you can change `server.port` in the config, then the healthcheck must match).
- **Non-root default**: since v1.3 the image runs as the `filebrowser` user (UID/GID 1000:1000), not root. If your host user is a different UID, `chown -R` the mounted directories accordingly.
- **Healthcheck**: the image ships a built-in healthcheck for port 80; I&apos;ve made it explicit so it&apos;s easy to adjust if you change the port.

### Step 4: Start and verify

```sh
docker compose up -d
```

&lt;Notice type=&quot;success&quot; title=&quot;Verify the deployment&quot;&gt;
```sh
# Container should be Up (healthy)
docker ps --filter name=filebrowser

# Watch the startup logs
docker logs -f filebrowser

# Health endpoint returns 200
curl -f http://localhost:8080/health

# Config and DB were created in the data volume
ls -la data/
```
&lt;/Notice&gt;

Log in at `http://your-server:8080` with the default credentials — **`admin` / `admin`** — and change the password immediately.

&lt;Notice type=&quot;warning&quot; title=&quot;Change the password and enable 2FA now&quot;&gt;
The default admin credentials are known to the entire internet. After first login: change the password, then enable **two-factor authentication** in the user settings. Quantum supports TOTP 2FA out of the box — the bots that scan every public IP will find your instance, so lock it before pointing a domain at it.
&lt;/Notice&gt;

### Adding more sources later

Sources can be added without recreating the container — but there&apos;s a gotcha: **Quantum caches the config into `database.db` on first run**. If you edit `config.yaml` after the first start and nothing changes, that&apos;s why.

```sh
# Stop the container, clear the cached config, restart to re-init from config.yaml
docker compose down
rm -f data/database.db
rm -rf data/tmp
docker compose up -d
```

This wipes user accounts and settings stored in the DB — export/recreate them if needed. For day-to-day source additions there&apos;s also a **UI admin panel** (Settings → Sources) that updates things live without touching the file.

## Share Links: Send Files Without Accounts

Sharing is one of Quantum&apos;s strongest features and the reason I reach for it daily. Select any file or folder → **Share** → and you get:

- Public/anonymous links — the recipient doesn&apos;t need an account or login
- **Expiration time** on every share
- Per-share permissions: view, edit, or upload
- Custom styling/theme for the share page

This turns Quantum into a poor man&apos;s file-drop service: right-click a file, copy the `https://files.yourdomain.com/s/...` link, send it anywhere.

## Secure Remote Access with a Reverse Proxy

&lt;Notice type=&quot;warning&quot; title=&quot;Never expose the port directly&quot;&gt;
Quantum has no TLS of its own — always terminate TLS at a reverse proxy and keep the container port private. Prefer running Quantum on a shared Docker network so you don&apos;t even publish a host port.
&lt;/Notice&gt;

**Option 1: Same Docker network as your proxy (what I do).**

Drop the `ports:` section and attach both containers to the proxy&apos;s network:

```yaml
services:
  filebrowser:
    image: gtstef/filebrowser:stable
    container_name: filebrowser
    restart: unless-stopped
    networks:
      - web
    volumes:
      - /opt/workspace:/srv/workspace
      - /opt/projects:/srv/projects
      - ./data:/home/filebrowser/data

networks:
  web:
    external: true
```

Then in Caddy:

```
files.example.com {
    reverse_proxy filebrowser:80
}
```

`docker compose up -d` and Caddy gets a Let&apos;s Encrypt certificate automatically. This is exactly how my instance runs at `files.ai.bitdoze.com` — no host port exposed, only reachable through the proxy.

**Option 2: Cloudflare Tunnel.**

If you use Cloudflare, add a hostname to your existing tunnel pointing at the service:

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel setup&quot; /&gt;

Point it at `http://localhost:8080` (or the host port you chose) and let Cloudflare handle TLS.

**Option 3: Traefik.**

Check [Traefik as a reverse proxy for Docker](/traefik-proxy-docker/) or [CloudPanel as a reverse proxy](/cloudpanel-setup-dockge/) for the full walkthroughs.

If you get a 502/503 through the proxy, verify the container is running (`docker ps`) and that the proxy&apos;s target port matches the container&apos;s internal port (80 unless you changed `server.port`).

## How I Use FileBrowser Quantum with My AI Agent Setup

This is the part that changed my workflow. I run a self-hosted [Mastra](https://mastra.ai/) AI assistant — the same assistant that helps me write articles on this blog. I documented the full build in [Build Your Own AI Agent with Mastra (Files, Web, Browser)](/build-ai-agent-mastra/), and the code is open source at [github.com/bitdoze/mastra-assistant](https://github.com/bitdoze/mastra-assistant).

The agent lives on the same server as FileBrowser Quantum, and the two work together as a loop:

**1. The agent&apos;s files are a Quantum source.**

My agent&apos;s workspace and project directories are mounted directly into Quantum as sources. When the agent writes a draft, generates a cover image, or produces an audio file, I see it instantly in the browser — no SSH, no terminal. Markdown renders nicely, images and PDFs preview natively, and audio/video play inline (that&apos;s the ffmpeg in the `stable` image).

**2. Share links are the handoff mechanism.**

When the agent finishes something — say a blog post draft — I open Quantum, right-click the file, and create a share link with an expiry. That link goes into Slack, Discord, or an email, and anyone can view it without an account. For my own review loop, the share link is also what I paste into other AI chats when I want a second opinion on a file the agent produced.

**3. The reverse direction: I drop reference files in.**

If I want the agent to work from a specific source — a brief, an exported notes file, a competitor&apos;s screenshot — I drag it into the mounted source directory via Quantum. The agent reads it from the same path on disk. Both of us see the same files; Quantum is simply my human-friendly window into the agent&apos;s filesystem.

**4. Automation via the API (optional).**

Quantum exposes a documented REST API with long-lived API tokens (`/swagger` when API is enabled). You could wire an agent tool that lists or downloads files from Quantum programmatically. I keep it simple — direct file access through mounted volumes is faster — but the API is there if you want a decoupled setup.

**5. Security posture for the agent setup.**

- Only the workspace and projects directories are mounted as sources — not `/`, not the whole home directory
- The instance sits behind the reverse proxy with 2FA enabled, no public port
- Sources that shouldn&apos;t be shared can be marked `private: true` in the config (disables sharing for that source)
- The agent&apos;s credentials and secrets live outside the mounted directories

The result: my AI assistant does the heavy lifting, and Quantum gives me a zero-friction way to see, review, and distribute what it produces. If you&apos;re building your own agent, [start with the Mastra guide](/build-ai-agent-mastra/), then wire Quantum as its file front-end.

## Security Hardening for FileBrowser Quantum

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Never expose Quantum directly — always reverse proxy with TLS&lt;/li&gt;
&lt;li&gt;Enable 2FA on the admin account, and on any user with write access&lt;/li&gt;
&lt;li&gt;Change the default admin password immediately&lt;/li&gt;
&lt;li&gt;Run as non-root — the default `filebrowser` user (1000:1000) since v1.3; don&apos;t switch back to root&lt;/li&gt;
&lt;li&gt;Mount only the directories you serve. Don&apos;t mount `/` or `/var`&lt;/li&gt;
&lt;li&gt;Mark sensitive sources `private: true` so they can&apos;t be shared&lt;/li&gt;
&lt;li&gt;Use `denyByDefault` + explicit allow rules for per-directory access control&lt;/li&gt;
&lt;li&gt;Create separate users with restricted source scopes instead of sharing admin&lt;/li&gt;
&lt;li&gt;Keep the image pinned (`stable` is updated; pin a specific version if you need reproducibility)&lt;/li&gt;
&lt;li&gt;Block direct access to the container port with firewall rules if you publish one&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re behind a rootless runtime (Podman, Docker rootless) and Quantum fails to bind a port below 1024, add `cap_add: [NET_BIND_SERVICE]` to the compose service instead of running privileged.

For VPS-level protection beyond the app, see [securing your VPS with CrowdSec](/crowdsec-secure-server/). For secrets in compose files, see [Docker Compose secrets management](/docker-compose-secrets/).

## Switching from the Original FileBrowser

&lt;Accordion label=&quot;Do I need to migrate?&quot; group=&quot;faq&quot;&gt;
If the original works for you and you accept the risk of an archived project (no more security patches), you can keep running it. But the known unpatched issues — the command execution bugs and non-revocable JWTs — are exactly the kind of thing that ages badly. Quantum is a strict upgrade: same UI concept, actively maintained, and it removed the shell command feature entirely.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s involved?&quot; group=&quot;faq&quot;&gt;
Quantum uses a different config format (`config.yaml` vs `settings.json`) and a different database schema, so there&apos;s no in-place upgrade:

1. Deploy Quantum fresh following the steps above
2. Mount the same host directories as sources
3. Recreate your users (with 2FA this time)
4. Point your reverse proxy at the new container
5. Keep the old container running until you&apos;ve verified everything — then stop it
&lt;/Accordion&gt;

## Troubleshooting

&lt;Accordion label=&quot;Config changes don&apos;t apply after editing config.yaml&quot; group=&quot;faq&quot;&gt;
Quantum caches the config into `database.db` on first run. Stop the container, delete `data/database.db` and `data/tmp`, then start again to re-init from the YAML. Note this also resets users stored in the DB. Prefer the Settings → Sources admin UI for day-to-day changes.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Startup error: Settings.Server.Sources required&quot; group=&quot;faq&quot;&gt;
Your config.yaml is missing the `server.sources` key (the docs once showed a wrong top-level `sources`). The correct structure nests sources under `server`:

```yaml
server:
  sources:
    - path: /srv/workspace
      config:
        defaultEnabled: true
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;permission denied on sources or data directory&quot; group=&quot;faq&quot;&gt;
Since v1.3 the container runs as `filebrowser` (1000:1000). Make the mounted directories match:

```sh
chown -R 1000:1000 /opt/workspace /opt/projects
chown -R 1000:1000 ./data
```

If you run with a different UID (e.g. `user: &quot;1001:1001&quot;`), chown to that instead.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;502/503 through the reverse proxy&quot; group=&quot;faq&quot;&gt;
Check `docker logs filebrowser`. Common causes:

1. Port mismatch — the proxy must target the container&apos;s internal port (80 by default)
2. `baseURL` — if you serve Quantum under a path prefix, set the matching `baseURL` in the config
3. Container not healthy yet — wait for the healthcheck (`start_period` is 10s)
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can&apos;t bind port 80/443 inside the container&quot; group=&quot;faq&quot;&gt;
On rootless engines or stricter capability profiles, a non-root user can&apos;t bind privileged ports. Add `cap_add: [NET_BIND_SERVICE]` to the service, or just use a high port in `server.port` and proxy to it.
&lt;/Accordion&gt;

## Backing Up FileBrowser Quantum

&lt;Notice type=&quot;info&quot; title=&quot;What to back up&quot;&gt;
`database.db` holds all user accounts, hashed passwords, share settings, and config cache. `config.yaml` holds your source definitions. The actual served files live in your mounted directories and need their own backup strategy.
&lt;/Notice&gt;

The data volume holds everything app-specific:

```sh
# Simple daily backup of the whole data directory
cp -r filebrowser/data filebrowser/data.bak-$(date +%Y%m%d)

# Or archive it
tar czf filebrowser-data-$(date +%Y%m%d).tar.gz filebrowser/data
```

I back up the `data/` directory to S3-compatible storage with a cron job, and the source directories are covered by the server&apos;s regular backup routine. Test a restore at least once — a backup you&apos;ve never restored from is not a backup.

## Conclusion

FileBrowser Quantum is what FileBrowser should have become: the same self-hosted file manager concept, actively maintained, with multiple sources, modern auth, indexed search, and proper share links — and none of the shell-command attack surface. The Docker Compose setup is a few files and one command: `docker compose up -d`.

On my server it does double duty: a general file manager behind the reverse proxy, and the file window into my [Mastra AI assistant](/build-ai-agent-mastra/) — browse what the agent produces, share it with a link, drop reference files back in. If you&apos;re self-hosting AI agents, I can&apos;t recommend this combination enough.

To [monitor server CPU, memory, and disk usage](/sever-monitoring/) after deployment, check my server monitoring guide.

&lt;Button text=&quot;Explore FileBrowser Quantum&quot; link=&quot;https://github.com/gtsteffaniak/filebrowser&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>filebrowser</category><category>filebrowser-quantum</category><category>docker</category></item><item><title>Docmost Docker Compose Install: Self-Hosted Wiki for Teams</title><link>https://www.bitdoze.com/docmost-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/docmost-docker-install/</guid><description>Install Docmost with Docker Compose: self-hosted wiki setup with PostgreSQL, Redis, SSL, reverse proxy, WebSocket config, and troubleshooting.</description><pubDate>Sat, 01 Aug 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/07/docmost-ui.png&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Docmost](https://docmost.com/) is an open-source collaborative wiki and documentation platform built as a self-hosted alternative to Confluence and Notion. With over 21,200 GitHub stars and an active community, it&apos;s become one of the most popular self-hosted wiki options for teams that want to own their data.

Docmost gives you a real-time collaborative editor with rich text formatting, markdown support, page nesting, spaces for organizing content by team or project, and inline commenting. It runs on TypeScript with PostgreSQL and Redis, which makes the Docker Compose install straightforward. Three containers and you&apos;re running.

This guide walks through a complete Docmost Docker Compose install on a Linux VPS: the updated compose configuration, environment variables, SSL with a reverse proxy, and the gotchas that trip people up (WebSocket headers, APP_SECRET length, database passwords).

&gt; If you want to monitor server resources like CPU, memory, and disk space after deployment, check [how to monitor server and Docker resources](https://www.bitdoze.com/sever-monitoring/).

If you are looking for other self-hosted documentation and note-taking apps, also check:

- [How to Install Outline Wiki on Docker](https://www.bitdoze.com/outline-install/)
- [How to Install Memos with Docker Compose](https://www.bitdoze.com/memos-install/)

## What is Docmost? A self-hosted Confluence alternative

Docmost positions itself directly against Confluence and Notion. The difference: you host it yourself, you own the data, and the Community Edition is free under the AGPL 3.0 license.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/jFxf4dFKh9s&quot;
  label=&quot;Docmost Installation&quot;
/&gt;
The project is backed by a commercial company that sells Enterprise features (SSO, MFA, AI chat) on top of the open-source core. The Community Edition covers everything most teams need for a shared wiki: real-time editing, spaces, permissions, page history, attachments, and search.

### Key features (Community vs Enterprise)

**Community Edition (free, AGPL 3.0):**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Real-time collaborative rich-text editor (tables, LaTeX math, callouts)&lt;/li&gt;
&lt;li&gt;Spaces for organizing content by team, project, or department&lt;/li&gt;
&lt;li&gt;Permissions and access controls for users and groups&lt;/li&gt;
&lt;li&gt;Inline commenting on pages&lt;/li&gt;
&lt;li&gt;Page history with version tracking and revert&lt;/li&gt;
&lt;li&gt;Nested pages with drag-and-drop reordering&lt;/li&gt;
&lt;li&gt;Full-text search powered by PostgreSQL&lt;/li&gt;
&lt;li&gt;Attachments with S3, Azure Blob, and local storage drivers&lt;/li&gt;
&lt;li&gt;Backlinks and synced blocks (transclusion)&lt;/li&gt;
&lt;li&gt;Page labels, tags, and favorites&lt;/li&gt;
&lt;li&gt;Watch spaces for update notifications&lt;/li&gt;
&lt;li&gt;PDF embed and audio player&lt;/li&gt;
&lt;li&gt;10+ language translations&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Enterprise Edition (paid):**

SSO (SAML, OIDC, LDAP), MFA (TOTP), AI Chat with MCP support, page verification workflows, SCIM provisioning, PDF import, DOCX export, Typesense search, audit logs, and security controls.

&lt;Notice type=&quot;info&quot; title=&quot;Enterprise pricing note&quot;&gt;
The Business Edition costs $3.50 per seat per month with a minimum of 10 seats ($420/year). SSO and MFA are Business-tier only, which has drawn criticism in the self-hosting community. MFA especially, since it&apos;s a basic security feature. Free trial licenses are available at &lt;a href=&quot;https://customers.docmost.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;customers.docmost.com&lt;/a&gt;.
&lt;/Notice&gt;

## Docmost Docker Compose installation guide

This is the full step-by-step Docmost installation guide with Docker Compose. The compose file below is based on the official template from the Docmost repository (v0.95.0, July 2025).

### Prerequisites for Docker deployment

Before you start, make sure you have:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS with at least 2 vCPU and 4 GB RAM (8 GB recommended for teams with concurrent editing). You can use a VPS from &lt;a href=&quot;https://go.bitdoze.com/hetzner&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Hetzner&lt;/a&gt;, &lt;a href=&quot;https://go.bitdoze.com/hostinger-vps&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Hostinger VPS&lt;/a&gt;, or a &lt;a href=&quot;https://go.bitdoze.com/asus-dc510&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;mini PC as a home server&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose v2 plugin installed (&lt;code&gt;docker compose version&lt;/code&gt; should work)&lt;/li&gt;
&lt;li&gt;A domain name pointed at the server (A record or CNAME)&lt;/li&gt;
&lt;li&gt;A reverse proxy ready: Cloudflare Tunnel, Nginx, or Traefik (see the &lt;a href=&quot;https://www.bitdoze.com/best-self-hosted-panels/&quot;&gt;best self-hosted server panels for managing Docker&lt;/a&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;RAM matters here&quot;&gt;
PostgreSQL, Redis, and the Node.js app all compete for memory. On a 2 GB VPS you&apos;ll likely hit OOM kills under any real load. Start with 4 GB minimum for production use.
&lt;/Notice&gt;

&gt; If you want to manage your Docker containers with a web UI, check [Dockge: Docker Compose manager for self-hosting](https://www.bitdoze.com/dockge-install/).

### Docker Compose configuration

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker Compose (inline vars)&quot;&gt;
This is the approach the official docs recommend: all environment variables inline in the compose file. One file, no .env to sync.

```yaml
services:
  docmost:
    image: docmost/docmost:latest
    depends_on:
      - db
      - redis
    environment:
      APP_URL: &apos;https://docs.example.com&apos;
      APP_SECRET: &apos;REPLACE_WITH_LONG_SECRET&apos;
      DATABASE_URL: &apos;postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost?schema=public&apos;
      REDIS_URL: &apos;redis://redis:6379&apos;
    ports:
      - &quot;3000:3000&quot;
    restart: unless-stopped
    volumes:
      - docmost:/app/data/storage

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: docmost
      POSTGRES_USER: docmost
      POSTGRES_PASSWORD: STRONG_DB_PASSWORD
    restart: unless-stopped
    volumes:
      - db_data:/var/lib/postgresql

  redis:
    image: redis:8
    command: [&quot;redis-server&quot;, &quot;--appendonly&quot;, &quot;yes&quot;, &quot;--maxmemory-policy&quot;, &quot;noeviction&quot;]
    restart: unless-stopped
    volumes:
      - redis_data:/data

volumes:
  docmost:
  db_data:
  redis_data:
```

&lt;/Tab&gt;
&lt;Tab name=&quot;With .env file&quot;&gt;

If you prefer separating secrets from the compose file, the `.env` approach still works. Replace `STRONG_DB_PASSWORD` with your actual password in both the compose file and `.env` file.

```yaml
services:
  docmost:
    image: docmost/docmost:latest
    depends_on:
      - db
      - redis
    environment:
      APP_URL: &quot;${APP_URL}&quot;
      APP_SECRET: &quot;${APP_SECRET}&quot;
      DATABASE_URL: &quot;postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}?schema=public&quot;
      REDIS_URL: &quot;redis://redis:6379&quot;
    ports:
      - &quot;3000:3000&quot;
    restart: unless-stopped
    volumes:
      - docmost:/app/data/storage

  db:
    image: postgres:18
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    restart: unless-stopped
    volumes:
      - db_data:/var/lib/postgresql

  redis:
    image: redis:8
    command: [&quot;redis-server&quot;, &quot;--appendonly&quot;, &quot;yes&quot;, &quot;--maxmemory-policy&quot;, &quot;noeviction&quot;]
    restart: unless-stopped
    volumes:
      - redis_data:/data

volumes:
  docmost:
  db_data:
  redis_data:
```

And the matching `.env` file:

```sh
APP_URL=https://docs.example.com
APP_SECRET=REPLACE_WITH_GENERATED_SECRET
POSTGRES_DB=docmost
POSTGRES_USER=docmost
POSTGRES_PASSWORD=STRONG_DB_PASSWORD
```

&lt;Notice type=&quot;warning&quot; title=&quot;Single source of truth problem&quot;&gt;
The password in &lt;code&gt;DATABASE_URL&lt;/code&gt; and &lt;code&gt;POSTGRES_PASSWORD&lt;/code&gt; must match exactly. If you change one without the other, you&apos;ll get a &lt;code&gt;password authentication failed&lt;/code&gt; error that&apos;s hard to debug. This is the most common issue with the .env approach.
&lt;/Notice&gt;

&lt;/Tab&gt;
&lt;/Tabs&gt;

Three services:

1. **`docmost`**: the application. Maps port 3000 on the host. Mounts a named volume for file attachments at `/app/data/storage`.
2. **`db`**: PostgreSQL 18. Stores all wiki content, users, and metadata. Uses a named volume for data persistence.
3. **`redis`**: Redis 8. Handles caching and session state. Runs with `appendonly yes` for persistence and `noeviction` to prevent data loss under memory pressure.

Key changes from older Docmost Docker Compose guides: `postgres:18` (non-Alpine, larger image but more compatible), `redis:8` with explicit memory policy, named volumes instead of bind mounts, and no `version: &quot;3&quot;` line (deprecated in Docker Compose v2).

### Environment variables explained

| Variable | Required | Default | Notes |
|---|---|---|---|
| `APP_URL` | Yes | - | Full URL with `https://` protocol. See warning below. |
| `APP_SECRET` | Yes | - | Min 32 characters. Generate with `openssl rand -hex 32` |
| `DATABASE_URL` | Yes | - | PostgreSQL connection string. Service name must match (`db`, not `docmost-db`) |
| `REDIS_URL` | Yes | - | Redis connection string |
| `PORT` | No | `3000` | App listen port |
| `JWT_TOKEN_EXPIRES_IN` | No | `30d` | JWT token expiry |
| `FILE_UPLOAD_SIZE_LIMIT` | No | `50mb` | Max file upload size |
| `FILE_IMPORT_SIZE_LIMIT` | No | `100mb` | Max import size |
| `DISABLE_TELEMETRY` | No | - | Set to `true` to opt out of anonymous telemetry |

Generate the APP_SECRET:

```bash
openssl rand -hex 32
```

This produces a 64-character hex string (well above the 32-character minimum).

&lt;Notice type=&quot;error&quot; title=&quot;APP_SECRET must be at least 32 characters&quot;&gt;
Since v0.80.0 (April 2025), Docmost enforces a 32-character minimum for &lt;code&gt;APP_SECRET&lt;/code&gt;. If your secret is too short, the app fails to start with: &lt;code&gt;&quot;minLength&quot;:&quot;APP_SECRET must be longer than or equal to 32 characters&quot;&lt;/code&gt;. The old &lt;code&gt;openssl rand -base64&lt;/code&gt; command can produce shorter strings. Always use &lt;code&gt;openssl rand -hex 32&lt;/code&gt; instead.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;APP_URL must include https://&quot;&gt;
Docmost sets the auth cookie&apos;s &lt;code&gt;secure&lt;/code&gt; flag based on the &lt;code&gt;APP_URL&lt;/code&gt; protocol. If you use &lt;code&gt;docs.example.com&lt;/code&gt; without &lt;code&gt;https://&lt;/code&gt;, authentication cookies won&apos;t work behind a TLS-terminating reverse proxy. Always use the full URL: &lt;code&gt;https://docs.example.com&lt;/code&gt;.
&lt;/Notice&gt;

For production secrets, consider [securely managing Docker Compose secrets](https://www.bitdoze.com/docker-compose-secrets/) rather than hardcoding them in the compose file.

&lt;Accordion label=&quot;SMTP configuration&quot; group=&quot;env&quot;&gt;
To enable email notifications (invites, password resets), add these environment variables to the `docmost` service:

```yaml
MAIL_DRIVER: smtp
SMTP_HOST: smtp.example.com
SMTP_PORT: &quot;587&quot;
SMTP_USERNAME: your_username
SMTP_PASSWORD: your_password
SMTP_SECURE: &quot;false&quot;
MAIL_FROM_ADDRESS: hello@example.com
MAIL_FROM_NAME: Docmost
```

Set `SMTP_SECURE` to `false` for STARTTLS on port 587, or `true` for implicit TLS on port 465. See the full reference at [Docmost environment variables](https://docmost.com/docs/self-hosting/environment-variables).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;S3 / Azure storage configuration&quot; group=&quot;env&quot;&gt;
For production deployments, offloading file attachments to S3 or Azure Blob is better than storing them on the VPS disk.

**S3-compatible storage:**

```yaml
STORAGE_DRIVER: s3
AWS_S3_ACCESS_KEY_ID: &quot;your-key&quot;
AWS_S3_SECRET_ACCESS_KEY: &quot;your-secret&quot;
AWS_S3_REGION: &quot;auto&quot;
AWS_S3_BUCKET: &quot;docmost-attachments&quot;
AWS_S3_ENDPOINT: &quot;https://your-s3-endpoint.com&quot;  # for non-AWS providers
AWS_S3_FORCE_PATH_STYLE: &quot;true&quot;  # required for most S3-compatible providers
```

**Azure Blob Storage:**

```yaml
STORAGE_DRIVER: azure
AZURE_STORAGE_ACCOUNT_NAME: &quot;your-account&quot;
AZURE_STORAGE_ACCOUNT_KEY: &quot;your-key&quot;
AZURE_STORAGE_CONTAINER: &quot;docmost-attachments&quot;
```

Set `STORAGE_DRIVER` to `local` (default) if you don&apos;t need external storage.
&lt;/Accordion&gt;

### Deploying the stack

Once you have your compose file ready (and `.env` file if using that approach), deploy with:

```bash
docker compose up -d
```

This pulls the images, creates the containers, and starts them in the background. First startup takes 60-90 seconds because Docmost runs database migrations before accepting connections.

Note: the command is `docker compose` (v2 plugin, no hyphen). The old `docker-compose` (v1) still works as an alias on most systems, but the official docs use the v2 syntax.

### Verifying your installation

After `docker compose up -d`, run these checks:

```bash
# All 3 services should show &quot;Up&quot;
docker compose ps
```

Expected output:

```
NAME                IMAGE                  STATUS
docmost-app-1       docmost/docmost:latest Up
docmost-db-1        postgres:18            Up
docmost-redis-1     redis:8                Up
```

Check the application logs for migration progress:

```bash
docker compose logs -f docmost
```

Once you see the app listening on port 3000, test the health endpoint:

```bash
curl http://localhost:3000/api/health
```

Should return `OK`. Then open your `APP_URL` in a browser. You should see the setup page to create your admin account.

&lt;Notice type=&quot;success&quot; title=&quot;Setup page means success&quot;&gt;
If you see the account creation page in your browser, Docmost is running correctly. Create your admin account and you&apos;re ready to go.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Monitoring after deployment&quot;&gt;
Once Docmost is running, you should &lt;a href=&quot;https://www.bitdoze.com/beszel-uptime-kuma/&quot;&gt;set up monitoring with Beszel and Uptime Kuma&lt;/a&gt; to track resource usage and uptime.
&lt;/Notice&gt;

## Reverse proxy and SSL for Docmost

Docmost needs a reverse proxy for TLS termination and domain routing. There&apos;s one critical requirement that catches most people:

&lt;Notice type=&quot;error&quot; title=&quot;WebSocket support is required&quot;&gt;
Docmost&apos;s real-time collaborative editor uses WebSocket connections. If your reverse proxy doesn&apos;t forward WebSocket &lt;code&gt;Upgrade&lt;/code&gt; and &lt;code&gt;Connection&lt;/code&gt; headers, the page editor will load but be &lt;strong&gt;read-only&lt;/strong&gt;. This is the #1 reported issue. Every reverse proxy config below includes the required WebSocket headers.
&lt;/Notice&gt;

### Cloudflare Tunnel setup with WebSocket support

[Cloudflare Tunnels](https://www.cloudflare.com/products/tunnel/) support WebSocket connections by default. No extra configuration needed. This is the simplest SSL approach.

Go to **Access &gt; Tunnels** in the Cloudflare dashboard, choose your tunnel, and add a hostname:

- **Hostname**: `docs.yourdomain.com`
- **Service**: `http://127.0.0.1:3000`

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel configuration for Docmost Docker Compose deployment&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Cloudflare Tunnel origin URL&quot;&gt;
If &lt;code&gt;cloudflared&lt;/code&gt; runs on the host (not inside Docker), use &lt;code&gt;127.0.0.1:3000&lt;/code&gt; as the origin — not &lt;code&gt;docmost:3000&lt;/code&gt;. The &lt;code&gt;docmost&lt;/code&gt; hostname only resolves inside the Docker network. If &lt;code&gt;cloudflared&lt;/code&gt; runs in a container on the same Docker network, then &lt;code&gt;docmost:3000&lt;/code&gt; works.
&lt;/Notice&gt;

&gt; You can also use [CloudPanel reverse proxy with Docker](https://www.bitdoze.com/cloudpanel-setup-dockge/) for a different approach.

### Nginx reverse proxy configuration

For Nginx with Let&apos;s Encrypt SSL (via certbot), create this server block:

```nginx
server {
    listen 80;
    server_name docs.example.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Then get the SSL certificate with certbot:

```bash
certbot --nginx -d docs.example.com
```

The `proxy_http_version 1.1`, `Upgrade`, and `Connection &quot;upgrade&quot;` lines are the mandatory WebSocket headers. Without them, the editor loads read-only.

### Traefik integration with Docker labels

If you use Traefik (v3.6+), add these labels to the `docmost` service in your compose file. Traefik natively supports WebSockets — no extra configuration needed.

```yaml
  docmost:
    image: docmost/docmost:latest
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.docmost.rule=Host(`docs.example.com`)&quot;
      - &quot;traefik.http.routers.docmost.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.docmost.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.docmost.loadbalancer.server.port=3000&quot;
    # ... rest of service config
```

&gt; For a full Traefik setup with Docker, see [Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

## Upgrading your Docmost Docker installation

If you&apos;re running an older version of Docmost, upgrading is usually straightforward:

```bash
cd ~/docmost
docker compose pull
docker compose up -d
```

This pulls the latest images and restarts the containers. Docmost handles database migrations automatically on startup.

&lt;Notice type=&quot;error&quot; title=&quot;APP_SECRET minimum length (v0.8.0+)&quot;&gt;
If you&apos;re upgrading from a version before v0.80.0 (April 2025), your &lt;code&gt;APP_SECRET&lt;/code&gt; must be at least 32 characters. If it&apos;s shorter, the app will fail to start with a validation error. Regenerate with &lt;code&gt;openssl rand -hex 32&lt;/code&gt;, update your compose file or .env, and restart.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Migrating from the old Docker Compose format&quot; group=&quot;upgrade&quot;&gt;
If your existing setup uses the old compose format (service names `docmost-db`/`docmost-redis`, bind mounts like `./docmost-db`, `postgres:16-alpine`), you&apos;ll need to update:

1. **Service names**: The `DATABASE_URL` references `db` (was `docmost-db`). Update the hostname in your connection string.

2. **PostgreSQL data path**: Old format mounted `./docmost-db:/var/lib/postgresql/data:rw`. The new `postgres:18` image uses `/var/lib/postgresql` (no `/data` suffix). If migrating volumes, you may need to move the data directory.

3. **Named vs bind volumes**: The new format uses named volumes (`db_data`, `docmost`, `redis_data`). If you want to keep your existing bind mount data, either convert to named volumes or keep bind mounts — both work.

4. **Redis memory policy**: The new config adds `--maxmemory-policy noeviction`. Some hosts also need `vm.overcommit_memory=1` set on the host: `sysctl vm.overcommit_memory=1`.

Before making changes, back up your database (see the backup section below).
&lt;/Accordion&gt;

## Backup and restore for your self-hosted wiki

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t skip backups&quot;&gt;
A documentation wiki is only valuable if you can recover it. Set up automated backups from day one.
&lt;/Notice&gt;

**Backup the database:**

```bash
docker compose exec db pg_dump -U docmost docmost &gt; docmost-backup-$(date +%Y%m%d).sql
```

Automate with a cron job (daily at 3 AM):

```bash
0 3 * * * cd /path/to/docmost &amp;&amp; docker compose exec db pg_dump -U docmost docmost &gt; /backups/docmost-$(date +\%Y\%m\%d).sql
```

**Backup file attachments:**

If using local storage (the default), the attachments live in the `docmost` named volume. Back it up with:

```bash
docker run --rm -v docmost_docmost:/data -v /backups:/backup alpine tar czf /backup/docmost-files-$(date +%Y%m%d).tar.gz -C /data .
```

If using S3 or Azure storage, your attachments are already off-server — just make sure your bucket has versioning enabled.

**Restore the database:**

```bash
docker compose exec -T db psql -U docmost docmost &lt; docmost-backup-20250708.sql
```

**Off-server storage**: Copy your backups to a separate server or S3-compatible storage. A backup on the same disk is not a backup.

## Troubleshooting common Docmost Docker issues

&lt;Accordion label=&quot;Editor loads but is read-only — fix WebSocket headers&quot; group=&quot;troubleshooting&quot; expanded&gt;
**Symptom**: You can view pages but can&apos;t edit them. The editor appears but typing does nothing.

**Cause**: Your reverse proxy isn&apos;t forwarding WebSocket `Upgrade` and `Connection` headers.

**Fix**: Add these headers to your reverse proxy config (see the Nginx and Traefik sections above):

```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection &quot;upgrade&quot;;
```

Cloudflare Tunnel users: WebSockets are supported by default. If the editor is still read-only, check that your origin URL is correct (`127.0.0.1:3000` for host-based cloudflared).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;APP_SECRET length errors after upgrade&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: App fails to start. Logs show `&quot;minLength&quot;:&quot;APP_SECRET must be longer than or equal to 32 characters&quot;`.

**Cause**: Your APP_SECRET was generated with `openssl rand -base64` (old recommendation) and is shorter than 32 characters.

**Fix**:

```bash
openssl rand -hex 32
# Copy the output, replace APP_SECRET in your compose file or .env
docker compose up -d
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Database connection and password issues&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: `DATABASE_URL must be a valid postgres connection string` or `password authentication failed`.

**Cause**: Special characters in the password not URL-encoded, or the password in `DATABASE_URL` doesn&apos;t match `POSTGRES_PASSWORD`.

**Fix**:

1. Use an alphanumeric password (no special characters) to avoid URL-encoding issues.
2. Make sure the same password appears in both `DATABASE_URL` and `POSTGRES_PASSWORD`.

```bash
# Check logs for the exact error
docker compose logs db
docker compose logs docmost
```

If the password was changed after the volume was created, the database still has the old password. Nuclear option (data loss — only if you have a backup):

```bash
docker compose down -v  # WARNING: deletes all data volumes
# Fix the password in your compose file
docker compose up -d
```

&lt;Notice type=&quot;error&quot; title=&quot;Never use down -v without a backup&quot;&gt;
&lt;code&gt;docker compose down -v&lt;/code&gt; deletes all named volumes. Your entire database and file attachments will be gone. Only use this if you have a recent backup or this is a fresh install.
&lt;/Notice&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;502 Bad Gateway behind reverse proxy&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: Browser shows 502 Bad Gateway.

**Cause**: Docmost container isn&apos;t running or hasn&apos;t finished starting.

**Fix**:

```bash
docker compose ps       # Check if docmost is &quot;Up&quot;
docker compose logs docmost  # Check for startup errors
```

First startup takes 60-90 seconds for database migrations. If the container is restarting in a loop, check the logs for specific errors.

If you&apos;re low on disk space and containers won&apos;t start, you may need to [reclaim disk space by cleaning Docker overlay2](https://www.bitdoze.com/clean-docker-overlay2-dir/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloudflare Tunnel can&apos;t reach the service&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom**: Tunnel is configured but the domain returns an error.

**Cause**: Wrong origin URL. The URL depends on where `cloudflared` runs.

**Fix**:

- **cloudflared on the host** (installed via apt/systemd): Use `http://127.0.0.1:3000` as the service URL. The container port 3000 is mapped to the host.
- **cloudflared in Docker** (on the same Docker network): Use `http://docmost:3000` as the service URL. Container names resolve within Docker networks.

Check `cloudflared` logs for connection errors:

```bash
journalctl -u cloudflared -f
```

&lt;/Accordion&gt;

## Conclusion

Docmost is a solid self-hosted wiki and documentation platform. With over 21,200 GitHub stars, active development (current version v0.95.0), and a real feature set for team collaboration, it&apos;s one of the strongest Confluence alternatives you can run yourself.

The Docker Compose install is three containers — app, PostgreSQL, and Redis. The main gotchas are:

- **APP_SECRET** must be 32+ characters (generate with `openssl rand -hex 32`)
- **APP_URL** must include `https://` for auth cookies to work
- **WebSocket headers** must be configured in your reverse proxy or the editor is read-only
- **Database passwords** must match between `DATABASE_URL` and `POSTGRES_PASSWORD`

Set up backups from day one. A `pg_dump` cron job costs nothing and saves you when things go wrong.

For more self-hosted Docker containers for your home server, check out the [full list of Docker containers for home server](https://www.bitdoze.com/docker-containers-home-server/).

&lt;Button text=&quot;Explore More Docker Containers&quot; link=&quot;/docker-containers-home-server/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>wiki</category></item><item><title>100+ Essential Linux Commands You MUST Know (2026 Guide)</title><link>https://www.bitdoze.com/linux-commands/</link><guid isPermaLink="true">https://www.bitdoze.com/linux-commands/</guid><description>Master the Linux command line with this cheat sheet of 100+ essential Linux commands. Covers file management, networking, system admin, and modern CLI tools, all with practical examples for 2026.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Whether you&apos;re a beginner or a seasoned sysadmin, knowing the right **Linux commands** can transform your workflow. This essential Linux commands cheat sheet covers over 100 commands, from basic file navigation to advanced system administration and modern CLI tools. It&apos;s the reference guide I wish I had when I started managing Linux servers, and the one I still keep bookmarked today.

Linux powers the majority of the world&apos;s servers, supercomputers, and cloud infrastructure. According to the Stack Overflow Developer Survey 2025, Bash/Shell is the 5th most popular language with 49% adoption. If you&apos;re running a [Hetzner VPS](https://go.bitdoze.com/hetzner), deploying Docker containers, or managing remote machines, these commands are your daily toolkit.

This guide is organized by task, not alphabetically. Each section covers what the commands do, how to use them, and what to watch out for. I&apos;ve included verify steps after critical operations and called out common failure modes, the kind of stuff that saves you from a 2 AM incident.

&lt;ListCheck&gt;
&lt;h5&gt;What this guide covers&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;File management and navigation, the commands you use every minute&lt;/li&gt;
&lt;li&gt;Text search and processing, grep, find, awk, sed and friends&lt;/li&gt;
&lt;li&gt;File permissions and ownership, chmod, chown, and why 777 is never the answer&lt;/li&gt;
&lt;li&gt;System monitoring and information, what&apos;s eating your CPU and disk&lt;/li&gt;
&lt;li&gt;Process and service management, kill, systemctl, journalctl&lt;/li&gt;
&lt;li&gt;Networking and remote access, SSH, firewalls, diagnostics&lt;/li&gt;
&lt;li&gt;Package management, apt, dnf, pacman across distros&lt;/li&gt;
&lt;li&gt;Shell productivity, aliases, history, pipes, and shortcuts&lt;/li&gt;
&lt;li&gt;Scripting and automation, cron, systemd timers, bash basics&lt;/li&gt;
&lt;li&gt;Developer and DevOps tools, git, docker, kubectl essentials&lt;/li&gt;
&lt;li&gt;Modern CLI alternatives, faster Rust/Go replacements for classic tools&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

---

## Basic navigation and file management

These are the Linux commands you&apos;ll use in the first five minutes of any session. If you&apos;re new, start here.

### Navigating the filesystem (`pwd`, `ls`, `cd`)

#### `pwd` - print working directory

Shows where you are in the filesystem.

```bash
$ pwd
/home/username
```

#### `ls` - list directory contents

The most-run Linux command (seriously, it tops shell history analyses). Key flags:

- `-l`: Long listing with permissions, owner, size, date
- `-a`: Include hidden files (dotfiles)
- `-h`: Human-readable sizes (4.0K instead of 4096)
- `-R`: Recurse into subdirectories

```bash
$ ls
Desktop  Documents  Downloads  Music  Pictures  Videos

$ ls -lah
total 36K
drwxr-xr-x 5 username username 4.0K Jan 15 10:22 .
drwxr-xr-x 3 root    root    4.0K Jan 10 08:00 ..
-rw------- 1 username username  128 Jan 15 10:22 .bash_history
drwxr-xr-x 2 username username 4.0K Jan 10 08:01 Desktop
drwxr-xr-x 2 username username 4.0K Jan 10 08:01 Documents
```

#### `cd` - change directory

```bash
$ cd /var/log       # absolute path
$ cd ..             # up one level
$ cd ~/Documents    # ~ is your home directory
$ cd -              # back to previous directory
```

### File operations (`touch`, `cp`, `mv`, `rm`, `ln`)

#### `touch` - create empty file or update timestamp

```bash
$ touch newfile.txt
```

#### `cp` - copy files and directories

```bash
$ cp file1.txt file2.txt           # copy file
$ cp -r dir1/ dir2/                # copy directory recursively
$ cp -iv file1.txt file2.txt       # interactive + verbose
```

Verify with `ls -l` on the destination.

#### `mv` - move or rename

```bash
$ mv oldname.txt newname.txt       # rename
$ mv file.txt /path/to/dest/       # move
```

#### `rm` - remove files and directories

```bash
$ rm file.txt                      # remove file
$ rm -r directory/                 # remove directory recursively
$ rm -i *.log                      # interactive, asks before each delete
```

&lt;Notice type=&quot;error&quot; title=&quot;rm has no undo&quot;&gt;
There is no trash can. `rm` deletes permanently. The classic footgun is `rm -rf $VAR/` when the variable `$VAR` is empty, that becomes `rm -rf /`. Always double-check variables. Consider `trash-cli` (`trash-put file.txt`) as a safer alternative for interactive use.
&lt;/Notice&gt;

#### `ln` - create links

Links let you reference a file from another location without copying it.

```bash
$ ln -s /path/to/target link_name    # symbolic (soft) link
$ ln /path/to/target link_name       # hard link
```

Symbolic links are far more common. They&apos;re used everywhere in deployment setups, linking config files, pointing `/usr/local/bin` to your compiled binaries, etc.

### Archives and compression (`tar`, `gzip`, `zip`)

#### `tar` - the Swiss army knife of archives

The flag mnemonic: **c**reate, e**x**tract, **z** (gzip), **j** (bzip2), **f** (file).

```bash
# Create
$ tar -czf archive.tar.gz /path/to/dir/       # gzipped (most common)
$ tar -cjf archive.tar.bz2 /path/to/dir/      # bzip2 (smaller, slower)

# Extract
$ tar -xzf archive.tar.gz                     # extract gzipped
$ tar -xf archive.tar.gz                      # modern tar auto-detects compression

# List contents (always check before extracting)
$ tar -tzf archive.tar.gz | head
```

&lt;Notice type=&quot;info&quot; title=&quot;Modern tar auto-detects compression&quot;&gt;
On any recent Linux distro, `tar -xf` works without `-z` or `-j`, it figures out the compression from the file header. So `tar -xf archive.tar.gz` is fine.
&lt;/Notice&gt;

#### `gzip` / `gunzip` - standalone compression

```bash
$ gzip file.txt          # compresses file.txt → file.txt.gz (removes original)
$ gunzip file.txt.gz     # decompresses
```

#### `zip` / `unzip` - cross-platform archives

```bash
$ zip -r archive.zip directory/
$ unzip archive.zip
$ unzip archive.zip -d /destination/
```

---

## Viewing, searching, and editing files

### Viewing file contents (`cat`, `more`, `less`, `head`, `tail`)

```bash
$ cat file.txt                 # dump entire file to stdout
$ less file.txt                # paginated viewer (arrows, /search, q to quit)
$ head -20 file.txt            # first 20 lines
$ tail -20 file.txt            # last 20 lines
$ tail -f /var/log/syslog      # follow live, essential for log monitoring
```

`less` is almost always better than `cat` for reading. `cat` is for combining files or piping into other commands.

### Searching text (`grep`)

One of the top 5 most-used Linux commands. If you&apos;re not using `grep`, you&apos;re doing too much by hand.

```bash
$ grep &quot;error&quot; /var/log/syslog                # basic search
$ grep -r &quot;password&quot; /etc/                    # recursive search
$ grep -i &quot;warning&quot; file.txt                  # case-insensitive
$ grep -v &quot;DEBUG&quot; file.txt                    # invert, exclude matches
$ grep -n &quot;TODO&quot; *.py                         # show line numbers
$ grep -c &quot;error&quot; /var/log/syslog             # count matches
$ grep -E &quot;err|warn|crit&quot; file.txt            # extended regex (OR pattern)
$ grep -B2 -A3 &quot;panic&quot; logfile.txt            # 2 lines before, 3 after
$ ps aux | grep nginx                         # filter pipeline output
```

Verify with `grep -c` before doing any destructive operation based on matches.

### Finding files (`find`)

```bash
$ find . -name &quot;*.txt&quot;                        # by name
$ find . -type f -name &quot;*.log&quot;                # files only
$ find . -type d -name &quot;node_modules&quot;         # directories only
$ find . -mtime -7                            # modified in last 7 days
$ find . -size +100M                          # files larger than 100MB
$ find . -name &quot;*.log&quot; -delete                # delete matches (GNU find)
$ find . -name &quot;*.txt&quot; | xargs grep &quot;error&quot;   # find + grep combo
```

&lt;Notice type=&quot;warning&quot; title=&quot;Test find before using -exec or -delete&quot;&gt;
`find . -name &quot;*.log&quot; -exec rm {} \;` is powerful and dangerous. Always test with `ls` first: `find . -name &quot;*.log&quot; -exec ls {} \;`. Also, start with `.` (current directory), not `/`, searching the entire filesystem is slow and noisy.
&lt;/Notice&gt;

### Text processing (`sort`, `uniq`, `wc`, `cut`, `awk`, `sed`, `diff`)

These are pipeline tools, they shine when chained with `|`.

#### `sort` and `uniq`

```bash
$ sort file.txt                   # alphabetical sort
$ sort -n numbers.txt             # numeric sort
$ sort -r file.txt                # reverse
$ sort -k2 -t: data.txt           # sort by 2nd field, colon-delimited
$ sort file.txt | uniq            # remove adjacent duplicates (always sort first)
$ sort file.txt | uniq -c         # count occurrences
```

#### `wc` - word count

```bash
$ wc -l file.txt                  # line count
$ wc -w file.txt                  # word count
$ wc -c file.txt                  # byte count
```

#### `cut` - extract columns

```bash
$ cut -d&apos;:&apos; -f1 /etc/passwd       # first field, colon-delimited
$ cut -c1-10 file.txt             # first 10 characters of each line
```

#### `awk` - pattern scanning and processing

```bash
$ awk &apos;{print $1}&apos; file.txt                   # first column
$ awk -F: &apos;{print $1, $3}&apos; /etc/passwd        # fields 1 and 3, colon-delimited
$ awk &apos;$3 &gt; 100&apos; data.txt                     # lines where 3rd field &gt; 100
```

#### `sed` - stream editor

```bash
$ sed &apos;s/old/new/g&apos; file.txt                  # replace all occurrences
$ sed -i &apos;s/old/new/g&apos; file.txt               # in-place edit (careful!)
$ sed -n &apos;10,20p&apos; file.txt                     # print lines 10-20
$ sed &apos;/^#/d&apos; config.txt                       # delete comment lines
```

#### `diff` - compare files

```bash
$ diff file1.txt file2.txt
$ diff -u file1.txt file2.txt                  # unified format (easier to read)
```

&lt;Tabs&gt;
&lt;Tab name=&quot;Extract first column&quot;&gt;
Using different tools to get the first colon-delimited field from `/etc/passwd`:

```bash
# Using cut
$ cut -d&apos;:&apos; -f1 /etc/passwd

# Using awk
$ awk -F: &apos;{print $1}&apos; /etc/passwd

# Using sed
$ sed &apos;s/:.*//&apos; /etc/passwd
```

`cut` is simplest for fixed-delimiter data. `awk` is better when you need logic. `sed` is overkill here but works.
&lt;/Tab&gt;
&lt;Tab name=&quot;Count lines with &apos;error&apos;&quot;&gt;
```bash
# Using grep
$ grep -c &quot;error&quot; logfile.txt

# Using awk
$ awk &apos;/error/ {count++} END {print count}&apos; logfile.txt

# Using wc + grep
$ grep &quot;error&quot; logfile.txt | wc -l
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Editors (`nano`, `vim`)

```bash
$ nano file.txt       # simple, beginner-friendly (Ctrl+X to exit)
$ vim file.txt        # powerful, steep learning curve (Esc then :wq to save+quit)
```

`vim` is worth learning because it&apos;s installed on virtually every Linux server. When you SSH into a fresh [Hetzner VPS](https://go.bitdoze.com/hetzner), `vim` is there. `nano` may not be.

---

## File permissions and ownership

### Understanding permissions

Every file has an owner, a group, and permissions for owner/group/others. Use `ls -l` to see them:

```
-rwxr-xr-- 1 alice developers 4096 Jan 15 10:00 deploy.sh
│└┬┘└┬┘└┬┘
│ │   │   └── others: read only (r--)
│ │   └────── group: read + execute (r-x)
│ └────────── owner: read + write + execute (rwx)
└──────────── regular file (-)
```

Numeric (octal) notation:

| Digit | Permissions | Meaning |
|-------|------------|---------|
| 7 | rwx | read + write + execute |
| 6 | rw- | read + write |
| 5 | r-x | read + execute |
| 4 | r-- | read only |
| 0 | --- | no permissions |

Common permission modes:

- `755`, owner full, everyone else read+execute (scripts, directories)
- `644`, owner read+write, everyone else read (config files, data)
- `600`, owner only (private keys, sensitive files)

### Changing permissions (`chmod`)

```bash
# Symbolic notation
$ chmod u+x script.sh          # add execute for owner
$ chmod g-w file.txt            # remove write for group
$ chmod a+r file.txt            # add read for everyone

# Numeric (octal) notation
$ chmod 755 script.sh           # rwxr-xr-x
$ chmod 644 config.txt          # rw-r--r--
$ chmod 600 ~/.ssh/id_ed25519   # rw------- (private key)
```

Verify after every change: `ls -l file`

&lt;Notice type=&quot;error&quot; title=&quot;Never chmod 777 in production&quot;&gt;
`chmod 777` gives everyone full access. On SSH keys, it&apos;s even worse, SSH will refuse to use a private key with open permissions. You&apos;ll get &quot;Permissions 0664 for &apos;id_ed25519&apos; are too open.&quot; Fix: `chmod 600 ~/.ssh/id_ed25519`.
&lt;/Notice&gt;

### Changing ownership (`chown`, `chgrp`)

```bash
$ chown user:group file.txt
$ chown -R www-data:www-data /var/www/    # recursive (common for web servers)
$ chgrp developers project/               # change group only
```

---

## System information and monitoring

### System info (`uname`, `hostname`, `uptime`, `free`, `dmesg`)

```bash
$ uname -a                    # all system info (kernel, arch, etc.)
$ uname -r                    # kernel release only
$ hostname                    # machine hostname
$ uptime                      # how long running + load averages
$ free -h                     # memory usage in human-readable format
$ dmesg | tail                # kernel ring buffer (hardware/driver issues)
```

`free -h` and `df -h` are the first two commands I run when a VPS feels slow or misbehaves. They tell you in seconds whether it&apos;s a memory or disk problem.

Want to go deeper on monitoring? See how to [monitor CPU usage and send email alerts](/monitor-cpu-usage-and-send-email-alerts-in-linux) for proactive alerting.

### Disk usage (`df`, `du`)

```bash
$ df -h                       # filesystem disk space overview
$ du -sh /var/log             # total size of /var/log
$ du -sh /*                   # size of each top-level directory
$ du -sh /var/* | sort -h     # find the biggest directory under /var
```

&lt;Notice type=&quot;info&quot; title=&quot;Disk full on your VPS?&quot;&gt;
Quick troubleshooting sequence:
1. `df -h` — which filesystem is full?
2. `du -sh /*` — which top-level dir is the hog?
3. `du -sh /var/*` — drill down
4. `journalctl --disk-usage` — systemd journals eating disk?
5. `apt autoremove &amp;&amp; apt clean` — reclaim package cache
6. `journalctl --vacuum-size=500M` — cap journal size

For a deep-dive on disk imaging and cloning, see the [dd command guide](/linux-dd-command-guide).
&lt;/Notice&gt;

### Listing block devices (`lsblk`, `blkid`)

```bash
$ lsblk                       # list all block devices and mount points
$ blkid                       # show UUIDs (needed for /etc/fstab entries)
```

These are essential when adding extra volumes to a VPS.

---

## User and group management

### User commands (`whoami`, `id`, `useradd`, `usermod`, `passwd`, `userdel`)

```bash
$ whoami                      # current username
$ id                          # current user&apos;s UID, GID, and groups
$ sudo useradd -m -s /bin/bash newuser      # create user with home dir
$ sudo usermod -aG sudo newuser             # add to sudo group
$ sudo passwd newuser                       # set password
$ sudo userdel -r olduser                   # delete user + home directory
```

&lt;Notice type=&quot;info&quot; title=&quot;sudo — running commands as root&quot;&gt;
Prefix any command with `sudo` to run it as root. Most system administration commands require it. Use `sudo -i` for a full root shell. Use `sudo !!` to re-run the last command with sudo prepended (handy when you forget).
&lt;/Notice&gt;

Failure mode: `userdel` without `-r` leaves the home directory behind. Always use `userdel -r` to clean up.

### Group commands (`groupadd`, `groupdel`, `groups`, `who`)

```bash
$ sudo groupadd newgroup       # create group
$ sudo groupdel oldgroup       # delete group
$ groups                       # show current user&apos;s groups
$ who                          # who is logged in
$ w                            # who is logged in and what they&apos;re doing
```

---

## Process management

### Viewing processes (`ps`, `top`, `htop`)

```bash
$ ps aux                       # all processes, BSD format
$ ps -ef                       # all processes, full format
$ ps -u username               # processes for a specific user
$ top                          # real-time process viewer (press q to quit)
$ htop                         # interactive, user-friendly top (may need install)
```

When `top` or `htop` shows high memory, check [swap usage](/swap-usage-linux) to see if processes are being swapped out.

### Managing processes (`kill`, `killall`, `pkill`, `bg`, `fg`, `nohup`, `jobs`)

&lt;Notice type=&quot;warning&quot; title=&quot;Always try SIGTERM first&quot;&gt;
`kill PID` sends SIGTERM (signal 15) — a polite request to exit. The process can catch it, clean up temp files, close connections, and shut down gracefully. Only use `kill -9 PID` (SIGKILL) as a last resort — it can&apos;t be caught or ignored, and the process gets no chance to clean up.
&lt;/Notice&gt;

```bash
$ kill PID                     # SIGTERM — polite shutdown (default signal)
$ kill -9 PID                  # SIGKILL — force kill, last resort
$ kill -l                      # list all signal names
$ killall processname           # kill all processes by name (SIGTERM)
$ pkill -f &quot;pattern&quot;           # kill by matching full command line
```

Background jobs:

```bash
$ long_running_command &amp;
$ jobs                         # list background jobs
$ fg %1                        # bring job 1 to foreground
$ bg %1                        # resume job 1 in background
$ nohup ./server.sh &amp;          # keep running after you log out (essential for VPS)
```

Verify after killing: `ps aux | grep processname` — make sure it&apos;s gone.

Failure modes:
- Never `kill -9` PID 1 (init/systemd) — kernel panic or system hang.
- `killall nginx` kills *all* nginx processes. Be specific.

### Process priority (`nice`, `renice`)

```bash
$ nice -n 10 ./heavy_task.sh         # start with lower priority (nicer)
$ renice -n 5 -p PID                 # change priority of running process
```

Priority range: -20 (highest) to 19 (lowest). Default is 0.

---

## Service management with systemd

`systemctl` and `journalctl` are how you manage services on any modern Linux distro. This section was completely missing from the original article — and it&apos;s the first thing you need when a service won&apos;t start.

### Managing services (`systemctl`)

```bash
$ systemctl status nginx            # check service status
$ systemctl start nginx             # start now
$ systemctl stop nginx              # stop now
$ systemctl restart nginx           # restart
$ systemctl enable nginx            # start at boot
$ systemctl disable nginx           # don&apos;t start at boot
$ systemctl enable --now nginx      # enable + start (common pattern)
$ systemctl disable --now nginx     # disable + stop
$ systemctl list-units --state=failed   # see all failed services
$ systemctl daemon-reload           # reload unit files after editing
```

&lt;Notice type=&quot;info&quot; title=&quot;enable vs start&quot;&gt;
`systemctl start nginx` starts the service right now. `systemctl enable nginx` makes it start automatically at boot. You usually want both: `systemctl enable --now nginx`.
&lt;/Notice&gt;

Verify after any restart: `systemctl status nginx` and `journalctl -u nginx --since &quot;1 min ago&quot;`.

Failure mode: `systemctl disable` without `systemctl stop` means the service runs until the next reboot but won&apos;t start after that. Confusing. Use `disable --now`.

### Viewing logs (`journalctl`)

```bash
$ journalctl -u nginx                 # logs for nginx
$ journalctl -u nginx -f              # follow live (like tail -f)
$ journalctl -u nginx --since &quot;1 hour ago&quot;
$ journalctl -p err                   # only errors across all services
$ journalctl --disk-usage             # how much disk are journals using?
$ journalctl --vacuum-size=500M       # cap journal size at 500MB
```

&lt;Notice type=&quot;info&quot; title=&quot;Journals can eat your disk&quot;&gt;
On long-running VPS instances, systemd journals can silently grow to several gigabytes. Use `journalctl --disk-usage` to check. Set a cap in `/etc/systemd/journald.conf` with `SystemMaxUse=500M`, or use `journalctl --vacuum-size=500M` to trim.
&lt;/Notice&gt;

---

## Networking and remote access

### Connectivity and diagnostics (`ping`, `traceroute`, `dig`, `nc`)

```bash
$ ping -c 4 google.com                # send 4 packets
$ traceroute google.com               # trace network path
$ dig bitdoze.com                     # DNS lookup (A record)
$ dig bitdoze.com MX                  # mail exchanger records
$ nc -zv host.example.com 22          # test if port 22 is open
$ nc -zv host.example.com 443         # test HTTPS port
```

`dig` is more powerful than `nslookup` and gives cleaner output. `nc` (netcat) is invaluable for debugging &quot;can&apos;t connect&quot; issues.

### Network configuration (`ip`, `ss`)

&lt;Notice type=&quot;warning&quot; title=&quot;net-tools may not be installed&quot;&gt;
`ifconfig` and `netstat` are part of the deprecated `net-tools` package. They&apos;re removed from RHEL 9 default install and not in minimal Ubuntu 24.04. If you run `ifconfig` and get &quot;command not found,&quot; that&apos;s why. Use `ip` and `ss` instead — they&apos;re more powerful and always available.
&lt;/Notice&gt;

```bash
$ ip a                            # show all IP addresses (replaces ifconfig)
$ ip link                         # show network interfaces
$ ip route                        # show routing table
$ ss -tuln                        # show listening TCP/UDP ports (replaces netstat)
$ ss -tp                          # show established connections with process names
```

`ss -tuln` is the modern way to check what&apos;s listening. It&apos;s faster than `netstat` and doesn&apos;t need a separate package.

### Remote access (`ssh`, `ssh-keygen`, `scp`, `sftp`, `rsync`)

#### SSH basics

```bash
$ ssh user@remote-host                        # connect
$ ssh -p 2222 user@remote-host                # custom port
$ ssh -i ~/.ssh/my_key user@remote-host       # specific key
$ ssh -L 8080:localhost:80 user@remote-host   # local port forwarding
```

#### SSH keys

```bash
$ ssh-keygen -t ed25519 -C &quot;your_email@example.com&quot;
$ ssh-copy-id user@remote-host                # copy public key to server
```

&lt;Notice type=&quot;info&quot; title=&quot;Use Ed25519 keys, not RSA&quot;&gt;
Always generate Ed25519 keys (`ssh-keygen -t ed25519`). They&apos;re shorter, faster, and more secure than RSA. RSA 2048-bit keys are considered weak by modern standards. Ed25519 is the default on all modern OpenSSH versions.
&lt;/Notice&gt;

Verify: `ls -la ~/.ssh/` after keygen. Test passwordless login with `ssh user@remote-host`.

Failure mode: `chmod 777` on SSH private key → SSH refuses it. Always `chmod 600 ~/.ssh/id_*`.

For more on hardening SSH, see [securing your SSH server](/secure-ssh-server-linux).

#### File transfer (`scp`, `sftp`, `rsync`)

```bash
$ scp file.txt user@remote:/path/                    # copy to remote
$ scp user@remote:/path/file.txt ./                   # copy from remote
$ sftp user@remote                                    # interactive file transfer
$ rsync -avz /local/dir/ user@remote:/path/           # sync directories
$ rsync -avz --delete /src/ /dest/                    # mirror (deletes extras)
```

`rsync -avz` is my default for any file sync. It only transfers changed files, compresses in transit, and preserves permissions.

### Firewalls (`ufw`, `firewall-cmd`, `iptables`/`nftables`)

&lt;Tabs&gt;
&lt;Tab name=&quot;ufw (Ubuntu/Debian)&quot;&gt;
```bash
$ sudo ufw allow 22/tcp               # allow SSH
$ sudo ufw allow 443/tcp              # allow HTTPS
$ sudo ufw enable                     # activate firewall
$ sudo ufw status verbose             # show rules
```
&lt;/Tab&gt;
&lt;Tab name=&quot;firewall-cmd (RHEL/Fedora)&quot;&gt;
```bash
$ sudo firewall-cmd --permanent --add-service=ssh
$ sudo firewall-cmd --permanent --add-service=https
$ sudo firewall-cmd --reload
$ sudo firewall-cmd --list-all
```
&lt;/Tab&gt;
&lt;Tab name=&quot;iptables (low-level)&quot;&gt;
```bash
$ sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT
$ sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT
$ sudo iptables -L -n -v
```

Note: `iptables` rules are lost on reboot unless saved with `iptables-save`. On modern distros, `nftables` is the kernel backend — `iptables` commands work via a compatibility layer.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;error&quot; title=&quot;Never flush firewall rules on a remote VPS&quot;&gt;
`iptables -F` deletes all rules, including the one allowing your SSH connection. You&apos;ll lock yourself out. Always ensure SSH (port 22) is allowed before applying new rules.
&lt;/Notice&gt;

---

## Package management

### Debian/Ubuntu (`apt`)

```bash
$ sudo apt update                 # refresh package index
$ sudo apt upgrade                # upgrade installed packages
$ sudo apt install nginx          # install a package
$ sudo apt remove nginx           # remove (keeps config)
$ sudo apt purge nginx            # remove package AND config
$ sudo apt autoremove             # remove unused dependencies
$ sudo apt search keyword         # search for packages
$ sudo apt show nginx             # package details
```

&lt;Notice type=&quot;info&quot; title=&quot;apt vs apt-get&quot;&gt;
`apt` is for interactive terminal use — it has progress bars and color output. `apt-get` is for scripts and Dockerfiles where you want stable, parseable output between versions. Use `apt` at the terminal, `apt-get` in your CI pipelines.
&lt;/Notice&gt;

### RHEL/Fedora (`dnf` / `dnf5`)

Fedora 41 (October 2024) made DNF5 the default package manager. RHEL 9 and CentOS Stream 9 still use `dnf` (v4). The commands are the same:

```bash
$ sudo dnf install nginx
$ sudo dnf update
$ sudo dnf remove nginx
$ sudo dnf search keyword
```

`yum` is effectively dead on modern systems. If you&apos;re still using it, switch to `dnf`.

### Other package managers

```bash
# Arch Linux
$ sudo pacman -S package_name         # install
$ sudo pacman -Syu                    # update all
$ sudo pacman -R package_name         # remove

# openSUSE
$ sudo zypper install package_name
$ sudo zypper update

# Flatpak (cross-distro)
$ flatpak install flathub org.app.Name
$ flatpak update

# Snap (Ubuntu/Canonical)
$ sudo snap install package_name
$ snap list
```

&lt;Tabs&gt;
&lt;Tab name=&quot;Install a package&quot;&gt;
```bash
# Debian/Ubuntu
sudo apt install nginx

# RHEL/Fedora
sudo dnf install nginx

# Arch
sudo pacman -S nginx

# openSUSE
sudo zypper install nginx
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Update all packages&quot;&gt;
```bash
# Debian/Ubuntu
sudo apt update &amp;&amp; sudo apt upgrade

# RHEL/Fedora
sudo dnf upgrade

# Arch
sudo pacman -Syu

# openSUSE
sudo zypper update
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

---

## Disk and storage management

### Partitioning (`fdisk`, `parted`)

```bash
$ sudo fdisk /dev/sda             # MBR partitioning (interactive)
$ sudo parted /dev/sda            # GPT partitioning (for disks &gt;2TB)
```

&lt;Notice type=&quot;error&quot; title=&quot;Double-check the disk!&quot;&gt;
`fdisk` on the wrong disk = irreversible data loss. Always run `lsblk` first to confirm which disk is which. Once you press `w` (write) in fdisk, there&apos;s no undo.
&lt;/Notice&gt;

### Filesystems (`mkfs`, `mount`, `umount`, `fsck`)

```bash
$ sudo mkfs.ext4 /dev/sda1        # create ext4 filesystem
$ sudo mount /dev/sda1 /mnt       # mount
$ sudo umount /mnt                # unmount
$ sudo fsck /dev/sda1             # check filesystem for errors
```

For mounting [NFS shares](/setup-nfs-linux) on a network, use `mount -t nfs server:/share /mnt/nfs`.

To make mounts persistent across reboots, add entries to `/etc/fstab` (use `blkid` to get UUIDs).

---

## Shell productivity and history

### Command shortcuts (`alias`, `history`, `!!`, `!$`)

```bash
$ alias ll=&apos;ls -la&apos;              # create shortcut
$ alias                          # list all aliases
$ history                        # show command history
$ history | grep docker          # search history
$ !!                             # repeat last command (useful with sudo: sudo !!)
$ !$                             # last argument of previous command
```

Press `Ctrl+R` for reverse incremental search through history. Start typing and it finds matching commands.

For a better experience, set up [command autocomplete in Zsh](/enable-command-autocomplete-in-zsh) and [syntax highlighting in Zsh](/enable-syntax-highlighting-zsh).

### Pipes, redirects, and utilities (`tee`, `xargs`, `watch`, `export`, `env`, `source`)

```bash
$ command | tee output.log        # write to file AND stdout
$ find . -name &quot;*.tmp&quot; | xargs rm    # build rm command from stdin
$ watch -n 2 docker ps           # repeat every 2 seconds (great for monitoring)
$ export VAR=value                # set environment variable
$ env                             # list all environment variables
$ source ~/.bashrc                # reload shell config without restarting terminal
```

&lt;Notice type=&quot;info&quot; title=&quot;The power of pipes&quot;&gt;
The real power of Linux commands comes from combining them with pipes (`|`). Each command does one thing well — chain them together:

```bash
$ ps aux | grep nginx | awk &apos;{print $2}&apos; | xargs kill
```

This finds all nginx processes, extracts their PIDs, and kills them. One line.
&lt;/Notice&gt;

---

## Scripting and automation

### Shell scripting basics

```bash
#!/bin/bash
echo &quot;Starting backup...&quot;
DATE=$(date +%Y%m%d)
tar -czf /backups/backup_$DATE.tar.gz /data/
echo &quot;Backup complete: backup_$DATE.tar.gz&quot;
```

Key constructs:

```bash
# Variables
name=&quot;World&quot;
echo &quot;Hello, $name!&quot;

# Conditionals
if [ -f /tmp/lockfile ]; then
    echo &quot;Lock file exists&quot;
elif [ -d /tmp/workdir ]; then
    echo &quot;Working directory found&quot;
else
    echo &quot;Clean state&quot;
fi

# For loop
for file in *.log; do
    echo &quot;Processing $file&quot;
done

# While loop
count=1
while [ $count -le 5 ]; do
    echo &quot;Count: $count&quot;
    count=$((count + 1))
done
```

### Task scheduling (`cron`, `at`)

The crontab syntax diagram:

```
* * * * * command_to_execute
- - - - -
| | | | |
| | | | +----- Day of week (0-7, Sunday = 0 or 7)
| | | +------- Month (1-12)
| | +--------- Day of month (1-31)
| +----------- Hour (0-23)
+------------- Minute (0-59)
```

```bash
$ crontab -e                              # edit your crontab
$ crontab -l                              # list current cron jobs

# Examples:
0 2 * * * /path/to/backup.sh              # every day at 2 AM
*/5 * * * * /path/to/healthcheck.sh       # every 5 minutes
0 0 * * 0 /path/to/weekly_cleanup.sh      # every Sunday at midnight
```

```bash
$ at 2:00 PM                              # one-time scheduled job
at&gt; /path/to/script.sh
at&gt; &lt;Ctrl+D&gt;
```

### systemd timers (modern alternative to cron)

systemd timers offer better logging, dependency management, and random delay support. They&apos;re the modern approach on all systemd-based distros.

```ini
# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup timer

[Timer]
OnCalendar=*-*-* 02:00:00
RandomizedDelaySec=900
Persistent=true

[Install]
WantedBy=timers.target
```

```ini
# /etc/systemd/system/backup.service
[Unit]
Description=Run backup

[Service]
Type=oneshot
ExecStart=/path/to/backup.sh
```

```bash
$ sudo systemctl enable --now backup.timer
$ systemctl list-timers                     # see all active timers
```

&lt;Notice type=&quot;info&quot; title=&quot;cron vs systemd timers&quot;&gt;
cron is simpler and universally understood. systemd timers offer better logging (`journalctl -u backup`), dependency management, and `RandomizedDelaySec` to avoid thundering herd problems. For simple tasks, cron is fine. For anything that depends on other services, consider systemd timers.
&lt;/Notice&gt;

---

## Developer and DevOps tools

### Version control (`git`)

Git is used by 93.87% of developers (Stack Overflow 2025). These are the commands you&apos;ll use daily:

```bash
$ git init                            # initialize new repo
$ git clone https://github.com/...    # clone remote repo
$ git status                          # what&apos;s changed?
$ git add .                           # stage all changes
$ git commit -m &quot;message&quot;             # commit staged changes
$ git push                            # push to remote
$ git pull                            # pull from remote
$ git log --oneline                   # compact history
$ git diff                            # see unstaged changes
```

For a comprehensive deep-dive, see [Git commands](/git-commands).

### Containers (`docker`)

Docker has 71.1% adoption among developers (Stack Overflow 2025). These are the most-used commands:

```bash
$ docker run -d -p 80:80 nginx        # run container in background
$ docker ps                           # list running containers
$ docker ps -a                        # list all containers (including stopped)
$ docker logs container_name          # view logs
$ docker exec -it container_name bash # shell into running container
$ docker stop container_name          # stop
$ docker rm container_name            # remove stopped container
$ docker images                       # list images
$ docker compose up -d                # start compose stack
$ docker compose down                 # stop and remove compose stack
```

For the full reference, see [Docker commands](/docker-commands).

### Container orchestration (`kubectl`)

If you&apos;re running Kubernetes:

```bash
$ kubectl get pods                    # list pods
$ kubectl logs pod_name               # view pod logs
$ kubectl exec -it pod_name -- bash   # shell into pod
$ kubectl apply -f manifest.yaml      # apply configuration
$ kubectl get services                # list services
```

This is only relevant if you&apos;re running K8s — skip it if you&apos;re using Docker Compose or a PaaS.

---

## Modern CLI alternatives

### Why use modern tools?

Over the past few years, developers have rewritten many classic Unix tools in Rust and Go. The results are faster, prettier, and often more ergonomic. The tradeoff: they need installation, while legacy tools are always available on a minimal VPS.

If you manage servers for a living, these tools are worth the install time. If you&apos;re writing a Dockerfile that needs to work everywhere, stick with the classics.

### The modern toolkit

| Legacy Tool | Modern Alternative | Key Benefit |
|------------|-------------------|-------------|
| `cat` | [bat](https://github.com/sharkdp/bat) | Syntax highlighting, line numbers, git integration |
| `ls` | [eza](https://github.com/eza-community/eza) | Colors, icons, tree view, git awareness |
| `cd` | [zoxide](https://github.com/ajeetdsouza/zoxide) | Remembers frequent dirs, jump with partial names |
| `grep` | [ripgrep (rg)](https://github.com/BurntSushi/ripgrep) | Faster, respects .gitignore, clean output |
| `find` | [fd](https://github.com/sharkdp/fd) | Intuitive syntax, faster, colorized |
| `top`/`htop` | [btop](https://github.com/aristocratos/btop) | Rich TUI, GPU/network/disk graphs |
| `du` | [ncdu](https://dev.yorhel.nl/ncdu) / [dust](https://github.com/bootandy/dust) | Interactive disk usage browsing |
| `df` | [duf](https://github.com/muesli/duf) | Colorful, filterable disk overview |
| `man` | [tldr](https://github.com/tldr-pages/tldr) | Practical examples, community-driven |
| `sed` | [sd](https://github.com/chmln/sd) | Simpler find-and-replace syntax |
| `curl` (APIs) | [httpie](https://github.com/httpie/cli) | JSON-aware, syntax-highlighted |
| `diff` | [delta](https://github.com/dandavison/delta) | Syntax-highlighted, side-by-side |
| Ctrl+R | [fzf](https://github.com/junegunn/fzf) | Fuzzy finder for files, history, everything |
| History | [atuin](https://github.com/atuinsh/atuin) | Synced, searchable shell history across machines |

Note: `exa` (the original `ls` replacement) is unmaintained since 2023. Use `eza` — it&apos;s the active community fork.

For a full setup guide on `zoxide`, see [Zoxide: The Smarter Way to Navigate Your Terminal](/zoxide).

### Installing a starter pack

&lt;Tabs&gt;
&lt;Tab name=&quot;Ubuntu/Debian&quot;&gt;
```bash
$ sudo apt install bat ripgrep fd-find eza btop ncdu fzf zoxide

# Fix name conflicts on Ubuntu:
$ sudo ln -s /usr/bin/batcat /usr/local/bin/bat
$ sudo ln -s /usr/bin/fdfind /usr/local/bin/fd
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Fedora&quot;&gt;
```bash
$ sudo dnf install bat ripgrep fd-find eza btop ncdu duf fzf zoxide
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Arch&quot;&gt;
```bash
$ sudo pacman -S bat ripgrep fd eza btop ncdu duf fzf zoxide
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Quick start on Ubuntu&quot;&gt;
Install the essentials in one command:

```bash
sudo apt install bat ripgrep fd-find eza btop ncdu fzf zoxide
```

Then add to `~/.bashrc` for shell integration:
```bash
eval &quot;$(zoxide init bash)&quot;
eval &quot;$(fzf --bash)&quot;
alias ls=&apos;eza --icons&apos;
alias cat=&apos;bat --paging=never&apos;
```
&lt;/Notice&gt;

Failure mode: On Ubuntu, `fd` is installed as `fdfind` and `bat` as `batcat` due to package name conflicts. Create symlinks as shown above.

---

## Quick reference cheat sheet

This condensed table covers every command in this guide. Bookmark it.

| Category | Command | Description | Example |
|----------|---------|-------------|---------|
| Navigation | `pwd` | Current directory | `pwd` |
| Navigation | `ls` | List contents | `ls -lah` |
| Navigation | `cd` | Change directory | `cd /var/log` |
| Files | `touch` | Create empty file | `touch new.txt` |
| Files | `cp` | Copy | `cp -r dir1/ dir2/` |
| Files | `mv` | Move/rename | `mv old new` |
| Files | `rm` | Remove | `rm -i file.txt` |
| Files | `ln` | Create link | `ln -s target link` |
| Archives | `tar` | Archive | `tar -czf a.tar.gz dir/` |
| Archives | `gzip` | Compress | `gzip file.txt` |
| Archives | `zip`/`unzip` | Zip archives | `zip -r a.zip dir/` |
| View | `cat` | Print file | `cat file.txt` |
| View | `less` | Paginated viewer | `less file.txt` |
| View | `head`/`tail` | First/last lines | `tail -f logfile` |
| Search | `grep` | Text search | `grep -rn &quot;err&quot; .` |
| Search | `find` | File search | `find . -name &quot;*.log&quot;` |
| Text | `sort` | Sort lines | `sort -n nums.txt` |
| Text | `uniq` | Deduplicate | `sort f \| uniq -c` |
| Text | `wc` | Count | `wc -l file.txt` |
| Text | `cut` | Extract columns | `cut -d: -f1 /etc/passwd` |
| Text | `awk` | Pattern processing | `awk &apos;{print $1}&apos; f` |
| Text | `sed` | Stream edit | `sed &apos;s/old/new/g&apos; f` |
| Text | `diff` | Compare files | `diff -u f1 f2` |
| Perms | `chmod` | Change permissions | `chmod 755 script.sh` |
| Perms | `chown` | Change ownership | `chown user:group f` |
| System | `uname` | System info | `uname -a` |
| System | `uptime` | Load averages | `uptime` |
| System | `free` | Memory usage | `free -h` |
| System | `df` | Disk space | `df -h` |
| System | `du` | Directory size | `du -sh /var/*` |
| System | `lsblk` | Block devices | `lsblk` |
| System | `dmesg` | Kernel messages | `dmesg \| tail` |
| Users | `whoami` | Current user | `whoami` |
| Users | `id` | User/group info | `id` |
| Users | `useradd` | Add user | `useradd -m newuser` |
| Users | `passwd` | Set password | `passwd newuser` |
| Users | `groups` | User&apos;s groups | `groups` |
| Users | `sudo` | Run as root | `sudo command` |
| Process | `ps` | Process snapshot | `ps aux` |
| Process | `top`/`htop` | Real-time viewer | `htop` |
| Process | `kill` | Terminate (SIGTERM) | `kill PID` |
| Process | `kill -9` | Force kill (SIGKILL) | `kill -9 PID` |
| Process | `pkill` | Kill by name | `pkill nginx` |
| Process | `nohup` | Survive logout | `nohup ./app &amp;` |
| Process | `nice`/`renice` | Process priority | `nice -n 10 cmd` |
| Services | `systemctl` | Manage services | `systemctl status nginx` |
| Services | `journalctl` | View logs | `journalctl -u nginx -f` |
| Network | `ping` | Test connectivity | `ping -c4 host` |
| Network | `traceroute` | Trace path | `traceroute host` |
| Network | `dig` | DNS lookup | `dig domain.com` |
| Network | `nc` | Port test | `nc -zv host 22` |
| Network | `ip` | Network config | `ip a` |
| Network | `ss` | Socket stats | `ss -tuln` |
| Remote | `ssh` | Remote shell | `ssh user@host` |
| Remote | `ssh-keygen` | Generate key | `ssh-keygen -t ed25519` |
| Remote | `scp` | Secure copy | `scp f user@h:/path/` |
| Remote | `rsync` | Sync files | `rsync -avz src/ dest/` |
| Firewall | `ufw` | Ubuntu firewall | `ufw allow 443/tcp` |
| Firewall | `firewall-cmd` | RHEL firewall | `firewall-cmd --add-service=https` |
| Packages | `apt` | Debian/Ubuntu | `apt install pkg` |
| Packages | `dnf` | RHEL/Fedora | `dnf install pkg` |
| Packages | `pacman` | Arch | `pacman -S pkg` |
| Disk | `fdisk` | Partition | `fdisk /dev/sda` |
| Disk | `mkfs` | Create filesystem | `mkfs.ext4 /dev/sda1` |
| Disk | `mount`/`umount` | Mount/unmount | `mount /dev/sda1 /mnt` |
| Shell | `alias` | Command shortcut | `alias ll=&apos;ls -la&apos;` |
| Shell | `history` | Command history | `history \| grep docker` |
| Shell | `tee` | Pipe + file | `cmd \| tee log.txt` |
| Shell | `xargs` | Build from stdin | `find . -name &quot;*.tmp&quot; \| xargs rm` |
| Shell | `watch` | Repeat command | `watch -n2 docker ps` |
| Shell | `export` | Set env var | `export KEY=value` |
| Shell | `source` | Reload config | `source ~/.bashrc` |
| Script | `echo` | Print text | `echo &quot;hello&quot;` |
| Script | `read` | Read input | `read -p &quot;? &quot; var` |
| Script | `cron` | Schedule tasks | `crontab -e` |
| Script | `at` | One-time schedule | `at 2:00 PM` |
| Dev | `git` | Version control | `git commit -m &quot;msg&quot;` |
| Dev | `docker` | Containers | `docker compose up -d` |
| Dev | `kubectl` | Kubernetes | `kubectl get pods` |
| Monitoring | `vmstat` | Virtual memory | `vmstat 1 5` |
| Monitoring | `iostat` | I/O stats | `iostat -x 1 3` |
| Monitoring | `sar` | System activity | `sar -u 1 3` |
| Misc | `wget` | Download file | `wget url` |
| Misc | `curl` | Transfer data | `curl -I url` |
| Misc | `file` | File type | `file mystery_file` |
| Misc | `stat` | File details | `stat file.txt` |
| Misc | `which` | Locate command | `which python3` |
| Misc | `lsof` | Open files | `lsof -i :80` |
| Misc | `env` | Environment vars | `env` |

---

## Frequently asked questions

&lt;Accordion label=&quot;What are the most important Linux commands to learn first?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Start with these 12 commands — they cover 80% of daily terminal work:

`ls`, `cd`, `cp`, `mv`, `rm`, `cat`, `grep`, `find`, `chmod`, `sudo`, `ssh`, `tar`

Once you&apos;re comfortable, add `awk`, `sed`, `systemctl`, and `docker`. After that, the rest comes naturally as you need it.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the difference between apt and apt-get?&quot; group=&quot;faq&quot;&gt;
`apt` is the modern, user-friendly command for interactive terminal use — it has progress bars, color output, and a cleaner interface. `apt-get` is the older command designed for scripts and automation where output stability between versions matters. Use `apt` at the terminal, `apt-get` in Dockerfiles and CI scripts.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I find a file in Linux?&quot; group=&quot;faq&quot;&gt;
Use `find`: `find / -name &quot;filename.txt&quot; 2&gt;/dev/null`

For faster name-based search: `find . -name &quot;*.conf&quot;`

If you have `locate` installed: `locate filename.txt` (uses a pre-built database, much faster but needs `updatedb` to refresh).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I check disk space in Linux?&quot; group=&quot;faq&quot;&gt;
```bash
df -h              # filesystem-level overview
du -sh /path       # directory size
du -sh /* | sort -h    # find biggest directories
```

When your VPS disk fills up, start with `df -h` to see which filesystem is full, then drill down with `du -sh`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the difference between kill and kill -9?&quot; group=&quot;faq&quot;&gt;
`kill PID` sends SIGTERM (signal 15) — a polite request to shut down. The process can catch it, clean up, and exit gracefully.

`kill -9 PID` sends SIGKILL (signal 9) — an immediate, uncatchable termination. The process gets no chance to clean up temp files, close database connections, or release locks.

Always try `kill` first. Use `kill -9` only when the process ignores SIGTERM.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I check which ports are open?&quot; group=&quot;faq&quot;&gt;
```bash
ss -tuln           # listening TCP and UDP ports (modern, always available)
ss -tp             # established connections with process names
```

To test if a specific port is open on a remote host: `nc -zv host 443`

For a comprehensive port scan (use responsibly): `nmap host`
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What are the best modern alternatives to classic Linux commands?&quot; group=&quot;faq&quot;&gt;
The most impactful swaps:
- `cat` → `bat` (syntax highlighting)
- `ls` → `eza` (colors, icons, tree)
- `grep` → `ripgrep` (faster, smarter)
- `find` → `fd` (simpler syntax)
- `top` → `btop` (rich TUI)
- `cd` → `zoxide` (remembers your directories)
- `Ctrl+R` → `fzf` (fuzzy search everything)

See the [Modern CLI Alternatives](#modern-cli-alternatives) section for install commands.
&lt;/Accordion&gt;

---

## Conclusion

Mastering Linux commands is the single most valuable skill for anyone managing servers, self-hosting applications, or developing on Linux. This cheat sheet covers 100+ commands organized by task — from basic navigation to modern CLI tools.

&lt;ListCheck&gt;
&lt;h5&gt;Key takeaways&lt;/h5&gt;
&lt;ul&gt;
&lt;li&gt;Learn the basics first: ls, cd, cp, mv, rm, cat, grep, find, chmod, sudo&lt;/li&gt;
&lt;li&gt;Use modern alternatives where they help: ripgrep, fd, bat, eza, btop, fzf&lt;/li&gt;
&lt;li&gt;Always verify after changes: ls -l after chmod, systemctl status after restart&lt;/li&gt;
&lt;li&gt;Never rm -rf without thinking twice — there&apos;s no undo&lt;/li&gt;
&lt;li&gt;Use sudo wisely — don&apos;t run everything as root&lt;/li&gt;
&lt;li&gt;Practice on a real server — muscle memory beats memorization&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The best way to learn is to use these commands on a real server. If you need one, [Hetzner](https://go.bitdoze.com/hetzner) has affordable Linux VPS starting at a few euros per month — perfect for practice.

Bookmark this cheat sheet and refer back to it as you build your Linux skills. For related deep-dives, check out [Docker commands](/docker-commands) and [Git commands](/git-commands).

For quick command references without reading full man pages, install `tldr` — it gives you practical examples instead of exhaustive documentation: `tldr tar` tells you the 5 things you actually need to know.</content:encoded><category>linux</category><category>linux</category><category>linux-commands</category><category>bash</category></item><item><title>How to Secure an SSH Server in Linux: Hardening Guide</title><link>https://www.bitdoze.com/secure-ssh-server-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/secure-ssh-server-linux/</guid><description>Secure your Linux SSH server with this hardening guide. Covers key-based auth, sshd_config best practices, fail2ban, firewall rules, and OpenSSH security.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Secure Shell (SSH) is how most of us manage remote Linux servers, and it&apos;s the first thing attackers probe when your VPS comes online. If you&apos;re running a Linux server, SSH hardening isn&apos;t optional. Unsecured SSH leads to unauthorized access, data breaches, and compromised infrastructure.

This guide takes you from a default sshd_config to a production-hardened setup. Every recommendation is based on current OpenSSH behavior (10.x as of 2025), not outdated blog posts that still reference protocol 1 or deprecated directives.

If you need a server to practice on, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers affordable VPS instances starting at ~€4/month, good enough for testing everything in this guide.

&lt;Notice type=&quot;warning&quot; title=&quot;regreSSHion (CVE-2024-6387)&quot;&gt;
If you&apos;re running OpenSSH 8.5p1 through 9.7p1, you had a critical unauthenticated remote code execution vulnerability (regreSSHion, discovered by Qualys). It was fixed in OpenSSH 9.8 (July 2024). Update your SSH server immediately before doing anything else: `sudo apt update &amp;&amp; sudo apt install openssh-server` (or `sudo dnf update openssh-server` on RHEL/Fedora).
&lt;/Notice&gt;

## Understanding SSH

SSH is a cryptographic protocol for secure remote access over an unsecured network. It replaced Telnet and rsh in the mid-1990s and has become the standard for remote server management on Linux, BSD, and macOS.

SSH operates on a client-server model:

1. The client initiates a connection to the SSH server
2. The server sends its public key to the client
3. The client verifies the server&apos;s identity (or prompts you to accept it)
4. A secure encrypted channel is established
5. User authentication takes place (public key, password, etc.)
6. Upon successful authentication, the session begins

| Component  | Description                                                                         |
| ---------- | ----------------------------------------------------------------------------------- |
| SSH Server | The program (sshd) running on the remote machine that listens for incoming connections |
| SSH Client | The program (ssh) used to connect to an SSH server                                   |
| Encryption | Algorithms used to secure the communication channel (key exchange, ciphers, MACs)    |

&lt;Notice type=&quot;info&quot;&gt;
Modern OpenSSH (7.6+, released 2017) supports only SSH protocol version 2. Protocol 1 was removed entirely, there is no `Protocol` directive to configure. You&apos;re already using protocol 2.
&lt;/Notice&gt;

If you manage Linux servers regularly, you&apos;ll want a solid grasp of [essential Linux commands](/linux-commands/) alongside SSH.

## Basic SSH Server Security Measures

These are the quick wins, the things every operator should do on a new server before anything else.

### Disable Root Login

Allowing direct root login via SSH gives attackers half the battle. They only need to crack one password or find one key to own the entire system.

Edit `/etc/ssh/sshd_config` (or use a drop-in file, more on that later):

```sh
PermitRootLogin no
```

Test the config before restarting:

```sh
sudo sshd -t
sudo systemctl restart sshd
```

Verify it took effect:

```sh
sudo sshd -T | grep permitrootlogin
```

Expected output: `permitrootlogin no`

&lt;Notice type=&quot;warning&quot; title=&quot;Keep an active session open&quot;&gt;
Before restarting sshd after any config change, always keep one SSH session open. Test the new config from a second terminal. If you lock yourself out, the open session is your lifeline. For remote servers, know where your provider&apos;s web console is (Hetzner, DigitalOcean, and [Vultr](https://go.bitdoze.com/vultr) all offer one).
&lt;/Notice&gt;

### Use Key-Based Authentication (Not Passwords)

Key-based authentication is the single most important SSH security improvement you can make. Keys can&apos;t be brute-forced the way passwords can.

Generate an Ed25519 key pair on your local machine:

```sh
ssh-keygen -t ed25519 -a 100
```

&lt;Notice type=&quot;info&quot;&gt;
Since OpenSSH 9.5 (Oct 2023), `ssh-keygen` generates Ed25519 keys by default. You can omit `-t ed25519` on modern systems. For legacy systems that don&apos;t support Ed25519, use `ssh-keygen -t rsa -b 4096`.
&lt;/Notice&gt;

Copy the public key to your server:

```sh
ssh-copy-id user@server_ip
```

Test the login. You should connect without a password prompt:

```sh
ssh user@server_ip
```

For more on generating and using SSH keys with specific services, see [this guide on linking GitHub with SSH keys](/link-github-with-ssh-maco-linux/).

### Disable Password Authentication

Once key-based auth is working, disable passwords entirely. Set these in your sshd_config or drop-in file:

```sh
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
```

Verify:

```sh
sudo sshd -T | grep -i passwordauth
```

Expected output: `passwordauthentication no`

### Change the Default SSH Port (Optional)

&lt;Notice type=&quot;warning&quot;&gt;
Changing the SSH port is optional. It reduces automated scan noise but doesn&apos;t stop targeted attacks. The measures above (key auth, no root login) matter far more. There are real operational costs: firewall rules, scripts, team muscle memory all need updating. I&apos;d skip it for single-server setups and consider it only if you&apos;re seeing excessive log spam.
&lt;/Notice&gt;

If you do change the port, update your firewall rules **before** restarting sshd:

```sh
# In /etc/ssh/sshd_config
Port 2222
```

```sh
# Update firewall first
sudo ufw allow 2222/tcp
sudo ufw deny 22/tcp   # optional: close the old port
sudo sshd -t &amp;&amp; sudo systemctl restart sshd
```

Test from a new terminal before closing your current session:

```sh
ssh -p 2222 user@server_ip
```

## Advanced SSH Hardening Techniques

Beyond the quick wins, these settings tighten your SSH server against more sophisticated attacks.

### Limit User Access with AllowUsers / AllowGroups

Restrict which users can log in via SSH:

```sh
# Only these users can SSH in
AllowUsers admin deploy

# Or restrict by group
AllowGroup sshusers
```

If `AllowUsers` is set, all other users are denied by default. You can use wildcards: `AllowUsers admin* deploy*`.

Practical `Match` blocks let you apply rules per-user or per-address:

```sh
Match User git
    ForceCommand internal-sftp
    AllowTcpForwarding no

Match Address 192.168.1.0/24
    PasswordAuthentication yes
```

Verify:

```sh
sudo sshd -T | grep -i allowusers
```

### Implement Two-Factor Authentication (2FA)

2FA adds a TOTP code on top of key-based authentication. An attacker would need both your private key AND your phone.

&lt;Notice type=&quot;info&quot;&gt;
`ChallengeResponseAuthentication` is deprecated since OpenSSH 8.7 (2021). Use `KbdInteractiveAuthentication` instead. It&apos;s the same directive under a new name.
&lt;/Notice&gt;

**1. Install Google Authenticator:**

```sh
sudo apt-get update
sudo apt-get install libpam-google-authenticator
```

**2. Configure PAM.** Edit `/etc/pam.d/sshd` and add:

```
auth required pam_google_authenticator.so
```

**3. Update SSH config.** In `/etc/ssh/sshd_config` (or a drop-in like `sshd_config.d/90-2fa.conf`):

```
KbdInteractiveAuthentication yes
UsePAM yes
AuthenticationMethods publickey,keyboard-interactive
```

The `AuthenticationMethods publickey,keyboard-interactive` line requires **both** a key AND a 2FA code. Stronger than allowing either one alone.

**4. Set up the authenticator** for each user:

```sh
google-authenticator
```

Follow the prompts to scan the QR code and save the emergency scratch codes.

**5. Restart and test:**

```sh
sudo sshd -t &amp;&amp; sudo systemctl restart sshd
```

Test from another terminal. You should be prompted for your key passphrase and then the TOTP code. If you hit issues, see [troubleshooting SSH authentication failures](/fix-ssh-too-many-authentication-failures/).

### Configure Idle Timeout Correctly

Idle timeout disconnects inactive sessions, useful if someone walks away from a terminal.

&lt;Notice type=&quot;error&quot; title=&quot;Breaking change in OpenSSH 8.2&quot;&gt;
The original version of this article recommended `ClientAliveCountMax 0`. Since OpenSSH 8.2 (Feb 2020), this **disables** connection killing entirely. It does NOT cause immediate termination. Use `ClientAliveCountMax 1` instead.
&lt;/Notice&gt;

Correct configuration for a 5-minute timeout:

```
ClientAliveInterval 300
ClientAliveCountMax 1
```

This sends a keepalive probe every 300 seconds. If the client doesn&apos;t respond to one probe, the connection is terminated (total ~5 minutes).

| Desired Timeout | ClientAliveInterval | ClientAliveCountMax |
| --------------- | ------------------- | ------------------- |
| 5 minutes       | 300                 | 1                   |
| 10 minutes      | 600                 | 1                   |
| 15 minutes      | 900                 | 1                   |
| 30 minutes      | 1800                | 1                   |

### Enforce Authentication Limits

Two directives that limit brute-force effectiveness per connection:

```
MaxAuthTries 3
LoginGraceTime 30s
```

- `MaxAuthTries 3` (default: 6), limits auth attempts per connection
- `LoginGraceTime 30s` (default: 120s), the window a client has to authenticate after connecting. OpenSSH 9.9+ adds random jitter (up to 4s) to this value.

Both go in the drop-in config shown in the next section.

## sshd_config Hardening Best Practices

This is the section you&apos;ll bookmark and return to. A single drop-in config file that covers all the hardening directives in one place.

### Use sshd_config.d/ for Clean Configuration

Modern distributions (Ubuntu 22.04+, Debian 12+, RHEL 9+) support `/etc/ssh/sshd_config.d/*.conf` for configuration snippets. This is the preferred way to harden without touching the main config file.

Create a drop-in file with all your hardening settings:

```sh
sudo cat &gt; /etc/ssh/sshd_config.d/90-hardening.conf &lt;&lt; &apos;EOF&apos;
# Authentication
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30s

# Idle timeout (5 min)
ClientAliveInterval 300
ClientAliveCountMax 1

# Disable forwarding if not needed
AllowTcpForwarding no
AllowAgentForwarding no
X11Forwarding no

# Minimum RSA key size
RequiredRSASize 3072

# Crypto hardening (verify with: ssh -Q kex/cipher/mac)
KexAlgorithms sntrup761x25519-sha512@openssh.com,curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes256-ctr
MACs hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,umac-128-etm@openssh.com
EOF
```

Test and restart:

```sh
sudo sshd -t &amp;&amp; sudo systemctl restart sshd
```

&lt;Notice type=&quot;warning&quot; title=&quot;Always test before restarting&quot;&gt;
Run `sudo sshd -t` before `sudo systemctl restart sshd`. This catches syntax errors that would prevent sshd from starting and lock you out. Keep one SSH session open while you test from another.
&lt;/Notice&gt;

### Restrict Key Exchange, Ciphers, and MACs

Explicit algorithm lists remove weak defaults. The list above is based on sshaudit.com recommendations. Before setting these, verify what your OpenSSH build supports:

```sh
ssh -Q kex        # Key exchange algorithms
ssh -Q cipher     # Ciphers
ssh -Q mac        # MAC algorithms
ssh -Q key        # Key types
ssh -Q sig        # Signature algorithms
```

If a client can&apos;t connect after you restrict algorithms, check both sides. The client needs to support at least one algorithm in your server&apos;s list. For SSH tunneling use cases, see [SSH port forwarding and tunneling](/ssh-tunneling-linux/) for context on what forwarding controls affect.

### Set a Minimum RSA Key Size (RequiredRSASize)

`RequiredRSASize 3072` (available since OpenSSH 9.1) rejects RSA keys shorter than 3072 bits. This protects against weak keys from older clients or legacy automation scripts.

If you&apos;re still using Ed25519 keys (recommended), this directive has no effect on your primary keys, but it catches weak RSA keys from other sources.

### Disable Unnecessary Forwarding

If your server doesn&apos;t need SSH tunneling, agent forwarding, or X11, disable them:

```
AllowTcpForwarding no
AllowAgentForwarding no
X11Forwarding no
```

Or use the single option: `DisableForwarding yes`. This disables all forwarding features at once. Only do this if you don&apos;t need [SSH port forwarding and tunneling](/ssh-tunneling-linux/).

### Test Your Config Before Restarting

This can&apos;t be stressed enough:

```sh
# Test syntax (catches errors before they lock you out)
sudo sshd -t

# Dump effective config (shows values after all Match blocks and includes)
sudo sshd -T | grep -E &apos;permitrootlogin|passwordauth|pubkeyauth|maxauthtries&apos;
```

`sshd -T` shows the actual running configuration, not just what&apos;s in the file. This is how you verify that `Match` blocks and drop-in files are applying correctly.

## Firewall Configuration for SSH

Network-level access control is your outermost defense layer.

### Using ufw (Ubuntu/Debian)

ufw is the recommended firewall for Ubuntu and Debian:

```sh
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp    # or your custom port
sudo ufw enable
sudo ufw status verbose
```

If you changed your SSH port, replace `22` with your custom port number.

### Using firewalld (RHEL/Fedora)

firewalld is the recommended firewall for RHEL, Fedora, and CentOS:

```sh
sudo firewall-cmd --permanent --add-service=ssh
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
```

### Using iptables (Advanced)

iptables gives you fine-grained control but requires more care:

```sh
# Allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow SSH (adjust port if necessary)
iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Rate-limit new connections (max 4 per minute)
iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --set
iptables -A INPUT -p tcp --dport 22 -m state --state NEW -m recent --update --seconds 60 --hitcount 4 -j DROP

# Drop everything else
iptables -A INPUT -j DROP
```

### Docker and Firewall Bypass Warning

&lt;Notice type=&quot;error&quot; title=&quot;Docker bypasses your firewall&quot;&gt;
Docker manipulates iptables directly, potentially exposing container ports even if ufw or firewalld blocks them. If you run Docker on the same server as SSH, read the [dedicated guide on Docker bypassing firewall rules](/docker-bypasses-firewall/).
&lt;/Notice&gt;

After configuring your firewall, verify the SSH port is reachable: [check remote port connectivity](/check-remote-port-in-linux-nc/) using `nc` or `nmap`.

For server-level intrusion prevention beyond firewalls, consider [CrowdSec](/crowdsec-secure-server/) as a complement to your firewall rules.

## Brute-Force Protection: fail2ban &amp; PerSourcePenalties

You need layers of brute-force defense. OpenSSH 9.8+ has a built-in system, and fail2ban adds more flexibility.

### Built-in Brute-Force Protection with PerSourcePenalties (OpenSSH 9.8+)

OpenSSH 9.8 (July 2024) introduced `PerSourcePenalties`, a built-in penalty system that tracks and delays misbehaving source IPs. It&apos;s **on by default**.

Default configuration:

```
PerSourcePenalties crash:90s authfail:30s noauth:10s
PerSourcePenaltyExemptList 192.168.0.0/16
PerSourceNetBlockSize 32:128
```

How it works: IPs that trigger authentication failures get progressively longer delays before sshd responds. Crashes trigger a 90-second penalty, auth failures 30 seconds.

&lt;Notice type=&quot;info&quot;&gt;
`PerSourcePenalties` is on by default in OpenSSH 9.8+. If your server is behind a NAT gateway or reverse proxy, all clients may appear to come from the same IP. Tune `PerSourcePenaltyExemptList` to exempt your NAT range, or you&apos;ll penalize all clients when one misbehaves.
&lt;/Notice&gt;

### Setting Up fail2ban for SSH

fail2ban adds value beyond PerSourcePenalties: longer ban durations, email alerts, custom actions, and multi-service protection (not just SSH).

&lt;Tabs&gt;
&lt;Tab name=&quot;Ubuntu/Debian&quot;&gt;
```sh
sudo apt-get update
sudo apt-get install fail2ban
sudo systemctl enable fail2ban
```
&lt;/Tab&gt;
&lt;Tab name=&quot;RHEL/Fedora&quot;&gt;
```sh
sudo dnf install epel-release
sudo dnf install fail2ban
sudo systemctl enable fail2ban
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Create `/etc/fail2ban/jail.local` (don&apos;t edit `jail.conf` directly — it gets overwritten on updates):

```ini
[sshd]
enabled = true
port = ssh
backend = systemd
maxretry = 3
bantime = 3600
findtime = 600
```

- `backend = systemd` uses journald instead of tailing `/var/log/auth.log` — more reliable on modern distros
- `maxretry = 3` — three failed attempts triggers a ban
- `bantime = 3600` — ban lasts 1 hour
- `findtime = 600` — the window for counting failures (10 minutes)

Start fail2ban:

```sh
sudo systemctl restart fail2ban
```

Verify it&apos;s running:

```sh
sudo fail2ban-client status sshd
```

## Monitoring &amp; Logging SSH Access

### Configure SSH Logging

Set verbose logging to capture key algorithm information and detailed auth events:

```
LogLevel VERBOSE
SyslogFacility AUTH
```

View SSH logs with journalctl:

```sh
sudo journalctl -u sshd -f                    # Live tail
sudo journalctl -u sshd --since &quot;1 hour ago&quot;  # Recent activity
sudo sshd -T | grep -E &apos;loglevel|syslog&apos;      # Check effective config
```

### Monitor SSH Access Attempts

Practical commands for watching activity:

```sh
sudo lastb                 # Failed login attempts
who                        # Currently logged-in users
sudo journalctl -u sshd | grep &quot;Failed password&quot; | tail -20   # Recent failed passwords
```

For deeper monitoring, tools like OSSEC (host-based intrusion detection) or [server monitoring dashboards](/sever-monitoring/) give you alerting and trend analysis.

## Keeping OpenSSH Updated &amp; Patched

### Why CVE-2024-6387 (regreSSHion) Matters

In July 2024, Qualys disclosed regreSSHion — a critical unauthenticated RCE in sshd affecting OpenSSH 8.5p1 through 9.7p1. A signal handler race condition allowed an attacker to execute arbitrary code as root without any credentials.

This was the most serious SSH vulnerability in years. It was fixed in OpenSSH 9.8.

Two more vulnerabilities were fixed in OpenSSH 9.9p2 (February 2025):
- **CVE-2025-26465**: MITM impersonation when `VerifyHostKeyDNS` is enabled (off by default)
- **CVE-2025-26466**: Memory/CPU DoS via SSH2_MSG_PING packets (mitigated by `PerSourcePenalties`)

These are real-world reasons to keep OpenSSH updated. Not theoretical.

### Post-Quantum Key Exchange: What Changed in OpenSSH 10.0

&lt;Notice type=&quot;info&quot;&gt;
Post-quantum key exchange is automatic in OpenSSH 10.0+. No configuration needed. If you explicitly set `KexAlgorithms`, include `mlkem768x25519-sha256` to retain post-quantum protection.
&lt;/Notice&gt;

OpenSSH 10.0 (April 2025) uses `mlkem768x25519-sha256` — a hybrid of ML-KEM (post-quantum) and X25519 (classical) — as the default key exchange algorithm. This protects against &quot;harvest now, decrypt later&quot; attacks where adversaries record encrypted traffic to decrypt it once quantum computers are available.

Earlier versions (9.0+) used `sntrup761x25519-sha512@openssh.com` as the default post-quantum KEX. Both provide quantum resistance — the 10.0 algorithm is just the standardized version.

### How to Check and Update Your OpenSSH Version

```sh
ssh -V       # Client version
sshd -V      # Server version (available since OpenSSH 9.2)
```

Update on Ubuntu/Debian:

```sh
sudo apt update
sudo apt install openssh-server
```

Update on RHEL/Fedora:

```sh
sudo dnf update openssh-server
```

After updating, verify and restart:

```sh
ssh -V
sudo sshd -t &amp;&amp; sudo systemctl restart sshd
```

Set up automatic security updates to catch SSH patches without manual intervention:

```sh
# Ubuntu/Debian
sudo apt install unattended-upgrades
sudo dpkg-reconfigure unattended-upgrades
```

## SSH Key Management Best Practices

### Secure Key Generation

Ed25519 is the recommended key type. It&apos;s been the default since OpenSSH 9.5 (Oct 2023) — fast, small keys, and no known weaknesses.

```sh
ssh-keygen -t ed25519 -a 100     # Primary (recommended)
ssh-keygen -t rsa -b 4096        # Legacy fallback for older systems
```

Key type comparison:

| Key Type | Recommended Size | Status in OpenSSH 10.x |
|----------|------------------|------------------------|
| Ed25519  | 256 bits (fixed) | Default, recommended    |
| RSA      | 4096 bits        | Supported, use sha2-512 |
| ECDSA    | 256-521 bits     | Supported, avoid if possible |
| DSA      | —                | **Removed in OpenSSH 10.0** |

&lt;Notice type=&quot;error&quot; title=&quot;DSA keys removed in OpenSSH 10.0&quot;&gt;
OpenSSH 10.0 (April 2025) completely removed DSA support. DSA was disabled at compile time in 9.8 (July 2024). If you still have DSA keys, migrate to Ed25519 immediately.
&lt;/Notice&gt;

### Key Storage and Protection

- Store private keys on your local machine only — never on the server
- Set restrictive permissions: `chmod 700 ~/.ssh; chmod 600 ~/.ssh/id_ed25519`
- Always use a passphrase when generating keys
- Use `ssh-agent` for convenience:

```sh
eval $(ssh-agent)
ssh-add ~/.ssh/id_ed25519
```

For high-security environments, consider hardware security keys (YubiKey, etc.) that store the private key in tamper-resistant hardware.

### Regular Key Rotation

Rotate SSH keys every 6–12 months, or immediately if a key may have been compromised:

1. Generate a new key pair
2. Add the new public key to `~/.ssh/authorized_keys` on the server
3. Test the new key
4. Remove the old public key from the server
5. Delete the old private key from your local machine

For teams managing many servers, SSH certificates offer centralized key management with automatic expiration. This is the scalable alternative to distributing authorized_keys files everywhere. See [SSH ProxyJump and jump host configuration](/ssh-proxyjump-jumphost/) for managing access to servers behind bastion hosts.

## Testing Your SSH Security

### Run ssh-audit

ssh-audit is a Python tool that checks your SSH server&apos;s configuration and flags weak algorithms, insecure settings, and known issues.

```sh
# Install
sudo apt install ssh-audit     # or: pipx install ssh-audit

# Run against your server
ssh-audit localhost
ssh-audit your_server_ip
```

The output shows color-coded findings — red for critical, yellow for warnings, green for good. Match the recommendations against the algorithm lists in this article. See [sshaudit.com](https://sshaudit.com) for detailed hardening guides that align with the `KexAlgorithms`/`Ciphers`/`MACs` settings recommended here.

### Verify Your Configuration

```sh
# Dump effective config (shows actual values after all includes and Match blocks)
sudo sshd -T | grep -E &apos;permitrootlogin|passwordauth|pubkeyauth|maxauthtries&apos;

# Verbose connection test (shows what algorithms are negotiated)
ssh -v user@localhost

# List supported algorithms
ssh -Q kex
ssh -Q cipher
ssh -Q mac
```

### Penetration Testing (Optional)

For a deeper check:

```sh
# Port scan
nmap -sV -p22 your_server_ip

# Test weak algorithm negotiation
ssh -oKexAlgorithms=+diffie-hellman-group1-sha1 user@your_server_ip
# This should FAIL — if it succeeds, your server still accepts weak algorithms
```

Tools like Hydra can test brute-force resistance, but only run these against servers you own or have authorization to test. See [checking remote port connectivity](/check-remote-port-in-linux-nc/) for verifying your firewall rules.

## Common SSH Security Mistakes &amp; Troubleshooting

### Locked Out After Config Change

Wrong `AllowUsers`, bad port, or firewall misconfiguration — it happens. Fix:

- Use your VPS provider&apos;s web console (Hetzner, [Vultr](https://go.bitdoze.com/vultr), DigitalOcean all offer one)
- Log in through the console, fix the config, restart sshd
- Prevention: always keep one session open while testing new config

&lt;Accordion label=&quot;How do I recover if I&apos;m locked out of SSH?&quot; group=&quot;faq&quot;&gt;
Use your VPS provider&apos;s web console or IPMI access. Log in, fix `/etc/ssh/sshd_config` (or remove the offending drop-in from `sshd_config.d/`), and restart sshd with `sudo systemctl restart sshd`. If you can&apos;t access the console, most providers let you boot into a rescue mode to edit files on the disk.
&lt;/Accordion&gt;

### Key Ignored Due to Wrong Permissions

`Permission denied (publickey)` is almost always a permissions issue on the server:

```sh
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
```

Also verify `PubkeyAuthentication yes` is set in sshd_config.

### Connection Refused After Port Change

Forgot to update firewall rules. Check:

```sh
sudo ufw status          # Ubuntu/Debian
sudo firewall-cmd --list-all   # RHEL/Fedora
sudo iptables -L -n      # Manual iptables
```

### Algorithm Mismatch Errors

If a client can&apos;t connect after you restricted algorithms, check what the client supports:

```sh
ssh -Q kex    # Run on the client
```

Add a compatible algorithm to the server&apos;s `KexAlgorithms` list, or update the client&apos;s OpenSSH.

## Conclusion

SSH hardening is a layered defense. No single setting makes you secure — but combined, these measures make your server a hard target:

1. **Key-based auth only** — no passwords, no root login
2. **Restrict access** — AllowUsers, firewall rules, MaxAuthTries
3. **Harden the config** — drop-in file with restricted algorithms, idle timeouts, no forwarding
4. **Brute-force protection** — PerSourcePenalties + fail2ban
5. **Monitor and update** — LogLevel VERBOSE, journalctl, automatic security updates
6. **Test regularly** — ssh-audit, sshd -T, verbose connection tests

If you&apos;re running multiple servers, [CrowdSec for server-level intrusion prevention](/crowdsec-secure-server/) adds community-driven threat intelligence on top of everything above.

For more on SSH workflows, see [SSH port forwarding and tunneling](/ssh-tunneling-linux/) and [securing your control panel](/secure-cloudpanel/).

&lt;Button text=&quot;Secure Your Server with CrowdSec&quot; link=&quot;/crowdsec-secure-server/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>linux</category><category>ssh</category><category>openssh</category><category>server-hardening</category></item><item><title>Top AI GitHub Repos Worth Starring in 2026</title><link>https://www.bitdoze.com/top-ai-github-repos/</link><guid isPermaLink="true">https://www.bitdoze.com/top-ai-github-repos/</guid><description>Curated top AI GitHub repos in 2026: OpenClaw, Hermes, Pi Agent, OpenCode, DeepSeek-V4, skills, frameworks, memory, and MCP. Shortlists with stars and setup guides.</description><pubDate>Fri, 31 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

GitHub is full of AI repos. Most are forks, thin wrappers, or things nobody committed to in six months. This page is the short list I actually use and recommend: projects with real maintenance, clear jobs, and enough community weight that you&apos;re not gambling on a weekend demo.

Same idea as my [Docker containers for home server](/docker-containers-home-server/) catalog, but for AI tools instead of self-hosted apps. I cut hard. If a project stalled or the company shut down (Coqui TTS, Bark, AUTOMATIC1111 SD WebUI, Zep, Flowise freeze, Helicone maintenance mode), it&apos;s not here.

Star counts are approximate as of late July 2026 and move fast. Treat them as a momentum signal, not a quality ranking.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/8mgDbi5Vc1M&quot;
  label=&quot;I Tested Every Popular GitHub AI Tool—Here&apos;s What Survived&quot;
/&gt;

## How to use this page

1. **Pick a lane** in the table below (local AI, assistants, coding, frameworks, etc.).
2. **Star a shortlist** — three to five repos per category is plenty.
3. **Open one section** you care about. Each has a quick-pick table, then short notes on the ones that matter most.
4. Where I have a full guide on bitdoze, I link it.

&lt;Notice type=&quot;info&quot; title=&quot;Don&apos;t star 50 repos and call it research&quot;&gt;
Pick what matches what you&apos;re building this month. A focused watchlist beats a starred junk drawer.
&lt;/Notice&gt;

### Pick your lane

| I want… | Go here | Typical first picks |
| --- | --- | --- |
| Run LLMs on my hardware | [Local AI](#local-ai--model-runners) | Ollama, Open WebUI, llama.cpp |
| Strong open-weight models | [Open-weight models](#open-weight-ai-models) | DeepSeek-V4, Kimi K3, GLM-5.2 |
| A personal AI assistant | [Assistants](#ai-personal-assistants) | OpenClaw, Hermes Agent |
| Humans + agents in one workspace | [Assistants](#ai-personal-assistants) | Buzz |
| Coding agent in the terminal | [Coding agents](#ai-coding-agents--tools) | OpenCode, Pi Agent |
| Skills / playbooks for agents | [Skills](#agent-skills--playbooks) | Spec Kit, superpowers, pi-skills |
| Multi-agent systems in code | [Frameworks](#ai-agent-frameworks) | LangChain, Mastra, Agno, CrewAI |
| Visual AI workflows | [Workflows](#workflow-orchestration) | n8n, Dify, Langflow |
| Long-term agent memory | [Memory](#ai-memory--rag) | Mem0, Cognee, Hindsight |
| Embeddings / RAG storage | [Vector DBs](#vector-databases) | pgvector, Qdrant, Milvus |
| Images and video | [Multimodal](#multimodal--generation) | ComfyUI, Remotion |
| Trace and test LLM apps | [Observability](#observability--gateways) | Langfuse, promptfoo |
| One API across providers | [Observability](#observability--gateways) | LiteLLM, OmniRoute |
| Connect tools via MCP | [MCP](#mcp--tool-connectivity) | awesome-mcp-servers |
| Learn the stack | [Learning](#learning--reference) | Generative AI for Beginners |

### What changed in 2026 (short)

- **OpenClaw** went from a niche self-hosted assistant to ~385K stars. Local-first, many chat channels, skills that extend themselves. Creator joined OpenAI; the project moved toward a foundation model of ownership. I still run it — with a [security review](/openclaw-security-guide/).
- **Hermes Agent** from Nous Research (~223K stars) is the other heavyweight personal agent. Built-in learning loop, skill creation from experience, OpenClaw migration path. Full write-up: [Hermes setup guide](/hermes-agent-setup-guide/).
- **OpenCode** (~191K stars under `anomalyco/opencode`) and **Pi Agent** (`earendil-works/pi`) are the open terminal coding agents I actually install. Pi stays minimal; you add skills and extensions yourself.
- **Open-weight coding models** (DeepSeek-V4, Kimi K3, GLM-5.2, MiniMax M3, and friends) made cheap agents practical. Full roundup: [best open-source LLMs as Claude alternatives](/best-open-source-llms-claude-alternative/).
- **Buzz** by Block (July 2026) is the new workspace play: humans and agents share channels, git, and workflows on a self-hostable Nostr relay. Guide: [Buzz Docker setup](/buzz-block-docker-setup/).
- **Skills and playbooks** became a real category: Spec Kit, superpowers, pi-skills, token savers, deploy skills. Agents without skills are just chat with tools.
- **Agent memory** is its own category now — Mem0, Cognee, Hindsight, Letta. People stopped treating &quot;the context window&quot; as long-term memory.
- **MCP** stuck as the common way to plug tools into agents. The awesome list is the index.
- **Dead or stalled** projects are gone from this page on purpose. Alive code only.

---

## Local AI &amp; model runners

Run models on your CPU, GPU, or Apple Silicon. No API key required for the happy path.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [Ollama](https://github.com/ollama/ollama) | 177K | Go | Download, run, and serve LLMs with one command |
| 2 | [Open WebUI](https://github.com/open-webui/open-webui) | 147K | Python | ChatGPT-style UI for Ollama and OpenAI-compatible APIs |
| 3 | [llama.cpp](https://github.com/ggml-org/llama.cpp) | 122K | C++ | Inference engine under most local stacks |
| 4 | [vLLM](https://github.com/vllm-project/vllm) | 88K | Python | High-throughput serving for production |
| 5 | [AnythingLLM](https://github.com/Mintplex-Labs/anything-llm) | 64K | JS | Desktop AI with built-in RAG over your docs |

**Ollama** is still how most people start. `ollama run` pulls a model and serves it. Desktop apps exist for macOS and Windows. Pair it with Open WebUI for a full self-hosted chat product. I covered install in [Ollama with Docker](/ollama-docker-install/) and wiring it into OpenClaw in [OpenClaw + Ollama](/openclaw-ollama-local-models/).

**Open WebUI** is the dashboard: chat UI, RAG, multi-user options, voice bits. If Ollama is the engine, this is the cockpit.

**llama.cpp** is the library almost everyone builds on for local inference. You may never clone it, but it&apos;s in your dependency tree if you care about GGUF on consumer hardware.

**vLLM** is what you reach for when &quot;it works on my laptop&quot; becomes &quot;we need tokens per second on a GPU box.&quot;

---

## Open-weight AI models

This catalog is mostly **tools and harnesses**. For model picks (pricing, benchmarks, when to use each), use the full guides:

- **[Best open-source LLMs as Claude alternatives](/best-open-source-llms-claude-alternative/)** — Kimi K3, GLM-5.2, Qwen 3.6 Plus, MiniMax M3, MiMo V2.5 Pro, Mistral Medium 3.5 vs Opus 5 / Fable 5 / GPT-5.6
- **[Best open-source models for OpenClaw](/best-opensource-models-for-openclaw/)** — practical OpenClaw primary + fallback
- **[Best cheap models for Hermes](/best-cheap-models-hermes-agent/)** — agent pricing and DeepSeek-V4 in the cheap 1M-context stack

### Quick picks (models I actually point people at)

| # | Model | Home | Role |
| --- | --- | --- | --- |
| 1 | [DeepSeek-V4-Pro](https://huggingface.co/deepseek-ai/DeepSeek-V4-Pro) | HF + API | Flagship MoE, ~1M context, heavy coding / agent work |
| 2 | [DeepSeek-V4-Flash](https://huggingface.co/deepseek-ai/DeepSeek-V4-Flash) | HF + API | Smaller V4 — good default behind always-on agents |
| 3 | [Kimi K3](https://www.kimi.com/ai-models/kimi-k3) | Moonshot | Largest open-weight MoE; coding + web peaks |
| 4 | [GLM-5.2](https://z.ai/blog/glm-5.2) | Z.AI | Daily-driver coding workhorse |
| 5 | [MiniMax M3](https://go.bitdoze.com/minimax) | MiniMax | Budget always-on agent model, 1M multimodal context |
| 6 | [MiMo V2.5 Pro](https://go.bitdoze.com/mimo) | Xiaomi | Long-horizon agent / tool-heavy runs |
| 7 | [Qwen 3.6 Plus](https://qwen.ai/apiplatform) | Alibaba | Frontend / vibe-coding on a budget |
| 8 | [Mistral Medium 3.5](https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04) | Mistral | Dense open weights, realistic self-host option |

**DeepSeek-V4** is the 2026 open-weight line to know by name: Pro for heavy work, Flash when cost and latency matter. Weights live on Hugging Face; the API is live for both.

The rest of the table is the shortlist from the Claude-alternatives article. Don&apos;t star eight model cards and call it research — pick one primary and one fallback for the agent you&apos;re running, then read the full comparison if the bill or quality isn&apos;t right.

---

## AI personal assistants

Always-on agents that live on your machine or VPS, talk over chat apps, and do work for you.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [OpenClaw](https://github.com/openclaw/openclaw) | 385K | TypeScript | Personal AI assistant, many channels, skills, local-first |
| 2 | [Hermes Agent](https://github.com/NousResearch/hermes-agent) | 223K | Python | Self-improving agent with learning loop (Nous Research) |
| 3 | [AutoGPT](https://github.com/Significant-Gravitas/AutoGPT) | 186K | Python | Long-running autonomous agent platform |
| 4 | [Buzz](https://github.com/block/buzz) | 19K | Rust | Humans + agents workspace (chat, git, workflows) |

**OpenClaw** is the default answer when someone says &quot;I want my own AI assistant on a VPS.&quot; Messaging integrations (Telegram, Slack, Discord, WhatsApp, and more), shell and browser tools, skills marketplace, and a huge community. It also needs care: broad permissions and community skills mean you should read [OpenClaw security](/openclaw-security-guide/) before you open it to production systems. Setup: [OpenClaw guide](/clawdbot-setup-guide/). Alternatives: [OpenClaw alternatives](/openclaw-alternatives/).

**Hermes Agent** is the one I put next to OpenClaw on purpose. Nous Research built a learning loop: skills from completed work, recall across sessions, user modeling. It can migrate OpenClaw config and memories. If you want an agent that improves instead of only following a static prompt pack, start here. Guides: [Hermes setup](/hermes-agent-setup-guide/), [Hermes dashboards](/best-hermes-dashboards/), [cheap models for Hermes](/best-cheap-models-hermes-agent/).

**AutoGPT** pioneered the autonomous-agent hype in 2023 and still has a huge star count. Better as a platform for long-running automation than as a &quot;chat with me on Telegram&quot; daily driver.

**Buzz** (buzz.xyz) is different enough that people mis-file it. Block released it as an open-source workspace where **agents are first-class members** with cryptographic identities, not bots bolted onto Slack. Channels, DMs, git hosting, workflows, desktop app, self-hostable relay. Early (pre-1.0) but already useful if your problem is &quot;team + agents in one place.&quot; Full path: [self-host Buzz with Docker](/buzz-block-docker-setup/).

&lt;Notice type=&quot;warning&quot; title=&quot;Security on always-on agents&quot;&gt;
OpenClaw, Hermes, and friends need shell, browser, or message access to be useful. Treat them like privileged ops tools: least privilege, review skills, don&apos;t paste production secrets into prompts, isolate with Docker or a dedicated VPS.
&lt;/Notice&gt;

---

## AI coding agents &amp; tools

Agents that live in the terminal or IDE, read the repo, and take multi-step actions.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [OpenCode](https://github.com/anomalyco/opencode) | 191K | TypeScript | Open coding agent with a serious TUI, multi-provider |
| 2 | [Pi Agent](https://github.com/earendil-works/pi) | 81K | TypeScript | Minimal terminal coding harness — skills + extensions |
| 3 | [OpenHands](https://github.com/OpenHands/OpenHands) | 83K | TypeScript | Autonomous software engineer in a sandbox |
| 4 | [Cline](https://github.com/cline/cline) | 65K | TypeScript | VS Code autonomous coding agent |
| 5 | [Herdr](https://github.com/herdrdev/herdr) | 23K | Rust | Terminal multiplexer built for agent herds |

**OpenCode** is my batteries-included pick. Multi-provider, strong TUI, plan mode, no single-vendor lock-in. Repo lives at `anomalyco/opencode` (old `sst/opencode` redirects). Guides: [OpenCode setup](/opencode-setup-guide/), [OpenCode Go plan](/opencode-go-plan/).

**Pi Agent** is the minimal one I keep next to OpenCode. Core tools only (read, write, edit, bash); memory, MCP, sub-agents, and themes come from extensions and skills you choose. Built by Mario Zechner, now under Earendil (`earendil-works/pi`). Install via `pi.dev` or npm. Full path: [Pi coding agent setup](/pi-coding-agent-setup-guide/). Side-by-side: [OpenCode vs Pi](/opencode-vs-pi-agent/).

**OpenHands** is the &quot;agent in a box that can drive a browser and fix its own errors&quot; approach. Heavier, more autonomy when you want it.

**Cline** if you want the agent inside VS Code with terminal and browser control.

**Herdr** is not a coding model. It&apos;s the runtime/multiplexer when you run several agents at once and need to see which one is blocked. Review: [Herdr agent multiplexer](/herdr-agent-multiplexer/).

Skills that plug into these agents (Spec Kit, superpowers, pi-skills, etc.) live in the [skills section](#agent-skills--playbooks) below.

---

## Agent skills &amp; playbooks

Reusable skills, methodologies, and skill packs. This is how modern agents get domain behavior without rewriting the harness.

### Quick picks

| # | Repo | Stars | What it does |
| --- | --- | --- | --- |
| 1 | [superpowers](https://github.com/obra/superpowers) | 264K | Agentic skills framework + software-dev methodology |
| 2 | [Spec Kit](https://github.com/github/spec-kit) | 125K | GitHub&apos;s toolkit for spec-driven development |
| 3 | [caveman](https://github.com/JuliusBrussee/caveman) | 95K | Skill that cuts output tokens by talking like a caveman |
| 4 | [claude-mem](https://github.com/thedotmack/claude-mem) | ~89K | Persistent context across coding-agent sessions |
| 5 | [scientific-agent-skills](https://github.com/K-Dense-AI/scientific-agent-skills) | 32K | Skills that turn an agent into a research assistant |
| 6 | [pi-skills](https://github.com/badlogic/pi-skills) | ~2K | Skills for Pi (also usable with Claude Code / Codex) |
| 7 | [pi-config](https://github.com/amosblomqvist/pi-config) | — | Curated Pi extensions: web, PDF, bash guards, Reddit |

**superpowers** is both a skill pack and a way of working with coding agents (structured workflows, sub-agents, brainstorming protocols). Huge star count because people were tired of freeform vibe coding.

**Spec Kit** is GitHub&apos;s official SDD kit: write the PRD/spec first, then let the agent implement against it. Belongs here more than under &quot;coding agents.&quot;

**caveman** and **claude-mem** are practical painkillers: fewer tokens in the response, and memory that survives session restarts.

**pi-skills** and **pi-config** are what I install after Pi itself. LazyPi bundles a bigger skill pack in one command if you want a curated starter set (see the [Pi setup guide](/pi-coding-agent-setup-guide/)).

For home-lab deploy automation, I also wrote a reusable [docker-deploy skill](/ai-docker-deploy-skill/) you can drop into OpenClaw, OpenCode, or a custom agent.

---

## AI agent frameworks

Code-first libraries when you&apos;re building products, not only chatting with an assistant.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [LangChain](https://github.com/langchain-ai/langchain) | 143K | Python | Default agent/app framework + ecosystem |
| 2 | [MetaGPT](https://github.com/FoundationAgents/MetaGPT) | 70K | Python | Multi-agent &quot;software company&quot; simulation |
| 3 | [AutoGen](https://github.com/microsoft/autogen) | 60K | Python | Microsoft multi-agent conversations |
| 4 | [CrewAI](https://github.com/crewAIInc/crewAI) | 56K | Python | Role-based agent crews and tasks |
| 5 | [LlamaIndex](https://github.com/run-llama/llama_index) | 51K | Python | Data/RAG-first agents and indexes |
| 6 | [Agno](https://github.com/agno-agi/agno) | 42K | Python | Build and run agent platforms (teams, memory) |
| 7 | [Mastra](https://github.com/mastra-ai/mastra) | 27K | TypeScript | TypeScript agents, workflows, memory, Studio UI |

**LangChain** still sits at the center of a lot of tutorials and integrations. Pair it with LangGraph when you need cycles and durable state.

**CrewAI**, **AutoGen**, and **MetaGPT** take different multi-agent angles: roles and tasks, multi-agent chat, and a full &quot;virtual software company&quot; pipeline.

**Agno** is the Python framework I keep recommending for real bots (Discord, teams, knowledge bases). Guides: [Agno getting started](/agno-get-start/), [Discord bot with Agno](/create-your-own-ai-agent/), [Agno squads](/agno-squad/), [Agno MCP tools](/agno-mcp-tools-context7/).

**Mastra** is the TypeScript option if your stack is already Node/Bun. Agents, Zod tools, memory, schedules, Studio UI. Guides: [build an agent with Mastra](/build-ai-agent-mastra/), [Mastra tools vs MCP](/mastra-tools-vs-mcp/), [Mastra vs Eve](/mastra-vs-eve-typescript-ai-agents/).

---

## Workflow orchestration

Visual builders when non-developers (or you on a Friday) need pipelines without a full custom app.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [n8n](https://github.com/n8n-io/n8n) | 199K | TypeScript | Workflow automation + native AI nodes, self-hostable |
| 2 | [Langflow](https://github.com/langflow-ai/langflow) | 153K | Python | Drag-and-drop agents and RAG graphs |
| 3 | [Dify](https://github.com/langgenius/dify) | 151K | TypeScript | Production AI app platform: workflows, RAG, agents |

**n8n** is the one I install first for &quot;when X happens, call an LLM and do Y.&quot; Fair-code, huge integration list, solid self-host story. Guide: [n8n self-host](/n8n-self-host-workflow-automation/).

**Langflow** is better when the product *is* the agent graph (prompts, retrievers, tools on a canvas). Guide: [Langflow Docker](/langflow-docker-install/).

**Dify** sits between them: ship AI apps with workflows, knowledge bases, and monitoring without starting from a blank Python repo.

Flowise is **not** on this list. Development freezes and sunset talk make it a bad default for new work.

---

## AI memory &amp; RAG

Give agents something longer than the current context window.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [Firecrawl](https://github.com/firecrawl/firecrawl) | 158K | TypeScript | Turn websites into clean Markdown/JSON for LLMs |
| 2 | [RAGFlow](https://github.com/infiniflow/ragflow) | 86K | Go | Full RAG engine: parse, index, cite, agentic flows |
| 3 | [Mem0](https://github.com/mem0ai/mem0) | 62K | Python | Universal memory layer with a simple API |
| 4 | [Cognee](https://github.com/topoteretes/cognee) | 30K | Python | Knowledge-graph memory for agents |
| 5 | [Letta](https://github.com/letta-ai/letta) | 24K | Python | Stateful agents with self-editing memory (ex-MemGPT) |
| 6 | [Hindsight](https://github.com/vectorize-io/hindsight) | 19K | Python | Memory that stores experiences/patterns, not only chat logs |

**Mem0** is the easy &quot;add memory&quot; default for many apps.

**Cognee** builds a structured knowledge graph. I self-host it: [Cognee self-host](/cognee-self-host/).

**Hindsight** aims at agents that learn from experience rather than replaying transcripts. Deploy notes: [Hindsight Docker](/hindsight-docker-deploy/). Head-to-head: [Cognee vs Hindsight](/cognee-vs-hindsight/).

**Letta** (formerly MemGPT) is still the research-rooted stateful agent platform.

**Firecrawl** and **RAGFlow** cover the document/web side: get clean data in, then answer with citations.

---

## Vector databases

Where embeddings live for RAG and semantic search.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [Milvus](https://github.com/milvus-io/milvus) | 45K | Go | Scale-out vector DB for large corpora |
| 2 | [Qdrant](https://github.com/qdrant/qdrant) | 34K | Rust | Fast vector search, clean ops story |
| 3 | [Chroma](https://github.com/chroma-core/chroma) | 29K | Rust | Lightweight embedding DB for prototypes and apps |
| 4 | [pgvector](https://github.com/pgvector/pgvector) | 22K | C | Postgres extension — often enough |

If you already run Postgres, **pgvector** is the boring correct choice. I use it that way. Milvus or Qdrant when scale or pure vector workloads justify another service. Related: [pgvector + pgAdmin in Docker](/deploy-pgvector-pgadmin-docker/).

---

## Multimodal &amp; generation

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [ComfyUI](https://github.com/Comfy-Org/ComfyUI) | 123K | Python | Node-based image/video/audio generation |
| 2 | [Deep-Live-Cam](https://github.com/hacksider/Deep-Live-Cam) | 95K | Python | Real-time face swap / live video transforms |
| 3 | [Remotion](https://github.com/remotion-dev/remotion) | 55K | TypeScript | Make videos programmatically with React |
| 4 | [HyperFrames](https://github.com/heygen-com/hyperframes) | 39K | TypeScript | Write HTML, render video (agent-friendly) |
| 5 | [OpenVoice](https://github.com/myshell-ai/OpenVoice) | 37K | Python | Instant voice cloning with style controls |
| 6 | [F5-TTS](https://github.com/SWivid/F5-TTS) | 15K | Python | Strong zero-shot voice cloning |

**ComfyUI** is the default image (and increasingly video) workbench after AUTOMATIC1111 stalled. Node graphs, huge ecosystem, still moving.

**Remotion** is the React path for video: compositions as components, render with Node/Lambda, full control over timeline and data. Good fit when agents or scripts should *compose* motion graphics and explainers instead of only calling a black-box video API. Company-friendly license for commercial use (check their licensing for production).

**HyperFrames** is closer to &quot;write HTML, get a video&quot; for agent pipelines. Use Remotion when you want a real React codebase; HyperFrames when the agent should ship HTML frames.

Voice: **F5-TTS** and **OpenVoice** cover most local cloning needs. Coqui and Bark are out because the upstream stories ended.

---

## Observability &amp; gateways

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [LiteLLM](https://github.com/BerriAI/litellm) | 55K | Python | OpenAI-compatible proxy over 100+ providers |
| 2 | [OmniRoute](https://github.com/diegosouzapw/OmniRoute) | 35K | TypeScript | Free MIT AI gateway — many providers, free tiers, one endpoint |
| 3 | [Langfuse](https://github.com/langfuse/langfuse) | 32K | TypeScript | Tracing, evals, prompt management for LLM apps |
| 4 | [promptfoo](https://github.com/promptfoo/promptfoo) | 24K | TypeScript | CLI evals and red-teaming, CI-friendly |

**LiteLLM** is the glue when you don&apos;t want every service hardcoding a different SDK. Cost tracking, fallback, load balancing. Guide: [LiteLLM Docker](/litellm-docker-install/).

**OmniRoute** is the free-tier-friendly gateway: one endpoint, a huge provider list (including free ones), token compression, and smart fallback. Useful when you want coding agents (Claude Code, Codex, Cursor, OpenCode, Pi) to keep working without rewriting configs every time a free endpoint dies. MIT, actively maintained.

**Langfuse** is what I reach for to see traces and cost. Guide: [Langfuse Docker](/langfuse-docker-install/).

**promptfoo** is for &quot;does this prompt still pass the suite?&quot; before you ship.

---

## MCP &amp; tool connectivity

MCP (Model Context Protocol) is how agents talk to tools without every vendor inventing a private plugin format.

### Quick picks

| # | Repo | Stars | Lang | What it does |
| --- | --- | --- | --- | --- |
| 1 | [awesome-mcp-servers](https://github.com/punkpeye/awesome-mcp-servers) | 92K | — | Directory of MCP servers for apps and APIs |
| 2 | [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) | 24K | Python | Official SDK for servers and clients |

If you&apos;re new to the protocol, start with [MCP for beginners](/mcp-introduction-beginners/). For concrete wiring into agents, see the Agno and Mastra MCP pieces linked above.

---

## Learning &amp; reference

### Quick picks

| # | Repo | Stars | What it does |
| --- | --- | --- | --- |
| 1 | [prompts.chat](https://github.com/f/prompts.chat) | 151K | Large open prompt library |
| 2 | [System prompts of AI tools](https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools) | 130K | Collected system prompts from popular tools |
| 3 | [Generative AI for Beginners](https://github.com/microsoft/generative-ai-for-beginners) | 108K | Microsoft course, lessons + code |
| 4 | [LLMs from Scratch](https://github.com/rasbt/LLMs-from-scratch) | 88K | Build a ChatGPT-like model from first principles |

Use the courses to learn. Use the prompt repos to steal structure, not to paste blindly into production agents.

---

## Default picks by use case

| Use case | Default pick |
| --- | --- |
| Local chat | Ollama + Open WebUI |
| Open-weight model | DeepSeek-V4-Flash / MiniMax M3 (cheap) or GLM-5.2 / Kimi K3 (quality) — [full list](/best-open-source-llms-claude-alternative/) |
| Personal assistant | OpenClaw or Hermes Agent |
| Team + agents workspace | Buzz |
| Terminal coding agent | OpenCode or Pi Agent |
| Minimal coding harness | Pi Agent + pi-skills / pi-config |
| Spec-driven coding | Spec Kit or superpowers |
| VS Code agent | Cline |
| Python multi-agent product | Agno or CrewAI |
| TypeScript agent product | Mastra |
| Visual automation | n8n |
| Visual AI apps | Dify or Langflow |
| Simple agent memory | Mem0 |
| Graph memory | Cognee |
| Learning memory | Hindsight |
| Vector search (simple) | pgvector |
| Vector search (scale) | Milvus or Qdrant |
| Image generation | ComfyUI |
| Programmatic video (React) | Remotion |
| Agent-friendly HTML video | HyperFrames |
| LLM proxy | LiteLLM or OmniRoute |
| Observability | Langfuse |
| MCP discovery | awesome-mcp-servers |

### Getting started (one weekend)

1. **Local chat** — `ollama run` + Open WebUI in Docker ([guide](/ollama-docker-install/)).
2. **Assistant** — OpenClaw or Hermes on a small VPS ([OpenClaw](/clawdbot-setup-guide/), [Hermes](/hermes-agent-setup-guide/)).
3. **Coding** — OpenCode or Pi against a real repo ([OpenCode](/opencode-setup-guide/), [Pi](/pi-coding-agent-setup-guide/)).
4. **Skills** — add Spec Kit or pi-skills instead of pasting giant system prompts.
5. **Automation** — one n8n workflow that calls an LLM ([n8n](/n8n-self-host-workflow-automation/)).
6. **Memory** — Mem0 or Cognee on something you already run ([Cognee](/cognee-self-host/)).
7. **Observability** — Langfuse if you&apos;re past demos ([Langfuse](/langfuse-docker-install/)).

Don&apos;t star the whole page on day one. Pick one row from the table, ship something, then expand.

---

## Guides on this site (related)

| Topic | Article |
| --- | --- |
| OpenClaw install | [OpenClaw setup](/clawdbot-setup-guide/) |
| OpenClaw safety | [OpenClaw security](/openclaw-security-guide/) |
| OpenClaw alternatives | [Alternatives roundup](/openclaw-alternatives/) |
| Open-weight models | [Claude open-source alternatives](/best-open-source-llms-claude-alternative/) |
| Models for OpenClaw | [OpenClaw model picks](/best-opensource-models-for-openclaw/) |
| Hermes Agent | [Hermes setup](/hermes-agent-setup-guide/) |
| Pi Agent | [Pi setup](/pi-coding-agent-setup-guide/) |
| OpenCode vs Pi | [Comparison](/opencode-vs-pi-agent/) |
| Buzz workspace | [Buzz Docker setup](/buzz-block-docker-setup/) |
| Deploy skill | [Docker deploy skill](/ai-docker-deploy-skill/) |
| Mastra | [Build with Mastra](/build-ai-agent-mastra/) |
| Agno | [Agno start](/agno-get-start/) |
| OpenCode | [OpenCode setup](/opencode-setup-guide/) |
| Memory comparison | [Cognee vs Hindsight](/cognee-vs-hindsight/) |
| Home server context | [Docker containers list](/docker-containers-home-server/) |

---

## FAQ

&lt;Accordion label=&quot;Why isn&apos;t project X on this list?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Usually one of: abandoned or code-frozen, pure hype with no maintenance, duplicate of something better on the list, or too narrow for a &quot;top dogs&quot; catalog. Star count alone doesn&apos;t get you in. OpenClaw and Hermes are here because people run them daily, not only because they trend.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I use OpenClaw or Hermes Agent?&quot; group=&quot;faq&quot;&gt;
OpenClaw if you want the biggest ecosystem, channels, and skills community. Hermes if you care more about the learning loop and Nous Research&apos;s approach. Plenty of people run both. Start with one, migrate later if needed — Hermes has an OpenClaw migration path.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Where does Buzz fit?&quot; group=&quot;faq&quot;&gt;
Buzz is not a drop-in OpenClaw clone. It&apos;s a collaboration workspace (chat + git + workflows) where agents have first-class identities. Use it when the problem is multi-human, multi-agent collaboration. Self-host with the [Buzz Docker guide](/buzz-block-docker-setup/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;OpenCode or Pi Agent?&quot; group=&quot;faq&quot;&gt;
OpenCode if you want a full TUI, plan mode, and lots built in. Pi if you want a small core and you like assembling skills/extensions yourself. I use both. Details: [OpenCode vs Pi](/opencode-vs-pi-agent/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Python or TypeScript frameworks?&quot; group=&quot;faq&quot;&gt;
Python still has more agent framework surface area (LangChain, CrewAI, Agno). TypeScript is fine and often better if your product is already Node/Bun — use Mastra. Don&apos;t rewrite your whole stack just to match a framework language.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How often will this page update?&quot; group=&quot;faq&quot;&gt;
When major projects rise, die, or change ownership. The 2026 story already killed or sidelined several previous &quot;must install&quot; tools. If a repo on this page goes dark, it comes off.
&lt;/Accordion&gt;

---

## Bottom line

Most of GitHub&apos;s AI section is noise. The useful core is small:

- Run models: Ollama, Open WebUI, llama.cpp, vLLM
- Open weights: DeepSeek-V4, Kimi K3, GLM-5.2, MiniMax M3 ([full guide](/best-open-source-llms-claude-alternative/))
- Personal assistants: OpenClaw, Hermes Agent
- Team + agents workspace: Buzz
- Coding agents: OpenCode, Pi Agent, Cline, OpenHands
- Skills: Spec Kit, superpowers, pi-skills, claude-mem
- Build products: LangChain, Agno, Mastra, CrewAI
- Workflows: n8n, Dify, Langflow
- Multimodal: ComfyUI, Remotion, HyperFrames
- Memory / RAG: Mem0, Cognee, Hindsight, RAGFlow, pgvector or Qdrant/Milvus
- Ops: LiteLLM, OmniRoute, Langfuse, MCP directory

Star what matches your next project. Ignore the rest until you have a real reason to care.</content:encoded><category>ai</category><category>github</category><category>ai-agents</category><category>open-source</category></item><item><title>Best Open Source LLMs to Replace Claude Fable 5, Opus 5, or GPT-5.6: Affordable AI Coding Alternatives 2026</title><link>https://www.bitdoze.com/best-open-source-llms-claude-alternative/</link><guid isPermaLink="true">https://www.bitdoze.com/best-open-source-llms-claude-alternative/</guid><description>Top open source language models that can replace Claude Fable 5, Opus 5, or GPT-5.6 for coding at a fraction of the cost: Kimi K3, GLM-5.2, Qwen 3.6 Plus, MiniMax M3, MiMo V2.5 Pro, and Mistral Medium 3.5.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>Claude Opus 5, Claude Fable 5, and GPT-5.6 are the current proprietary top tier, and they are expensive. Opus 5 is $5 per million input tokens and $25 per million output. Fable 5 is $10 and $50. GPT-5.6 Sol is $5 and $30. If you run coding agents or burn tokens every day, that adds up fast.

Open source has kept up. Six models now handle coding, reasoning, and agent work at a fraction of that cost: Kimi K3, GLM-5.2, Qwen 3.6 Plus, MiniMax M3, MiMo V2.5 Pro, and Mistral Medium 3.5. I have been testing them next to the proprietary options. The gap is smaller than most people expect.

&lt;Notice type=&quot;info&quot; title=&quot;Cost Comparison Overview&quot;&gt;

Claude Opus 5 costs $5–25 per million tokens. Claude Fable 5 costs $10–50. GPT-5.6 Sol costs $5–30. These open source alternatives range from about $0.30 to $3.00 per million input tokens (Kimi K3 cache-hit pricing is lower still), so you can cut spend by roughly 70–98% depending on the model and cache hit rate.

&lt;/Notice&gt;

## Why consider open source LLM alternatives?

Open source models improved a lot and can compete with proprietary options on real work. Reasons people switch:

&lt;ListCheck&gt;

- **Cost**: API prices sit well below Opus 5 / Fable 5 / GPT-5.6 Sol
- **Transparency**: Open weights (where available) let you inspect, fine-tune, or self-host
- **Performance**: Several models match or beat Opus 5 on specific coding and agent benchmarks
- **Flexibility**: Self-host or use OpenRouter / provider APIs
- **Active development**: New releases land every few weeks

&lt;/ListCheck&gt;

### What to evaluate

- **Coding**: Generate, debug, and explain code
- **Reasoning**: Multi-step problems and logic
- **Context length**: How much of a repo or conversation fits in one shot
- **Agentic work**: Tools, function calling, long multi-step runs
- **Cost per useful result**: Not just sticker price per million tokens

## What&apos;s new with Claude Opus 5, Fable 5, and GPT-5.6?

Before the alternatives, a quick look at the proprietary bar. Anthropic released **[Claude Opus 5](https://www.anthropic.com/news/claude-opus-5)** on July 24, 2026 (after Opus 4.8 in May). **[Claude Fable 5](https://www.anthropic.com/news/claude-fable-5-mythos-5)** landed June 9, 2026. OpenAI released **[GPT-5.6](https://openai.com/index/introducing-gpt-5-6/)** on July 9, 2026. Claude Sonnet 5 (June 30) sits under Opus at $3/$15 after intro pricing. Mythos 5 appears above Fable 5 in some benchmark tables (roughly 80.3% SWE-Bench Pro, 88% Terminal-Bench) and is not the everyday product tier most of us price against.

### Claude Opus 5

&lt;ListCheck&gt;

- **Near Fable 5 intelligence at half the price**: $5/$25 vs Fable 5&apos;s $10/$50
- **Frontier-Bench v0.1**: Surpasses prior Opus generations; roughly 2× Opus 4.8 on that suite
- **CursorBench 3.2**: Within about 0.5% of Fable 5 at half the cost
- **ARC-AGI 3**: About 3× the next-best public model on that eval
- **OSWorld 2.0**: Strong computer-use scores at this price band
- **SWE-Bench Pro**: 69.2%; **Terminal-Bench 2.1**: 78.9%
- **Fast mode**: About 2.5× default speed at 2× base price
- **Pricing**: $5 per million input, $25 per million output (same list price as Opus 4.8)

&lt;/ListCheck&gt;

### Claude Fable 5

&lt;ListCheck&gt;

- **Mythos-class product tier**: Anthropic&apos;s strongest model sold as general availability under the Fable brand; Mythos 5 sits above it in some leaderboards
- **State-of-the-art coding on several internal and partner evals**: Stripe and others report large multi-month engineering compressions on huge codebases
- **Safety fallbacks**: Routes some cyber, bio/chem, and distillation queries to safer policies / lower tiers
- **Vision**: Strong screenshot and game-style vision demos
- **Long context**: 1M context, memory-style note use across long sessions
- **Pricing**: $10 per million input, $50 per million output

&lt;/ListCheck&gt;

### GPT-5.6

&lt;ListCheck&gt;

- **Three tiers**: Sol ($5/$30), Terra ($2.50/$15), Luna ($1/$6)
- **SWE-Bench Pro**: 64.6% (Sol)
- **Terminal-Bench 2.1**: 88.8% (Sol), 91.9% with ultra
- **AA Coding Agent Index**: 80 (ahead of Fable 5&apos;s 77.2 on that index)
- **BrowseComp**: 90.4% (92.2% ultra)
- **1M context**: MRCR v2 8-needle 512K–1M at 73.8%
- **Ultra mode**: Up to 4 parallel agents
- **Programmatic tool calling**: Built for dense multi-tool agent loops

&lt;/ListCheck&gt;

All three are strong. Continuous agent use still costs hundreds of dollars a month. The open models below do comparable work for roughly $15–180/month depending on which model and how hard you hit cache.

## 1. Kimi K3: Largest open-weight model, coding and web leader

**[Kimi K3](https://www.kimi.com/ai-models/kimi-k3)** is Moonshot AI&apos;s flagship, API launch around July 16, 2026, with open weights under the &quot;Kimi K3 License&quot; on July 27, 2026. At 2.8T MoE parameters (16B active of 896 experts) it is the largest open-weight model released so far. Native image and video input, 1M context, and top ranks on Frontend Code Arena, SWE Marathon, and BrowseComp. It is no longer the &quot;cheap Kimi&quot; of the K2.6 era: fresh input is $3/M and output is $15/M. Cache hits at $0.30/M input keep warm coding loops sane.

### Technical Specifications

| Feature                | Specification                          |
| ---------------------- | -------------------------------------- |
| **Total Parameters**   | 2.8 Trillion (MoE)                     |
| **Active Parameters**  | 16 Billion (16 of 896 experts)         |
| **Context Length**     | 1M tokens                              |
| **Architecture**       | MoE + Kimi Delta Attention (KDA)       |
| **Multimodal**         | Yes (text + image + video)             |
| **AA Intelligence**    | 57 (#4 of 189)                         |
| **BrowseComp**         | 91.2                                   |
| **SWE Marathon**       | 42.0                                   |
| **Terminal-Bench 2.1** | 88.3%                                  |
| **Program Bench**      | 77.8                                   |
| **FrontierSWE**        | 81.2                                   |
| **Input Cost**         | $0.30/M cache-hit / $3.00/M fresh      |
| **Output Cost**        | $15.00/M                               |
| **License**            | Kimi K3 License (open weights)         |

### What stands out

&lt;ListCheck&gt;

- **Largest open-weight release**: 2.8T total params; self-hosting needs cluster-class hardware, not a laptop
- **SWE Marathon 42.0**: Beats GPT-5.6 Sol and Fable 5 on that eval
- **#1 Frontend Code Arena**: Ahead of Claude Fable 5 on that leaderboard
- **BrowseComp 91.2**: Top-tier web browsing / research
- **Terminal-Bench 2.1 at 88.3%**: Competitive with GPT-5.6 Sol (88.8%)
- **1M context + multimodal**: Image and video in the same long sessions
- **Pricing caveat**: $15/M output and $3/M fresh input; design for cache hits above 90% in coding loops
- **Launch reasoning**: Only &quot;max&quot; reasoning level at launch; reasoning tokens bill at the output rate

&lt;/ListCheck&gt;

### Benchmark snapshot

- **AA Intelligence**: 57 (rank #4 of 189)
- **SWE Marathon**: 42.0
- **Terminal-Bench 2.1**: 88.3%
- **BrowseComp**: 91.2
- **Program Bench**: 77.8
- **FrontierSWE**: 81.2

&lt;Button
  text=&quot;Kimi K3 Model Page&quot;
  url=&quot;https://www.kimi.com/ai-models/kimi-k3&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### When to use it

- **Hard coding and agent marathons**: SWE Marathon and Frontend Arena leadership
- **Web-heavy research agents**: BrowseComp 91.2
- **Multimodal product work**: Screenshots, designs, short video frames
- **Warm multi-turn coding**: Cache-hit input pricing is the real lever
- **Skip if you need cheapest possible tokens**: MiniMax M3 or Qwen 3.6 Plus win on raw dollar spend

## 2. GLM-5.2: Strong overall coding workhorse

**[GLM-5.2](https://z.ai/blog/glm-5.2)** is Z.AI&apos;s flagship from June 16, 2026, and still one of the strongest open source coding models. It scores 62.1% on SWE-Bench Pro (ahead of GPT-5.5, close to GPT-5.6 Sol at 64.6%). On Terminal-Bench 2.1 it hits 81.0%, which beats Claude Opus 5&apos;s 78.9%. You get a 1M context window and High/Max effort control. Kimi K3 now leads several agent and coding-arena metrics; GLM-5.2 remains the steadier &quot;daily driver&quot; for many teams on price/performance.

### Technical Specifications

| Feature               | GLM-5.2                          |
| --------------------- | -------------------------------- |
| **Parameters**        | 753B                             |
| **Context Length**    | 1M tokens                        |
| **Max Output**        | 128K tokens                      |
| **SWE-Bench Pro**     | 62.1%                            |
| **Terminal-Bench 2.1**| 81.0%                            |
| **Input Cost**        | $1.40/M tokens                   |
| **Output Cost**       | $4.40/M tokens                   |
| **License**           | Open source                      |

&lt;Button
  text=&quot;GLM-5.2 Announcement&quot;
  link=&quot;https://z.ai/blog/glm-5.2&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

### Key strengths

&lt;ListCheck&gt;

- **62.1% SWE-Bench Pro**: Still among the best open scores; competitive with GPT-5.6 Sol (64.6%)
- **81.0% Terminal-Bench 2.1**: Beats Claude Opus 5 (78.9%)
- **1M token context**: IndexShare keeps long-context compute reasonable
- **Effort control**: High vs Max for speed/cost vs depth
- **Speculative decoding**: Roughly 20% better acceptance length for faster inference
- **Low hallucination reports**: Useful when agents run shell commands on real servers

&lt;/ListCheck&gt;

### Performance highlights

- **Coding**: 62.1% SWE-Bench Pro
- **Terminal agents**: 81.0% Terminal-Bench 2.1 (ahead of Opus 5)
- **FrontierSWE**: 74.4%
- **Cost**: $1.40/M input is $3.60 cheaper per million than Opus 5

&lt;Button
  text=&quot;Try GLM-5.2&quot;
  url=&quot;https://z.ai/subscribe?ic=NKNUNYDRZT&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;GLM Coding Plans&quot;&gt;

Z.AI offers [GLM Coding Plans](https://z.ai/subscribe?ic=NKNUNYDRZT) starting at $18/month for the Lite tier. GLM-5.2 consumes quota at 3× during peak hours and 2× during off-peak hours. Through September 2026, off-peak usage is billed at 1×.

&lt;/Notice&gt;

### Best use cases

- **Long-running agent tasks**: 1M context and steady multi-step execution
- **Production coding**: Full-stack work when you want Opus-adjacent quality without Opus bills
- **Enterprise / ops agents**: Low hallucination matters when mistakes are expensive
- **Tool-heavy workflows**: Reasoning plus search and function calling
- **Effort-sensitive tasks**: High for quick replies, Max when stuck

## 3. Qwen 3.6 Plus: Best for frontend and vibe coding

**[Qwen 3.6 Plus](https://qwen.ai/apiplatform)** is Alibaba&apos;s production coding/API option in the Qwen 3.6 line. It handles coding, reasoning, and general tasks with a 256K context window. Where it stands out is frontend work and &quot;vibe coding&quot;: responsive UIs, design-heavy pages, CSS, and components. Qwen 3.7 Max is available as API-only in places; Qwen 3.8 (about 2.4T) was announced at WAIC July 19, 2026 with open weights promised soon. For this list, 3.6 Plus is still the practical, cheap frontend pick.

### Technical Specifications

| Feature               | Specification        |
| --------------------- | -------------------- |
| **Context Length**    | 256K tokens          |
| **Architecture**      | Advanced Transformer |
| **Input Cost**        | $0.33/M tokens       |
| **Output Cost**       | $1.33/M tokens       |
| **API Compatibility** | OpenAI format        |

### What it offers

&lt;ListCheck&gt;

- **Frontend-first strength**: Responsive layouts, CSS, UI components
- **256K context**: Large enough for many monorepo slices
- **OpenAI-compatible API**: Swap base URL and key
- **Vibe coding**: Fast UI prototyping loops
- **Very cheap**: $0.33/M input, well under Opus 5

&lt;/ListCheck&gt;

### When to use it

- **Frontend and design-to-code**: Interfaces, CSS, layout
- **Rapid UI prototypes**: Iterate without burning budget
- **Budget coding**: One of the cheapest options here
- **Existing OpenAI-shaped stacks**: Drop-in endpoint swap
- **Watch the Qwen 3.8 release**: Open weights at 2.4T may change the ranking soon

## 4. MiniMax M3: The budget pick

**[MiniMax M3](https://go.bitdoze.com/minimax)** is MiniMax&apos;s flagship from June 1, 2026. It combines solid coding, a 1M context window, and native image/video input. MiniMax Sparse Attention (MSA) cuts long-context compute vs the M2.7 generation (about 1/20th per-token compute at 1M, with much faster prefill and decode). At $0.30 per million input tokens, an always-on coding agent often lands around $7–15 per month. SWE-Bench Pro at 59.0% still beats older GPT-5.5-class and Gemini 3.1 Pro numbers, but GPT-5.6 Sol at 64.6% is ahead. Treat M3 as the value workhorse, not the absolute coding king.

### Technical Specifications

| Feature                  | MiniMax M3                            |
| ------------------------ | ------------------------------------- |
| **Architecture**         | MiniMax Sparse Attention (MSA)        |
| **Context Length**       | 1M tokens                             |
| **Max Output**           | 512K tokens                           |
| **SWE-Bench Pro**        | 59.0%                                 |
| **Terminal-Bench 2.1**   | 66.0%                                 |
| **BrowseComp**           | 83.5                                  |
| **Multimodal**           | Native (image + video input)          |
| **Input Cost**           | $0.30/M tokens                        |
| **Output Cost**          | $1.20/M tokens                        |
| **Cache Read**           | $0.06/M tokens                        |

&lt;Button
  text=&quot;MiniMax M3 (10% Off)&quot;
  link=&quot;https://go.bitdoze.com/minimax&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
/&gt;

### What it does well

&lt;ListCheck&gt;

- **$0.30/M input**: Cheapest frontier-class option on this list (~17× cheaper than Opus 5 on input)
- **59.0% SWE-Bench Pro**: Beats GPT-5.5-era and Gemini 3.1 Pro numbers; sits under GPT-5.6 Sol (64.6%)
- **1M context with MSA**: Long context without absurd compute bills
- **83.5 BrowseComp**: Strong web search / browsing
- **Native multimodality**: Image and video understanding
- **66.0% Terminal-Bench 2.1**: Solid CLI agents
- **Agent frameworks**: Claude Code, OpenCode, Hermes Agent, and similar tools

&lt;/ListCheck&gt;

### Benchmark results

- **SWE-Bench Pro**: 59.0% (competitive, not above GPT-5.6 Sol)
- **Terminal-Bench 2.1**: 66.0%
- **BrowseComp**: 83.5
- **Cache read**: $0.06/M for repeated context
- **Output speed**: Around 100 tokens/sec in many deployments

&lt;Notice type=&quot;success&quot; title=&quot;Cheapest option&quot;&gt;

Running M3 continuously for a month of coding often costs $7–15. That is coffee-money relative to Opus 5 or Fable 5 on the same workload.

&lt;/Notice&gt;

### Long-horizon demos from launch

- **Paper reproduction**: Autonomously reproduced an ICLR 2025 paper in about 12 hours (18 commits, 23 figures)
- **CUDA kernel optimization**: 24-hour run lifted FP8 hardware utilization from 7.6% to 71.3% (about 9.4×)
- **Autonomous model training**: 0.37 on PostTrainBench end-to-end training another model

### Token Plan pricing

MiniMax offers a [Token Plan](https://platform.minimax.io/subscribe/token-plan) with discounted rates. Plans start at $20/month (Plus, ~1.7B tokens), $50/month (Max, ~5.1B tokens), and $120/month (Ultra, ~9.8B tokens). Sign up through [go.bitdoze.com/minimax](https://go.bitdoze.com/minimax) for 10% off.

### Best use cases

- **Always-on coding agents**: Cheap enough to leave running
- **Long-context whole-repo work**: 1M tokens without panic
- **Multimodal workflows**: Screenshots and mockups
- **High-volume cheap tokens**: Default model for everyday tasks

## 5. MiMo V2.5 Pro: The agent powerhouse

**[MiMo V2.5 Pro](https://go.bitdoze.com/mimo)** is Xiaomi&apos;s flagship for agent scenarios: complex software engineering, long-horizon tasks, and sessions with hundreds of tool calls. Internal demos include a full SysY compiler in Rust in 4.3 hours (672 tool calls) and a working video editor web app (8,192 lines) in 11.5 hours of autonomous work.

### Technical Specifications

| Feature               | MiMo V2.5 Pro                     |
| --------------------- | --------------------------------- |
| **Context Window**    | 1M tokens                         |
| **AA Intelligence**   | 53.8 (better than 98% of models)  |
| **AA Coding**         | 45.5 (better than 94% of models)  |
| **AA Agentic**        | 67.4 (better than 98% of models)  |
| **Input Cost (≤256K)** | $1.00/M tokens                   |
| **Output Cost (≤256K)**| $3.00/M tokens                   |
| **Input Cost (&gt;256K)** | $2.00/M tokens                   |
| **Output Cost (&gt;256K)**| $6.00/M tokens                   |
| **Cache Read**        | $0.20/M tokens                    |

&lt;Button
  text=&quot;MiMo Token Plan ($2 Bonus)&quot;
  link=&quot;https://go.bitdoze.com/mimo&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
/&gt;

### What it does well

&lt;ListCheck&gt;

- **AA Agentic Index 67.4**: Highest agentic score among the open models on this list
- **1M context**: Entire codebases in one session
- **Token efficiency**: On ClawEval, same score band as prior Kimi generations with ~42% fewer tokens
- **Long-horizon execution**: Hundreds of tool calls without losing the thread
- **Real build demos**: Compiler and multi-thousand-line app construction

&lt;/ListCheck&gt;

### Token Plan pricing

The [MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) starts at $72/year for the Lite tier (720 million credits). The Pro tier at $600/year gives 8.4 billion credits. Off-peak hours (16:00–24:00 UTC) get a 20% discount on top of the plan rate.

&lt;Notice type=&quot;info&quot; title=&quot;MiMo bonus&quot;&gt;
Sign up through [go.bitdoze.com/mimo](https://go.bitdoze.com/mimo) and get a $2 bonus credit on the MiMo Token Plan.
&lt;/Notice&gt;

### Best use cases

- **Complex agent workflows**: Highest agentic score here
- **Software engineering marathons**: Multi-hour builds and refactors
- **Token-efficient long runs**: Fewer tokens for similar task outcomes
- **OpenClaw / Hermes-style always-on agents**: Pair with a cheaper fallback

## 6. Mistral Medium 3.5: Frontier coding with open weights

**[Mistral Medium 3.5](https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04)** is Mistral&apos;s frontier dense model from April 2026. It is a 128B dense transformer (all parameters active each step), which often feels more coherent across large repos than MoE models of similar effective size. Open weights under a Modified MIT license; 77.6% on SWE-Bench Verified.

### Technical Specifications

| Feature               | Mistral Medium 3.5               |
| --------------------- | -------------------------------- |
| **Total Parameters**  | 128B (Dense)                     |
| **Active Parameters** | 128B                             |
| **Context Length**    | 256K tokens                      |
| **Architecture**      | Dense Transformer                |
| **SWE-Bench Verified**| 77.6%                            |
| **Input Cost**        | $1.50/M tokens                   |
| **Output Cost**       | $7.50/M tokens                   |
| **License**           | Modified MIT (open weights)      |

&lt;Button
  text=&quot;Mistral Medium 3.5 Docs&quot;
  link=&quot;https://docs.mistral.ai/models/model-cards/mistral-medium-3-5-26-04&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

### What it does well

&lt;ListCheck&gt;

- **77.6% SWE-Bench Verified**: Solid coding for a dense 128B
- **Dense architecture**: Full parameter use for long coherent edits
- **Open weights**: Self-hostable on roughly 4 GPUs
- **Multimodal**: Vision for screenshots, mockups, docs
- **Configurable reasoning**: Dial effort by task
- **Agentic coding**: Tool use and multi-step workflows

&lt;/ListCheck&gt;

### Benchmark results

- **SWE-Bench Verified**: 77.6%
- **Self-hostable**: vLLM and similar stacks on multi-GPU boxes
- **Multimodal**: Code-from-screenshot workflows
- **Cost**: $1.50/M input vs Opus 5&apos;s $5

### Best use cases

- **Coding agents with self-host requirements**
- **EU / GDPR-sensitive workloads**: Mistral is EU-based
- **Vision + code**: Mockups to implementation
- **When you want dense, not MoE**: Predictable activation every step

## Side-by-side comparison

How the six open models and three proprietary references line up (numbers as of late July 2026; blanks mean not published or not comparable on that exact suite):

### Performance comparison table

| Benchmark | GLM-5.2 | Kimi K3 | Qwen 3.6 Plus | MiniMax M3 | MiMo V2.5 Pro | Mistral Medium 3.5 | Claude Opus 5 | Claude Fable 5 | GPT-5.6 Sol |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| **SWE-Bench Pro** | 62.1% | — | — | 59.0% | — | — | 69.2% | ~80% | 64.6% |
| **Terminal-Bench 2.1** | 81.0% | 88.3% | — | 66.0% | — | — | 78.9% | 83.1% | 88.8% |
| **AA Intelligence** | — | 57 | — | — | 53.8 | — | 55.7 | 59.9 | 58.9 |
| **AA Agentic** | — | — | — | — | 67.4 | — | — | — | — |
| **Context** | 1M | 1M | 256K | 1M | 1M | 256K | 1M | 1M | 1M |
| **Multimodal** | No | Yes (img+video) | No | Yes (img+video) | No | Yes (vision) | Yes (vision) | Yes (vision) | Yes |
| **Open Weights** | Yes | Yes | Yes* | Yes | Yes | Yes | No | No | No |
| **Input $/M** | $1.40 | $0.30–$3.00 | $0.33 | $0.30 | $1.00 | $1.50 | $5.00 | $10.00 | $5.00 |
| **Output $/M** | $4.40 | $15.00 | $1.33 | $1.20 | $3.00 | $7.50 | $25.00 | $50.00 | $30.00 |

\*Qwen product line mixes API and open-weight variants; 3.6 Plus is the practical API tier for most users. Qwen 3.8 open weights are announced but not the default production pick yet.

### Feature comparison matrix

![LLM Feature Comparison Matrix](../../assets/images/25/07/feature-llm-comp.svg)

## Getting started

### Step 1: Choose access

&lt;ListCheck&gt;

- **OpenRouter**: One key, many models
- **Direct provider APIs**: Best rate limits and plan discounts (GLM, MiniMax, MiMo, Kimi, Mistral)
- **Self-hosting**: Kimi K3 needs serious hardware; Mistral Medium 3.5 is the realistic 4-GPU open option
- **IDE / agent tools**: OpenCode, Claude Code-compatible stacks, Hermes, Codex app any-model configs

&lt;/ListCheck&gt;

### Step 2: Environment (OpenRouter example)

```bash
# Install OpenAI SDK
pip install openai

# Set environment variables
export OPENROUTER_API_KEY=&quot;your_api_key_here&quot;
export OPENROUTER_BASE_URL=&quot;https://openrouter.ai/api/v1&quot;
```

### Step 3: Basic call

```python
import openai

client = openai.OpenAI(
    base_url=&quot;https://openrouter.ai/api/v1&quot;,
    api_key=&quot;your_openrouter_api_key&quot;
)

# GLM-5.2 for everyday agent coding
response = client.chat.completions.create(
    model=&quot;z-ai/glm-5.2&quot;,
    messages=[
        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a helpful coding assistant.&quot;},
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Create a Python web scraper for product prices&quot;}
    ]
)

print(response.choices[0].message.content)
```

### Step 4: Match context to the job

&lt;Notice type=&quot;warning&quot; title=&quot;Context length&quot;&gt;

GLM-5.2, Kimi K3, MiniMax M3, and MiMo V2.5 Pro all offer 1M tokens of context, matching Opus 5 / Fable 5 / GPT-5.6 Sol. Qwen 3.6 Plus and Mistral Medium 3.5 sit at 256K. For even cheaper 1M context options, see DeepSeek V4 Pro in our [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide.

&lt;/Notice&gt;

## Cost breakdown

Illustrative monthly cost at 10M input + 10M output tokens (API list rates; real bills vary with cache, plans, and mix). Opus 5 is the open-source comparison baseline.

### Monthly cost comparison (10M in + 10M out)

| Model | Input | Output | Total | vs Opus 5 |
| --- | --- | --- | --- | --- |
| **Claude Fable 5** | $100 | $500 | $600 | — |
| **Claude Opus 5** | $50 | $250 | $300 | Baseline |
| **GPT-5.6 Sol** | $50 | $300 | $350 | — |
| **GLM-5.2** | $14 | $44 | $58 | ~81% less |
| **Kimi K3 (cache-hit input)** | $3 | $150 | $153 | ~49% less |
| **Kimi K3 (fresh input)** | $30 | $150 | $180 | ~40% less |
| **Qwen 3.6 Plus** | $3.30 | $13.30 | $16.60 | ~94% less |
| **MiniMax M3** | $3 | $12 | $15 | ~95% less |
| **MiMo V2.5 Pro** | $10 | $30 | $40 | ~87% less |
| **Mistral Medium 3.5** | $15 | $75 | $90 | ~70% less |

Kimi K3&apos;s $15/M output is the surprise for people who remember K2.6. Without high cache-hit rates on input, it is not the budget pick anymore. MiniMax M3 and Qwen 3.6 Plus still win pure dollar races.

### What the savings buy you

- More experiments and longer agent runs without finance panic
- Team-wide access instead of a few seats on Opus/Fable
- AI in more pipelines (CI bots, review agents, always-on assistants)

## Tips and common mistakes

### What works

&lt;ListCheck&gt;

- **Match model to task**: Cheap models for boilerplate; Kimi K3 / GLM-5.2 / MiMo for hard agent work
- **Watch context**: Long windows are not free
- **Prompt per model**: Each family likes different instruction styles
- **Batch where you can**: Fewer round-trips, less overhead
- **Measure in your domain**: Public benches are not your codebase

&lt;/ListCheck&gt;

### What to avoid

- Defaulting every task to the most expensive proprietary model
- Shipping agent output without domain checks
- Ignoring token meters until the invoice lands
- Single-model lock-in with no fallback when a provider hiccups

## What&apos;s coming next

- **Qwen 3.8** (~2.4T, open weights promised) after the July 19 WAIC announcement; 3.7 Max already exists as API-only in some channels
- **Kimi K3 weights** already out; tooling and quantization for self-host will matter more than marketing slides
- **DeepSeek V4** leaving preview windows and joining cheap 1M-context agent stacks
- **Multimodal defaults**: Image/video as table stakes, not extras
- **Faster inference**: Real-time agent UX improving month over month

## Which one should you pick?

### Kimi K3 if you want:

- Largest open-weight model (2.8T) with released weights
- SWE Marathon 42.0 and #1 Frontend Code Arena
- BrowseComp 91.2 and 1M multimodal context
- Strong Terminal-Bench (88.3%) next to GPT-5.6 Sol
- You can live with $15/M output and design for cache hits

&lt;Button
  text=&quot;Try Kimi K3&quot;
  url=&quot;https://www.kimi.com/ai-models/kimi-k3&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### GLM-5.2 if you need:

- 62.1% SWE-Bench Pro and everyday coding reliability
- 81.0% Terminal-Bench 2.1 (beats Opus 5&apos;s 78.9%)
- 1M context and High/Max effort control
- Predictable $1.40/$4.40 pricing without Kimi&apos;s output tax

&lt;Button
  text=&quot;Try GLM-5.2&quot;
  url=&quot;https://z.ai/subscribe?ic=NKNUNYDRZT&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### Qwen 3.6 Plus if you care about:

- Frontend and vibe coding
- $0.33/M input
- OpenAI-compatible endpoints
- 256K context for large UI monorepos

&lt;Button
  text=&quot;Explore Qwen 3.6 Plus&quot;
  url=&quot;https://openrouter.ai/qwen/qwen3.6-plus&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### MiniMax M3 if you want:

- Cheapest frontier-class option at $0.30/M input (~17× cheaper than Opus 5 on input)
- 1M context with sparse attention
- 59.0% SWE-Bench Pro (beats older GPT-5.5 / Gemini 3.1 Pro; under GPT-5.6 Sol)
- Native image + video
- Always-on agents for roughly $7–15/month

&lt;Button
  text=&quot;MiniMax M3 (10% Off)&quot;
  link=&quot;https://go.bitdoze.com/minimax&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### MiMo V2.5 Pro if you need:

- Strongest AA Agentic score on this open list (67.4)
- 1M context matching Opus 5 class windows
- Token-efficient long tool chains
- Hundreds of tool calls in one session

&lt;Button
  text=&quot;MiMo Token Plan ($2 Bonus)&quot;
  link=&quot;https://go.bitdoze.com/mimo&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

### Mistral Medium 3.5 if you need:

- Self-hostable open weights on ~4 GPUs
- Dense 128B coherence across big repos
- Vision for screenshot-driven coding
- EU-based provider for compliance stories

&lt;Button
  text=&quot;Try Mistral Medium 3.5&quot;
  url=&quot;https://console.mistral.ai/&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

Any of these six models will undercut Claude Opus 5, Fable 5, or GPT-5.6 Sol on continuous use. Kimi K3 is the largest open-weight release and leads SWE Marathon plus Frontend Code Arena. GLM-5.2 is still the practical coding daily driver with Terminal-Bench that beats Opus 5. MiniMax M3 is the cheap always-on default with 1M multimodal context. Qwen 3.6 Plus owns frontend-on-a-budget. MiMo V2.5 Pro owns agentic scores. Mistral Medium 3.5 is the self-host dense option. Pick for your workflow and bill, not for the loudest launch blog post.

&lt;Notice type=&quot;success&quot; title=&quot;Ready to get started?&quot;&gt;

All six models are available through their providers and (in most cases) OpenRouter. If you use the Codex app, our [Codex app with any model guide](/codex-app-any-model/) shows how to plug GLM-5.2, MiniMax, and MiMo in with a short config. For coding agents on a cheap VPS, see the [OpenCode setup guide](/opencode-setup-guide/) and [Pi coding agent setup guide](/pi-coding-agent-setup-guide/). Full pricing and Hermes-focused benches live in [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/). For the wider tooling map (OpenClaw, Hermes, Pi, gateways, memory, MCP), see [top AI GitHub repos](/top-ai-github-repos/).

&lt;/Notice&gt;</content:encoded><category>ai</category><category>llm</category></item><item><title>Best Open Source Models for OpenClaw</title><link>https://www.bitdoze.com/best-opensource-models-for-openclaw/</link><guid isPermaLink="true">https://www.bitdoze.com/best-opensource-models-for-openclaw/</guid><description>GLM-5.2 and MiniMax M3 are the best open source models for running OpenClaw. Setup, pricing, why Claude Code or Gemini CLI subscriptions are a bad idea, and when to try Kimi K3.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>I&apos;ve been running [OpenClaw](https://www.bitdoze.com/clawdbot-setup-guide/) for a while and tried a pile of models with it. After swapping providers and watching API bills, two still make the most sense for day-to-day use: **GLM-5.2** and **MiniMax M3**.

Below: why I landed there, how to set them up, why routing a Claude Code or Gemini CLI subscription through OpenClaw is a bad idea, and when **Kimi K3** is worth the higher output price.

&lt;Notice type=&quot;warning&quot; title=&quot;Subscription Risk Warning&quot;&gt;

Using your Claude Code, Gemini CLI, or ChatGPT/Codex subscription OAuth tokens with OpenClaw can get your account banned. Anthropic, Google, and OpenAI watch for automated usage patterns that fall outside normal CLI use. Stick with API keys.

&lt;/Notice&gt;

## Why open source models make sense for OpenClaw

OpenClaw runs 24/7 on your server. It handles messages, scheduled jobs, and skills nonstop. That continuous load gets expensive on proprietary models.

Open source models through API providers give you:

&lt;ListCheck&gt;

- **Predictable costs**: Pay per token, no surprise subscription overages
- **No ban risk**: API access is meant for automated use
- **Model flexibility**: Swap models in config
- **Better rate limits**: API tiers usually beat subscription OAuth for throughput

&lt;/ListCheck&gt;

If you&apos;re new to OpenClaw, the [setup guide](https://www.bitdoze.com/clawdbot-setup-guide/) covers install. For other platforms, see [OpenClaw alternatives](https://www.bitdoze.com/openclaw-alternatives/). For the wider coding-model roundup (Opus 5, GPT-5.6 Sol, Kimi K3, and the rest), see [best open source LLMs as Claude alternatives](https://www.bitdoze.com/best-open-source-llms-claude-alternative/).

## The risks of using Claude Code or Gemini CLI subscriptions

I&apos;ll get this out of the way first because people ask constantly.

OpenClaw can take OAuth tokens from Claude Code, Gemini CLI, and OpenAI Codex. Technically you can point a $20/month Claude Pro plan or a Google AI sub at OpenClaw instead of paying API credits. It works. You&apos;re also gambling.

### Why you can get banned

Anthropic, Google, and OpenAI restrict how subscription tokens may be used. Route a Claude Code OAuth token through OpenClaw and this is what changes:

&lt;ListCheck&gt;

- **Usage patterns**: Normal Claude Code looks like a human in a terminal. OpenClaw fires automated requests around the clock, often in bursts when jobs run
- **Token volume**: An always-on assistant burns more than a human coding session
- **Retries and parallel calls**: Failures and fan-out look like scraping to detectors
- **IP and fingerprinting**: VPS data-center traffic does not look like a laptop on residential internet

&lt;/ListCheck&gt;

### What happens when you get banned

| Platform | Consequence | Recovery |
|----------|-------------|----------|
| **Claude Code** | Account suspended, subscription cancelled | Appeal exists, no guarantee |
| **Gemini CLI** | Google account flagged, API access revoked | Can spill into other Google services |
| **OpenAI Codex** | Account banned, subscription terminated | Limited appeal options |

&lt;Notice type=&quot;error&quot; title=&quot;Real Risk&quot;&gt;

People have lost accounts within days of routing subscription tokens through automated tools. Detection is getting better, not worse. A Claude Max sub is not worth risking over a few dollars of API spend.

&lt;/Notice&gt;

### What to do instead

Use API keys. Every provider sells pay-as-you-go access built for automation:

- **Anthropic API**: [console.anthropic.com](https://console.anthropic.com/)
- **Google AI**: [aistudio.google.com](https://aistudio.google.com/)
- **OpenAI API**: [platform.openai.com](https://platform.openai.com/)

Or skip the ban question entirely and use open models. That&apos;s the rest of this post.

## 1. GLM-5.2: The one I use for serious work

**[GLM-5.2](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)** from Z.AI is the open model I trust most for OpenClaw&apos;s hard path. It reasons well, rarely invents facts when it shouldn&apos;t, and holds multi-step agent work without falling apart halfway through. On Terminal-Bench 2.1 it scores 81.0%, which beats Claude Opus 5&apos;s 78.9%. SWE-Bench Pro sits at 62.1%, close to GPT-5.6 Sol&apos;s 64.6%.

### Why GLM-5.2 works for OpenClaw

OpenClaw is not a chatbot. It plans, calls tools, runs scripts, and keeps context across long threads. GLM-5.2 handles that kind of work reliably.

&lt;ListCheck&gt;

- **62.1% SWE-Bench Pro**: Among the best open coding scores; matters when OpenClaw edits or runs code on your box
- **81.0% Terminal-Bench 2.1**: Beats Opus 5 on that agent CLI suite
- **1M context**: Room for long OpenClaw history plus tool dumps
- **Effort control**: High vs Max when you want speed or depth
- **Low hallucination reports**: When the assistant shells out on a live server, wrong answers hurt
- **Open source license**: Fine for automated commercial workflows

&lt;/ListCheck&gt;

### Technical specs

| Feature               | GLM-5.2                          |
| --------------------- | -------------------------------- |
| **Parameters**        | 753B                             |
| **Context Length**    | 1M tokens                        |
| **SWE-Bench Pro**     | 62.1%                            |
| **Terminal-Bench 2.1**| 81.0%                            |
| **Input Cost**        | $1.40/M tokens                   |
| **Output Cost**       | $4.40/M tokens                   |
| **License**           | Open source                      |

&lt;Button
  text=&quot;GLM-5.2 Coding Plans&quot;
  link=&quot;https://z.ai/subscribe?ic=NKNUNYDRZT&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

### Setting up GLM-5.2 with OpenClaw

```bash
openclaw configure --section models
```

Or edit the config directly:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;z-ai/glm-5.2&quot;,
        &quot;fallback&quot;: [&quot;minimax/m3&quot;]
      }
    }
  }
}
```

Then restart the gateway:

```bash
openclaw gateway restart
```

GLM-5.2 is on OpenRouter and the Z.AI API. For OpenClaw I&apos;d use Z.AI coding plans; they&apos;re priced for continuous developer workloads.

&lt;Notice type=&quot;info&quot; title=&quot;GLM Coding Plans&quot;&gt;

Z.AI offers [GLM Coding Plans](https://z.ai/subscribe?ic=NKNUNYDRZT) aimed at people running continuous workloads like OpenClaw. Peak hours still burn more quota (3× peak / 2× off-peak on some plans; check current Z.AI docs).

&lt;/Notice&gt;

### Where GLM-5.2 shines in OpenClaw

- **Scheduled tasks**: Morning briefings, server checks, cron jobs that need to actually work
- **Coding skills**: Scripts, Docker, repo edits
- **Research**: Pairs well with [DuckDuckGo search](https://www.bitdoze.com/duckduckgo-openclaw-search/)
- **Multi-step skills**: Chains of tool calls without losing the plot
- **Long sessions**: 1M context helps when history piles up

## 2. MiniMax M3: The cheap one that still holds up

**[MiniMax M3](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)** costs a fraction of GLM-5.2 and still posts 59.0% SWE-Bench Pro, 1M context, and native image/video. For always-on OpenClaw chat and light skills, that price gap matters over a month. It beats older GPT-5.5 / Gemini 3.1 Pro coding numbers; GPT-5.6 Sol at 64.6% is still ahead. That&apos;s fine. M3 is the value default, not the absolute peak.

### Why MiniMax M3 works for OpenClaw

Most OpenClaw traffic does not need the absolute best model. Quick questions, reminders, file ops, simple research. A cheaper model handles those. M3 is cheap enough that you stop thinking about it.

&lt;ListCheck&gt;

- **59.0% SWE-Bench Pro**: Enough for most OpenClaw coding skills
- **$0.30/M input / $1.20/M output**: Always-on use often lands around $7–15/month
- **1M context with MSA**: Long context without absurd compute cost
- **Native multimodality**: Screenshots and short video when a skill needs eyes
- **66.0% Terminal-Bench 2.1**: Solid for shell-heavy skills
- **Agent frameworks**: Works with the usual Claude Code-compatible / agent stacks

&lt;/ListCheck&gt;

### Technical specs

| Feature                  | MiniMax M3                            |
| ------------------------ | ------------------------------------- |
| **Architecture**         | MiniMax Sparse Attention (MSA)        |
| **Context Length**       | 1M tokens                             |
| **SWE-Bench Pro**        | 59.0%                                 |
| **Terminal-Bench 2.1**   | 66.0%                                 |
| **Input Cost**           | $0.30/M tokens                        |
| **Output Cost**          | $1.20/M tokens                        |
| **Cache Read**           | $0.06/M tokens                        |

&lt;Button
  text=&quot;MiniMax Coding Plans (10% Off)&quot;
  link=&quot;https://go.bitdoze.com/minimax&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
/&gt;

### Setting up MiniMax M3 with OpenClaw

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;minimax/m3&quot;,
        &quot;fallback&quot;: [&quot;z-ai/glm-5.2&quot;]
      }
    }
  }
}
```

If your provider exposes a faster variant, point interactive chat there and keep standard M3 (or GLM) for heavier skills. Model IDs move; check OpenRouter / MiniMax for the current slug.

&lt;Notice type=&quot;success&quot; title=&quot;Cost Breakdown&quot;&gt;

At list rates, heavy always-on OpenClaw on MiniMax M3 often lands around $7–15/month depending on tool spam and context size. Claude API for the same pattern is easy $50–150+.

&lt;/Notice&gt;

### Where MiniMax M3 shines in OpenClaw

- **Always-on chat**: Cheap enough you don&apos;t ration messages
- **Quick tasks**: Reminders, files, simple lookups
- **Coding assist**: 59% SWE-Bench Pro covers a lot of everyday skill work
- **Scheduled jobs**: Briefings and server checks on a budget
- **Fallback**: Secondary model when the primary hits rate limits

## 3. Optional: Kimi K3 when the hard path needs more

**[Kimi K3](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)** (2.8T MoE, 1M context, multimodal, open weights July 27, 2026) leads SWE Marathon and Frontend Code Arena and scores 88.3% on Terminal-Bench 2.1. AA Intelligence sits around 57 (#4 of 189).

I do **not** run it as my default OpenClaw model. Fresh input is $3/M and output is $15/M. That output price is higher than GLM-5.2 and wrecks the &quot;cheap always-on&quot; story unless cache-hit input ($0.30/M) stays very high. Use K3 for:

- Tough multi-hour coding / agent marathons
- Web-heavy research (BrowseComp 91.2)
- Frontend-heavy skills where Arena rank matters

Example primary/fallback if you want it only for a dedicated agent:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;z-ai/glm-5.2&quot;,
        &quot;fallback&quot;: [&quot;minimax/m3&quot;]
      }
    }
  }
}
```

Point a specific high-stakes agent at the Kimi K3 provider ID when you need it; leave chat and cron on MiniMax or GLM.

## GLM-5.2 vs MiniMax M3: head to head

| Feature | GLM-5.2 | MiniMax M3 |
|---------|---------|------------|
| **SWE-Bench Pro** | 62.1% | 59.0% |
| **Terminal-Bench 2.1** | 81.0% | 66.0% |
| **Input Cost** | $1.40/M | $0.30/M |
| **Output Cost** | $4.40/M | $1.20/M |
| **Context Length** | 1M | 1M |
| **Multimodal** | No | Yes (img+video) |
| **Best for** | Complex tasks, coding, research | Always-on chat, budget usage |
| **Monthly cost (est.)** | $30–60 | $7–15 |

### What I actually run

Both. GLM-5.2 as primary, MiniMax M3 as fallback:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;z-ai/glm-5.2&quot;,
        &quot;fallback&quot;: [&quot;minimax/m3&quot;]
      }
    }
  }
}
```

GLM does the hard stuff. MiniMax catches rate limits and keeps simple chat cheap. For low-stakes skills and scheduled noise, point those agents at MiniMax directly.

## DuckDuckGo search with your models

GLM-5.2 and MiniMax M3 both work with OpenClaw web search. Setup: [DuckDuckGo OpenClaw search guide](https://www.bitdoze.com/duckduckgo-openclaw-search/).

Without search, OpenClaw is stuck on training cutoffs. With search, it can pull current docs and status pages when skills need them.

## Cost comparison: open source vs subscriptions

Rough monthly OpenClaw spend by approach:

| Approach | Monthly Cost | Ban Risk | Notes |
|----------|-------------|----------|-------|
| **Claude Code OAuth** | $20–200 (sub) | High | ToS risk, suspension |
| **Gemini CLI OAuth** | $0–20 (sub) | High | Can hit the whole Google account |
| **Claude API (Opus 5 / Sonnet 5)** | $50–200+ | None | Expensive for 24/7 |
| **GPT-5.6 Sol API** | $50–150+ | None | Strong coding, still pricey always-on |
| **GLM-5.2 API** | $30–60 | None | Best balance for hard work |
| **MiniMax M3 API** | $7–15 | None | Best pure value |
| **Kimi K3 API** | $40–180+ | None | Depends hard on cache / output volume |
| **GLM-5.2 + M3 combo** | $20–45 | None | What I run |

API plus open models usually costs less than a Claude Max-style setup for always-on agents, and you keep the account.

## Tips from running this setup

### Prompt tuning

GLM-5.2 and MiniMax M3 react differently to the same prompt. GLM likes clear, structured instructions. MiniMax is fine with casual chat but needs explicit format rules for structured output.

Put shared guardrails in `~/.openclaw/workspace/SOUL.md`:

```markdown
When executing tasks:
- Break complex requests into clear steps
- Confirm before running destructive commands
- Use structured output for research results
```

### Context management

Both support 1M tokens, but shorter threads still work better. Use `/compact` when history balloons, or `/new` when the session is toast.

### Monitoring costs

Watch usage the first week. Cron and background skills burn more tokens than chat alone:

- **Z.AI**: [z.ai/dashboard](https://z.ai/dashboard)
- **MiniMax**: [platform.minimax.io](https://platform.minimax.io)

### Fallback configuration

Always set a fallback. Providers go down. If GLM is out at 3 AM and a briefing is scheduled, MiniMax should pick it up.

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Can I use both GLM-5.2 and MiniMax M3 at the same time?**

Yes. Primary + fallback in config, or different models per agent/skill.

**Will using my Claude subscription with OpenClaw definitely get me banned?**

Not guaranteed, but the risk is real. Anthropic monitors automated OAuth patterns. Several people have reported suspensions. API access exists for a reason.

**How much does OpenClaw cost with MiniMax M3 for a month?**

Most light-to-medium setups land around $7–15. Heavy cron + long contexts can hit $20–25. Still far under Opus 5 / GPT-5.6 Sol API for the same pattern.

**Should I use Kimi K3 as my default OpenClaw model?**

Only if you need its coding/web peaks and you understand $15/M output. For 24/7 chat and light skills, MiniMax M3 or GLM-5.2 are more rational. K3 shines on hard one-off agent runs and warm multi-turn coding with high cache hits.

**Can I switch models without restarting OpenClaw?**

Yes. `/model` in chat for on-the-fly switches. Permanent changes: edit config, then `openclaw gateway restart`.

**Do these models support DuckDuckGo search in OpenClaw?**

Yes. See the [DuckDuckGo search integration](https://www.bitdoze.com/duckduckgo-openclaw-search/).

**What about local models with Ollama?**

Solid if you have the hardware. [OpenClaw with Ollama](/openclaw-ollama-local-models/) covers tiers, Nanbeige-class small models, and hybrid local primary + API fallback. On a typical VPS, GLM-5.2 / MiniMax M3 over API still beat what you can run locally.

&lt;/Accordion&gt;

There is no good reason to risk a Claude or Gemini subscription on an always-on agent. GLM-5.2 handles the hard work, MiniMax M3 keeps everyday traffic cheap, and Kimi K3 is there when a single job needs the open-weight peak. Set primary + fallback, lock the box down, and stop worrying about ban emails.

Harden first if you have not already. The [OpenClaw security guide](/openclaw-security-guide/) covers CVE-2026-25253, 40+ patched issues, and a lockdown checklist.

For the full model breakdown (Kimi K3, Qwen 3.6 Plus, MiMo V2.5 Pro, Mistral Medium 3.5, Opus 5, GPT-5.6 Sol, DeepSeek-V4), read [best open source LLMs for coding](https://www.bitdoze.com/best-open-source-llms-claude-alternative/). Platform shopping: [OpenClaw alternatives](https://www.bitdoze.com/openclaw-alternatives/). Broader AI tooling on GitHub (assistants, coding agents, gateways, memory): [top AI GitHub repos](/top-ai-github-repos/). Container-isolated Claude agents: [NanoClaw deploy](/nanoclaw-deploy-guide/). Small Zig binary with many providers: [NullClaw deploy](/nullclaw-deploy-guide/). Visibility into sessions, costs, and cron: [best OpenClaw dashboards](https://www.bitdoze.com/best-openclaw-dashboards/).</content:encoded><category>ai</category><category>openclaw</category><category>llm</category></item><item><title>Top 60+ Docker Commands Every Developer MUST Know in 2025</title><link>https://www.bitdoze.com/docker-commands/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-commands/</guid><description>Master 60+ Docker commands in this updated 2025 guide. Covers everything from basic containers and Compose v2 to buildx, disk cleanup, and health checks.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you ship software in containers, you use Docker commands every day. Whether you run a few services on a [home server](/docker-containers-home-server/) or manage [self-hosted Docker containers for business](/docker-containers-business/), knowing the CLI saves time and prevents outages.

This guide covers 60+ Docker commands: basic image and container operations, Compose v2, buildx multi-platform builds, disk cleanup, health checks, and registry authentication. Everything is updated for Docker Engine 28.x/29.x (2025). If you&apos;re still seeing `docker-compose` with a hyphen in old tutorials, that&apos;s dead and gone.

## Quick-reference cheat sheet

| Category | Command | Description |
|----------|---------|-------------|
| **Info** | `docker version` | Show client and server version |
| **Info** | `docker info` | System-wide Docker information |
| **Images** | `docker pull &lt;image&gt;:&lt;tag&gt;` | Download image from registry |
| **Images** | `docker images` | List local images |
| **Images** | `docker rmi &lt;image&gt;` | Remove an image |
| **Images** | `docker image prune` | Remove dangling images |
| **Images** | `docker build -t &lt;name&gt; .` | Build image from Dockerfile |
| **Images** | `docker tag &lt;src&gt; &lt;dst&gt;` | Tag an image |
| **Images** | `docker push &lt;image&gt;` | Push image to registry |
| **Images** | `docker history &lt;image&gt;` | Show image layer history |
| **Containers** | `docker run -d --name &lt;n&gt; &lt;img&gt;` | Run container in background |
| **Containers** | `docker run -it &lt;img&gt; /bin/bash` | Run interactive container |
| **Containers** | `docker ps` | List running containers |
| **Containers** | `docker ps -a` | List all containers |
| **Containers** | `docker stop &lt;container&gt;` | Stop a running container |
| **Containers** | `docker start &lt;container&gt;` | Start a stopped container |
| **Containers** | `docker restart &lt;container&gt;` | Restart a container |
| **Containers** | `docker rm &lt;container&gt;` | Remove a stopped container |
| **Containers** | `docker exec -it &lt;c&gt; /bin/bash` | Run command in container |
| **Containers** | `docker logs -f &lt;container&gt;` | Follow container logs |
| **Containers** | `docker inspect &lt;container&gt;` | Low-level container info |
| **Containers** | `docker stats` | Live resource usage |
| **Containers** | `docker top &lt;container&gt;` | Running processes in container |
| **Containers** | `docker cp &lt;c&gt;:/path /host` | Copy files from container |
| **Containers** | `docker update --restart=always &lt;c&gt;` | Update restart policy live |
| **Networking** | `docker network ls` | List networks |
| **Networking** | `docker network create &lt;name&gt;` | Create a network |
| **Networking** | `docker network connect &lt;net&gt; &lt;c&gt;` | Connect container to network |
| **Volumes** | `docker volume ls` | List volumes |
| **Volumes** | `docker volume create &lt;name&gt;` | Create a volume |
| **Volumes** | `docker run -v &lt;vol&gt;:/path &lt;img&gt;` | Mount a volume |
| **Compose** | `docker compose up -d` | Start services (detached) |
| **Compose** | `docker compose down` | Stop and remove services |
| **Compose** | `docker compose ps` | List compose services |
| **Compose** | `docker compose logs -f` | Follow compose logs |
| **Compose** | `docker compose --profile &lt;p&gt; up` | Start specific profile |
| **Buildx** | `docker buildx ls` | List builder instances |
| **Buildx** | `docker buildx build --platform linux/amd64,linux/arm64` | Multi-platform build |
| **Cleanup** | `docker system df` | Show disk usage |
| **Cleanup** | `docker system prune -a` | Remove all unused resources |
| **Cleanup** | `docker system prune -a --volumes` | Nuclear cleanup (includes volumes) |
| **Registry** | `docker login` | Authenticate to registry |
| **Registry** | `docker logout` | Log out from registry |
| **Advanced** | `docker save -o file.tar &lt;img&gt;` | Save image to tar |
| **Advanced** | `docker load -i file.tar` | Load image from tar |
| **Advanced** | `docker export &lt;c&gt; &gt; file.tar` | Export container filesystem |
| **Advanced** | `docker import file.tar` | Import container filesystem |
| **Advanced** | `docker context create` | Manage remote Docker daemons |

## Prerequisites

Before running these commands:

&lt;ListCheck&gt;
- Docker Engine 24+ installed (28.x or 29.x recommended)
- Your user is in the `docker` group, or you&apos;re using `sudo`
- Verify the daemon is running: `docker version` should show both Client and Server sections
&lt;/ListCheck&gt;

```sh
$ docker version
Client:
 Version:           28.1.1
 API version:       1.49
 Go version:        go1.23.8
 OS/Arch:           linux/amd64

Server:
 Engine:
  Version:          28.1.1
  API version:      1.49 (minimum version 1.24)
  Go version:       go1.23.8
  OS/Arch:          linux/amd64
```

&lt;Notice type=&quot;warning&quot; title=&quot;Common startup errors&quot;&gt;
&quot;Cannot connect to the Docker daemon&quot; means the service isn&apos;t running. Fix with `sudo systemctl start docker &amp;&amp; sudo systemctl enable docker`. &quot;permission denied&quot; means your user isn&apos;t in the docker group. Fix with `sudo usermod -aG docker $USER` then log out and back in.
&lt;/Notice&gt;

Output shown is from Docker 28.x. Your version numbers will differ: that&apos;s fine. The commands are stable across versions.

## Section 1: Basic Docker Commands

### 1.1 Docker Version and Info

Understanding what version of Docker you&apos;re running and the system-wide configuration is the first step.

- **`docker --version`**: quick check that Docker is installed and which version.

  ```sh
  $ docker --version
  Docker version 28.1.1, build 4eba7b2
  ```

- **`docker version`**: detailed client and server version info (shown in Prerequisites above).

- **`docker info`**: system-wide overview: container/image counts, storage driver, logging driver, kernel version, total memory.

  ```sh
  $ docker info
  Containers: 5
   Running: 3
   Paused: 0
   Stopped: 2
  Images: 12
  Storage Driver: overlay2
  Logging Driver: json-file
  Cgroup Driver: systemd
  Cgroup Version: 2
  Kernel Version: 6.8.0-49-generic
  Operating System: Ubuntu 24.04.1 LTS
  CPUs: 4
  Total Memory: 7.771GiB
  ```

  If `Server:` section is missing, the Docker daemon isn&apos;t running.

### 1.2 Docker Help

Docker has built-in help for every command:

- **`docker --help`**: lists all available commands and management commands.

- **`docker &lt;command&gt; --help`**: detailed options for a specific command. This is the most useful one. For example:

  ```sh
  $ docker run --help

  Usage:  docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

  Run a command in a new container

  Options:
    -d, --detach                         Run container in background
    --name string                        Assign a name to the container
    -p, --publish list                   Publish a container&apos;s port(s) to the host
    -v, --volume list                    Bind mount a volume
    --rm                                 Remove container when it exits
    -i, --interactive                    Keep STDIN open
    -t, --tty                            Allocate a pseudo-TTY
    --restart string                     Restart policy (no, always, unless-stopped, on-failure)
    --env list                           Set environment variables
    --network string                     Connect to a network
    --health-cmd string                  Health check command
    --init                               Run an init process inside the container
    --gpus gpu-request                   GPU devices to add to the container
  ```

  When in doubt, `docker &lt;cmd&gt; --help` is faster than searching the web.

## Section 2: Working with Docker Images

### 2.1 Pulling Images

Pulling images from a registry is usually the first step.

- **`docker pull &lt;image&gt;`**: downloads the `latest` tag by default.

  ```sh
  $ docker pull ubuntu
  Using default tag: latest
  latest: Pulling from library/ubuntu
  6d28e14ab8c8: Pull complete
  Digest: sha256:abc123...
  Status: Downloaded newer image for ubuntu:latest
  docker.io/library/ubuntu:latest
  ```

- **`docker pull &lt;image&gt;:&lt;tag&gt;`**: pull a specific version.

  ```sh
  $ docker pull nginx:alpine
  alpine: Pulling from library/nginx
  Digest: sha256:def456...
  Status: Downloaded newer image for nginx:alpine
  docker.io/library/nginx:alpine
  ```

&lt;Notice type=&quot;warning&quot; title=&quot;Docker Hub rate limits&quot;&gt;
Unauthenticated pulls are limited to 100 per 6 hours per IPv4 address (or IPv6 /64 subnet). Free authenticated accounts get 200 per 6 hours. If you hit rate limits in CI/CD, run `docker login` first. Paid subscriptions get unlimited pulls.
&lt;/Notice&gt;

### 2.2 Listing Images

- **`docker images`**: list all local images.

  ```sh
  $ docker images
  REPOSITORY   TAG       IMAGE ID       CREATED        SIZE
  nginx        alpine    a1b2c3d4e5f6   2 weeks ago    43MB
  ubuntu       latest    f6e5d4c3b2a1   3 weeks ago    77.9MB
  ```

- **`docker images -a`**: include intermediate build layers.

- **`docker images --filter &quot;dangling=true&quot;`**: show untagged images not referenced by any container. These are safe to remove.

### 2.3 Removing Images

- **`docker rmi &lt;image&gt;`**: remove an image by name or ID.

  ```sh
  $ docker rmi nginx:alpine
  Untagged: nginx:alpine
  Deleted: sha256:a1b2c3d4e5f6...
  ```

  If a container is still using the image, you&apos;ll get &quot;image is being used by running container&quot;: stop and remove the container first.

- **`docker image prune`**: remove all dangling images (untagged). Add `-a` to remove all unused images.

For a deeper dive into reclaiming disk space from Docker&apos;s storage layer, see [how to reclaim disk space from Docker overlay2](/clean-docker-overlay2-dir/).

### 2.4 Building Images

- **`docker build -t &lt;name&gt;:&lt;tag&gt; .`**: build an image from a Dockerfile in the current directory.

  ```sh
  $ docker build -t myapp:1.0 .
  [+] Building 12.5s (8/8) FINISHED
  ```

&lt;Notice type=&quot;info&quot; title=&quot;BuildKit is now the default&quot;&gt;
Since Docker Engine 23.0 (February 2023), BuildKit is the default builder. You no longer need `DOCKER_BUILDKIT=1`. BuildKit gives you faster builds, better caching, and multi-stage build support out of the box.
&lt;/Notice&gt;

- **`docker build --no-cache -t myapp:1.0 .`**: rebuild from scratch, ignoring layer cache. Useful when dependency versions change.

- **`docker build -f Dockerfile.prod -t myapp:prod .`**: use a specific Dockerfile.

For advanced multi-platform builds and build secrets, see [Section 8: Modern Build with Docker Buildx](#section-8-modern-build-with-docker-buildx). To learn about build args and [Docker environment variables (ARG vs ENV)](/docker-env-vars/), check the dedicated guide. You can also learn how to [copy multiple files in one Dockerfile layer](/copy-multiple-files-in-one-layer-using-a-dockerfile/) to reduce image size.

## Section 3: Managing Docker Containers

### 3.1 Running Containers

The `docker run` command is the most important Docker command. Here are the essential variations:

- **`docker run &lt;image&gt;`**: create and start a container. Runs the image&apos;s default command.

- **`docker run -d &lt;image&gt;`**: detached mode (background). Returns the container ID.

- **`docker run -it &lt;image&gt; /bin/bash`**: interactive mode with a TTY. Drops you into a shell inside the container.

- **`docker run --name myapp -d -p 8080:80 nginx`**: named container with port mapping (host 8080 → container 80).

- **`docker run --rm -it ubuntu /bin/bash`**: auto-remove the container when it exits. Good for throwaway debugging.

- **`docker run -e MY_VAR=value -d &lt;image&gt;`**: pass environment variables. See [Docker environment variables (ARG vs ENV)](/docker-env-vars/) for the full breakdown.

&lt;Notice type=&quot;success&quot; title=&quot;Production-ready run command&quot;&gt;
Here&apos;s a `docker run` that covers the basics for a production service:

```sh
docker run -d \
  --name myapp \
  --restart unless-stopped \
  --health-cmd=&quot;curl -f http://localhost:3000/health || exit 1&quot; \
  --health-interval=30s \
  --health-retries=3 \
  -p 3000:3000 \
  myapp:latest
```

This gives you: auto-restart on failure, health monitoring, and port mapping. For more on running apps in Docker, see [deploy FileBrowser with Docker](/deploy-filebrowser-docker/) as a practical example.
&lt;/Notice&gt;

Additional flags worth knowing:

| Flag | What it does |
|------|-------------|
| `--restart unless-stopped` | Auto-restart on crash or reboot (stops only if you explicitly stop it) |
| `--health-cmd`, `--health-interval`, `--health-retries` | Define container health checks |
| `--init` | Run tini as PID 1: handles zombie processes cleanly |
| `--shm-size 256m` | Increase shared memory (needed for browsers, PostgreSQL, some ML workloads) |
| `--add-host host.docker.internal:host-gateway` | Let container reach the host machine |
| `--gpus all` | Pass GPU access to container (requires nvidia-container-toolkit) |
| `--user 1000:1000` | Run as a specific UID/GID instead of root |

For managing users inside containers, see [how to add users to a Docker container](/add-users-to-docker-container/). To [run Python apps in Docker](/docker-run-python/), we have a dedicated guide.

### 3.2 Listing Containers

- **`docker ps`**: list running containers.

  ```sh
  $ docker ps
  CONTAINER ID   IMAGE          COMMAND                  STATUS         NAMES
  a1b2c3d4e5f6   nginx:alpine   &quot;/docker-entrypoint.…&quot;   Up 2 hours     web-server
  f6e5d4c3b2a1   postgres:16    &quot;docker-entrypoint.s…&quot;   Up 5 hours     db
  ```

- **`docker ps -a`**: list all containers (including stopped).

- **`docker ps --format &quot;table {{.Names}}\t{{.Status}}\t{{.Ports}}&quot;`**: custom output format. Cleaner than the default.

- **`docker ps --filter &quot;status=exited&quot;`**: filter by status. Useful for finding containers to clean up.

### 3.3 Stopping and Starting Containers

- **`docker stop &lt;container&gt;`**: graceful stop (SIGTERM, then SIGKILL after timeout). Default timeout is 10 seconds.

- **`docker stop --timeout 30 &lt;container&gt;`**: wait 30 seconds before force-killing. Give apps more time to shut down cleanly.

&lt;Notice type=&quot;warning&quot; title=&quot;--time is deprecated&quot;&gt;
The `--time` flag on `docker stop` and `docker restart` was deprecated in Docker 28.4. Use `--timeout` instead. The old flag still works for now but will be removed in v30.
&lt;/Notice&gt;

- **`docker start &lt;container&gt;`**: start a stopped container (preserves its configuration).

- **`docker restart &lt;container&gt;`**: stop then start. Equivalent to `docker stop` + `docker start`.

- **`docker kill &lt;container&gt;`**: immediate force stop (SIGKILL). No graceful shutdown. Use when a container is stuck.

### 3.4 Removing Containers

- **`docker rm &lt;container&gt;`**: remove a stopped container.

- **`docker rm -f &lt;container&gt;`**: force-remove a running container (sends SIGKILL first).

- **`docker container prune`**: remove all stopped containers at once. The interactive version asks for confirmation.

  ```sh
  $ docker container prune --force
  Deleted Containers:
  a1b2c3d4e5f6...
  f6e5d4c3b2a1...

  Total reclaimed space: 152.3MB
  ```

## Section 4: Inspecting and Logging

### 4.1 Inspecting Containers and Images

`docker inspect` returns detailed JSON about any Docker object. The raw output is huge: use Go templates to extract what you need.

- **`docker inspect &lt;container&gt;`**: full JSON output (usually hundreds of lines). Pipe to `jq` for readability.

- **`docker inspect --format=&apos;{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}&apos; &lt;container&gt;`**: get the container&apos;s IP address.

- **`docker inspect --format=&apos;{{.State.Status}}&apos; &lt;container&gt;`**: get container state (running, exited, etc.).

- **`docker inspect --format=&apos;{{json .State.Health}}&apos; &lt;container&gt; | jq`**: get health check status.

- **`docker inspect --format=&apos;{{.HostConfig.RestartPolicy.Name}}&apos; &lt;container&gt;`**: check restart policy.

### 4.2 Viewing Logs

- **`docker logs &lt;container&gt;`**: dump all logs.

- **`docker logs -f &lt;container&gt;`**: follow (tail) logs in real time. Press Ctrl+C to stop.

- **`docker logs --tail 100 &lt;container&gt;`**: show last 100 lines only.

- **`docker logs --since 1h &lt;container&gt;`**: logs from the last hour.

- **`docker logs --since 2025-01-15T10:00:00 --until 2025-01-15T11:00:00 &lt;container&gt;`**: logs for a specific time window.

- **`docker logs --timestamps &lt;container&gt;`**: prefix each line with a timestamp.

Combine flags: `docker logs -f --tail 50 --timestamps myapp` is a common pattern for live debugging.

## Section 5: Networking

### 5.1 Managing Networks

Docker networks let containers talk to each other by name (DNS resolution on user-defined bridge networks).

- **`docker network ls`**: list all networks.

  ```sh
  $ docker network ls
  NETWORK ID     NAME      DRIVER    SCOPE
  abc123def456   bridge    bridge    local
  789ghi012jkl   host      host      local
  345mno678pqr   none      null      local
  ```

- **`docker network create mynet`**: create a user-defined bridge network.

- **`docker network create --driver bridge --subnet 172.20.0.0/16 mynet`**: with custom subnet.

- **`docker network rm mynet`**: remove a network.

- **`docker network inspect mynet`**: detailed info about a network (connected containers, IP addresses).

&lt;Notice type=&quot;warning&quot; title=&quot;Docker bypasses your firewall&quot;&gt;
Docker manipulates iptables directly to route container traffic. This means published ports (`-p 8080:80`) can bypass UFW and other host firewalls. If you&apos;re exposing containers to the internet, understand the implications. See [Docker bypassing your firewall](/docker-bypasses-firewall/) for the full explanation and workarounds.
&lt;/Notice&gt;

### 5.2 Connecting and Disconnecting Containers

- **`docker network connect mynet mycontainer`**: attach a running container to an additional network. The container now has interfaces on both networks.

- **`docker network disconnect mynet mycontainer`**: detach from a network.

- **`docker run --network mynet --name app -d myimage`**: start a container directly on a specific network.

## Section 6: Volumes and Data Management

### 6.1 Managing Volumes

Volumes are the preferred way to persist data. They survive container removal and are managed by Docker.

- **`docker volume ls`**: list all volumes.

- **`docker volume create mydata`**: create a named volume.

- **`docker volume inspect mydata`**: show volume details (mount point on host, driver, labels).

- **`docker volume rm mydata`**: remove a volume. Only works if no container is using it.

### 6.2 Using Volumes

There are two syntaxes for mounting volumes:

- **`-v` (bind mount shorthand)**:

  ```sh
  docker run -v mydata:/var/lib/postgresql/data -d postgres:16
  docker run -v /host/path:/container/path -d myimage
  ```

- **`--mount` (explicit, recommended)**:

  ```sh
  docker run --mount type=volume,source=mydata,target=/var/lib/postgresql/data -d postgres:16
  docker run --mount type=bind,source=/host/path,target=/container/path -d myimage
  ```

Use `--mount` for production: it&apos;s more explicit, supports more options, and gives clearer error messages. The `-v` shorthand is fine for quick local dev.

## Section 7: Docker Compose (v2)

&lt;Notice type=&quot;error&quot; title=&quot;Docker Compose v1 is dead&quot;&gt;
`docker-compose` (with a hyphen) was the v1 Python binary. It was deprecated in July 2023 and removed from all Docker Desktop versions. Use `docker compose` (with a space): the v2 Go plugin. If you see old tutorials using `docker-compose`, mentally translate to `docker compose`. The `version:` key in compose.yaml is also obsolete: Compose v2 ignores it.
&lt;/Notice&gt;

### 7.1 Compose v2 Basic Commands

&lt;Tabs&gt;
&lt;Tab name=&quot;Old v1 (deprecated)&quot;&gt;
```sh
docker-compose up -d
docker-compose down
docker-compose ps
docker-compose logs -f
```
&lt;/Tab&gt;
&lt;Tab name=&quot;New v2 (use this)&quot;&gt;
```sh
docker compose up -d
docker compose down
docker compose ps
docker compose logs -f
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

- **`docker compose up -d`**: start all services in detached mode. Creates containers, networks, and volumes as defined in `compose.yaml`.

- **`docker compose down`**: stop and remove containers and networks. Add `--volumes` to also remove named volumes (data loss!).

- **`docker compose ps`**: list services and their status.

- **`docker compose logs -f`**: follow logs from all services. Add a service name to filter: `docker compose logs -f web`.

- **`docker compose config`**: validate and print the resolved compose file. Useful for catching YAML errors before deploying.

For managing Docker Compose apps in production, tools like [Dockge](/dockge-install/) or [Dokploy](/dokploy-docker-compose-app/) give you a web UI. For [managing secrets in Docker Compose](/docker-compose-secrets/), see the dedicated guide.

### 7.2 Managing Services

- **`docker compose build`**: build or rebuild services that have a `build:` section.

- **`docker compose pull`**: pull the latest images for all services.

- **`docker compose restart`**: restart all services (or a specific one: `docker compose restart web`).

- **`docker compose start` / `docker compose stop`**: start/stop without removing containers. Unlike `up`/`down`, containers persist.

- **`docker compose top`**: display running processes inside each service container.

- **`docker compose exec web /bin/bash`**: exec into a running service container.

- **`docker compose run --rm web python manage.py migrate`**: run a one-off command in a service. `--rm` removes the container after it finishes.

### 7.3 Compose v2 New Features

&lt;Notice type=&quot;info&quot; title=&quot;Compose watch for development&quot;&gt;
`docker compose up --watch` monitors your source files and automatically syncs or rebuilds when they change. This replaces complex bind-mount + nodemon/fswatch setups. Define what to watch in the `develop.watch` section of your compose.yaml. Available since Compose 2.22.0 (GA).
&lt;/Notice&gt;

- **`docker compose up --watch`**: start services with file-watch mode. Source changes trigger automatic sync (for static files) or rebuild (for code that affects the image).

- **`docker compose --profile dev up`**: only start services tagged with the `dev` profile. Useful for optional services like debug tools or test databases.

  ```yaml
  services:
    web:
      build: .
    debug-tools:
      image: busybox
      profiles: [&quot;dev&quot;]
  ```

- **`docker compose --env-file .env.staging up`**: use a custom environment file instead of the default `.env`.

## Section 8: Modern Build with Docker Buildx

`docker buildx` is Docker&apos;s modern build system. Since Docker 23.0, `docker build` is essentially an alias for `docker buildx build`. But for multi-platform builds, remote caching, and build secrets, you need the full `buildx` interface.

### 8.1 Buildx Basics

- **`docker buildx ls`**: list available builder instances.

  ```sh
  $ docker buildx ls
  NAME/NODE     DRIVER/ENDPOINT     STATUS    BUILDKIT   PLATFORMS
  default       docker                                    linux/amd64, linux/arm64
  ```

- **`docker buildx create --use --name mybuilder`**: create and switch to a new builder. Required for multi-platform builds.

- **`docker buildx build -t myapp:latest .`**: build using the active builder. Same as `docker build` but with buildx features available.

### 8.2 Multi-Platform Builds

Build images for multiple architectures in a single command:

```sh
docker buildx build --platform linux/amd64,linux/arm64 \
  -t myuser/myapp:latest \
  --push .
```

This uses QEMU emulation to cross-compile. It works but emulated builds are slow: an ARM64 build on an x86 host can be 5-10x slower than native.

&lt;Notice type=&quot;info&quot; title=&quot;Native ARM builders are faster&quot;&gt;
If you&apos;re building ARM64 images regularly, consider using a native ARM builder. [Hetzner Cloud](https://go.bitdoze.com/hetzner) ARM64 instances (starting around EUR 4/month) work well as remote buildx builders and are much faster than QEMU emulation on x86.
&lt;/Notice&gt;

Without `--push`, the image stays local (multi-platform images need a registry; they can&apos;t be loaded locally). You can also use `--load` for single-platform builds.

### 8.3 Build Secrets and Remote Cache

- **`docker buildx build --secret id=mytoken,src=./token.txt -t myapp .`**: pass secrets during build without baking them into layers. In your Dockerfile: `RUN --mount=type=secret,id=mytoken cat /run/secrets/mytoken`.

- **`docker buildx build --cache-to type=registry,ref=myuser/myapp:cache --cache-from type=registry,ref=myuser/myapp:cache -t myapp .`**: export and import build cache via a registry. Essential for CI/CD to avoid rebuilding from scratch every time.

- **`docker buildx bake`**: build multiple targets from a `docker-bake.hcl`, JSON, or compose file. Useful for projects with many related images.

- **`docker buildx prune`**: clean the build cache. Add `--all` to nuke everything.

## Section 9: Docker Disk Space and Cleanup

This is the section VPS operators need most. Docker accumulates images, containers, volumes, and build cache fast: especially on cheap VPS instances with limited disk.

### 9.1 Checking Disk Usage

- **`docker system df`**: show disk usage breakdown.

  ```sh
  $ docker system df
  TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
  Images          12        5         3.45GB    2.1GB (60%)
  Containers      8         3         152MB     98MB (64%)
  Local Volumes   6         2         1.2GB     800MB (66%)
  Build Cache     15        0         512MB     512MB (100%)
  ```

- **`docker system df -v`**: verbose breakdown per image, container, volume, and cache entry.

Run this weekly. On a [EUR 5/month Hetzner VPS](https://go.bitdoze.com/hetzner) with 40GB disk, Docker can easily eat 10-15GB if left unchecked.

### 9.2 Pruning Unused Resources

&lt;Notice type=&quot;warning&quot; title=&quot;Volume prune deletes data permanently&quot;&gt;
`docker volume prune` and `docker system prune -a --volumes` permanently delete volume data: databases, uploads, everything stored in volumes. Always verify what will be removed before confirming. Back up volumes first if the data matters.
&lt;/Notice&gt;

```sh
# Remove all stopped containers
docker container prune --force

# Remove dangling images (untagged, unreferenced)
docker image prune --force

# Remove ALL unused images (not just dangling)
docker image prune -a --force

# Remove unused volumes (DATA LOSS: be careful)
docker volume prune --force

# Remove unused networks
docker network prune --force

# Remove build cache
docker builder prune --force

# Remove ALL unused resources (images, containers, networks, cache: NOT volumes)
docker system prune -a --force

# The nuclear option: everything unused, including volumes
docker system prune -a --volumes --force
```

### 9.3 Safe Cleanup Strategies for VPS

The safest approach: time-based pruning. Keep recent stuff, clean the rest.

```sh
# Remove unused resources older than 7 days
docker system prune -a --filter &quot;until=168h&quot; --force
```

For a weekly cron job:

```sh
# /etc/cron.d/docker-cleanup
0 3 * * 0 root docker system prune -a --filter &quot;until=168h&quot; --force &gt; /var/log/docker-prune.log 2&gt;&amp;1
```

On budget VPS hosting like [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps), disk is the constraint, not CPU or RAM. A cleanup cron job is cheap insurance. For more approaches, see [how to clean up all Docker resources](/cleanup-all-docker-things/) and [reclaim disk space from Docker overlay2](/clean-docker-overlay2-dir/). If you&apos;re running Docker on a managed panel, check the [best self-hosted server panels](/best-self-hosted-panels/) for built-in cleanup tools.

## Section 10: Docker Health Checks and Production Readiness

Health checks let Docker (and orchestrators) know if your application is actually working: not just &quot;the process is running.&quot;

### 10.1 Adding Health Checks to Containers

In a Dockerfile:

```dockerfile
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl -f http://localhost:3000/health || exit 1
```

Or via `docker run`:

```sh
docker run -d \
  --health-cmd=&quot;curl -f http://localhost:3000/health || exit 1&quot; \
  --health-interval=30s \
  --health-timeout=3s \
  --health-start-period=10s \
  --health-retries=3 \
  --name myapp \
  -p 3000:3000 \
  myapp:latest
```

Key parameters:
- `--interval`: how often to run the check (default: 30s)
- `--timeout`: how long to wait for a response (default: 30s)
- `--start-period`: grace period before health checks count as failures (default: 0s)
- `--retries`: consecutive failures before marking unhealthy (default: 3)

In Compose:

```yaml
services:
  web:
    image: myapp:latest
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:3000/health&quot;]
      interval: 30s
      timeout: 3s
      start_period: 10s
      retries: 3
```

### 10.2 Monitoring Container Health

```sh
# Check health status via inspect
docker inspect --format=&apos;{{json .State.Health}}&apos; myapp | jq

# Filter containers by health status
docker ps --filter &quot;health=healthy&quot;
docker ps --filter &quot;health=unhealthy&quot;
docker ps --filter &quot;health=starting&quot;
```

Since Docker 29.5.0, `docker ps --format` supports `.HealthStatus` directly:

```sh
docker ps --format &quot;table {{.Names}}\t{{.Status}}\t{{.HealthStatus}}&quot;
```

Health checks are critical when using `--restart` policies. Without them, Docker restarts a broken app that&apos;s stuck in a crash loop indefinitely. With a health check, the container reports `unhealthy` and you can act on it.

## Section 11: Docker Hub and Registry Authentication

### 11.1 Docker Login

- **`docker login`**: interactive login to Docker Hub (prompts for username and password).

- **`docker login --username myuser --password-stdin`**: non-interactive for CI/CD:

  ```sh
  echo &quot;$DOCKER_PASSWORD&quot; | docker login --username &quot;$DOCKER_USERNAME&quot; --password-stdin
  ```

- **`docker login ghcr.io --username myuser --password-stdin`**: log in to GitHub Container Registry.

  ```sh
  echo &quot;$GITHUB_TOKEN&quot; | docker login ghcr.io --username &quot;$GITHUB_USERNAME&quot; --password-stdin
  ```

- **`docker logout`** / **`docker logout ghcr.io`**: remove stored credentials.

### 11.2 Rate Limits and Registry Mirrors

&lt;Notice type=&quot;warning&quot; title=&quot;Docker Hub rate limits&quot;&gt;
Pull limits (per 6 hours per IP/account):
- **Unauthenticated**: 100 pulls
- **Authenticated free account**: 200 pulls
- **Paid subscription**: Unlimited (fair use)

If your CI/CD pipelines fail with &quot;toomanyrequests&quot; or &quot;rate limit exceeded&quot;, authenticate first with `docker login`. For high-volume environments, consider a pull-through cache or registry mirror.
&lt;/Notice&gt;

To check your current rate limit status, inspect the response headers:

```sh
TOKEN=$(curl -s &quot;https://auth.docker.io/token?service=registry.docker.io&amp;scope=repository:library/ubuntu:pull&quot; | jq -r .token)
curl -s -I -H &quot;Authorization: Bearer $TOKEN&quot; &quot;https://registry-1.docker.io/v2/library/ubuntu/manifests/latest&quot; | grep -i ratelimit
```

## Section 12: Advanced Commands

### 12.1 Docker Exec

Run commands inside a running container:

- **`docker exec -it mycontainer /bin/bash`**: interactive shell. Most common usage.

- **`docker exec -it mycontainer sh`**: use `sh` if bash isn&apos;t available (Alpine images).

- **`docker exec -u root -it mycontainer /bin/bash`**: exec as a different user.

- **`docker exec mycontainer cat /etc/hosts`**: run a one-off command without interactive mode.

### 12.2 Docker Export and Import

Export a container&apos;s filesystem as a tar archive. This captures the filesystem state but **not** volumes, metadata, or the image history.

- **`docker export mycontainer &gt; mycontainer.tar`**: export filesystem.

- **`docker import mycontainer.tar myimage:restored`**: import as a new image.

Use case: migrating a container&apos;s filesystem to another host, or creating a minimal image snapshot. For image-level backup, use `save`/`load` instead (preserves metadata).

### 12.3 Docker Save and Load

Save an image (with all layers and metadata) to a tar file:

- **`docker save -o myimage.tar myimage:latest`**: save an image.

- **`docker load -i myimage.tar`**: load an image from a tar file.

Unlike `export`/`import`, `save`/`load` preserves the full image: layers, tags, metadata. Use this for offline transfers or air-gapped environments.

### 12.4 Docker Contexts

Manage connections to multiple Docker daemons from a single machine. No more juggling `DOCKER_HOST` environment variables.

```sh
# List contexts
docker context ls

# Create a context for a remote VPS
docker context create my-vps --docker &quot;host=ssh://user@vps-ip&quot;

# Switch to it
docker context use my-vps

# Now all docker commands target the remote daemon
docker ps
docker compose up -d

# Switch back to local
docker context use default
```

This is useful if you run containers on a remote VPS but develop locally. SSH-based contexts work out of the box: just make sure your SSH key is set up.

### 12.5 Docker Init, Scout, and Other Modern Commands

&lt;Notice type=&quot;info&quot; title=&quot;Desktop-only commands&quot;&gt;
`docker init` and `docker scout` require Docker Desktop or separate plugin installation on headless Linux servers. On a typical VPS, they may not be available out of the box.
&lt;/Notice&gt;

- **`docker init`**: scaffold a Dockerfile, compose.yaml, and .dockerignore for your project. Walks you through an interactive wizard. Only available in Docker Desktop (4.18+).

- **`docker scout quickview &lt;image&gt;`**: quick vulnerability scan of an image.

- **`docker scout cves &lt;image&gt;`**: list CVEs found in an image. Requires the Docker Scout plugin.

- **`docker cp &lt;container&gt;:/path /host/path`**: copy files from a container to the host. Works both directions: `docker cp /host/file container:/path`.

- **`docker update --restart=always &lt;container&gt;`**: change the restart policy on a running container without recreating it.

- **`docker update --cpus 2 --memory 512m &lt;container&gt;`**: adjust CPU and memory limits on a running container.

- **`docker port &lt;container&gt;`**: list port mappings for a container.

- **`docker rename &lt;old-name&gt; &lt;new-name&gt;`**: rename a container.

- **`docker history &lt;image&gt;`**: show the build history of an image (each layer&apos;s size and command).

- **`docker stats --no-stream`**: one-shot resource usage snapshot. Useful in scripts:

  ```sh
  docker stats --no-stream --format &quot;table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}&quot;
  ```

For running AI CLI tools and coding agents safely inside containers, see [safe Docker environments for AI CLI tools](/docker-podman-ai-cli-tools-safe-environment/).

## Troubleshooting Common Docker Errors

| Error | Cause | Fix |
|-------|-------|-----|
| `Cannot connect to the Docker daemon` | Daemon not running | `sudo systemctl start docker` |
| `permission denied while trying to connect` | User not in docker group | `sudo usermod -aG docker $USER` then re-login |
| `no space left on device` | Disk full | Run `docker system df` then `docker system prune -a`. See [Docker cleanup guide](/cleanup-all-docker-things/) |
| `port is already allocated` | Port in use | `docker ps` to find the container using it, then `docker stop` or change `-p` mapping |
| `toomanyrequests` / rate limit | Too many unauthenticated pulls | `docker login` first, or use a registry mirror |
| `network xxx not found` | Compose project name changed | Run `docker compose down` cleanly before renaming, check `docker network ls` |
| `image is being used by running container` | Can&apos;t remove an image in use | Stop and remove the container first: `docker rm -f &lt;container&gt;` |

## Wrapping Up

You now have a reference covering 60+ Docker commands: from daily container management to Compose v2, buildx multi-platform builds, disk cleanup, health checks, and registry authentication. Bookmark the quick-reference cheat sheet table at the top for fast lookups.

Docker changes fast. The biggest shifts since 2023: Compose v1 is dead, BuildKit is default, and the containerd image store is the new default for fresh Docker 29 installs. If you&apos;re still running old habits, now is a good time to update.

For more Docker guides, check out [100+ best Docker containers for a home server](/docker-containers-home-server/): it&apos;s a solid starting point for figuring out what to actually run.</content:encoded><category>self-hosting</category><category>docker</category><category>docker-compose</category></item><item><title>Best 125+ Docker Containers for Home Server in 2026</title><link>https://www.bitdoze.com/docker-containers-home-server/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-containers-home-server/</guid><description>Practical list of 125+ Docker containers for a home server in 2026: media, VPN, PaaS, monitoring, finance, and more. Updated for the Plex paywall, Coolify, and current self-hosted picks.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

[Docker](https://www.docker.com/) packs an app with its runtime so it runs the same on a mini PC, VPS, or rack. This page is a **catalog of 125+ home-server containers** for 2026, not a &quot;install everything&quot; checklist.

## How to use this page

1. **Pick a goal** in the table below (media, files, lab control plane, …).
2. **Install the short starter stack** if you are new.
3. **Open one category** you care about. Each section starts with a **quick-pick table**, then short notes on the important tools.
4. Skip the rest until you need it.

&lt;Notice type=&quot;info&quot; title=&quot;Don&apos;t install 125 containers&quot;&gt;
Run 8–15 that match what you actually use. A quiet lab beats a noisy one.
&lt;/Notice&gt;

### Pick a goal

| I want… | Go here | Typical first picks |
| --- | --- | --- |
| Free media streaming + downloads | [Media](#media-management-containers) | Jellyfin, Prowlarr, Sonarr/Radarr, qBittorrent, Gluetun |
| Google Photos replacement | [Photography](#photography-and-image-management-containers) | Immich |
| Dropbox / Google Drive replacement | [Files](#file-sharing-and-sync-containers) | Nextcloud or Seafile, Filebrowser Quantum |
| Ad blocking + remote access | [Network](#network-management-containers) | AdGuard or Pi-hole, Tailscale, Caddy |
| Know when things break | [Monitoring](#monitoring-and-analytics-containers) | Uptime Kuma, Dozzle, Beszel |
| Deploy apps like Vercel at home | [Development](#development-containers) | Coolify or Dokploy |
| Passwords + simple SSO | [Security](#security-and-privacy-containers) | Vaultwarden, Pocket ID or Authelia |
| Local AI chat | [AI](#ai-applications-containers) | Ollama + OpenWebUI |
| Budget / recipes / bookmarks | [Productivity](#productivity-containers), [Finance](#personal-finance-containers) | Mealie, Actual Budget, Karakeep |

### What changed in 2026 (short)

- **Plex remote streaming needs a paid pass** (local still free). Lifetime Pass for new buyers is **$749.99** since July 1, 2026. Many people switched to **Jellyfin**.
- **Filebrowser Quantum** is the active file-manager fork (original is maintenance-only).
- **Coolify / Dokploy**, **Gluetun**, **Dozzle**, and **Pocket ID** are common defaults now.

More detail is in [What&apos;s changed in 2026](#whats-changed-in-2026) at the end.

---

## Starter stack (do this first)

If you are starting from zero, this set covers streaming, downloads, files, dashboard, DNS, management, and basic monitoring.

| # | Container | Role | Why | Details |
| --- | --- | --- | --- | --- |
| 1 | Dockge | Manage Compose stacks | Easier than raw CLI | [Network](#network-management-containers) |
| 2 | AdGuard Home or Pi-hole | DNS + ads | Whole network | [Network](#network-management-containers) |
| 3 | Homepage or Glance | Dashboard | One URL for everything | [Dashboards](#personal-dashboard-containers) |
| 4 | Filebrowser Quantum | Web files | Quick upload without full cloud | [Files](#file-sharing-and-sync-containers) |
| 5 | Jellyfin | Media | Free remote; no Plex tax | [Media](#media-management-containers) |
| 6 | qBittorrent + optional Gluetun | Downloads | VPN only the downloader | [Media](#media-management-containers) |
| 7 | CrowdSec | IPS | Blocks noisy attackers | [Security](#security-and-privacy-containers) |
| 8 | Uptime Kuma + Dozzle + Beszel | Ops | Up? Why down? Logs? | [Monitoring](#monitoring-and-analytics-containers) |

**Order that works:** Dockge → DNS → dashboard → files → media → downloads → CrowdSec → monitoring.

### Minimal compose example

Three services to get Docker muscle memory. Add the rest as separate stacks in Dockge.

```yaml
services:
  adguardhome:
    image: adguard/adguardhome
    container_name: adguardhome
    ports:
      - &quot;53:53/tcp&quot;
      - &quot;53:53/udp&quot;
      - &quot;3000:3000/tcp&quot;
    volumes:
      - ./adguard/work:/opt/adguardhome/work
      - ./adguard/conf:/opt/adguardhome/conf
    restart: unless-stopped

  homepage:
    image: ghcr.io/gethomepage/homepage:latest
    container_name: homepage
    environment:
      - TZ=America/New_York
    volumes:
      - ./homepage-config:/app/config
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - &quot;3001:3000&quot;
    restart: unless-stopped

  dockge:
    image: louislam/dockge:1
    container_name: dockge
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./dockge-data:/app/data
      - ./dockge-stacks:/opt/stacks
    ports:
      - &quot;5001:5001&quot;
    restart: unless-stopped
```

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Static IP (or DHCP reservation) on the server&lt;/li&gt;
&lt;li&gt;Compose files + env samples in Git&lt;/li&gt;
&lt;li&gt;Data on SSD; media libraries can live on HDDs&lt;/li&gt;
&lt;li&gt;No public ports without a reverse proxy + auth&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Accordion label=&quot;Hardware, OS, and Docker install (expand if you need it)&quot; group=&quot;setup&quot;&gt;

**Hardware (rough guide)**

| Component | Minimum | Comfortable | Heavy (4K + AI) |
| --- | --- | --- | --- |
| CPU | Quad-core 2.0 GHz | Quad-core 2.5 GHz+ | 6+ cores, Quick Sync / GPU |
| RAM | 8 GB | 16–32 GB | 64 GB+ |
| Storage | 128 GB SSD | 512 GB NVMe + HDD | 1 TB NVMe + multi-HDD |
| Network | 1 GbE | 2.5 GbE | 10 GbE |

Transcoding and Immich/Nextcloud are the usual RAM/CPU hogs. Mini PC picks: [Best mini PC for home servers](https://www.bitdoze.com/best-mini-pc-home-server/).

**OS options:** Ubuntu Server or Debian for plain Docker; Unraid / TrueNAS Scale if storage is the point; Proxmox if you want VMs + containers.

**Install Docker (Debian/Ubuntu-style):**

```bash
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER
# log out/in, then:
docker run hello-world
```

Compose V2 is the `docker compose` plugin (no separate `docker-compose` binary required). The top-level `version:` key in compose files is optional now.

More ops notes: [Monitor server and Docker resources](https://www.bitdoze.com/sever-monitoring/). More apps: [toolhunt.net self-hosted](https://toolhunt.net/sh/).

&lt;/Accordion&gt;

---

## Full catalog

Use the jump table, then skim each section&apos;s **quick picks** before the writeups.

| Category | Start here | Also see |
| --- | --- | --- |
| [Media](#media-management-containers) | Jellyfin, *arr, qBittorrent, Gluetun | Audiobookshelf, Overseerr |
| [Files](#file-sharing-and-sync-containers) | Nextcloud / Seafile, Filebrowser Quantum | Syncthing, RustFS |
| [AI](#ai-applications-containers) | Ollama + OpenWebUI | LiteLLM, Flowise |
| [Home automation](#home-automation-containers) | n8n, Home Assistant | Node-RED |
| [Network](#network-management-containers) | AdGuard, Tailscale, Caddy, Dockge | Traefik, Komodo, WireGuard Easy |
| [Monitoring](#monitoring-and-analytics-containers) | Uptime Kuma, Dozzle, Beszel | Scrutiny, Netdata |
| [Notifications](#notification-containers) | ntfy | Gotify, Apprise |
| [Security](#security-and-privacy-containers) | Vaultwarden, CrowdSec, Pocket ID | Authelia, Authentik |
| [Productivity](#productivity-containers) | Stirling PDF, Mealie, Bookstack | Karakeep, Memos |
| [Development](#development-containers) | Coolify or Dokploy, Gitea | Watchtower, Code-Server |
| [Backup](#backup-and-recovery-containers) | Backrest or Kopia | Duplicati, Restic |
| [Finance](#personal-finance-containers) | Actual Budget or Firefly III | Wallos |
| [Photos](#photography-and-image-management-containers) | Immich | PhotoPrism |
| [Dashboards](#personal-dashboard-containers) | Homepage or Glance | Homer |
| [Databases](#database-containers) | PostgreSQL, Valkey | MariaDB |
| [Airtable-style](#self-hosted-database-solutions-airtable-alternatives) | NocoDB or Grist | Baserow |
| [Specialty](#specialty-tools) | RSS, games, chat, VoIP, analytics | RustDesk, Dawarich, Matomo |

### Media management containers

Stream your own library. Most people start here.

**How the stack fits together**

```
Prowlarr → Sonarr / Radarr → qBittorrent (+ Gluetun) or SABnzbd
                ↓
         Jellyfin / Plex
                ↑
     Bazarr (subs) · Overseerr (requests) · Audiobookshelf (audio)
```

#### Quick picks

| Need | Pick | Image |
| --- | --- | --- |
| Free media server | **Jellyfin** ⭐ | `jellyfin/jellyfin` |
| Polished apps, will pay for remote | Plex | `linuxserver/plex` |
| TV automation | Sonarr | `linuxserver/sonarr` |
| Movies | Radarr | `linuxserver/radarr` |
| Indexers (once for all *arr) | **Prowlarr** ⭐ | `linuxserver/prowlarr` |
| Torrents | **qBittorrent** ⭐ | `linuxserver/qbittorrent` |
| VPN only downloads | **Gluetun** ⭐ | `qmcgaw/gluetun` |
| Family requests | Overseerr / Jellyseerr | `linuxserver/overseerr` |
| Audiobooks / podcasts | **Audiobookshelf** ⭐ | `advplyr/audiobookshelf` |

#### Media servers

**Jellyfin ⭐** - [jellyfin.org](https://jellyfin.org/)  
Free, open source, no account. After the Plex paywall this is the usual recommendation for remote streaming. Image: `jellyfin/jellyfin`.

**Plex ⭐** - [plex.tv](https://www.plex.tv/)  
Still the smoothest clients. **Remote streaming is paid** since April 2025 (Plex Pass or Remote Watch Pass). Local on LAN stays free. New Lifetime Pass is **$749.99** (July 1, 2026); existing lifetime holders keep theirs. Image: `linuxserver/plex`.

| | Plex | Jellyfin | Emby |
| --- | --- | --- | --- |
| Remote | Paid | Free | Free / premium extras |
| Open source | No | Yes | Partial |
| Best if | Nicest apps + budget for Pass | Free remote + control | Already on Emby |

Emby image: `emby/embyserver`.

#### *arr automation and downloads

**Prowlarr ⭐** - Configure indexers once; syncs to Sonarr/Radarr. Prefer this over Jackett for new installs. `linuxserver/prowlarr`

**Sonarr ⭐** / **Radarr ⭐** - TV and movies. `linuxserver/sonarr`, `linuxserver/radarr`

**qBittorrent ⭐** - Default torrent client for *arr. Route it through Gluetun. `linuxserver/qbittorrent`

**Gluetun ⭐** - VPN client container (40+ providers). Use `network_mode: &quot;service:gluetun&quot;` so only downloads leave via VPN. `qmcgaw/gluetun`

**Bazarr ⭐** - Subtitles for Sonarr/Radarr. `linuxserver/bazarr`

**Overseerr ⭐** / **Jellyseerr** - Request UI for family (Plex vs Jellyfin). `linuxserver/overseerr` or `fallenbagel/jellyseerr`

**Audiobookshelf ⭐** - Audiobooks + podcasts with apps and progress sync. `advplyr/audiobookshelf`

#### Also useful (media)

| Tool | Role | Image |
| --- | --- | --- |
| SABnzbd | Usenet downloader | `linuxserver/sabnzbd` |
| FlareSolverr | Cloudflare bypass for some indexers | `ghcr.io/flaresolverr/flaresolverr` |
| Jackett | Older indexer proxy; use Prowlarr if new | `linuxserver/jackett` |
| Transmission | Lighter torrent client | `linuxserver/transmission` |
| Pinchflat | Auto-archive YouTube channels (yt-dlp) | `ghcr.io/pinchflat/pinchflat` |
| MeTube | Paste URL → download | `alexta69/metube` |

### File sharing and sync containers

Replace Dropbox/Google Drive, or just get a web UI on your disks.

#### Quick picks

| Need | Pick | Image |
| --- | --- | --- |
| Full personal cloud | **Nextcloud** ⭐ | `nextcloud` |
| Fast sync, big libraries | **Seafile** ⭐ | `seafileltd/seafile-mc` |
| P2P device sync, no &quot;cloud&quot; server | **Syncthing** ⭐ | `syncthing/syncthing` |
| Simple web file manager | **Filebrowser Quantum** ⭐ | `gtsteffaniak/filebrowser` |
| S3-compatible storage | **RustFS** ⭐ | `rustfs/rustfs` |

**Nextcloud ⭐** - Kitchen-sink cloud: files, calendar, contacts, office. Huge app ecosystem; can feel heavy on big libraries. `nextcloud`

**Seafile ⭐** - Usually much faster than Nextcloud. Tradeoff: proprietary block storage on disk. `seafileltd/seafile-mc`

**Syncthing ⭐** - Peer-to-peer encrypted sync, no central account. `syncthing/syncthing`

**Filebrowser Quantum ⭐** - Active fork of classic Filebrowser (original is maintenance-only). Multi-source, OIDC/2FA. New installs: use Quantum. Image: `gtsteffaniak/filebrowser`. Older notes: [Deploy Filebrowser](https://www.bitdoze.com/deploy-filebrowser-docker/).

**RustFS ⭐** - S3-compatible object storage (MinIO-style). Guide: [Self-host RustFS](https://www.bitdoze.com/rustfs-self-host/). `rustfs/rustfs`

| Also | Role | Image |
| --- | --- | --- |
| ownCloud | Classic file cloud | `owncloud/server` |
| PairDrop | AirDrop-style local share | `lscr.io/linuxserver/pairdrop` |
| SFTPGo | SFTP/WebDAV with web admin | `drakkan/sftpgo` |

| | Nextcloud | Seafile | Syncthing | Filebrowser Quantum |
| --- | --- | --- | --- | --- |
| Style | Full cloud suite | Fast sync | P2P only | Web file manager |
| Mobile apps | Yes | Yes | Yes | No |
| Best for | Features | Speed | Simplicity | Quick server browsing |

### AI applications containers

Local models need RAM (16 GB+ is realistic; a GPU helps a lot).

#### Quick picks

| Need | Pick | Image |
| --- | --- | --- |
| ChatGPT-style on your hardware | **Ollama + OpenWebUI** ⭐ | `ollama/ollama` + `ghcr.io/open-webui/open-webui:main` |
| Multi-provider chat + agents | LibreChat | `ghcr.io/danny-avila/librechat` |
| Visual LLM workflows | Flowise or Langflow | `flowiseai/flowise` / `langflowai/langflow` |
| One API for many providers | LiteLLM | `ghcr.io/berriai/litellm` |
| LLM tracing / cost visibility | Langfuse | `langfuse/langfuse` |

**Ollama + OpenWebUI ⭐** - Default local AI stack. Guide: [Ollama with Docker](https://www.bitdoze.com/ollama-docker-install/).

**Flowise ⭐** - Drag-and-drop LLM apps. [Install Flowise](https://www.bitdoze.com/flowiseai-install/).  
**Langflow** - Multi-agent / RAG builder. [Install Langflow](https://www.bitdoze.com/langflow-docker-install/).  
**LiteLLM** - OpenAI-compatible proxy over 100+ providers. [Install LiteLLM](https://www.bitdoze.com/litellm-docker-install/).  
**Langfuse** - Observability for LLM apps. [Install Langfuse](https://www.bitdoze.com/langfuse-docker-install/).

### Home automation containers

| Need | Pick | Image |
| --- | --- | --- |
| Zapier-style workflows | **n8n** ⭐ | `n8nio/n8n` |
| Smart home devices | **Home Assistant** ⭐ | `homeassistant/home-assistant` |
| Hardware / IoT flows | **Node-RED** ⭐ | `nodered/node-red` |

**n8n ⭐** - Visual automation, 400+ integrations, owns your data. Guide: [Self-host n8n](https://www.bitdoze.com/n8n-self-host-workflow-automation/).  
**Home Assistant ⭐** - Device hub + automations, local-first.  
**Node-RED ⭐** - Flow programming for sensors and APIs.

Use HA for devices, n8n for web-service glue. They work fine side by side.

### Network management containers

DNS, remote access, reverse proxy, Docker UI.

#### Quick picks

| Need | Pick | Image |
| --- | --- | --- |
| Network-wide ads | **AdGuard Home** ⭐ (or Pi-hole) | `adguard/adguardhome` / `pihole/pihole` |
| Remote access (family) | **Tailscale** ⭐ | `tailscale/tailscale` |
| Self-hosted mesh | NetBird | `netbirdio/netbird` |
| Easy reverse proxy UI | **Nginx Proxy Manager** ⭐ | `jc21/nginx-proxy-manager` |
| Simplest auto-HTTPS | **Caddy** | `caddy` |
| Label-driven proxy | Traefik | `traefik` |
| Compose UI | **Dockge** ⭐ | `louislam/dockge` |
| Phone-to-home VPN UI | **WireGuard Easy** ⭐ | `ghcr.io/wg-easy/wg-easy` |
| Public app without opening ports | Cloudflare Tunnel | `cloudflare/cloudflared` |

**DNS:** AdGuard is often easier for new installs; Pi-hole has more guides.  
**Remote:** Tailscale for most people; Headscale if you want the control plane self-hosted ([guide](https://www.bitdoze.com/headscale-self-hosted-tailscale-setup/)). NetBird when you want OSS end-to-end.  
**Proxy:** Caddy for simple Caddyfiles; NPM for click-ops; Traefik if you like Docker labels.  
**Docker UI:** Dockge for Compose ([install](https://www.bitdoze.com/dockge-install/)); Portainer is fine but compare [Portainer alternatives](https://www.bitdoze.com/portainer-alternatives/) (Komodo multi-host, etc.).

| VPN-ish tool | Job |
| --- | --- |
| Tailscale / NetBird | Mesh: *you* reach the lab |
| WireGuard Easy | Classic VPN + QR peers |
| Gluetun | *Containers* exit via commercial VPN |
| Cloudflare Tunnel | Publish one HTTP service without port forwards |

Unbound (`mvance/unbound`) pairs with Pi-hole as a recursive resolver if you want that.

### Monitoring and analytics containers

| Need | Pick | Image |
| --- | --- | --- |
| Is it up? | **Uptime Kuma** ⭐ | `louislam/uptime-kuma` |
| Live Docker logs | **Dozzle** ⭐ | `amir20/dozzle` |
| Light host metrics | **Beszel** | `henrygd/beszel` |
| Deep metrics | Netdata | `netdata/netdata` |
| Disk S.M.A.R.T. | Scrutiny | `linuxserver/scrutiny` |
| New devices on LAN | NetAlertX | see project docs |
| YAML uptime (GitOps) | Gatus | `twinproduction/gatus` |

If you only install one: **Uptime Kuma**. Add **Dozzle** when debugging. Guide: [Beszel &amp; Uptime Kuma](https://www.bitdoze.com/beszel-uptime-kuma/).

### Notification containers

| Tool | Role | Image |
| --- | --- | --- |
| **ntfy** ⭐ | HTTP push + mobile apps | `binwiederhier/ntfy` |
| Gotify | Simple push server | `gotify/server` |
| Apprise | Fan-out to 100+ notifiers | `caronc/apprise` |

Point Uptime Kuma / *arr / scripts at ntfy and stop configuring Discord webhooks in twelve places.

### Security and privacy containers

Passwords, IPS, SSO, private search.

#### Quick picks

| Need | Pick | Image |
| --- | --- | --- |
| Password manager | **Vaultwarden** ⭐ | `vaultwarden/server` |
| Block attackers | **CrowdSec** ⭐ | `crowdsecurity/crowdsec` |
| Simple passkey SSO | **Pocket ID** ⭐ | `pocket-id/pocket-id` |
| Lightweight SSO + 2FA | Authelia | `authelia/authelia` |
| Full IdP (LDAP/SAML) | Authentik | `ghcr.io/goauthentik/server` |
| Private search | **SearXNG** ⭐ | `searxng/searxng` |

**Vaultwarden ⭐** - Bitwarden-compatible, much lighter than official server.  
**CrowdSec ⭐** - Prefer over Fail2Ban for new installs (shared threat intel). Fail2Ban still works: `crazymax/fail2ban`.  
**SSO ladder:** Pocket ID (passkeys only) → Authelia (YAML) → Authentik (heavy).  
**SearXNG ⭐** - Metasearch without tracking. Guide: [Self-host SearXNG](https://www.bitdoze.com/searxng-self-host-privacy-search/).

WireGuard image `linuxserver/wireguard` if you want raw peers; prefer [WireGuard Easy](#network-management-containers) for a UI. OpenVPN: `kylemanna/openvpn` (legacy setups).

### Productivity containers

| Need | Pick | Image |
| --- | --- | --- |
| Wiki | Bookstack or Docmost | `linuxserver/bookstack` / `docmost/docmost` |
| Notes (clients + E2E) | Joplin server | `joplin/server` |
| Light timeline notes | Memos | `neosmemo/memos` |
| Recipes | **Mealie** ⭐ | `ghcr.io/mealie-recipes/mealie` |
| Bookmarks + archive | **Karakeep** ⭐ | `karakeep-app/karakeep` |
| Minimal bookmarks | Linkding | `sissbruecker/linkding` |
| PDF toolkit | **Stirling PDF** ⭐ | `frooodle/s-pdf` |
| Dev Swiss-army tools | IT-Tools | `corentinth/it-tools` |
| Newsletter | Notifuse | see [guide](https://www.bitdoze.com/notifuse-self-host-newsletter/) |
| E-signatures | Documenso | `documenso/documenso` |
| Home inventory | Homebox | `ghcr.io/sysadminsmedia/homebox` |
| Kanban | Kanboard / Wekan | `kanboard/kanboard` / `wekanteam/wekan` |

**Stirling PDF ⭐** - Merge, OCR, compress, etc. [Self-host Stirling PDF](https://www.bitdoze.com/stirling-pdf-self-host-manipulation/).  
**Mealie ⭐** - Import recipes from URLs, meal plans, shopping lists. Tandoor is the nutrition-focused alternative (`vabene1111/recipes`).  
**Karakeep ⭐** (ex-Hoarder) - Bookmark + full-page archive + AI tags.  
**Docmost** - Collaborative knowledge base. [Install Docmost](https://www.bitdoze.com/docmost-docker-install/).

### Development containers

Git, browser IDE, PaaS panels, auto-updates.

| Need | Pick | Image / notes |
| --- | --- | --- |
| Self-hosted PaaS | **Coolify** ⭐ or **Dokploy** ⭐ | Coolify install script / `dokploy/dokploy` |
| Git hosting | Gitea or Forgejo | `gitea/gitea` / `codeberg.org/forgejo/forgejo` |
| VS Code in browser | Code-Server | `linuxserver/code-server` |
| Auto-update images | Watchtower | `containrrr/watchtower` |
| Classic CI | Jenkins | `jenkins/jenkins` |

**Coolify ⭐** - Full panel, many one-click apps. [Install](https://www.bitdoze.com/coolify-install-heroku-alternative/) · [v5 review](https://www.bitdoze.com/coolify-v5-self-hosted-paas-review/) · [vs Dokploy](https://www.bitdoze.com/coolify-vs-dokploy-vs-kamal-2/).  
**Dokploy ⭐** - Compose-friendly PaaS. [Install Dokploy](https://www.bitdoze.com/dokploy-install/).  
**Watchtower** - Auto-updates; exclude databases and pin critical tags.

### Database containers

| DB | Use when | Image |
| --- | --- | --- |
| **PostgreSQL** ⭐ | Most modern apps | `postgres` |
| **MariaDB** ⭐ | MySQL-compatible apps | `mariadb` |
| **Valkey** ⭐ | Redis-compatible cache (open-source default post-license change) | `valkey/valkey` |

### Backup and recovery containers

| Need | Pick | Image |
| --- | --- | --- |
| Restic with web UI | **Backrest** ⭐ | `garethgeorge/backrest` |
| Fast encrypted backup + UI | **Kopia** ⭐ | `kopia/kopia` |
| Simple multi-cloud UI | Duplicati | `linuxserver/duplicati` |
| CLI power user | Restic | `restic/restic` |

Back up volumes and compose files. Test a restore once.

### Personal finance containers

| Need | Pick | Image |
| --- | --- | --- |
| Envelope budgeting (YNAB-style) | **Actual Budget** ⭐ | `actualbudget/actual-server` |
| Full double-entry books | **Firefly III** ⭐ | `fireflyiii/core` |
| Subscription tracker | Wallos | `ellite/wallos` |

GnuCash / HomeBank exist as desktop-oriented options if you prefer that style.

### Photography and image management containers

| Need | Pick | Image |
| --- | --- | --- |
| Google Photos replacement | **Immich** ⭐ | `ghcr.io/immich-app/immich-server` |
| AI photo library (alternative) | PhotoPrism | `photoprism/photoprism` |

**Immich ⭐** is the usual choice now: mobile backup, faces, map, active releases. Follow upstream compose carefully when upgrading.

### E-book management containers

| Tool | Role | Image |
| --- | --- | --- |
| Calibre-web | E-book library web UI | `linuxserver/calibre-web` |
| Komga | Comics / manga | `gotson/komga` |
| Kavita | Multi-format reading server | `jvmilazz0/kavita` |

### Self-hosted database solutions (Airtable alternatives)

| Tool | Role | Image |
| --- | --- | --- |
| **NocoDB** ⭐ | Spreadsheet UI on SQL | `nocodb/nocodb` |
| **Baserow** ⭐ | Airtable-like | `baserow/baserow` |
| **Grist** ⭐ | Spreadsheet + Python formulas | `gristlabs/grist` |
| Teable | Fast real-time grid | `teableio/teable` |

More: [Self-hosted Airtable alternatives](https://www.bitdoze.com/self-hosted-airtable-alternatives/).

### Personal dashboard containers

| Tool | Style | Image |
| --- | --- | --- |
| **Homepage** ⭐ | Widget-heavy, Docker labels | `ghcr.io/gethomepage/homepage` |
| **Glance** ⭐ | Minimal, clean YAML | `glanceapp/glance` |
| Heimdall | Tile launcher | `linuxserver/heimdall` |
| Homer | Static links, tiny | `b4bz/homer` |
| Organizr | Tabbed media shell | `organizr/organizr` |

### Specialty tools

Less common but useful niches. Skim the tables; expand only what you need.

#### Remote desktop, location, analytics

| Tool | Role | Image |
| --- | --- | --- |
| **RustDesk** ⭐ | TeamViewer alternative (self-host relay) | `rustdesk/rustdesk-server` |
| **Dawarich** ⭐ | Google Timeline replacement | `freika/dawarich` |
| **Matomo** ⭐ | Self-hosted web analytics | `matomo` |
| GoAccess | Access-log analyzer | `allinurl/goaccess` |
| Metabase | BI dashboards over DBs | `metabase/metabase` |
| Postiz | Social media scheduling | check Postiz docs |

RustDesk is interactive desktop control. Tailscale only gets you on the network.

#### Documents, web hosting, RSS, games, chat

| Tool | Role | Image |
| --- | --- | --- |
| **Paperless-ngx** ⭐ | Document OCR archive | `ghcr.io/paperless-ngx/paperless-ngx` |
| WordPress | Full CMS | `wordpress` |
| Ghost | Publishing / newsletters | `ghost` |
| FreshRSS / Miniflux | RSS readers | `linuxserver/freshrss` / `miniflux/miniflux` |
| Minecraft / Valheim | Game servers | `itzg/minecraft-server` / `lloesche/valheim-server` |
| Pterodactyl | Multi-game panel | `ghcr.io/pterodactyl/panel` |
| Mattermost | Slack-like chat | `mattermost/mattermost-team-edition` |
| Jitsi Meet | Video calls | `jitsi/web` |
| Matrix Synapse | Federated chat homeserver | `matrixdotorg/synapse` |
| Rocket.Chat | Team chat | `rocket.chat` |

#### Time, weather, VoIP, passwords (extra)

| Tool | Role | Image |
| --- | --- | --- |
| Kimai / TimeTagger | Time tracking | `kimai/kimai2` / `almarklein/timetagger` |
| Weewx | Weather station software | `felddy/weewx` |
| FreePBX / Asterisk | VoIP | `tiredofit/freepbx` / `andrius/asterisk` |
| Passbolt | Team password manager | `passbolt/passbolt` |

Vaultwarden (above) is still the default personal password pick.

&lt;Accordion label=&quot;Best practices and troubleshooting (expand when something breaks)&quot; group=&quot;ops&quot;&gt;

**Organization**
- Name stacks clearly (`media-sonarr`, not `app2`)
- One compose stack per concern
- Shared networks per group; compose files in Git

**Resources**
- Memory limits on Immich, Nextcloud, DBs, LLMs
- Hardware encode for media when you can
- `docker stats` after adding services

**Security**
- Trusted images only; be careful with the Docker socket
- Secrets: [Compose secrets](https://www.bitdoze.com/docker-compose-secrets/), not committed passwords
- Reverse proxy + SSO before anything public
- CrowdSec (or Fail2Ban) on the edge

**Updates**
- Watchtower only for low-risk apps; pin DB tags
- Scheduled backups (Backrest / Kopia / Duplicati) and one test restore
- Dozzle + Uptime Kuma for overnight deaths

**Quick fixes**
- Ports: `docker ps` · Networks: `docker network ls`
- Conflicts: rename containers, fix bind mounts
- RAM: `docker stats`, then limits or more hardware

&lt;/Accordion&gt;

## Conclusions

### Cheat sheet

| Area | Default pick |
| --- | --- |
| Media | Jellyfin + Prowlarr + Sonarr/Radarr + qBittorrent (+ Gluetun) |
| Files | Nextcloud or Seafile + Filebrowser Quantum |
| DNS / remote | AdGuard + Tailscale |
| Proxy | Caddy or Nginx Proxy Manager |
| Docker UI | Dockge ([Portainer alternatives](https://www.bitdoze.com/portainer-alternatives/)) |
| Ops | Uptime Kuma + Dozzle + Beszel |
| Security | Vaultwarden + CrowdSec + Pocket ID/Authelia |
| PaaS | Coolify or Dokploy |
| Backup | Backrest or Kopia |
| Photos | Immich |
| Budget | Actual Budget or Firefly III |

### What&apos;s changed in 2026

- **Plex remote streaming is paywalled** (since April 2025). New Lifetime Pass is **$749.99** (July 1, 2026). Local free; many moved to Jellyfin.
- **Coolify** and **Dokploy** are the main self-hosted PaaS options.
- **Filebrowser Quantum** replaced the maintenance-only original.
- **Pocket ID**, **Gluetun**, **Dozzle**, **Karakeep**, **Dawarich**, **Actual Budget** are common new defaults.
- Immich for photos, Valkey instead of Redis, Tailscale for family access still hold.

### Getting started

1. Starter stack above (Dockge → DNS → dashboard → files → media)
2. Uptime Kuma + Dozzle
3. Vaultwarden + Tailscale
4. Backups before the data matters

Don&apos;t install 125 containers on day one. Pick one goal from the table at the top, finish that stack, then expand. More apps: [toolhunt.net self-hosted](https://toolhunt.net/sh/). For AI-specific GitHub projects (Ollama, OpenClaw, n8n, Langfuse, and friends), see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>100+ Git Commands Every Developer MUST Know (2026 Guide)</title><link>https://www.bitdoze.com/git-commands/</link><guid isPermaLink="true">https://www.bitdoze.com/git-commands/</guid><description>Master 100+ Git commands every developer needs, from git init to git worktree. The ultimate Git cheat sheet covering version control, branching, and modern Git workflows.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Git is the backbone of modern version control. Every team I&apos;ve worked with, from solo side projects on a cheap VPS to large-scale CI/CD pipelines, runs on Git. This guide covers 100+ Git commands from basics through advanced workflows, updated for 2026 (Git 2.55, with Git 3.0 on the horizon).

Whether you&apos;re learning Git for the first time or looking for a bookmarkable cheat sheet, this article has you covered. I&apos;ve organized the commands so you can jump to any section and find what you need. If you&apos;re setting up a full dev environment, you&apos;ll also want to check out [essential Linux commands](/linux-commands/) and [Docker commands every developer should know](/docker-commands/). These pair well with Git for any development workflow. If you use [AI coding tools that integrate with Git](/ai-coading-tools/), the worktree and automation sections will be especially useful.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Basic Git operations: init, clone, add, commit, config&lt;/li&gt;
&lt;li&gt;Modern branch management with git switch (replaces git checkout)&lt;/li&gt;
&lt;li&gt;git restore for discarding changes safely&lt;/li&gt;
&lt;li&gt;git worktree for parallel branch workflows&lt;/li&gt;
&lt;li&gt;Advanced debugging with git bisect run and git range-diff&lt;/li&gt;
&lt;li&gt;Repository maintenance with git maintenance and git fsck&lt;/li&gt;
&lt;li&gt;CI/CD automation commands and scripting snippets&lt;/li&gt;
&lt;li&gt;Git 3.0 readiness: SHA-256, reftable, and what&apos;s breaking&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Section 1: Basic Git Commands

These are the commands you&apos;ll use every day. Master these first.

### 1.1 git init

Initializes a new Git repository. Creates the `.git` directory with all the metadata Git needs to track changes.

**Example Usage:**

```bash
mkdir my_project
cd my_project
git init
# Initialized empty Git repository in /path/to/my_project/.git/
```

**Verify:** Run `git status`. You should see &quot;On branch main&quot; (or master, depending on your default branch config) with no commits yet.

### 1.2 git clone

Creates a local copy of a remote repository.

**Example Usage:**

```bash
git clone https://github.com/user/repository.git
# Cloning into &apos;repository&apos;...
# remote: Enumerating objects: 10, done.
# remote: Counting objects: 100% (10/10), done.
# remote: Compressing objects: 100% (8/8), done.
# remote: Total 10 (delta 2), reused 10 (delta 2), pack-reused 0
# Unpacking objects: 100% (10/10), done.
```

**Verify:** `ls -la .git/` should show the repository structure. For extra safety, run `git fsck` to verify integrity.

For shallow and partial clones (useful on metered VPS or in CI), see Section 3.5.

### 1.3 git status

Shows the current state of the working directory and staging area.

**Example Usage:**

```bash
git status
# On branch main
# Your branch is up to date with &apos;origin/main&apos;.
#
# Untracked files:
#   (use &quot;git add &lt;file&gt;...&quot; to include in what will be committed)
#     newfile.txt
#
# nothing added to commit but untracked files present (use &quot;git add&quot; to track)
```

This is the command I run most often. Use it before every commit to make sure you&apos;re staging exactly what you think you are.

### 1.4 git add

Adds file contents to the staging area for the next commit.

**Example Usage:**

```bash
# Stage a single file
git add newfile.txt

# Stage all changes
git add .

# Stage interactively (pick hunks)
git add -p
```

`git add -p` is worth learning early. It lets you stage parts of a file, which leads to cleaner commits.

### 1.5 git commit

Records staged changes as a new commit.

**Example Usage:**

```bash
git commit -m &quot;Add newfile.txt&quot;
# [main 1a2b3c4] Add newfile.txt
#  1 file changed, 1 insertion(+)
#  create mode 100644 newfile.txt
```

**Amend the last commit** (before you&apos;ve pushed):

```bash
git commit --amend -m &quot;Updated commit message&quot;
```

### 1.6 git config

Configures Git settings. In Git 2.46+ (July 2024), there&apos;s a cleaner subcommand-based syntax:

&lt;Tabs&gt;
&lt;Tab name=&quot;New syntax (Git 2.46+)&quot;&gt;
```bash
git config set --global user.name &quot;John Doe&quot;
git config set --global user.email &quot;johndoe@example.com&quot;
git config list
git config get user.name
git config unset user.name
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Legacy syntax (still works)&quot;&gt;
```bash
git config --global user.name &quot;John Doe&quot;
git config --global user.email &quot;johndoe@example.com&quot;
git config --list
git config --get user.name
git config --unset user.name
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Git 2.46 config subcommands&quot;&gt;
The new `git config set/get/list/unset` syntax is cleaner and easier to read. The old `--global`/`--get` flags still work, Git maintains backward compatibility. But if you&apos;re teaching new developers, use the subcommand form. Git 3.0 will lean further in this direction.
&lt;/Notice&gt;

### 1.7 git grep

Searches tracked files for a pattern. Faster than `grep -r` because it operates on the Git index.

**Example Usage:**

```bash
# Search for &quot;TODO&quot; in all tracked files
git grep &quot;TODO&quot;

# Search with line numbers
git grep -n &quot;function_name&quot;

# Search in a specific commit
git grep &quot;pattern&quot; HEAD~3

# Case-insensitive search
git grep -i &quot;error&quot;
```

### 1.8 git rm

Removes files from the working directory and staging area.

**Example Usage:**

```bash
# Remove a tracked file
git rm oldfile.txt

# Remove from tracking but keep the file on disk
git rm --cached secret.env
```

The `--cached` flag is useful when you accidentally committed a file that should be in `.gitignore`.

### 1.9 git mv

Moves or renames a tracked file.

**Example Usage:**

```bash
git mv old_name.txt new_name.txt
```

This is equivalent to `mv old_name.txt new_name.txt &amp;&amp; git add new_name.txt &amp;&amp; git rm old_name.txt`, but cleaner.

---

## Section 2: Branching and Merging

Branching lets you work on features, fixes, and experiments in isolation. This section uses the modern `git switch` as the primary command. If you&apos;re still using `git checkout` for everything, read the legacy note in 2.3.

### 2.1 git branch

List, create, and delete branches.

**Example Usage:**

```bash
# List local branches
git branch
# * main
#   feature-branch

# List all branches (including remote)
git branch -a

# Create a new branch
git branch new-feature

# Force-delete an unmerged branch
git branch -D experimental
```

### 2.2 git switch

The modern way to switch and create branches. Introduced in Git 2.23 (August 2019).

**Example Usage:**

```bash
# Switch to an existing branch
git switch main

# Create and switch to a new branch
git switch -c new-feature

# Switch to the previous branch
git switch -

# Create a branch from a specific commit
git switch -c hotfix abc1234
```

&lt;Notice type=&quot;info&quot; title=&quot;Why git switch over git checkout&quot;&gt;
`git checkout` does two unrelated things: switch branches and restore files. This causes confusion and mistakes. `git switch` handles branch operations only, making intent clear. Git 3.0 will strongly recommend `git switch` and `git restore` over `git checkout`.
&lt;/Notice&gt;

### 2.3 git checkout (legacy)

Still works, but `git switch` (branches) and `git restore` (files) are the recommended replacements since Git 2.23.

```bash
# Legacy: switch branch (use git switch instead)
git checkout new-feature

# Legacy: create and switch (use git switch -c instead)
git checkout -b new-feature

# Legacy: restore file (use git restore instead)
git checkout -- file.txt
```

&lt;Notice type=&quot;warning&quot; title=&quot;git checkout conflates two operations&quot;&gt;
Using `git checkout` for both branch switching and file restoration is a common source of mistakes. `git checkout -- file.txt` discards uncommitted changes to a file, while `git checkout main` switches branch. The same command, completely different outcomes. Use `git switch` and `git restore` to make your intent explicit.
&lt;/Notice&gt;

### 2.4 git merge

Combines changes from one branch into the current branch.

**Example Usage:**

```bash
# Merge a branch into current branch
git merge feature-branch

# Merge with an explicit merge commit (even if fast-forward is possible)
git merge --no-ff feature-branch -m &quot;Merge feature-branch&quot;

# Abort a merge with conflicts
git merge --abort
```

**Verify:** `git log --oneline --graph -10` to see the merge in the commit topology.

**Failure mode (merge conflicts):** When Git can&apos;t auto-merge, it marks conflicts in the files. Edit the conflicted files, then `git add` them and `git commit`. Or `git merge --abort` to back out completely.

### 2.5 git rebase

Reapplies commits on top of another base. Creates a linear history (no merge commits).

**Example Usage:**

```bash
# Rebase current branch onto main
git rebase main

# Interactive rebase (squash, reorder, edit commits)
git rebase -i HEAD~5

# Abort a rebase
git rebase --abort

# Continue after resolving conflicts
git rebase --continue
```

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t rebase shared branches&quot;&gt;
Never rebase commits that have been pushed and shared with others. Rebase rewrites commit hashes, which will cause conflicts for anyone else working on those commits. Rebase is safe for local-only branches.
&lt;/Notice&gt;

### 2.6 git worktree

Lets you check out multiple branches simultaneously in separate directories, all linked to the same repository. No need to stash or clone twice.

**Example Usage:**

```bash
# Create a worktree for main at ../hotfix
git worktree add ../hotfix main

# Create a worktree with a new branch
git worktree add -b hotfix-123 ../hotfix-123 main

# List all worktrees
git worktree list
# /home/user/project          abc1234 [main]
# /home/user/hotfix           def5678 [main]

# Remove a worktree
git worktree remove ../hotfix

# Clean up stale worktree metadata
git worktree prune
```

&lt;Notice type=&quot;info&quot; title=&quot;Worktrees are underrated&quot;&gt;
Worktrees let you work on a feature branch while quickly fixing a bug on main, no stashing, no second clone. They&apos;re also useful for [AI coding tools](/ai-coading-tools/) that can work on parallel feature branches. Each worktree shares the same `.git` database, so you avoid the disk cost of a full clone. I use worktrees whenever I need to context-switch between branches.
&lt;/Notice&gt;

**Failure mode (detached HEAD):** Running `git switch` to a specific commit hash (not a branch name) puts you in detached HEAD state. Your work isn&apos;t on any branch. Fix it with:

```bash
git switch -c new-branch-name
```

This saves your work onto a proper branch.

---

## Section 3: Remote Repository Commands

Working with remote repositories, GitHub, GitLab, Gitea, [Forgejo](/forgejo-woodpecker-ci-cicd/), or any Git server. If you&apos;re pushing over SSH, make sure you have your [SSH key setup for GitHub](/link-github-with-ssh-maco-linux/) configured first.

### 3.1 git remote

Manages remote repository connections.

**Example Usage:**

```bash
# List remotes
git remote
# origin

# List remotes with URLs
git remote -v
# origin  https://github.com/user/repo.git (fetch)
# origin  https://github.com/user/repo.git (push)

# Add a remote
git remote add upstream https://github.com/original/repo.git

# Remove a remote
git remote remove upstream

# Change a remote URL
git remote set-url origin git@github.com:user/repo.git

# Prune stale remote-tracking branches
git remote prune origin
```

### 3.2 git fetch

Downloads objects and refs from a remote repository without merging.

**Example Usage:**

```bash
# Fetch from origin
git fetch

# Fetch from a specific remote
git fetch upstream

# Fetch and prune deleted remote branches
git fetch --prune

# Fetch a specific branch
git fetch origin feature-branch
```

I use `git fetch --prune` as a habit. It cleans up remote-tracking branches that have been deleted on the server.

### 3.3 git pull

Fetches and merges changes from a remote branch. Equivalent to `git fetch` followed by `git merge`.

**Example Usage:**

```bash
# Pull and merge
git pull

# Pull with rebase instead of merge (cleaner history)
git pull --rebase

# Pull from a specific remote and branch
git pull upstream main
```

### 3.4 git push

Uploads local commits to a remote repository.

**Example Usage:**

```bash
# Push current branch to origin
git push

# Push and set upstream tracking
git push -u origin new-feature

# Delete a remote branch
git push --delete origin old-branch
```

### 3.5 git push --force-with-lease (safe force push)

&lt;Notice type=&quot;warning&quot; title=&quot;Never use --force on shared branches&quot;&gt;
`git push --force` overwrites the remote branch unconditionally. If someone pushed commits after your last fetch, those commits are gone. Always use `--force-with-lease`. It checks that the remote ref hasn&apos;t changed since your last fetch, and aborts if it has.
&lt;/Notice&gt;

**Example Usage:**

```bash
# Safe force push (checks remote state first)
git push --force-with-lease origin main

# Even safer (also checks local reflog)
git push --force-with-lease --force-if-includes origin main
```

`--force-with-lease` has saved me more than once. It&apos;s the difference between &quot;I rewrote history safely&quot; and &quot;I just destroyed my teammate&apos;s work.&quot;

### 3.6 Shallow and partial clones

When you&apos;re on a metered VPS, running CI, or working with a huge repo, full clones waste bandwidth and disk. These flags help.

**Example Usage:**

```bash
# Shallow clone  -  only latest commit (great for CI)
git clone --depth 1 https://github.com/user/large-repo.git

# Partial clone  -  download blobs on demand (saves disk)
git clone --filter=blob:none https://github.com/user/large-repo.git

# Convert a shallow clone to full history later
git fetch --unshallow
```

&lt;Notice type=&quot;info&quot; title=&quot;Shallow clones for CI/CD&quot;&gt;
A `--depth 1` clone downloads only the latest commit and tree  -  no history. This cuts clone time dramatically for large repos. In CI pipelines where you only need the latest code for building/testing, shallow clones are the default for a reason. For VPS deployments, partial clones (`--filter=blob:none`) are a good middle ground: you get history but download file contents on demand.
&lt;/Notice&gt;

---

## Section 4: Viewing History and Comparing Changes

Understanding what happened and when. These commands are your time machine.

### 4.1 git log

Shows commit history. The default output is verbose  -  learn the flags that matter.

**Example Usage:**

```bash
# Basic log
git log

# One-line format (my daily driver)
git log --oneline

# Visual branch graph
git log --graph --oneline --all

# Limit output
git log -10

# Show files changed per commit
git log --stat

# Search for commits that introduced/removed a string (pickaxe)
git log -S&quot;buggy_function&quot;

# Trace the history of a specific function
git log -L :function_name:file.c

# Show commits since a date
git log --since=&quot;2026-01-01&quot;

# Show commits by author
git log --author=&quot;Dragos&quot;

# Cap graph lane width (Git 2.55+)
git log --graph --oneline --all --graph-lane-limit=5
```

&lt;Notice type=&quot;info&quot; title=&quot;Pickaxe search is powerful&quot;&gt;
`git log -S&quot;string&quot;` finds every commit that added or removed that string. It&apos;s like `git blame` but across the entire history. I use this when tracking down when a bug was introduced. For regex matching, use `git log -G&quot;pattern&quot;` instead.
&lt;/Notice&gt;

### 4.2 git diff

Shows changes between commits, branches, or the working directory.

**Example Usage:**

```bash
# Changes not yet staged
git diff

# Changes staged for commit
git diff --cached

# Diff between two branches
git diff main..feature-branch

# Summary of changes (files and line counts)
git diff --stat

# Diff for a specific file
git diff HEAD -- file.txt
```

### 4.3 git show

Displays details of a specific commit (diff, message, author).

**Example Usage:**

```bash
# Show the latest commit
git show

# Show a specific commit
git show abc1234

# Show only the files changed
git show --stat abc1234
```

### 4.4 git blame

Shows who last modified each line of a file and when.

**Example Usage:**

```bash
# Show blame for a file
git blame file.txt

# Blame a specific line range
git blame -L 10,20 file.txt

# Use a different diff algorithm (Git 2.54+)
git blame --diff-algorithm=patience file.txt
```

### 4.5 git shortlog

Summarizes `git log` output by author. Useful for seeing contributor activity.

**Example Usage:**

```bash
# Summary by author
git shortlog -sn

# Summary since a date
git shortlog -sn --since=&quot;2026-01-01&quot;
```

### 4.6 git describe

Generates a human-readable identifier from the nearest tag.

**Example Usage:**

```bash
git describe --tags
# v1.2.3-14-gabc1234
# (14 commits after tag v1.2.3, commit starting with abc1234)
```

Use this in build scripts for versioning: the output is always unique and tells you exactly how far you are from the last release.

### 4.7 git range-diff

Compares two versions of a commit range. The primary use case: verifying a rebase didn&apos;t lose or alter anything.

**Example Usage:**

```bash
# Compare old and new branch versions after rebase
git range-diff origin/main..old-branch origin/main..new-branch
```

**Verify after rebase:** Run `git range-diff` after rebasing to confirm that each commit&apos;s diff is the same (or intentionally different). If commits are missing or their diffs changed unexpectedly, something went wrong.

---

## Section 5: Undoing Changes and Fixing Mistakes

Everyone makes mistakes. These commands are your safety net.

### 5.1 git reset

Moves HEAD and optionally modifies the staging area and working directory.

**Example Usage:**

```bash
# Soft reset  -  keep changes staged
git reset --soft HEAD~1

# Mixed reset (default)  -  keep changes unstaged
git reset HEAD~1

# Hard reset  -  discard everything since that commit
git reset --hard HEAD~1
```

&lt;Notice type=&quot;warning&quot; title=&quot;git reset --hard is destructive&quot;&gt;
`git reset --hard` discards uncommitted changes and moves HEAD. But commits aren&apos;t gone immediately  -  they&apos;re still in the reflog. If you accidentally hard reset, see the recovery section below.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Recovering from git reset --hard&quot; group=&quot;recovery&quot;&gt;

If you just ran `git reset --hard` and lost commits, they&apos;re still in the reflog:

```bash
# View the reflog (your safety net)
git reflog
# abc1234 HEAD@{0}: reset: moving to HEAD~3
# def5678 HEAD@{1}: commit: Important work here

# Restore to the commit before the reset
git reset --hard HEAD@{1}
# or
git reset --hard def5678
```

The reflog keeps entries for 90 days by default. As long as the garbage collector hasn&apos;t run, you can recover.

&lt;/Accordion&gt;

### 5.2 git restore

The modern way to discard working directory changes or unstage files. Replaces `git checkout -- &lt;file&gt;`.

**Example Usage:**

```bash
# Discard changes to a file (restore to last committed state)
git restore file.txt

# Unstage a file (move it out of the staging area)
git restore --staged file.txt

# Restore a file from a specific commit
git restore --source=HEAD~2 file.txt

# Restore all files in the working directory
git restore .
```

### 5.3 git revert

Creates a new commit that undoes a specific commit. Unlike `reset`, it doesn&apos;t rewrite history.

**Example Usage:**

```bash
# Revert a specific commit
git revert abc1234

# Revert without committing (stage the revert)
git revert --no-commit abc1234
```

Use `git revert` on shared branches where you can&apos;t rewrite history. Use `git reset` on local-only branches.

### 5.4 git clean

Removes untracked files from the working directory.

**Example Usage:**

```bash
# Dry run  -  show what would be deleted
git clean -n

# Remove untracked files
git clean -f

# Remove untracked files and directories
git clean -fd

# Include ignored files too
git clean -fdx
```

Always run with `-n` first to preview. There&apos;s no undo for `git clean`.

### 5.5 git stash

Temporarily shelves changes so you can switch branches or work on something else.

**Example Usage:**

```bash
# Stash current changes
git stash

# Stash with a descriptive message
git stash push -m &quot;WIP: login feature&quot;

# List all stashes
git stash list
# stash@{0}: On main: WIP: login feature
# stash@{1}: On main: experimental changes

# Apply the most recent stash (keep it in the list)
git stash apply

# Apply and remove the most recent stash
git stash pop

# Apply a specific stash
git stash apply stash@{1}

# Create a branch from a stash
git stash branch new-feature-branch stash@{0}

# Delete a specific stash
git stash drop stash@{0}

# Delete all stashes
git stash clear

# Show stash contents
git stash show -p stash@{0}
```

### 5.6 git cherry-pick

Applies a specific commit from one branch to the current branch.

**Example Usage:**

```bash
# Cherry-pick a single commit
git cherry-pick abc1234

# Cherry-pick without committing
git cherry-pick --no-commit abc1234

# Cherry-pick a range of commits
git cherry-pick abc1234..def5678
```

---

## Section 6: Advanced Git Commands

Debugging, automation, and power-user workflows.

### 6.1 git bisect

Uses binary search to find the commit that introduced a bug.

**Manual bisect:**

```bash
git bisect start
git bisect bad          # current commit is broken
git bisect good v1.0    # this tag/commit was working

# Git checks out a middle commit. Test it, then:
git bisect good         # this commit works
# or
git bisect bad          # this commit is broken

# Git narrows down until it finds the bad commit
# When done:
git bisect reset
```

**Automated bisect with a script:**

```bash
git bisect start HEAD v1.0
git bisect run ./test-script.sh
```

The test script should exit 0 for &quot;good&quot; and non-zero for &quot;bad.&quot; Git will automatically check out commits and run the script until it finds the first bad commit. This is the real power of bisect  -  let the computer do the work.

&lt;Notice type=&quot;info&quot; title=&quot;git bisect run saves hours&quot;&gt;
Write a test script that reproduces the bug, make it executable, and hand it to `git bisect run`. It will binary-search through hundreds of commits in minutes. I&apos;ve used this to track down regressions across months of commit history.
&lt;/Notice&gt;

### 6.2 git tag

Creates named references to specific commits (typically for releases).

**Example Usage:**

```bash
# Lightweight tag
git tag v1.0.0

# Annotated tag (recommended  -  includes metadata)
git tag -a v1.0.0 -m &quot;Release 1.0.0&quot;

# Tag a specific commit
git tag -a v1.0.0 abc1234

# List tags
git tag

# List tags with messages
git tag -n

# Push tags to remote
git push origin v1.0.0

# Push all tags
git push origin --tags
```

### 6.3 git submodule

Manages external repositories embedded in your repository.

**Example Usage:**

```bash
# Add a submodule
git submodule add https://github.com/user/library.git libs/library

# Initialize submodules after cloning
git submodule init
git submodule update

# Or combine both
git submodule update --init --recursive

# Update submodules to latest remote commits
git submodule update --remote
```

### 6.4 git archive

Creates a tar or zip archive of a repository at a specific point.

**Example Usage:**

```bash
# Create a tar archive of HEAD
git archive --format=tar HEAD &gt; project.tar

# Create a zip of a specific tag
git archive --format=zip v1.0.0 &gt; project-v1.0.0.zip

# Archive specific paths only
git archive --format=tar HEAD src/ &gt; src-only.tar
```

### 6.5 git reflog

Shows a log of where HEAD has been. Your safety net for recovery operations.

**Example Usage:**

```bash
# View reflog
git reflog
# abc1234 HEAD@{0}: commit: Latest change
# def5678 HEAD@{1}: reset: moving to HEAD~1
# ghi9012 HEAD@{2}: commit: Important work

# Reset to a reflog entry
git reset --hard HEAD@{2}
```

### 6.6 git notes

Attaches metadata to commits without rewriting history. Think of it as comments on commits.

**Example Usage:**

```bash
# Add a note to a commit
git notes add -m &quot;Reviewed-by: Jane&quot; abc1234

# View notes for a commit
git notes show abc1234

# List all notes
git notes list

# Push notes to remote (not pushed by default)
git push origin refs/notes/commits
```

Use cases: attaching CI results, code review metadata, or test coverage info to specific commits.

### 6.7 git commit --fixup and --autosquash

Clean up commit history before merging.

**Example Usage:**

```bash
# Create a fixup commit targeting a specific commit
git commit --fixup=abc1234

# Interactive rebase that automatically squashes fixup commits
git rebase -i --autosquash main
```

This is my preferred workflow for pull requests: make fixup commits as reviewers request changes, then squash them into the right places before merging.

### 6.8 git bundle

Creates a portable archive of a repository that can be cloned. Useful for air-gapped environments, offline transfers, or backups.

**Example Usage:**

```bash
# Create a bundle of all branches
git bundle create repo.bundle --all

# Create a bundle of specific branches
git bundle create repo.bundle main feature-branch

# Clone from a bundle
git clone repo.bundle new-repo

# Verify a bundle is valid
git bundle verify repo.bundle
```

### 6.9 git am

Applies patches from a mailbox (email-based workflows). Still used by the Linux kernel and other projects that rely on email-based code review.

**Example Usage:**

```bash
# Apply a patch file
git am &lt; patch-file.patch

# Apply a series of patches from a directory
git am patches/*.patch

# Abort if something goes wrong
git am --abort

# Apply with 3-way merge (resolves more cases)
git am --3way &lt; patch-file.patch
```

---

## Section 7: Collaboration and Patch Workflows

Working with patches, email submissions, and code review.

### 7.1 git cherry

Finds commits that haven&apos;t been applied upstream.

**Example Usage:**

```bash
# Show commits in the current branch not in upstream
git cherry upstream/main

# Verbose output with commit messages
git cherry -v upstream/main
```

### 7.2 git apply

Applies a patch created by `git diff` or `git format-patch`.

**Example Usage:**

```bash
# Verify a patch without applying
git apply --check changes.patch

# Apply a patch
git apply changes.patch

# Apply with reverse (undo a patch)
git apply -R changes.patch
```

Always run `git apply --check` first to verify the patch applies cleanly.

### 7.3 git format-patch

Creates patch files from commits. Each commit becomes a separate `.patch` file with the commit message.

**Example Usage:**

```bash
# Create patches for the last 3 commits
git format-patch -3

# Create patches for a range
git format-patch main..feature-branch

# Create patches in a directory
git format-patch -3 -o patches/
```

### 7.4 git send-email

Sends patch files via email. Used in projects that do email-based code review (Linux kernel, Git itself).

**Example Usage:**

```bash
# Send a patch series
git send-email patches/*.patch

# Send to a specific recipient
git send-email --to=maintainer@example.com patches/*.patch
```

### 7.5 git request-pull

Generates a pull request message for email-based workflows.

**Example Usage:**

```bash
git request-pull v1.0 https://github.com/user/repo.git main
```

---

## Section 8: Repository Maintenance and Performance

Keep your repositories fast and healthy. Especially important if you&apos;re hosting repos on a VPS with limited disk and I/O.

### 8.1 git gc

Garbage collection  -  cleans up unnecessary files and optimizes the repository.

**Example Usage:**

```bash
# Standard garbage collection
git gc

# Aggressive (slower but more thorough  -  use occasionally)
git gc --aggressive

# Auto-gc with higher thresholds (less frequent packing)
git gc --auto
```

**Verify:** Run `git log -1` after gc to confirm the repo still works.

&lt;Notice type=&quot;warning&quot; title=&quot;git gc --aggressive can be slow&quot;&gt;
`--aggressive` rewrites pack files for better compression. On large repos, this can take minutes to hours. Use it occasionally, not as a daily task. For routine maintenance, plain `git gc` or `git maintenance` is better.
&lt;/Notice&gt;

### 8.2 git fsck

Verifies the integrity of the repository database.

**Example Usage:**

```bash
# Check for corrupt objects
git fsck

# Check and report dangling objects
git fsck --dangling
```

Run `git fsck` after a suspicious clone or when you suspect corruption. If it reports errors, re-clone from a known good source.

### 8.3 git maintenance

Schedules background tasks to keep repositories fast. Introduced in Git 2.30.

**Example Usage:**

```bash
# Enable background maintenance (schedules via cron/systemd)
git maintenance start

# Run maintenance tasks manually
git maintenance run

# Disable background maintenance
git maintenance stop

# Run a specific task
git maintenance run --task=commit-graph
```

&lt;Notice type=&quot;info&quot; title=&quot;git maintenance avoids auto-packing pauses&quot;&gt;
Without `git maintenance`, Git runs `gc --auto` during certain operations (push, merge, etc.), which can pause for seconds or minutes on large repos. With `git maintenance start`, background tasks run on a schedule so those operations stay fast. Since Git 2.54, geometric repacking is the default strategy  -  better performance for most repos.
&lt;/Notice&gt;

### 8.4 git rerere

&quot;Reuse recorded resolution&quot;  -  remembers how you resolved a conflict and applies the same resolution automatically next time.

**Example Usage:**

```bash
# Enable rerere
git config set rerere.enabled true

# Once enabled, Git automatically records conflict resolutions
# and re-applies them during rebases or merges
```

If you rebase frequently or maintain long-lived branches, `rerere` saves significant time.

### 8.5 git count-objects

Shows disk usage of the Git object database.

**Example Usage:**

```bash
# Basic count
git count-objects

# Human-readable with verbose details
git count-objects -vH
# count: 0
# size: 0 bytes
# in-pack: 1523
# packs: 1
# size-pack: 2.34 MiB
# garbage: 0
# size-garbage: 0 bytes
```

Useful for monitoring repo size on VPS instances with limited disk.

### 8.6 git prune

Removes unreachable objects from the database. Normally handled by `git gc`, but useful for manual cleanup.

**Example Usage:**

```bash
# Dry run
git prune -n

# Prune unreachable objects
git prune
```

---

## Section 9: Scripting and Automation Commands

Commands you&apos;ll use in CI/CD pipelines, build scripts, and automation. If you&apos;re writing deployment scripts or integrating Git into your CI pipeline, these are essential.

### 9.1 git rev-parse

Parses revision specifications. The go-to for extracting info in shell scripts.

**Example Usage:**

```bash
# Get full commit hash
git rev-parse HEAD
# a1b2c3d4e5f6...

# Get short hash
git rev-parse --short HEAD
# a1b2c3d

# Get current branch name
git rev-parse --abbrev-ref HEAD
# main

# Get the repository root directory
git rev-parse --show-toplevel
# /home/user/project
```

### 9.2 git rev-list

Lists commit objects in reverse chronological order.

**Example Usage:**

```bash
# Count total commits
git rev-list --count HEAD

# Count commits since a date
git rev-list --since=&quot;2026-01-01&quot; --count HEAD

# List commits between two refs
git rev-list main..feature-branch
```

### 9.3 git ls-files / git ls-tree / git cat-file

Low-level inspection commands.

```bash
# List files in the index (staging area)
git ls-files

# List tree contents at a commit
git ls-tree HEAD

# Show object type and content
git cat-file -t HEAD    # &quot;commit&quot;
git cat-file -p HEAD    # commit details

# Show blob content
git cat-file -p HEAD:file.txt
```

### 9.4 CI/CD snippets

&lt;Accordion label=&quot;Copy-pasteable CI/CD Git snippets&quot; group=&quot;cicd&quot;&gt;

```bash
# Get changed files in the last commit (for selective CI jobs)
git diff --name-only HEAD~1

# Generate version string from tags
git describe --tags --always

# List commits between deploys (for release notes)
git log --oneline &lt;previous-deploy-ref&gt;..&lt;new-deploy-ref&gt;

# Create a deploy artifact
git archive --format=tar HEAD | gzip &gt; deploy.tar.gz

# Get the commit count since last tag (for build numbers)
git rev-list $(git describe --tags --abbrev=0)..HEAD --count

# Fast CI clone
git clone --depth 1 --branch main https://github.com/user/repo.git

# Verify repo integrity in CI
git fsck --no-dangling
```

&lt;/Accordion&gt;

---

## Section 10: Modern Git: Switch, Restore, Worktrees, and Beyond

This section consolidates the modern Git features that have matured since 2019. If you&apos;re still running Git workflows like it&apos;s 2015, start here.

### 10.1 git sparse-checkout

Work with a subset of a large repository. Essential for monorepos.

**Example Usage:**

```bash
# Clone with sparse checkout
git clone --filter=blob:none --sparse https://github.com/large/repo.git
cd repo

# Set which directories you want
git sparse-checkout set src/frontend docs/

# Add more directories later
git sparse-checkout add src/shared/

# List current sparse-checkout paths
git sparse-checkout list

# Disable sparse-checkout (get everything)
git sparse-checkout disable
```

Combined with partial clone (`--filter=blob:none`), you only download the files you need. Great for monorepos where you only work on one service.

### 10.2 SSH commit signing

GPG signing has been the traditional way to sign commits, but SSH-based signing is much simpler to set up and is supported by GitHub, GitLab, and Gitea.

&lt;Tabs&gt;
&lt;Tab name=&quot;SSH signing (recommended)&quot;&gt;
```bash
# Configure Git to use SSH signing
git config set gpg.format ssh

# Set your signing key (your public SSH key)
git config set user.signingkey ~/.ssh/id_ed25519.pub

# Sign a commit
git commit -S -m &quot;Signed commit&quot;

# Sign all commits by default
git config set commit.gpgsign true

# Register your SSH key as a signing key on GitHub/GitLab
# GitHub: Settings → SSH and GPG keys → New SSH key (select &quot;Signing key&quot;)
```
&lt;/Tab&gt;
&lt;Tab name=&quot;GPG signing (legacy)&quot;&gt;
```bash
# Generate a GPG key
gpg --full-generate-key

# Configure Git to use the GPG key
git config set user.signingkey YOUR_GPG_KEY_ID

# Sign a commit
git commit -S -m &quot;Signed commit&quot;

# Export public key for GitHub/GitLab
gpg --armor --export YOUR_GPG_KEY_ID
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

SSH signing is simpler: you already have the keys, no key management beyond what you use for push/pull, and no GPG agent to configure. If you&apos;re starting fresh, use SSH.

### 10.3 git history command (Git 2.54+)

New in Git 2.54, the `git history` command simplifies interactive rebase workflows.

```bash
# Reword a commit message (replaces interactive rebase for simple edits)
git history reword HEAD~3

# Split a commit into multiple commits
git history split HEAD~2

# Create a fixup commit and prepare for autosquash
git history fixup HEAD~5
```

&lt;Notice type=&quot;info&quot; title=&quot;git history is experimental&quot;&gt;
The `git history` command is still experimental as of Git 2.55. It works, but the interface may change. If you need stable behavior, use `git rebase -i` for now. But keep an eye on this  -  it&apos;s where Git is heading.
&lt;/Notice&gt;

### 10.4 Config-based hooks (Git 2.54+)

Hooks can now be configured via `git config` instead of placing scripts in `.git/hooks/`. This makes hooks easier to share across a team.

```bash
# Set a hook via config
git config set core.hooksPath .githooks
```

Place your hook scripts in `.githooks/` and commit that directory. Everyone who clones the repo gets the hooks automatically.

### 10.5 git stash export/import

Transfer stashes between repositories or machines.

```bash
# Export stash to a file
git stash export &gt; stash.bundle

# Import in another repo
git stash import &lt; stash.bundle
```

&lt;Notice type=&quot;info&quot; title=&quot;Zsh plugins for Git productivity&quot;&gt;
Shell plugins and aliases can speed up your Git workflow significantly. If you use Zsh, check out [Zsh plugins for Git productivity](/best-oh-my-zsh-plugins/) for tab completion, prompt integration, and shortcut aliases.
&lt;/Notice&gt;

---

## Section 11: Git 3.0  -  What&apos;s Coming

Git 3.0 is targeting late 2026. Here&apos;s what you need to know to prepare.

### 11.1 SHA-256 default hash algorithm

Git has used SHA-1 since its creation, but SHA-1 has known collision attacks (the SHAttered attack in 2017 made this practical). Git 3.0 will make SHA-256 the default for new repositories.

**What changes:**
- Hash length goes from 40 hex characters to 64 hex characters
- New repos created with Git 2.51+ already default to SHA-256
- Existing SHA-1 repos continue to work (no forced migration)

**The catch:** GitHub, GitLab, and other forges don&apos;t yet support SHA-256 repositories. The ecosystem needs to catch up before SHA-256 becomes practical for most teams.

### 11.2 Reftable reference backend

Reftable is a new format for storing Git references (branches, tags). It&apos;s available today:

```bash
# Create a repo with reftable backend
git init --ref-format=reftable
```

**Why it matters:**
- Up to 22x faster fetch and 18x faster push in repos with 10,000+ references
- Atomic reference updates (no more partial writes on crash)
- No more filesystem-level branch name conflicts (case-insensitive filesystems)
- Smaller disk footprint for reference storage

For repos with many branches (monorepos, large teams), reftable is a significant improvement. If you&apos;re [self-hosting Git with Forgejo](/forgejo-woodpecker-ci-cicd/), check its reftable support status.

### 11.3 Rust build requirement

Git 3.0 will require Rust as a build dependency. This enables memory-safe implementations of performance-critical code paths. The Meson build system integration continues.

**Impact:** Most users won&apos;t notice  -  they install Git from package managers. But building Git from source on platforms without Rust toolchains will require an extra step.

### 11.4 Breaking changes

- **`git-whatchanged` removed.** Use `git log --raw` instead (same output, more flags).
- **`git switch` and `git restore` no longer experimental.** Git 3.0 will strongly recommend them over `git checkout`. Start migrating now.
- **Various deprecated options removed.** Check `BreakingChanges.txt` in the Git source for the full list.

&lt;Notice type=&quot;warning&quot; title=&quot;Verify the Git 3.0 timeline&quot;&gt;
The late 2026 target is aspirational. Check [git-scm.com](https://git-scm.com/) for the latest status before relying on any Git 3.0 features in production.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Git 3.0 readiness checklist&quot; group=&quot;git3&quot;&gt;

- **Switch to `git switch` and `git restore`**  -  stop using `git checkout` for branch/file operations
- **Use the new `git config set/get/list` syntax**  -  the old flags still work but the new form is cleaner
- **Test `git init --ref-format=reftable`** on a non-production repo to see if it works for your workflow
- **Update CI scripts** to handle SHA-256 hashes (64 chars vs 40) if they parse commit hashes
- **Monitor your forge** (GitHub, GitLab, Gitea) for SHA-256 repo support announcements
- **Keep Git updated**  -  security fixes and features land in every release

&lt;/Accordion&gt;

---

## Section 12: Security Essentials

Git security matters for every team. These commands and configs help protect your repository.

### 12.1 Verifying objects on fetch/push

Catches corrupted or malicious objects during transfer.

```bash
# Enable object verification for all fetches/pushes
git config set transfer.fsckObjects true
```

With this enabled, Git verifies the integrity of every object received from a remote. Enable it globally or per-repository.

### 12.2 Commit and tag verification

Verify that commits and tags are signed by trusted keys.

```bash
# Verify a commit&apos;s signature
git verify-commit abc1234

# Verify a tag&apos;s signature
git verify-tag v1.0.0
```

### 12.3 Keeping Git updated

Recent security vulnerabilities you should be aware of:

&lt;Notice type=&quot;error&quot; title=&quot;Update Git immediately if you&apos;re on an old version&quot;&gt;
**CVE-2025-48384** (July 2025): Arbitrary file write on Linux/macOS via `git clone`. This is in the CISA KEV catalog  -  actively exploited. **CVE-2024-50349** and **CVE-2024-52006** (October 2024): Credential handling issues affecting all prior versions. Always upgrade to the latest stable Git release.
&lt;/Notice&gt;

```bash
# Check your Git version
git --version

# Update on Ubuntu/Debian
sudo apt update &amp;&amp; sudo apt install git

# Update on macOS
brew upgrade git

# Update on Fedora/RHEL
sudo dnf update git
```

---

## Section 13: Quick Reference Table

Bookmark this section. Every command listed here works in Git 2.50+. Commands marked with ★ are especially worth learning.

| Category | Command | What It Does |
|----------|---------|-------------|
| **File operations** | `git rm &lt;file&gt;` | Remove a tracked file |
| | `git rm --cached &lt;file&gt;` | Untrack a file (keep on disk) |
| | `git mv &lt;old&gt; &lt;new&gt;` | Rename/move a tracked file |
| | `git grep &lt;pattern&gt;` | Search tracked files |
| **Staging** | `git add -p` | Stage changes interactively (hunks) |
| | `git diff --cached` | Show staged changes |
| | `git restore --staged &lt;file&gt;` | Unstage a file |
| **Commit** | `git commit --amend` | Edit the last commit |
| | `git commit --fixup=&lt;hash&gt;` | Create a fixup commit ★ |
| | `git rebase -i --autosquash` | Auto-squash fixup commits ★ |
| **Branching** | `git branch -D &lt;branch&gt;` | Force-delete an unmerged branch |
| | `git switch -` | Switch to previous branch ★ |
| | `git worktree add &lt;path&gt; &lt;branch&gt;` | Check out branch in new directory ★ |
| | `git worktree list` | List all worktrees |
| | `git worktree remove &lt;path&gt;` | Remove a worktree |
| **Remotes** | `git remote prune origin` | Clean up stale remote-tracking refs |
| | `git fetch --prune` | Fetch and prune ★ |
| | `git push --delete origin &lt;branch&gt;` | Delete a remote branch |
| | `git push --force-with-lease` | Safe force push ★ |
| **History** | `git log --oneline --graph --all` | Visual branch map ★ |
| | `git log -S&quot;string&quot;` | Find commits that changed a string ★ |
| | `git log --follow &lt;file&gt;` | Follow file renames |
| | `git log -L :func:file` | Trace function history |
| | `git describe --tags` | Human-readable version string ★ |
| | `git range-diff &lt;old&gt; &lt;new&gt;` | Verify rebase correctness ★ |
| | `git shortlog -sn` | Commits per author |
| | `git blame -L 10,20 &lt;file&gt;` | Who changed lines 10–20 |
| **Undo** | `git stash push -m &quot;msg&quot;` | Stash with a message |
| | `git stash pop` | Apply and remove stash ★ |
| | `git stash branch &lt;name&gt;` | Create branch from stash |
| | `git stash show -p` | Show stash diff |
| | `git revert --no-commit &lt;hash&gt;` | Revert without committing |
| | `git clean -fd` | Remove untracked files + dirs |
| | `git merge --abort` | Abort a merge |
| | `git rebase --abort` | Abort a rebase |
| **Security** | `git verify-commit &lt;hash&gt;` | Verify commit signature |
| | `git verify-tag &lt;tag&gt;` | Verify tag signature |
| | `git config set transfer.fsckObjects true` | Verify objects on transfer ★ |
| **Maintenance** | `git gc` | Garbage collect |
| | `git gc --aggressive` | Thorough repack (slow) |
| | `git fsck` | Verify repo integrity ★ |
| | `git count-objects -vH` | Disk usage summary |
| | `git maintenance start` | Enable background maintenance ★ |
| **Low-level** | `git rev-parse HEAD` | Get current commit hash |
| | `git rev-parse --abbrev-ref HEAD` | Get current branch name |
| | `git rev-list --count HEAD` | Count commits |
| | `git ls-files` | List files in index |
| | `git ls-tree HEAD` | List tree at commit |
| | `git cat-file -p &lt;hash&gt;` | Show object content |
| | `git hash-object &lt;file&gt;` | Compute blob hash |
| | `git update-index` | Register file in index |
| **Networking** | `git daemon` | Simple Git server |
| | `git update-server-info` | Update info for dumb HTTP servers |
| | `git bundle create &lt;file&gt;` | Pack repo into portable file ★ |
| | `git clone &lt;bundle&gt;` | Clone from a bundle |
| **Diff/merge tools** | `git difftool` | Open diff in external tool |
| | `git mergetool` | Open merge tool for conflicts |
| **Patches** | `git am &lt; patch` | Apply mailbox patch |
| | `git format-patch -N` | Create patches from last N commits |
| | `git send-email` | Send patches via email |
| **Inspection** | `git instaweb` | Browse repo in local gitweb |
| | `git notes add -m &quot;text&quot;` | Attach metadata to commit |
| | `git interpret-trailers` | Parse/add commit trailers |
| | `git rerere` | Reuse recorded resolutions ★ |

That&apos;s **100+ commands** covering every workflow from basic operations to advanced automation.

---

## Conclusion

Git is one of those tools where knowing 15 commands well gets you through 90% of your day, but knowing 100+ gives you options when things go sideways or when you need to automate something.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;git init / clone / status / add / commit&lt;/strong&gt;  -  the daily workflow&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git switch / restore&lt;/strong&gt;  -  stop using git checkout for everything&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git log --oneline --graph --all&lt;/strong&gt;  -  always know where you are&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git stash&lt;/strong&gt;  -  context-switch without losing work&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git rebase -i&lt;/strong&gt;  -  clean up before merging&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git reflog&lt;/strong&gt;  -  your safety net when things go wrong&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git push --force-with-lease&lt;/strong&gt;  -  the only safe way to force push&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git bisect run&lt;/strong&gt;  -  automate bug hunting&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git worktree&lt;/strong&gt;  -  work on multiple branches at once&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;git maintenance start&lt;/strong&gt;  -  keep repos fast automatically&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re building out your development environment, these companion guides are worth bookmarking:
- [Essential Linux commands](/linux-commands/)  -  command line fundamentals
- [Docker commands](/docker-commands/)  -  container management
- [GitHub Copilot for writing Git commands](/github-copilot-complete-guide/)  -  AI-assisted workflow

&lt;Button text=&quot;Bookmark this Git cheat sheet&quot; link=&quot;/git-commands/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>tools</category><category>git</category><category>git-commands</category><category>version-control</category></item><item><title>Mastra Tools vs MCP: Which Should Your Agent Use?</title><link>https://www.bitdoze.com/mastra-tools-vs-mcp/</link><guid isPermaLink="true">https://www.bitdoze.com/mastra-tools-vs-mcp/</guid><description>Native Mastra createTool vs MCP servers for AI agents: architecture, memory cost, approval control, and when to pick each. Real numbers from a production Mastra app.</description><pubDate>Thu, 30 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you build agents with [Mastra](https://mastra.ai/), you hit this choice early: write a native tool with `createTool`, or plug in an MCP server and spread its tools onto the agent.

To the model they look the same: &quot;call tool X with these args.&quot; Ops and cost do not. I learned that the hard way when a WordPress MCP integration sat next to my Mastra process and quietly ate about 200 MB of always-on RAM, just so the agent *could* manage posts.

This piece is the practical split: what each path is, how they run, when MCP is worth it, and when a thin Mastra tool wins.

New to either side? Start with [Build an AI agent with Mastra](/build-ai-agent-mastra/) and [MCP for beginners](/mcp-introduction-beginners/).

&lt;Button text=&quot;Build a Mastra Agent&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;MCP Beginner Guide&quot; link=&quot;/mcp-introduction-beginners/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## What you are actually choosing

| | Mastra tools | MCP tools |
|--|------------------|---------------|
| Definition | Your code via `createTool` | Tools advertised by an MCP server |
| Where code runs | In-process (same Node as Mastra) | Usually another process (stdio) or remote HTTP |
| Who maintains it | You | Server author / vendor |
| RAM / processes | Only what your function needs | Extra Node (and often `npx`) for the life of the host |
| Schemas | Zod you own | JSON Schema from the server |
| Approvals / dry-run | Easy to wire | You wrap or trust the server |
| Workflow reuse | Export a plain function and call it | Awkward outside the agent + MCP client |
| Best for | Core product capabilities | Optional or third-party packs |

Same agent UX. Different runtime bill.

## How Mastra tools work

A native tool is a typed function with a description the model can read. Mastra uses Zod for inputs (and optional outputs). Execution stays inside your server.

```ts
import { createTool } from &quot;@mastra/core/tools&quot;;
import { z } from &quot;zod&quot;;

export const discordNotify = createTool({
  id: &quot;discord-notify&quot;,
  description: &quot;Post a short status message to a Discord webhook channel.&quot;,
  inputSchema: z.object({
    title: z.string(),
    body: z.string(),
    level: z.enum([&quot;info&quot;, &quot;success&quot;, &quot;error&quot;]).default(&quot;info&quot;),
  }),
  execute: async ({ context }) =&gt; {
    // fetch webhook, return { ok: true } / error
    return { ok: true, title: context.title };
  },
});
```

Register it on the agent:

```ts
export const assistant = new Agent({
  id: &quot;assistant&quot;,
  name: &quot;Assistant&quot;,
  // ...
  tools: {
    discordNotify,
    // tinyfishSearch, postTweet, queryDatabase, ...
  },
});
```

### Why this is the default for production agents

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;In-process: no IPC, no second V8 heap, no orphaned &lt;code&gt;npx&lt;/code&gt; parent&lt;/li&gt;
&lt;li&gt;Full control over validation, retries, logging, &lt;code&gt;requireApproval&lt;/code&gt;, and env checks&lt;/li&gt;
&lt;li&gt;Workflow-friendly: extract &lt;code&gt;publishTweet()&lt;/code&gt; and call it from a scheduled job without the agent&lt;/li&gt;
&lt;li&gt;Predictable deploys: tools ship with your bundle; no &lt;code&gt;@latest&lt;/code&gt; surprises at boot&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

In a real Mastra app you end up with a tools folder: web search, YouTube metadata, GitHub, Discord, X/Bluesky, DB query helpers, image generation, and so on. That is the core surface. Keep it native.

&lt;Notice type=&quot;info&quot; title=&quot;Safety pattern&quot;&gt;
For anything that posts or mutates production systems (X, Reddit, Hashnode, CMS writes), put &lt;code&gt;requireApproval: true&lt;/code&gt; on interactive tools. Unattended workflows should call plain functions, not auto-approved agent tools.
&lt;/Notice&gt;

## How MCP tools work in Mastra

[MCP (Model Context Protocol)](/mcp-introduction-beginners/) is a standard so a host (Mastra) can talk to servers that expose tools, resources, and prompts. Mastra&apos;s `@mastra/mcp` client can spawn a stdio server and load its tools:

```ts
import { MCPClient } from &quot;@mastra/mcp&quot;;

const mcpClient = new MCPClient({
  id: &quot;mcp-client&quot;,
  servers: {
    sureDash: {
      command: &quot;npx&quot;,
      args: [&quot;-y&quot;, &quot;@automattic/mcp-wordpress-remote@latest&quot;],
      env: {
        WP_API_URL: process.env.WP_API_URL!,
        WP_API_USERNAME: process.env.WP_API_USERNAME!,
        WP_API_PASSWORD: process.env.WP_API_PASSWORD!,
      },
    },
  },
});

const mcpTools = await mcpClient.listTools();

// Same agent shape as native tools:
// tools: { ...nativeTools, ...mcpTools }
```

### What actually runs

For a stdio MCP server started with `npx`, the process tree often looks like this:

```text
mastra node (your app + MCP client)
  └── npm exec / npx          ← often stays alive (~100+ MB)
        └── mcp server node   ← second V8 runtime (~80–100+ MB)
              └── remote API (WordPress, GitHub, …)
```

The model never sees that. It only sees tool names and schemas. You pay for the extra processes as long as the Mastra host is up, especially if you `listTools()` at module import and keep the connection open.

### Why MCP still exists (and is useful)

- Vendors ship full tool packs (posts, pages, media, taxonomies) without you writing each endpoint
- One protocol, many hosts: the same server can work with Claude Desktop, Cursor, Mastra, and others
- Optional integrations: great when you need WordPress next week, not every deploy

MCP is not worse tools. It is rented capability with a process and protocol tax.

## Real memory numbers (why I care)

On a long-running Mastra production service I measured roughly:

| Component | Role | RAM (approx.) |
|-----------|------|----------------|
| Main Mastra Node | Agents, memory, Studio, native tools | hundreds of MB (app-sized) |
| `npm exec` (npx parent) | Spawns the MCP package | ~120 MB |
| `mcp-wordpress-remote` | MCP stdio server (SDK + proxy stack) | ~96 MB |

That WordPress path alone was about 200 MB always-on, before any agent call. Peak service memory sat near 1 GB. After turning MCP off with a feature flag, the same service settled closer to ~450 MB with only the main Node process.

That is not a WordPress leak. It is:

1. A second Node server for stdio MCP
2. An `npx` parent that does not go away
3. A fat proxy package (MCP SDK, Express-ish stack, proxy agents, sometimes QuickJS for PAC)
4. Eager load at boot so tools exist for every chat session

Disk for the npx cache of that package was on the order of tens of MB. RAM is the expensive part on a small VPS.

## Architecture comparison

```text
Native Mastra tool
─────────────────
User → Agent → createTool.execute() → your API/SDK → result
                 (same process)

MCP tool
────────
User → Agent → MCP client → stdio/HTTP → MCP server process → remote API → result
                 (your process)          (another process)
```

Latency is usually fine either way for human chat. Idle cost and failure modes are not the same. MCP adds boot failures (`npx`, missing env, remote 401s) and a process you must monitor.

## Feature-flag MCP (recommended)

Do not leave optional MCP servers hard-wired to &quot;credentials present = always on.&quot; Keep credentials in `.env`, gate with an explicit flag:

```ts
function envFlag(name: string, defaultEnabled = false): boolean {
  const raw = process.env[name]?.trim().toLowerCase();
  if (raw === undefined || raw === &quot;&quot;) return defaultEnabled;
  return raw !== &quot;false&quot; &amp;&amp; raw !== &quot;0&quot; &amp;&amp; raw !== &quot;off&quot; &amp;&amp; raw !== &quot;no&quot;;
}

const enableWordpressMcp = envFlag(&quot;ENABLE_WORDPRESS_MCP&quot;, false);
const hasWpCreds = Boolean(
  process.env.WP_API_URL &amp;&amp;
    process.env.WP_API_USERNAME &amp;&amp;
    process.env.WP_API_PASSWORD,
);

if (enableWordpressMcp &amp;&amp; hasWpCreds) {
  // MCPClient + listTools()
} else {
  // log and skip; credentials can stay for later
}
```

```bash
# .env
ENABLE_WORDPRESS_MCP=false
WP_API_URL=https://example.com/wp-json/...
WP_API_USERNAME=...
WP_API_PASSWORD=...
```

Turn it on only when you need the tools, rebuild if you use a production bundle, restart the service. Same pattern works for any heavy MCP server.

## When to use Mastra tools vs MCP

### Prefer Mastra tools when

- The capability is core to the product (search, notify, post, DB, TTS, images)
- You need approval, dry-run, or strict logging
- The tool runs on a schedule or workflow without a chat UI
- You run on a small VPS and care about idle RAM
- You only need 2–3 endpoints of a huge vendor API

### Prefer MCP when

- A vendor already ships a solid server and you need many tools rarely
- You want the same server across Cursor and your Mastra app
- Integration is optional and can stay behind a flag
- You accept the process tax for faster time-to-tools

### Hybrid that works well

| Layer | Choice |
|-------|--------|
| Daily path (search, files, social, internal APIs) | Native `createTool` |
| Occasional CMS / third-party packs | MCP + `ENABLE_*=false` by default |
| Hot subset of an MCP surface | Promote to a few native tools later |

Example: if you only create and update posts, a 40-line Mastra tool against the WP REST API will beat a permanent WordPress MCP process every day of the week.

## Lighter MCP if you keep it

If MCP stays in the stack:

1. Avoid long-lived `npx`. Install the package and point `command` at the real binary or `node path/to/proxy.js`
2. Pin versions. Do not use `@latest` on every boot
3. Do not load at import unless every request needs those tools (lazy client is harder if agents need a static tools map, but worth designing for)
4. Cap service memory in systemd (`MemoryHigh` / `MemoryMax`) so a runaway server does not take the box
5. Monitor children with `systemctl status` / `ps --forest` so you see the npx + MCP nodes

## Decision cheat sheet

| Question | Lean this way |
|----------|----------------|
| Will this run every hour unattended? | Mastra tool / plain function |
| Is it a 50-tool vendor pack I touch monthly? | MCP (flagged) |
| Need human approval before side effects? | Mastra tool |
| RAM under 512 MB free? | Mastra tool first |
| Building once for Cursor + Mastra + Claude? | MCP server |

## FAQ

&lt;Accordion label=&quot;Do agents prefer MCP over native tools?&quot; group=&quot;faq&quot;&gt;
No. The model only sees name, description, and schema. Quality of description and reliability of the tool matter more than transport. Native tools often win because you control error messages and args.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Composio the same as MCP?&quot; group=&quot;faq&quot;&gt;
Not exactly. Composio (and similar routers) import tools via their SDK/API. They are also external tools, but usually not a local stdio Node process. Process-wise they are closer to an HTTP integration than to &lt;code&gt;npx mcp-wordpress-remote&lt;/code&gt;.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I mix both on one agent?&quot; group=&quot;faq&quot;&gt;
Yes. Spread native tools and MCP tools into the same &lt;code&gt;tools&lt;/code&gt; object. Just keep the MCP side optional so a failed MCP boot does not take down the whole app.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should every integration become an MCP server?&quot; group=&quot;faq&quot;&gt;
No. MCP shines when you share a tool surface across hosts or ship a productized server. Inside one Mastra app, &lt;code&gt;createTool&lt;/code&gt; is simpler, cheaper, and easier to secure.
&lt;/Accordion&gt;

## Wrap-up

Mastra tools are your in-process contract: fast, cheap, approval-friendly, workflow-ready. MCP tools are a standard way to rent someone else&apos;s tool surface. Powerful, but stdio servers (especially via `npx`) cost real RAM and ops attention.

Rule I use now:

- Core agent abilities → native Mastra tools
- Optional vendor packs → MCP behind a feature flag
- If an MCP server becomes daily critical → promote the hot path to native tools

That split kept the agent surface rich without paying ~200 MB forever for WordPress tools I was not actively using.

&lt;Button text=&quot;Mastra agent tutorial&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;MCP introduction&quot; link=&quot;/mcp-introduction-beginners/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>mastra</category><category>mcp</category><category>typescript</category></item><item><title>Deploy PGvector &amp; PGadmin on Docker and Ditch Pinecone</title><link>https://www.bitdoze.com/deploy-pgvector-pgadmin-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-pgvector-pgadmin-docker/</guid><description>Deploy PGvector &amp; PGadmin on Docker Compose and run your own vector database. Self-host embeddings, skip Pinecone&apos;s $50/month minimum, and save big.</description><pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;

**PGvector** turns PostgreSQL into a self-hosted vector database. It lets you store, index, and query embeddings right inside Postgres instead of paying for a separate managed service. **PGAdmin** gives you a web UI to manage it. Together on Docker, you get a vector database stack running on a cheap VPS in under 10 minutes.

The original guide used the `ankane/pgvector` Docker image (abandoned since 2023) and an outdated Docker Compose format. This update fixes all of that: the official `pgvector/pgvector:pg17` image, current Compose syntax, security improvements, and verification steps the original lacked.

If you&apos;ve been paying for Pinecone or another managed vector database, this is the guide that lets you cancel that bill.

&lt;Notice type=&quot;info&quot; title=&quot;About the video below&quot;&gt;
The video was recorded with an older version of the stack (`ankane/pgvector` image, Docker Compose v1). The written guide below has the fully updated steps.
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/WSkP9EkBsh0&quot;
  label=&quot;How to Deploy PGvector and PGadmin on Docker and Ditch Pinecone&quot;
/&gt;

## What is PGvector? PostgreSQL as a vector database

PGvector is a Postgres extension that adds vector similarity search to any PostgreSQL database. It supports exact and approximate nearest neighbor search with HNSW and IVFFlat indexing, distance operators for L2, cosine, and inner product, and up to 16,000 dimensions per vector (HNSW indexing supports up to 2,000 for full precision, 4,000 with `halfvec`, 64,000 with `bit`).

With 22,000+ GitHub stars, pgvector has become the default choice for teams that want vector search without running a separate service. The &quot;one database for everything&quot; argument is strong: your relational data and your embeddings live in the same Postgres instance. Same backup strategy, same access controls, same monitoring stack. No separate billing, no separate API keys.

Common use cases:

- **RAG pipelines**: store OpenAI or local LLM embeddings and retrieve relevant context for augmenting AI responses.
- **Semantic search**: find similar documents, products, or support tickets by vector distance instead of keyword matching.
- **Recommendations**: match users to items based on embedding similarity.
- **Image/audio search**: compare multimodal embeddings from CLIP or similar models.

For higher scale, TimescaleDB&apos;s `pgvectorscale` extension adds StreamingDiskANN indexing on top of pgvector. Not needed for most setups, but it&apos;s there when you outgrow basic HNSW.

&lt;Notice type=&quot;info&quot; title=&quot;PostgreSQL version support&quot;&gt;
pgvector 0.8.5 (July 2026) supports PostgreSQL 13 and later (including PG 18). PostgreSQL 12 support was dropped in pgvector 0.8.0. This guide uses the `pgvector/pgvector:pg17` image, which bundles PostgreSQL 17 with pgvector 0.8.5.
&lt;/Notice&gt;

## Why ditch Pinecone? Updated pricing and cost comparison

The &quot;ditch Pinecone&quot; angle was relevant in 2024 when this article was first published. It&apos;s gotten stronger since. In October 2025, Pinecone introduced a **$50/month minimum** on their Standard plan. That change effectively killed the hobby-tier use case and sent the vector database community looking for alternatives.

### Pinecone&apos;s $50/month minimum vs self-hosted pgvector

Here&apos;s the cost math:

| Setup | Monthly Cost | What You Get |
|-------|-------------|--------------|
| Pinecone Starter (free) | $0 | 2 GB storage, 1M read units/mo, 5 indexes, community support only |
| Pinecone Builder | $20/mo | Usage-capped, solo dev tier |
| Pinecone Standard | $50/mo | Production use, per-unit pricing on top: $8.25/M reads, $2.00/M writes |
| **Self-hosted pgvector on [Hetzner](https://go.bitdoze.com/hetzner) CX22** | **~€5/mo** | Unlimited queries, unlimited indexes, your data on your server |

A [Hetzner](https://go.bitdoze.com/hetzner) CX22 (2 vCPU, 4 GB RAM, 40 GB NVMe) at ~€5/month or a [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) KVM plan handles pgvector for thousands of embeddings with no per-query charges. No usage caps. No surprise invoices. You can also check [Vultr](https://go.bitdoze.com/vultr) for global datacenter options.

The savings compound: Pinecone&apos;s serverless pricing adds $0.33/GB/month for storage plus per-read/write units. At moderate traffic (10M reads/month), you&apos;re looking at $82.50 in read charges alone on top of the base fee. pgvector on a VPS? Same flat €5–15/month regardless of query volume.

&lt;Button text=&quot;Deploy on Hetzner&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### Vendor lock-in, latency, and other hidden costs

Beyond price, there&apos;s the operational reality:

- **Network latency**: every vector query to Pinecone crosses the internet. A local Postgres query hits localhost. For RAG pipelines making dozens of lookups per request, that adds up fast.
- **API key management**: Pinecone requires API keys, rate limit awareness, and error handling for service outages. pgvector is just a Postgres connection.
- **Data sovereignty**: your embeddings (which often encode sensitive business knowledge) stay on your infrastructure. No third-party data processing agreements needed.
- **Pricing risk**: Pinecone already changed their pricing once. With self-hosted, your cost is whatever your VPS provider charges, and you can move providers anytime.

If you&apos;re already running Docker for your app stack, you can also [self-host your own infrastructure](https://www.bitdoze.com/dokploy-install/) with Dokploy or use a [self-hosted platform as a service](https://www.bitdoze.com/coolify-install-heroku-alternative/) like Coolify. The point is: you control the bill.

## Prerequisites

Before you start, you need:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Linux VPS with Docker support. Any provider works: [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps), [Vultr](https://go.bitdoze.com/vultr), DigitalOcean, or your own hardware&lt;/li&gt;
&lt;li&gt;Docker Engine 24+ and Docker Compose v2+ installed. Verify with `docker compose version`&lt;/li&gt;
&lt;li&gt;Basic terminal/SSH access&lt;/li&gt;
&lt;li&gt;(Optional) A domain or subdomain for Cloudflare Tunnel remote access&lt;/li&gt;
&lt;li&gt;(Optional) [Dockge](https://www.bitdoze.com/dockge-install/) or another container manager for easier compose file management&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

No Kubernetes, no external databases, no cloud accounts needed. Docker Compose handles everything.

If you&apos;re looking for a good VPS deal, check [Hetzner](https://go.bitdoze.com/hetzner) for cheap EU instances or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) for budget global options. For more container ideas, see our guide to [self-hosted Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/).

## Environment variables (.env file)

Create a `.env` file next to your compose file. These credentials configure both Postgres and PGAdmin:

```
POSTGRES_USER=pguser
POSTGRES_PASSWORD=change-me-use-a-strong-password
POSTGRES_DB=vectors
PGADMIN_DEFAULT_EMAIL=admin@example.com
PGADMIN_DEFAULT_PASSWORD=change-me-too
```

Replace the placeholder passwords with real ones. The `POSTGRES_*` variables configure the database; the `PGADMIN_*` variables set your PGAdmin login.

The compose file uses `${VAR:-default}` syntax, so if you miss a variable it falls back to a default value. But don&apos;t rely on defaults for credentials. Set them explicitly.

For more on how Docker handles environment variables, see [environment variables in Docker Compose](https://www.bitdoze.com/docker-env-vars/).

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t commit .env to version control&quot;&gt;
Add `.env` to your `.gitignore`. For production deployments, consider [securing your Docker Compose credentials](https://www.bitdoze.com/docker-compose-secrets/) with Docker secrets or a vault.
&lt;/Notice&gt;

## Docker Compose file for pgvector and PGAdmin

This is the core deliverable. Copy this into `docker-compose.yml` (or `compose.yml`, both work):

### Complete docker-compose.yml

```yaml
services:
  db:
    image: pgvector/pgvector:pg17
    container_name: pgvector_db
    restart: unless-stopped
    shm_size: &apos;256mb&apos;
    ports:
      - &quot;127.0.0.1:5432:5432&quot;
    environment:
      POSTGRES_DB: ${POSTGRES_DB:-vector}
      POSTGRES_USER: ${POSTGRES_USER:-user}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./local_pgdata:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER:-user} -d ${POSTGRES_DB:-vector}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
      start_period: 10s

  pgadmin:
    image: dpage/pgadmin4:latest
    container_name: pgadmin4
    restart: unless-stopped
    ports:
      - &quot;5016:80&quot;
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
    user: &quot;${UID:-5050}:${GID:-5050}&quot;
    volumes:
      - ./pgadmin-data:/var/lib/pgadmin
    depends_on:
      db:
        condition: service_healthy
```

### What changed from the previous version

If you&apos;re migrating from the old article&apos;s compose file, here&apos;s every change and why:

| Change | Why |
|--------|-----|
| `ankane/pgvector` → `pgvector/pgvector:pg17` | The `ankane/pgvector` image is abandoned (last update: Oct 2023, pgvector 0.5.1). The official image moved to the `pgvector` Docker Hub org with pgvector 0.6.0. |
| Removed `version: &quot;3.8&quot;` | The `version` field is deprecated in Compose v2+ and ignored entirely. Modern compose files start with `services:` directly. |
| Added `shm_size: &apos;256mb&apos;` | Parallel HNSW index builds (pgvector 0.6.0+) use shared memory. Default Docker `/dev/shm` is 64 MB, which causes crashes during large index builds. |
| Port binding `0.0.0.0:5432` → `127.0.0.1:5432` | Don&apos;t expose Postgres to the internet. PGAdmin connects via Docker&apos;s internal network anyway. |
| Removed `POSTGRES_HOST_AUTH_METHOD=trust` | This disabled password authentication entirely. Anyone who could reach port 5432 could connect without credentials. |
| Added `depends_on` with `condition: service_healthy` | PGAdmin now waits for Postgres to be ready before starting. No more race conditions on fresh deploys. |
| Added `start_period: 10s` to healthcheck | Avoids false &quot;unhealthy&quot; reports during Postgres initialization. |
| Removed `hostname` | Not needed with modern Docker networking. `container_name` is sufficient for identification. |
| `docker-compose` (hyphen) → `docker compose` (space) | Docker Compose v1 (`docker-compose` with hyphen) was removed in April 2025. The command is now `docker compose`. |
| `${VAR:-default}` fallbacks | The compose file won&apos;t break if an env var is missing. It uses sensible defaults. |

&lt;Notice type=&quot;error&quot; title=&quot;POSTGRES_HOST_AUTH_METHOD=trust was a security risk&quot;&gt;
The old compose file included `POSTGRES_HOST_AUTH_METHOD=trust`, which disabled password authentication entirely. Anyone who could reach port 5432 could connect without a password. The updated file removes it. If you&apos;re migrating from the old setup, make sure `POSTGRES_PASSWORD` is set in your `.env` file and update any connection strings that relied on passwordless auth.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;The ankane/pgvector image is abandoned&quot;&gt;
The `ankane/pgvector` Docker image hasn&apos;t been updated since October 2023 (pgvector 0.5.1). The official image is now `pgvector/pgvector` on Docker Hub. Always use the official org image.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Why bind Postgres to 127.0.0.1?&quot; group=&quot;faq&quot;&gt;
By default, Docker maps published ports to `0.0.0.0`. That means anyone on the internet can reach your Postgres port if no firewall is in place. Binding to `127.0.0.1` means only processes on the host machine (and other Docker containers on the same network) can connect.

This is fine because PGAdmin connects to the `db` service via Docker&apos;s internal network, not through the host port. If you need remote Postgres access from another server, use an SSH tunnel or a VPN. Never expose port 5432 directly to the internet.
&lt;/Accordion&gt;

## Deploy pgvector and PGAdmin with Docker Compose

With the compose file and `.env` in place, deploy:

```bash
# Pull the latest images
docker compose pull

# Start in detached mode
docker compose up -d

# Verify both containers are running
docker compose ps

# Check Postgres logs for errors
docker compose logs db

# Check PGAdmin logs
docker compose logs pgadmin
```

**What success looks like:** `docker compose ps` shows both `pgvector_db` and `pgadmin4` as &quot;Up&quot;. The `db` service should show `(healthy)` in the status column after a few seconds.

**What failure looks like:** If `db` shows &quot;unhealthy&quot; or keeps restarting, check `docker compose logs db`. Common causes are port 5432 already in use on the host, or the data volume from a previous Postgres install with incompatible settings.

For more Docker commands and troubleshooting, see [essential Docker commands](https://www.bitdoze.com/docker-commands/).

&gt; If you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Accessing PGAdmin and connecting to pgvector

1. Open your browser to `http://your-vps-ip:5016`.
2. Log in with the `PGADMIN_DEFAULT_EMAIL` and `PGADMIN_DEFAULT_PASSWORD` from your `.env` file.
3. In PGAdmin, right-click **Servers** in the left panel → **Register** → **Server**.
4. In the **General** tab, give it a name (e.g., &quot;pgvector&quot;).
5. In the **Connection** tab, fill in:
   - **Host name/address**: `pgvector_db` (the container name, Docker resolves it via internal network)
   - **Port**: `5432`
   - **Username**: the value of `POSTGRES_USER` from your `.env`
   - **Password**: the value of `POSTGRES_PASSWORD` from your `.env`
6. Click **Save**. The database should appear in PGAdmin&apos;s browser panel.

**Verify:** Open a Query Tool (right-click the database → Query Tool) and run `SELECT version();` to confirm the Postgres version.

&lt;Notice type=&quot;info&quot; title=&quot;PGAdmin 9.x workspace layouts&quot;&gt;
PGAdmin 9.x uses Workspace layouts by default. If you prefer the classic interface, switch via the user menu in the top-right corner.
&lt;/Notice&gt;

## After deployment: verify pgvector and create your first index

The original article stopped at &quot;deploy.&quot; That&apos;s not enough. You need to confirm pgvector actually works, and understand how to index your data for real workloads.

### Verify the pgvector extension is working

Open a Query Tool in PGAdmin (or run via `docker compose exec db psql -U pguser -d vectors`) and execute:

```sql
-- Enable the extension (first time per database)
CREATE EXTENSION IF NOT EXISTS vector;

-- Check the version
SELECT extversion FROM pg_extension WHERE extname = &apos;vector&apos;;
-- Should return &apos;0.8.5&apos; or similar

-- Test a simple vector operation
SELECT &apos;[1,2,3]&apos;::vector &lt;-&gt; &apos;[4,5,6]&apos;::vector AS distance;
-- Should return: 5.196152422706632
```

If the version query returns a value &gt;= 0.8.2 and the distance query returns a number, pgvector is operational.

### Create an HNSW index for fast similarity search

For any real use case, you need an index. Without one, pgvector does a sequential scan on every query. Fine for 100 rows, terrible for 100,000.

```sql
-- Create a table with a vector column
CREATE TABLE items (
    id bigserial PRIMARY KEY,
    content text,
    embedding vector(1536)  -- OpenAI ada-002 dimension
);

-- HNSW index (recommended for most use cases)
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);

-- For production on large tables: create concurrently to avoid blocking writes
CREATE INDEX CONCURRENTLY ON items USING hnsw (embedding vector_cosine_ops);

-- Increase maintenance_work_mem for faster index builds
SET maintenance_work_mem = &apos;2GB&apos;;
```

### HNSW vs IVFFlat: which index should you use?

| | HNSW | IVFFlat |
|---|------|---------|
| **Query performance** | Better | Lower |
| **Build speed** | Slower (but parallel since 0.6.0) | Faster |
| **Memory during build** | Higher | Lower |
| **Works on empty table** | Yes | No, needs data first |
| **Default choice** | **Yes — use this** | Only if build time/memory is the bottleneck |

**Default recommendation:** Use HNSW. It&apos;s the better choice for almost every workload. IVFFlat is only worth considering when you have a very large dataset where index build time is the bottleneck and you can tolerate slightly lower query accuracy.

&lt;Notice type=&quot;info&quot; title=&quot;Parallel HNSW builds in pgvector 0.6.0+&quot;&gt;
pgvector 0.6.0+ supports parallel HNSW index builds, up to 30x faster than before. The `shm_size: &apos;256mb&apos;` setting in the compose file ensures this works correctly in Docker. If you increase `maintenance_work_mem` beyond 256 MB, bump `shm_size` to match.
&lt;/Notice&gt;

&lt;Accordion label=&quot;What about pgvectorscale and DiskANN?&quot; group=&quot;pgvectorscale&quot;&gt;
TimescaleDB&apos;s `pgvectorscale` extension adds StreamingDiskANN indexing with Statistical Binary Quantization (SBQ) on top of pgvector. For workloads that need higher scale than native pgvector offers, it&apos;s worth evaluating.

```sql
CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;
CREATE INDEX ON items USING diskann (embedding vector_cosine_ops);
```

Benchmark claims suggest 28x lower p95 latency and 16x higher throughput vs Pinecone&apos;s s1 index at 99% recall, at 75% less cost self-hosted. Not needed for most setups, but it&apos;s the upgrade path when you outgrow basic HNSW.
&lt;/Accordion&gt;

## Configure Cloudflare Tunnel for secure remote access

Instead of opening PGAdmin&apos;s port directly to the internet, you can expose it through a Cloudflare Tunnel. This gives you SSL, DDoS protection, and Cloudflare Access policies — without exposing your VPS IP address.

1. In the Cloudflare dashboard, go to **Access → Tunnels**.
2. Select your existing tunnel (or create a new one).
3. Add a hostname mapping your subdomain (e.g., `pgadmin.yourdomain.com`) to the service `http://localhost:5016`.
4. Save and wait for the tunnel to connect.

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel configuration connecting a subdomain to pgvector PGAdmin Docker container on port 5016&quot; /&gt;

Once the tunnel is active, access PGAdmin at `https://pgadmin.yourdomain.com` with full Cloudflare protection.

&lt;Notice type=&quot;info&quot; title=&quot;Alternative reverse proxies&quot;&gt;
You can also [set up Traefik as your reverse proxy](https://www.bitdoze.com/traefik-proxy-docker/) or [use CloudPanel as a reverse proxy with Docker](https://www.bitdoze.com/cloudpanel-setup-dockge/) instead of Cloudflare Tunnel. Pick whichever fits your stack.
&lt;/Notice&gt;

## Security best practices for your pgvector Docker setup

The updated compose file already addresses the biggest security issues from the original article. Here&apos;s the full picture:

### Remove POSTGRES_HOST_AUTH_METHOD=trust

Already done in the updated compose file. The old file included this setting, which disabled password authentication entirely. Anyone who could reach port 5432 could connect without credentials. If you&apos;re migrating from the old setup, verify that `POSTGRES_PASSWORD` is set and update any connection strings.

### Bind Postgres to localhost

The compose file binds `127.0.0.1:5432:5432` instead of `0.0.0.0:5432:5432`. PGAdmin connects via Docker&apos;s internal network (not the host port), so this only affects external access. If you need to reach Postgres from another server, use SSH tunnels or a VPN.

### Use strong passwords and secure your .env

Don&apos;t use `pass` or `pgpass` as your Postgres password. Generate random strings:

```bash
# Generate a random password
openssl rand -base64 32
```

Add `.env` to `.gitignore`. For production, consider [securing your Docker Compose credentials](https://www.bitdoze.com/docker-compose-secrets/) with Docker secrets or a vault.

### Keep pgvector updated

&lt;Notice type=&quot;error&quot; title=&quot;CVE-2026-3172: critical security fix&quot;&gt;
pgvector 0.8.0–0.8.1 had a heap buffer overflow vulnerability (CVE-2026-3172) in parallel HNSW index builds. This was fixed in 0.8.2. The `pgvector/pgvector:pg17` tag currently ships 0.8.5, which is safe. If you&apos;re running an older version, update immediately:

```bash
docker compose pull &amp;&amp; docker compose up -d
```
&lt;/Notice&gt;

Periodically check for updates. The `pgvector/pgvector:pg17` tag follows the latest pgvector release for PostgreSQL 17. A `docker compose pull &amp;&amp; docker compose up -d` gets you the latest patch.

## Back up your vector database

The original article didn&apos;t mention backups. For a self-hosted database, this is critical.

```bash
# Backup a single database
docker compose exec db pg_dump -U pguser vectors &gt; backup_$(date +%F).sql

# Backup the entire cluster (all databases)
docker compose exec db pg_dumpall -U pguser &gt; full_backup_$(date +%F).sql

# Restore from backup
docker compose exec -T db psql -U pguser vectors &lt; backup_2026-07-21.sql
```

For automated backups, add `pg_dump` to a cron job and sync the output to S3-compatible storage. The `./local_pgdata` volume directory can also be backed up directly — but stop the container first for a consistent snapshot, or use `pg_dump` for a live backup.

&lt;Notice type=&quot;warning&quot; title=&quot;Never back up the raw data directory while Postgres is running&quot;&gt;
File-level copies of `./local_pgdata` while Postgres is up can produce inconsistent snapshots. Use `pg_dump` for live backups, or `docker compose stop db` before copying the volume.
&lt;/Notice&gt;

## Scaling beyond the basics: halfvec, pgvectorscale and more

Once you have the basic setup running, pgvector has several levers for when your workload grows:

**`halfvec` type** (pgvector 0.7.0+) — stores vectors at half precision: 2 bytes per dimension instead of 4. OpenAI 1536-dim embeddings go from ~6 KB to ~3 KB each. Halves storage and memory costs.

**`sparsevec` type** (pgvector 0.7.0+) — sparse vectors for high-dimensional data where most values are zero. Useful for text search embeddings that use sparse representations.

**Binary quantization** via `binary_quantize()` — compress vectors to 1-bit per dimension. Massive storage savings at the cost of some recall accuracy. Good for initial filtering before a more precise reranking step.

**`ef_search` tuning** — HNSW parameter that trades query speed for recall accuracy. Higher values = better recall, slower queries. Default is 40; bump to 100+ for critical accuracy requirements.

&lt;Accordion label=&quot;Install pgvectorscale alongside pgvector&quot; group=&quot;scaling&quot;&gt;
For higher-scale workloads, TimescaleDB&apos;s `pgvectorscale` adds StreamingDiskANN indexing:

```sql
CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;
CREATE INDEX ON items USING diskann (embedding vector_cosine_ops);
```

This installs alongside pgvector (the `CASCADE` flag handles dependencies). DiskANN is designed for datasets in the tens of millions of vectors where HNSW memory usage becomes prohibitive.
&lt;/Accordion&gt;

## Conclusion

You now have a self-hosted vector database running PostgreSQL 17 with pgvector 0.8.5, managed through a PGAdmin web interface — all on Docker Compose. Total cost: whatever your VPS runs you (€5–15/month on [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps)). No per-query charges, no vendor lock-in, no pricing surprises.

Start with the basic setup. Verify the extension works, create an HNSW index, and run a few similarity queries. When you outgrow the basics, reach for `halfvec` or pgvectorscale.

If you&apos;re building out your self-hosted stack, explore more [self-hosted Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/) or check out [self-hosted server management panels](https://www.bitdoze.com/best-self-hosted-panels/) for managing multiple services.

&lt;Button text=&quot;Get a VPS and start&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>pgvector</category><category>postgresql</category><category>docker</category></item><item><title>How to Install Python on Mac, Upgrade It &amp; Use VENV (2026)</title><link>https://www.bitdoze.com/install-upgrade-python-mac/</link><guid isPermaLink="true">https://www.bitdoze.com/install-upgrade-python-mac/</guid><description>Step-by-step guide to install Python on Mac using Homebrew, upgrade to the latest version, and set up a Python virtual environment (venv) for your projects.</description><pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you&apos;ve tried to `pip install` something on a Mac recently, you&apos;ve hit the `externally-managed-environment` error. Since Python 3.12, Homebrew follows PEP 668, which means virtual environments are no longer optional. They&apos;re mandatory. This guide walks you through how to install Python on Mac using Homebrew, configure your PATH so `python` works, upgrade between versions without breaking things, and set up venv properly for your projects.

&lt;Notice type=&quot;info&quot; title=&quot;Updated for 2026&quot;&gt;
Python 3.14 is the current stable release (released October 2025). Homebrew&apos;s default `python` formula now installs 3.14.x. PEP 668 is enforced for all Homebrew Python 3.12+ builds. `pip install` outside a venv will fail by design.
&lt;/Notice&gt;

## Before you begin: prerequisites

### Check your macOS version and architecture (Apple Silicon vs Intel)

Homebrew installs to different locations depending on your Mac&apos;s processor:

| Mac type | Homebrew prefix | Python binary path |
|----------|----------------|-------------------|
| Apple Silicon (M1/M2/M3/M4) | `/opt/homebrew/` | `/opt/homebrew/bin/python3` |
| Intel | `/usr/local/` | `/usr/local/bin/python3` |

Check which you have:

```bash
uname -m
```

- `arm64` → Apple Silicon
- `x86_64` → Intel

&lt;Notice type=&quot;info&quot; title=&quot;Apple Silicon vs Intel&quot;&gt;
All commands in this guide use Apple Silicon paths (`/opt/homebrew/`). If you&apos;re on Intel, substitute `/usr/local/` where applicable. The commands themselves (`brew install`, `python3`, etc.) are identical.
&lt;/Notice&gt;

If you want a better terminal experience before diving in, consider setting up [a modern Mac development terminal](/ghostty-terminal/) first.

### Install Xcode Command Line Tools

macOS ships with its own Python 3 (installed via Xcode Command Line Tools, typically 3.9.x). You need the CLT for Homebrew to work, but **do not remove the system Python**. macOS tools depend on it.

```bash
xcode-select --install
```

Click &quot;Install&quot; in the dialog. This takes a few minutes.

Verify it installed:

```bash
xcode-select -p
```

Expected output: `/Library/Developer/CommandLineTools`

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t remove system Python&quot;&gt;
macOS uses its built-in Python 3 for system tools and scripts. Homebrew installs alongside it, not over it. Never delete or modify `/usr/bin/python3`.
&lt;/Notice&gt;

### Install Homebrew on Mac

Homebrew is the package manager that makes Python installation simple. If you don&apos;t have it yet:

```bash
/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
```

On Apple Silicon, Homebrew adds its PATH entry to `~/.zprofile` automatically. Follow the post-install instructions shown in the terminal. You may need to run:

```bash
eval &quot;$(/opt/homebrew/bin/brew shellenv)&quot;
```

Verify Homebrew is healthy:

```bash
brew doctor
```

Expected: `Your system is ready to brew.`

If you&apos;re customizing your shell, [supercharge your Zsh terminal](/best-oh-my-zsh-plugins/) with useful plugins and [enable syntax highlighting in Zsh](/enable-syntax-highlighting-zsh/) for a better experience.

## Install latest Python on Mac with Homebrew

With Homebrew ready, install the latest stable Python:

```bash
brew install python
```

This installs Python 3.14.x (the current stable release as of 2026), along with `pip`, `setuptools`, and `wheel`.

### Verify your Python installation

Run these checks to confirm everything is working:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;python3 --version&lt;/code&gt; → should show &lt;code&gt;Python 3.14.x&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;which python3&lt;/code&gt; → should show &lt;code&gt;/opt/homebrew/bin/python3&lt;/code&gt; (Apple Silicon)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;which pip3&lt;/code&gt; → should show &lt;code&gt;/opt/homebrew/bin/pip3&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;python --version&lt;/code&gt; → works after PATH configuration (see next section)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
python3 --version
which python3
which pip3
```

Expected output:

```sh
Python 3.14.6
/opt/homebrew/bin/python3
/opt/homebrew/bin/pip3
```

### Configure your PATH for Python on Mac

Here&apos;s the thing nobody tells you: after `brew install python`, the command `python3` works, but `python` does NOT. You&apos;ll get:

```sh
zsh: command not found: python
```

Homebrew installs the unversioned symlinks (`python`, `pip`) to a separate directory that&apos;s not on your PATH by default. Fix it:

```bash
# Add Homebrew Python&apos;s unversioned commands to PATH
echo &apos;export PATH=&quot;$(brew --prefix python)/libexec/bin:$PATH&quot;&apos; &gt;&gt; ~/.zprofile
source ~/.zprofile
```

&lt;Notice type=&quot;warning&quot; title=&quot;`python` command not found?&quot;&gt;
This is expected before PATH configuration. The fix is the `export PATH` line above. Add it to `~/.zprofile` (for login shells) or `~/.zshrc` (for interactive shells). Either works in Terminal.app.
&lt;/Notice&gt;

Verify:

```bash
python --version
```

Should show the same version as `python3 --version`.

## Install a specific Python version on Mac

Sometimes a project requires a specific Python version. Homebrew provides versioned formulae for this.

### How to search for available Python versions in Homebrew

```bash
brew search python@
```

You&apos;ll see entries like `python@3.9` through `python@3.14`. For the live, up-to-date list with exact version numbers:

&lt;Button text=&quot;View all Python formulae on Homebrew&quot; link=&quot;https://formulae.brew.sh/formula/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;sm&quot; /&gt;

Install the version you need. For example, Python 3.13:

```bash
brew install python@3.13
```

The versioned command works immediately after install:

```bash
python3.13 --version
```

```sh
Python 3.13.14
```

No linking required. You can use `python3.13` directly in your commands and virtual environments.

### Linking keg-only Python formulae

Versioned Python formulae (like `python@3.13`) are &quot;keg-only.&quot; Homebrew does not symlink them into your PATH by default. If you try to `brew link` one and another Python version is already linked, you&apos;ll get an error:

```sh
Error: Cannot link python@3.13
```

If you actually need `python3` to point to a different version:

```bash
# Unlink the current version first
brew unlink python

# Link the version you want
brew link --force python@3.13
```

&lt;Notice type=&quot;warning&quot; title=&quot;Relinking changes `python3` globally&quot;&gt;
After `brew link --force python@3.13`, the `python3` command will point to 3.13 instead of 3.14. This affects all your terminal sessions. In most cases, it&apos;s simpler to just use the versioned command (`python3.13`) and skip relinking entirely.
&lt;/Notice&gt;

## Upgrade Python to the latest version on Mac

This is where most guides get it wrong, including the previous version of this article.

### The truth about `brew upgrade python` (major vs patch upgrades)

`brew upgrade python` does **not** jump between major versions. It upgrades to the latest **patch** of whatever formula you have installed.

```bash
# Update Homebrew&apos;s package index
brew update

# Upgrade to latest patch of current formula (e.g., 3.14.5 to 3.14.6)
brew upgrade python
```

Verify:

```bash
python3 --version
```

&lt;Notice type=&quot;warning&quot; title=&quot;Major vs patch upgrades&quot;&gt;
&lt;code&gt;brew upgrade python&lt;/code&gt; will NOT upgrade from Python 3.13 to 3.14. To switch major versions, you must &lt;code&gt;brew install python@3.14&lt;/code&gt; explicitly. This is the #1 misconception about upgrading Python with Homebrew.
&lt;/Notice&gt;

To jump from one major version to another:

```bash
# Install the new major version
brew install python@3.14

# Use it with the versioned command
python3.14 --version
```

### Pin Python to prevent accidental upgrades

When Homebrew upgrades other packages, it can sometimes upgrade Python as a dependency. This changes the Python binary path, which **breaks existing virtual environments**. Pin your Python version to prevent this:

```bash
# Pin prevents accidental upgrades
brew pin python@3.14

# Check what&apos;s pinned
brew list --pinned

# Unpin when you&apos;re ready to upgrade
brew unpin python@3.14
```

&lt;Accordion label=&quot;Why did my virtual environment break?&quot; group=&quot;faq&quot;&gt;
When Homebrew upgrades Python (even a patch version), it can change the absolute path to the Python binary inside your `.venv`. Since venvs store absolute paths in `pyvenv.cfg` and inside the activate scripts, the old venv may stop working after an upgrade.

Fix: recreate the venv and reinstall dependencies.

```bash
rm -rf .venv
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

Prevention: use `brew pin` on your Python formula.
&lt;/Accordion&gt;

## Understanding PEP 668: why you need virtual environments

This is the section I wish existed when I first ran into the error. If you&apos;ve tried to install a Python package globally with pip and gotten a wall of red text, here&apos;s why.

### The `externally-managed-environment` error explained

Starting with Python 3.12, Homebrew marks its Python installation as &quot;externally managed&quot; (per [PEP 668](https://peps.python.org/pep-0668/)). This means:

```bash
pip install requests
```

Results in:

```
error: externally-managed-environment

× This environment is externally managed
╰─&gt; To install Python packages system-wide, try brew install
    xyz, where xyz is the package you are trying to install.
```

This is **not a bug**. It&apos;s a deliberate protection. Homebrew Python is managed by Homebrew. Letting pip install packages globally could conflict with Homebrew-managed packages and break your system.

The fix: use a virtual environment (for project dependencies) or `pipx` (for CLI tools). Both are covered in the next sections.

&lt;Notice type=&quot;error&quot; title=&quot;`externally-managed-environment` error?&quot;&gt;
This is expected behavior since Python 3.12, not a mistake. Activate a virtual environment before using &lt;code&gt;pip install&lt;/code&gt;, or use &lt;code&gt;pipx&lt;/code&gt; for global CLI tools. See the next sections for both approaches.
&lt;/Notice&gt;

Reference: [Homebrew and Python](https://docs.brew.sh/Homebrew-and-Python) documentation.

## Run Python in VENV on Mac

Virtual environments are now the standard (and required) way to manage Python project dependencies. Here&apos;s the full workflow.

### Create a Python virtual environment on Mac

Navigate to your project directory and create a venv:

```bash
cd ~/my-project
python3 -m venv .venv
```

&lt;Notice type=&quot;info&quot; title=&quot;Why `.venv`?&quot;&gt;
&lt;code&gt;.venv&lt;/code&gt; is the modern convention. It&apos;s what VS Code, PyCharm, uv, and Poetry auto-detect. It&apos;s hidden by default on macOS (the leading dot), and it&apos;s the name you&apos;ll see in most Python projects on GitHub. You can use a custom name if you prefer, but &lt;code&gt;.venv&lt;/code&gt; is the default for a reason.
&lt;/Notice&gt;

### Activate and use your virtual environment

```bash
source .venv/bin/activate
```

Your terminal prompt changes to show the active environment:

```sh
(.venv) user@mac my-project %
```

Verify it&apos;s working:

```bash
which python
```

Should show: `/Users/youruser/my-project/.venv/bin/python`

Now `pip install` works without PEP 668 errors:

```bash
pip install requests
```

### Install packages and freeze requirements

This is the practical workflow that was missing from the old article:

```bash
# Install packages inside your active venv
pip install requests pandas fastapi

# Freeze current dependencies to a file
pip freeze &gt; requirements.txt
```

The `requirements.txt` file lets anyone recreate the same environment:

```bash
# On another machine (or after cloning a repo):
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

&lt;Accordion label=&quot;Using uv instead of pip?&quot; group=&quot;faq&quot;&gt;
`uv` is a drop-in replacement for pip that&apos;s 10-100x faster. Install it with `brew install uv` and use:

```bash
uv pip install requests pandas
uv pip freeze &gt; requirements.txt
uv venv  # creates venvs faster than python -m venv
```

For a full walkthrough, see [uv, a modern Python package manager](/uv-get-start/).
&lt;/Accordion&gt;

### Deactivate the virtual environment

When you&apos;re done working:

```bash
deactivate
```

Your prompt returns to normal. The packages stay installed in `.venv/`. Nothing is lost.

### Add `.venv` to `.gitignore`

Never commit your virtual environment to git. Add these entries to your `.gitignore`:

```
.venv/
venv/
env/
```

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Add &lt;code&gt;.venv/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;venv/&lt;/code&gt; and &lt;code&gt;env/&lt;/code&gt; as well (common alternatives)&lt;/li&gt;
&lt;li&gt;Commit &lt;code&gt;requirements.txt&lt;/code&gt; instead of the venv directory&lt;/li&gt;
&lt;li&gt;GitHub&apos;s default Python &lt;code&gt;.gitignore&lt;/code&gt; template already includes these&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Troubleshooting common Python on Mac issues

&lt;Accordion label=&quot;Fix: `python` command not found&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;
**Cause**: Homebrew doesn&apos;t add `python` (unversioned) to your PATH by default.

**Fix**:

```bash
echo &apos;export PATH=&quot;$(brew --prefix python)/libexec/bin:$PATH&quot;&apos; &gt;&gt; ~/.zprofile
source ~/.zprofile
python --version
```

In the meantime, `python3` always works after `brew install python`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Fix: `brew link` fails for versioned Python&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: Versioned Python formulae are keg-only. Another Python version is already linked.

**Fix**: Use the versioned command directly instead of relinking:

```bash
# Just use the versioned command. No linking needed.
python3.13 --version
python3.13 -m venv .venv
```

If you must relink:

```bash
brew unlink python
brew link --force python@3.13
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Fix: `externally-managed-environment` error&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: You&apos;re running `pip install` outside a virtual environment. PEP 668 blocks this with Homebrew Python 3.12+.

**Fix**: Activate a venv first:

```bash
python3 -m venv .venv
source .venv/bin/activate
pip install &lt;package&gt;
```

For global CLI tools (black, ruff, mypy), use `pipx` instead:

```bash
brew install pipx
pipx ensurepath
pipx install black
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Fix: broken venv after `brew upgrade`&quot; group=&quot;troubleshooting&quot;&gt;
**Cause**: Homebrew upgraded Python, changing the binary path that your venv references.

**Fix**: Recreate the venv:

```bash
rm -rf .venv
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```

**Prevention**: Pin your Python version with `brew pin python@3.14`.
&lt;/Accordion&gt;

## Alternatives to consider

Homebrew is the simplest path, but it&apos;s not the only one. Here are the main alternatives.

### uv: a modern Python package manager

`uv` by Astral is an all-in-one Python tool that replaces pip, venv, pyenv, and pipx. It&apos;s written in Rust and significantly faster than pip.

```bash
brew install uv
uv venv           # create venvs faster
uv pip install requests  # drop-in pip replacement
uv python install 3.13   # install specific Python versions
```

If you want a single tool that handles everything covered in this article, `uv` is worth a look. Full guide: [uv, a modern Python package manager](/uv-get-start/). For deployment: [deploy a Python project with uv and Dokploy](/dokploy-python-railpack-uv/).

### pyenv: manage multiple Python versions

[pyenv](https://github.com/pyenv/pyenv) is for when you need to switch between Python versions frequently across different projects. Homebrew&apos;s own docs recommend it &quot;if you require stability of minor or patch versions for virtual environments.&quot;

```bash
brew install pyenv
pyenv install 3.13.14
pyenv install 3.14.6
pyenv global 3.14.6
```

pyenv manages versions independently of Homebrew, so upgrading Homebrew won&apos;t accidentally change your project&apos;s Python version. More overhead to set up, but more control.

### Official Python.org installers

Python.org provides [macOS universal2 installers](https://www.python.org/downloads/macos/) that work on both Apple Silicon and Intel. These are standalone, no Homebrew dependency.

Use this if you don&apos;t want Homebrew at all, or if you need a specific Python build. The downside: manual PATH setup, no automatic updates, and it doesn&apos;t integrate with the Homebrew ecosystem.

### pipx: install Python CLI tools globally

`pipx` installs Python CLI tools (like `black`, `ruff`, `mypy`, `poetry`) in isolated environments, making them available globally without polluting your system Python.

```bash
brew install pipx
pipx ensurepath
pipx install black
pipx install ruff
```

&lt;Notice type=&quot;info&quot; title=&quot;pipx vs venv&quot;&gt;
&lt;strong&gt;pipx&lt;/strong&gt; is for CLI tools you run from anywhere (formatters, linters, build tools). &lt;strong&gt;venv&lt;/strong&gt; is for project dependencies your code imports. They solve different problems and work well together.
&lt;/Notice&gt;

## Next steps: what to build with Python on Mac

Now that you have Python installed and know how to manage environments, here&apos;s where to go from here:

- [Explore the best Python web frameworks](/best-python-web-frameworks/): find the right framework for your next project
- [Build a UI for your Python app with NiceGUI](/nicegui-get-started/): quick-start guide for Python UI apps
- [Add multiple pages to your NiceGUI app](/nicegui-pages/): follow-up for NiceGUI projects
- [Run your Python app in Docker](/docker-run-python/): containerize your project for consistent deployments
- [Deploy a Python project with uv and Dokploy](/dokploy-python-railpack-uv/): production deployment on a VPS
- [Generate AI images locally with Python](/ai-images-mac/): run Flux models on your Mac

If you&apos;re deploying to a server, [Hetzner](https://go.bitdoze.com/hetzner) offers affordable EU VPS that works well for Python apps.

&lt;Button text=&quot;Browse All Python Tutorials&quot; link=&quot;/tags/python/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>tools</category><category>python</category><category>homebrew</category><category>venv</category></item><item><title>Add Voice Cloning TTS to Mastra with Fish Audio</title><link>https://www.bitdoze.com/mastra-fish-audio-tts/</link><guid isPermaLink="true">https://www.bitdoze.com/mastra-fish-audio-tts/</guid><description>Wire Fish Audio S2.1 Pro into Mastra: clone your voice, build a fish_tts tool, add emotion tags, and give agents real narration for video and content workflows.</description><pubDate>Wed, 29 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I already have a [Mastra assistant](/build-ai-agent-mastra/) that searches the web, reads files, runs shell commands, and remembers context. I also added an [image agent with Kie.ai](/mastra-image-agent-kie-ai/) for covers and thumbnails.

What was still missing for video was **voice**. Stock TTS sounds like every other AI demo. I wanted narration in **my** voice — cloned once, reused forever — with emotion control so technical explainers do not sound flat.

So I wired [Fish Audio](https://go.bitdoze.com/fish-audio) into Mastra as one tool: `fish_tts`. The agent writes the script (with `[emotion]` tags), calls the tool, and gets a `.wav` on disk. That file drops straight into HyperFrames for YouTube and Shorts.

This guide covers the full path: clone a voice, build the tool, register it on an agent, and use emotion tags for delivery. Same pattern works in the [mastra-assistant](https://github.com/bitdoze/mastra-assistant) repo or any existing Mastra app.

If you have not used Fish Audio yet, start with the [Fish Audio review](/fish-audio-review/) or the [2-minute clone walkthrough](/fish-audio-clone-voice/). Comparing vendors? See [Fish Audio vs ElevenLabs](/fish-audio-vs-elevenlabs/) and [Fish Audio vs MiniMax](/fish-audio-vs-minimax/).

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Build a Mastra Agent First&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Affiliate disclosure&quot;&gt;
Some links to Fish Audio in this article are affiliate links (`go.bitdoze.com/fish-audio`). I use the product for voiceover in my own Mastra video pipeline. Pricing and free-tier limits change; always verify on the official site and [docs.fish.audio](https://docs.fish.audio/).
&lt;/Notice&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/1qnuGqTKGL0&quot;
  label=&quot;Clone Your Voice with Fish Audio and Mastra)
&quot;
/&gt;

## Why Fish Audio for agent TTS

Most TTS APIs give you stock voices. Fine for prototypes. Bad when the product is *your* channel and viewers expect *your* voice.

| Feature | What you get |
| --- | --- |
| Voice cloning | ~15 seconds of sample audio → a reusable voice model ID |
| S2.1 Pro quality | Production TTS with natural prosody |
| Emotion control | `[happy]`, `[excited]`, `[calm]`, free-form cues like `[warm and friendly]` |
| Simple HTTP API | `POST https://api.fish.audio/v1/tts` → raw audio bytes (no long poll job) |
| Formats | `wav`, `mp3`, `opus` — `wav` works cleanly with video pipelines |
| Dev model | `s2.1-pro-free` — same quality as paid S2.1 Pro for development (fair-use; check current terms) |
| Languages | 80+ with automatic language detection |

Why this beats local Kokoro / OpenAI TTS / ElevenLabs for my agent setup:

- **Clone once, reuse forever** — `reference_id` points at your voice model
- **Sync response** — no create-task / poll loop like many image APIs
- **Emotion in the text** — the agent writes tags in the script; no separate style field to forget
- **One clear tool** — inputs are obvious, output is a binary file on disk

I use it as the **only** TTS path for the video-creator agent. Skills that default to HeyGen, ElevenLabs, or Kokoro get overridden: always `fish_tts`.

## What you will build

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A Fish Audio account with an API key and a cloned voice model ID&lt;/li&gt;
&lt;li&gt;A Mastra tool &lt;code&gt;fish_tts&lt;/code&gt; that posts text and writes audio to the workspace&lt;/li&gt;
&lt;li&gt;An agent (or video phase) that generates narration with emotion tags&lt;/li&gt;
&lt;li&gt;Optional: the same tool in a multi-phase video pipeline&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The tool can:

- Generate speech from text using your cloned voice
- Apply speed, volume, temperature, and latency settings
- Embed emotion / pause markers (`[confident]`, `[break]`, …)
- Save `wav` / `mp3` / `opus` under a path the agent chooses
- Return success, path, and bytes written (or a clear error)

## Prerequisites

- A working Mastra project (see [Build Your Own AI Agent with Mastra](/build-ai-agent-mastra/))
- Node.js 22+ or **Bun** (this repo defaults to Bun)
- An LLM API key for the agent brain (OpenRouter, OpenCode Go, etc.)
- A [Fish Audio](https://go.bitdoze.com/fish-audio) account

Optional: HyperFrames (or any video stack) if you want full voiceover → transcript → caption pipelines. TTS works without video — you only need a place to save the file.

## Architecture

```
User (Studio chat / workflow)
    │
    ▼
agent  (LLM + instructions)
    │
    └─ fish_tts  ──► POST api.fish.audio/v1/tts
                          │
                          ▼
                     workspace/.../narration.wav
                          │
                          ▼ (optional)
                     hyperframes transcribe → transcript.json
                     composition &lt;audio class=&quot;clip&quot;&gt;
```

The LLM never holds the API key. It only chooses tools. Auth, path safety, and binary download stay in TypeScript where you can test them.

## Step 1: Create an API key and clone your voice

1. Sign up at [Fish Audio](https://go.bitdoze.com/fish-audio)
2. Create an API key in the dashboard
3. Clone a voice: upload ~15 seconds of clean speech (one speaker, little noise)
4. Copy the resulting **voice model ID** — this is `reference_id` in the API

Need screenshots and recording tips? Follow [Clone your voice with Fish Audio in 2 minutes](/fish-audio-clone-voice/).

Add to `.env`:

```bash
FISH_API_KEY=your-fish-audio-key
FISH_VOICE_ID=your-cloned-voice-model-id
# TTS model default for fish_tts (override per call with model: still works)
FISH_TTS_MODEL=s2.1-pro-free
```

| Env var | Role |
| --- | --- |
| `FISH_API_KEY` | Bearer token for `api.fish.audio` |
| `FISH_VOICE_ID` | Default cloned voice model ID (`reference_id`) |
| `FISH_TTS_MODEL` | Default TTS model string (`DEFAULT_MODEL` in the tool). Use `s2.1-pro-free` while developing; switch to `s2.1-pro` for production SLA |

Never put the key in frontend code or commit it to git.

### Voice cloning tips

- Use a quiet room and natural pacing
- Prefer a continuous monologue over short clips stitched together
- Match the language you will narrate most often
- Re-clone if the source was compressed or noisy — bad clone means bad every generation

&lt;Button text=&quot;Clone Your Voice Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Step 2: Fish Audio client basics

Fish Audio TTS is a single POST. Success returns **raw audio bytes**, not JSON.

### Auth and endpoint

```
POST https://api.fish.audio/v1/tts
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
model: s2.1-pro-free   # or s2.1-pro | s2-pro | s1
```

### Request body (fields we use)

```json
{
  &quot;text&quot;: &quot;[confident] Docker makes deployment simple. [break] [excited] This tool makes it even easier.&quot;,
  &quot;reference_id&quot;: &quot;YOUR_VOICE_MODEL_ID&quot;,
  &quot;format&quot;: &quot;wav&quot;,
  &quot;prosody&quot;: {
    &quot;speed&quot;: 1.0,
    &quot;volume&quot;: 0,
    &quot;normalize_loudness&quot;: true
  },
  &quot;temperature&quot;: 0.7,
  &quot;top_p&quot;: 0.7,
  &quot;chunk_length&quot;: 300,
  &quot;normalize&quot;: true,
  &quot;latency&quot;: &quot;normal&quot;,
  &quot;max_new_tokens&quot;: 1024,
  &quot;repetition_penalty&quot;: 1.2,
  &quot;condition_on_previous_chunks&quot;: true
}
```

| Field | Role |
| --- | --- |
| `text` | Script + `[emotion]` markers (S2 / S2.1). Max ~10k chars in our tool. |
| `reference_id` | Cloned voice model ID |
| `format` | `wav` \| `mp3` \| `opus` |
| `prosody.speed` | 0.5–2.0 (1.0 = natural for explainers) |
| `prosody.volume` | dB offset (−20…20) |
| `temperature` | Higher = more expressive variation |
| `latency` | `normal` (quality) \| `balanced` \| `low` |

On success: write `Buffer.from(await res.arrayBuffer())` to disk. On error: parse JSON `message` when present.

### Models

| Model id | Notes |
| --- | --- |
| `s2.1-pro-free` | Same quality as S2.1 Pro for dev / fair use — good default while exploring |
| `s2.1-pro` | Production S2.1 Pro with SLA |
| `s2-pro` | Previous S2 generation; still uses `[bracket]` emotions |
| `s1` | Legacy; emotions use `(parentheses)` not brackets |

Emotion docs: [docs.fish.audio — Emotion Control](https://docs.fish.audio/developer-guide/core-features/emotions).

## Step 3: Mastra `fish_tts` tool

Create `src/mastra/tools/fish-audio-tts.ts`. Tools are `createTool` + Zod. The `description` field is the model-facing API docs — write it carefully so the agent embeds emotion tags correctly.

```typescript
import { createTool } from &quot;@mastra/core/tools&quot;;
import { z } from &quot;zod&quot;;
import { resolve, isAbsolute, dirname } from &quot;node:path&quot;;
import { mkdirSync, writeFileSync } from &quot;node:fs&quot;;

const FISH_API_BASE = &quot;https://api.fish.audio&quot;;
const DEFAULT_VOICE_ID = process.env.FISH_VOICE_ID ?? &quot;&quot;;
// Default TTS model from env (s2.1-pro-free for dev; s2.1-pro for production SLA)
const DEFAULT_MODEL = process.env.FISH_TTS_MODEL ?? &quot;s2.1-pro-free&quot;;
// Resolve relative output paths against your workspace root.
const WORKSPACE_PATH = process.env.WORKSPACE_PATH ?? `${process.cwd()}/workspace`;

function getApiKey(): string {
  const apiKey = process.env.FISH_API_KEY;
  if (!apiKey) {
    throw new Error(
      &quot;FISH_API_KEY is not set. Add it to .env to enable Fish Audio TTS.&quot;,
    );
  }
  return apiKey;
}

export function createFishTts(basePath: string = WORKSPACE_PATH) {
  return createTool({
    id: &quot;fish_tts&quot;,
    description: `Generate narration audio from text using Fish Audio S2.1 Pro TTS with a cloned voice.

EMOTION CONTROL (S2.1 Pro): Add emotion/style cues in [square brackets] directly in the text. Examples:
  [happy] What a great tool!
  [excited] And the best part? It&apos;s free!
  [calm] Let me walk you through the setup.
  [confident] This is the best option on the market.
  [curious] But what about performance?
  [sarcastic] Oh sure, because THAT always works.
  [whispering] Here&apos;s a secret most people miss.
  [laughing] Ha ha, yeah, I tried that too.
  [sighing] Another config file... sigh.

Combine emotions: [sad][whispering] for a quiet sad tone, [excited][laughing] for joyful energy.
Use [break] for a short pause, [long-break] for a longer pause.
Place emotion cues at the START of the sentence they should affect.
Natural language descriptions also work: [warm and friendly], [slightly annoyed], [very energetic].

For technical narration, [confident] or [calm] works best as the base tone. Use [excited] sparingly for highlights. Avoid [happy] for informational content — it sounds salesy.`,
    inputSchema: z.object({
      text: z
        .string()
        .max(10000)
        .describe(
          &quot;Narration text. Supports [emotion] tags like [happy], [excited], [calm], [whispering], [break]. Place cues at sentence starts.&quot;,
        ),
      outputPath: z
        .string()
        .describe(
          &quot;Path to save the audio. Relative paths resolve against the workspace. Extension must match format (.wav, .mp3, .opus).&quot;,
        ),
      speed: z
        .number()
        .min(0.5)
        .max(2)
        .optional()
        .describe(&quot;Speech speed. 1.0 = normal. Default 1.0.&quot;),
      volume: z
        .number()
        .min(-20)
        .max(20)
        .optional()
        .describe(&quot;Volume in dB. 0 = no change. Default 0.&quot;),
      voiceId: z
        .string()
        .optional()
        .describe(&quot;Fish Audio voice model ID. Defaults to FISH_VOICE_ID.&quot;),
      model: z
        .enum([&quot;s2.1-pro-free&quot;, &quot;s2.1-pro&quot;, &quot;s2-pro&quot;, &quot;s1&quot;])
        .optional()
        .describe(
          &quot;TTS model. Defaults to FISH_TTS_MODEL env (or s2.1-pro-free). Use s2.1-pro for production SLA.&quot;,
        ),
      format: z
        .enum([&quot;wav&quot;, &quot;mp3&quot;, &quot;opus&quot;])
        .optional()
        .describe(&quot;Output format. Default wav (good for video pipelines).&quot;),
      temperature: z
        .number()
        .min(0)
        .max(1)
        .optional()
        .describe(&quot;Expressiveness. Higher = more varied. Default 0.7.&quot;),
      latency: z
        .enum([&quot;normal&quot;, &quot;balanced&quot;, &quot;low&quot;])
        .optional()
        .describe(&quot;normal = best quality (default). low = fastest.&quot;),
    }),
    outputSchema: z.object({
      success: z.boolean(),
      path: z.string().optional(),
      bytesWritten: z.number().optional(),
      format: z.string().optional(),
      error: z.string().optional(),
    }),
    execute: async (input) =&gt; {
      const apiKey = getApiKey();
      const format = input.format ?? &quot;wav&quot;;
      const model = input.model ?? DEFAULT_MODEL;
      const voiceId = input.voiceId ?? DEFAULT_VOICE_ID;
      const speed = input.speed ?? 1.0;
      const volume = input.volume ?? 0;
      const temperature = input.temperature ?? 0.7;
      const latency = input.latency ?? &quot;normal&quot;;

      if (!voiceId) {
        return {
          success: false,
          error:
            &quot;No voice ID. Set FISH_VOICE_ID or pass voiceId. Clone a voice at Fish Audio first.&quot;,
        };
      }

      const absPath = isAbsolute(input.outputPath)
        ? input.outputPath
        : resolve(basePath, input.outputPath);

      try {
        mkdirSync(dirname(absPath), { recursive: true });
      } catch {
        // Directory may already exist
      }

      const body = {
        text: input.text,
        reference_id: voiceId,
        format,
        prosody: {
          speed,
          volume,
          normalize_loudness: true,
        },
        temperature,
        top_p: 0.7,
        chunk_length: 300,
        normalize: true,
        latency,
        max_new_tokens: 1024,
        repetition_penalty: 1.2,
        condition_on_previous_chunks: true,
      };

      try {
        const res = await fetch(`${FISH_API_BASE}/v1/tts`, {
          method: &quot;POST&quot;,
          headers: {
            Authorization: `Bearer ${apiKey}`,
            &quot;Content-Type&quot;: &quot;application/json&quot;,
            model,
          },
          body: JSON.stringify(body),
        });

        if (!res.ok) {
          let errMsg = `Fish Audio TTS failed (HTTP ${res.status})`;
          try {
            const errData = await res.json();
            errMsg = errData?.message || errMsg;
          } catch {
            // not JSON
          }
          return { success: false, error: errMsg };
        }

        const audioBuffer = Buffer.from(await res.arrayBuffer());
        if (audioBuffer.length === 0) {
          return {
            success: false,
            error: &quot;Fish Audio TTS returned empty audio data.&quot;,
          };
        }

        writeFileSync(absPath, audioBuffer);

        return {
          success: true,
          path: absPath,
          bytesWritten: audioBuffer.length,
          format,
        };
      } catch (error) {
        return {
          success: false,
          error: `Fish Audio TTS failed: ${
            error instanceof Error ? error.message : &quot;Unknown error&quot;
          }`,
        };
      }
    },
  });
}

// Default instance against workspace root
export const fishTts = createFishTts();
```

### Factory with a custom base path

Video projects often live under a dedicated directory (for example `workspace/hyperframes/`). Use the factory so relative paths resolve correctly:

```typescript
import { createFishTts } from &quot;../tools/fish-audio-tts&quot;;

// Paths like &quot;my-video/assets/narration.wav&quot; land under hyperframes/
const fishTts = createFishTts(&quot;/absolute/path/to/workspace/hyperframes&quot;);
```

That is how the video-creator agent is wired: one tool instance scoped to the HyperFrames workspace, not the whole monorepo.

## Step 4: Define a voice-aware agent

You can attach `fish_tts` to any agent. Minimal example — a focused voice agent for testing:

```typescript
import { Agent } from &quot;@mastra/core/agent&quot;;
import { fishTts } from &quot;../tools/fish-audio-tts&quot;;

const AGENT_MODEL =
  process.env.AGENT_MODEL ?? &quot;google/gemini-2.5-flash&quot;;

export const voiceAgent = new Agent({
  id: &quot;voice-agent&quot;,
  name: &quot;Voice Agent&quot;,
  instructions: () =&gt; {
    const iso = new Date().toISOString().split(&quot;T&quot;)[0];
    return `TODAY IS ${iso}.

You generate voiceover with the fish_tts tool using the user&apos;s cloned voice.

## Rules
- Always use fish_tts for audio. Never invent a local file path without calling the tool.
- Write narration WITH [emotion] tags. Base technical tone: [confident] or [calm].
- Highlights: [excited]. Problems: [frustrated]. Pauses: [break] or [long-break].
- Place emotion tags at the START of the sentence they affect. One primary emotion per sentence.
- Default speed 1.0. Default format wav.
- After success, report the local path and bytes written.
- If FISH_API_KEY or FISH_VOICE_ID is missing, say so clearly.
- TTS model defaults to FISH_TTS_MODEL (usually s2.1-pro-free); only pass model when you need a different one.`;
  },
  model: AGENT_MODEL,
  tools: {
    fishTts,
  },
  defaultOptions: { maxSteps: 10 },
});
```

### Video agent instructions (the real use case)

In a full video pipeline, voiceover is one step among research → design → script → TTS → build → render. The important instruction pattern:

```text
4b. VOICEOVER — Generate narration BEFORE building so the timeline can be sized to it.
- Finalize the narration script from research.
- Call fish_tts with text + output path like &lt;project&gt;/assets/narration.wav
- Keep speed at 1.0
- Embed emotion tags in the script, e.g.:
  &quot;[confident] Docker makes deployment simple. [break] [excited] But this new tool? It makes Docker look complicated.&quot;
- After TTS: transcribe for word-level timestamps (e.g. hyperframes transcribe)
- Drop audio into the composition as &lt;audio class=&quot;clip&quot;&gt;
```

And force a single TTS provider so the model does not wander:

```text
TTS: Always use fish_tts (cloned user voice).
Do NOT use Kokoro, hyperframes tts, HeyGen, or ElevenLabs for narration.
```

### Register the agent

In `src/mastra/index.ts`:

```typescript
import { Mastra } from &quot;@mastra/core/mastra&quot;;
import { assistant } from &quot;./agents/assistant&quot;;
import { voiceAgent } from &quot;./agents/voice-agent&quot;;
// or: import { videoCreator } from &quot;./agents/video-creator&quot;;

export const mastra = new Mastra({
  agents: {
    assistant,
    voiceAgent,
    // videoCreator,
  },
  // storage, logger, server — same as your existing app
});
```

If you use domain flags (lighter deploys), gate video/voice behind env:

```typescript
// ENABLE_VIDEO=false to skip
if (process.env.ENABLE_VIDEO !== &quot;false&quot;) {
  agents.videoCreator = videoCreator;
}
```

## Step 5: Run and try it

```bash
# .env must include:
# FISH_API_KEY=...
# FISH_VOICE_ID=...
# FISH_TTS_MODEL=s2.1-pro-free   # or s2.1-pro for production
# OPENROUTER_API_KEY=...   (or your LLM provider)
# AGENT_MODEL=google/gemini-2.5-flash

bun run dev
# or: npm run dev
```

Open Studio at `http://localhost:4111`, select **Voice Agent** (or **Video Creator**), and try:

```text
Generate a 20-second voiceover for a blog intro about Mastra + Fish Audio.
Save as voice/demo-narration.wav.
Use [confident] for the base tone and [excited] once when mentioning voice cloning.
```

What you should see in traces:

1. The agent drafts a short script with emotion tags
2. `fish_tts` with `text`, `outputPath`, `speed: 1.0`
3. Success with a local path and `bytesWritten`
4. File on disk under your workspace

### Sample narration with emotions

```text
[confident] If you already have a Mastra agent for research and coding, voice is the next unlock.
[break]
[calm] Clone your voice on Fish Audio in about fifteen seconds, then expose a single fish_tts tool.
[excited] From there, every video can sound like you — without sitting in front of a mic again.
```

### After TTS (video pipeline)

For HyperFrames-style caption timing:

```bash
npx hyperframes transcribe assets/narration.wav -d .
# → transcript.json with word-level timestamps
```

Use those timestamps for scene cuts and word-synced captions. Do not invent timings.

## Emotion cheat sheet (S2 / S2.1)

Square brackets. Place at the **start** of the sentence. Free-form descriptions work (`[warm and friendly]`).

| Use case | Tags |
| --- | --- |
| Technical base tone | `[confident]`, `[calm]` |
| Feature highlight | `[excited]` |
| Surprise / reveal | `[surprised]` |
| Pain point | `[frustrated]` |
| Aside / humor | `[sarcastic]`, `[laughing]` |
| Build-up | `[curious]` |
| Quiet tip | `[whispering]` |
| Pause | `[break]`, `[long-break]` |
| Combined | `[excited][laughing] We won!` |

&lt;Tabs&gt;
  &lt;Tab name=&quot;Do&quot;&gt;
    &lt;ul&gt;
      &lt;li&gt;One primary emotion per sentence&lt;/li&gt;
      &lt;li&gt;Match emotion to content (excited for wins, calm for setup steps)&lt;/li&gt;
      &lt;li&gt;Add a little &quot;Ha ha&quot; text after &lt;code&gt;[laughing]&lt;/code&gt; when you want audible laughter&lt;/li&gt;
      &lt;li&gt;Prefer &lt;code&gt;[confident]&lt;/code&gt; over &lt;code&gt;[happy]&lt;/code&gt; for tutorials (happy reads salesy)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Don&apos;t&quot;&gt;
    &lt;ul&gt;
      &lt;li&gt;Do not spam a tag every three words&lt;/li&gt;
      &lt;li&gt;Do not mix conflicting emotions in one breath&lt;/li&gt;
      &lt;li&gt;Do not use S1 &lt;code&gt;(parentheses)&lt;/code&gt; with S2 models (and vice versa)&lt;/li&gt;
    &lt;/ul&gt;
  &lt;/Tab&gt;
&lt;/Tabs&gt;

Full list: [Fish Audio emotion guide](https://docs.fish.audio/developer-guide/core-features/emotions).

## Where this fits with image + research agents

Same Mastra app, specialized tools:

| Agent | Job | Backend |
| --- | --- | --- |
| Assistant | Research, files, shell | TinyFish, workspace |
| Image agent | Covers / thumbnails | [Kie.ai](/mastra-image-agent-kie-ai/) |
| Video / voice | Narration + compositions | **Fish Audio** + HyperFrames |

Pattern that works:

1. Research topic (assistant tools)
2. Generate cover (image agent)
3. Script + `fish_tts` + render (video agent / pipeline)

Keep tools thin and auth server-side. The LLM picks tools; TypeScript owns HTTP and files.

### Multi-phase video pipeline

If a single agent hits context limits, split phases (research → assets → design → script → **voiceover** → build → validate → render). Only the voiceover phase needs `fish_tts`. Later phases read `assets/narration.wav` and `transcript.json` from disk — no need to regenerate audio unless the script changes.

## Production tips

1. **Clone quality is everything.** Garbage sample → garbage every video. Re-record if it sounds off.
2. **Default speed 1.0.** Agents love to crank speed; for explainers, natural pace keeps people listening.
3. **Write emotion tags into the script.** Do not generate dry text then hope the model adds tags later.
4. **Persist files in your workspace.** The tool already writes to disk — treat that path as source of truth for the next pipeline step.
5. **One voice ID per brand.** Store `FISH_VOICE_ID` in env; allow `voiceId` override only when you truly multi-voice.
6. **Set `FISH_TTS_MODEL` in env** (`DEFAULT_MODEL` in code). Dev: `s2.1-pro-free`. Production SLA: `s2.1-pro`. Agents can still pass `model` per call. Check [models overview](https://docs.fish.audio/developer-guide/models-pricing/models-overview) for current free-tier policy.
7. **Keep secrets server-side.** `FISH_API_KEY` only in `.env` / host secrets.
8. **Cap text length.** Long scripts: split into sections, generate multiple files, concat with ffmpeg if needed.
9. **Separate agent from coding assistant.** Voice + video burns steps. A dedicated agent keeps the research assistant focused.

## Troubleshooting

&lt;Accordion label=&quot;401 / auth errors&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Missing or wrong &lt;code&gt;Authorization: Bearer ...&lt;/code&gt;. Confirm &lt;code&gt;FISH_API_KEY&lt;/code&gt; is loaded by the process (Bun loads &lt;code&gt;.env&lt;/code&gt; automatically; Node may need extra config).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Empty audio / zero bytes&quot; group=&quot;faq&quot;&gt;
Rare empty body — treat as failure and retry. Check the Fish dashboard / status if it keeps happening.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Wrong voice / generic voice&quot; group=&quot;faq&quot;&gt;
&lt;code&gt;reference_id&lt;/code&gt; wrong or empty. Confirm &lt;code&gt;FISH_VOICE_ID&lt;/code&gt; matches the clone in the Fish dashboard. Pass &lt;code&gt;voiceId&lt;/code&gt; explicitly once to verify.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Emotions ignored&quot; group=&quot;faq&quot;&gt;
Using S1 syntax &lt;code&gt;(happy)&lt;/code&gt; with S2.1 models — switch to &lt;code&gt;[happy]&lt;/code&gt;. Tags placed mid-sentence far from the words they should color — move to sentence start. Over-tagging — simplify to one cue per sentence.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Unnatural delivery&quot; group=&quot;faq&quot;&gt;
Lower &lt;code&gt;temperature&lt;/code&gt; toward 0.5 for consistency, or raise slightly for variety. Keep &lt;code&gt;speed&lt;/code&gt; at 1.0. Space emotional changes.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Path not found / wrong directory&quot; group=&quot;faq&quot;&gt;
Relative paths resolve against the tool&apos;s &lt;code&gt;basePath&lt;/code&gt;. Video agents should use &lt;code&gt;createFishTts(HYPERFRAMES_PATH)&lt;/code&gt; so &lt;code&gt;project/assets/narration.wav&lt;/code&gt; lands next to the composition.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;HTTP 429 / rate limits&quot; group=&quot;faq&quot;&gt;
Back off and retry. Batch voiceovers with a short delay in workflows.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Agent uses Kokoro / another TTS&quot; group=&quot;faq&quot;&gt;
Instructions not strong enough. Explicitly ban alternate TTS tools and list &lt;code&gt;fish_tts&lt;/code&gt; as the only allowed voiceover path.
&lt;/Accordion&gt;

## Related Fish Audio and Mastra guides

- [Fish Audio review 2026](/fish-audio-review/) — pricing, free API, what is good and what is not
- [Clone your voice with Fish Audio](/fish-audio-clone-voice/) — 2-minute walkthrough with screenshots
- [Fish Audio vs ElevenLabs](/fish-audio-vs-elevenlabs/) — side-by-side quality, cloning, and yearly cost
- [Fish Audio vs MiniMax](/fish-audio-vs-minimax/) — English vs Chinese quality, pricing, free API
- [Build a Mastra agent](/build-ai-agent-mastra/) — files, web, browser, memory
- [Mastra tools vs MCP](/mastra-tools-vs-mcp/) — native createTool vs external MCP servers
- [Mastra image agent with Kie.ai](/mastra-image-agent-kie-ai/) — covers and thumbnails
- [Mastra vs Eve](/mastra-vs-eve-typescript-ai-agents/) — framework choice for TypeScript agents

Once voice works, the same pattern extends to full autonomous video: research → storyboard → `fish_tts` → HTML composition → render. I keep that as a dedicated video agent (or multi-phase workflow) so step budgets and instructions stay clean.

&lt;Button text=&quot;Get started with Fish Audio&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Mastra Assistant Guide&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Next step&quot;&gt;
Add &lt;code&gt;FISH_API_KEY&lt;/code&gt;, &lt;code&gt;FISH_VOICE_ID&lt;/code&gt;, and optionally &lt;code&gt;FISH_TTS_MODEL&lt;/code&gt; (defaults to &lt;code&gt;s2.1-pro-free&lt;/code&gt;), register the tool on an agent, open Studio, and generate a short &lt;code&gt;narration.wav&lt;/code&gt; into the workspace. If something fails, check the tool error string first — most issues are a missing key, empty voice ID, or S1 vs S2 emotion syntax.
&lt;/Notice&gt;</content:encoded><category>ai</category><category>mastra</category><category>fish-audio</category><category>tts</category></item><item><title>Add a New Drive to Ubuntu LVM and Mount It Permanently</title><link>https://www.bitdoze.com/add-new-drive-lvm/</link><guid isPermaLink="true">https://www.bitdoze.com/add-new-drive-lvm/</guid><description>Add a new drive to Ubuntu LVM: pvcreate, vgcreate, lvcreate, ext4 format, and persistent mount with fstab nofail. Step-by-step guide for Ubuntu 24.04.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Adding a new drive to Ubuntu LVM is the most flexible way to expand server storage without downtime. LVM (Logical Volume Manager) sits between your physical disks and the filesystem, letting you stripe across multiple drives, resize volumes live, and add more storage later with a handful of commands. This guide walks through the full workflow, from detecting the disk to a persistent, boot-safe mount, on Ubuntu 24.04 LTS.

The core sequence is: **pvcreate → vgcreate → lvcreate → mkfs → mount → fstab**. If you follow this order and verify each step, you&apos;ll have a working, persistent data volume in under 10 minutes.

## Prerequisites

Before you start, make sure the following are in place:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Root or sudo access on an Ubuntu system (22.04+ or 24.04 LTS recommended)&lt;/li&gt;
&lt;li&gt;A new disk physically attached and detected by the kernel&lt;/li&gt;
&lt;li&gt;LVM2 tools installed&lt;/li&gt;
&lt;li&gt;Any existing data on the target disk backed up. pvcreate is destructive&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Install LVM2 on Ubuntu

LVM2 is pre-installed on Ubuntu Server, but minimal or cloud images may not include it:

```sh
sudo apt update &amp;&amp; sudo apt install lvm2
```

Verify it&apos;s installed:

```sh
lvm version
```

You should see `LVM version: 2.03.16` (or later). If you&apos;re managing a fleet of servers, tools like a [self-hosted server panel](/best-self-hosted-panels/) can help you track what&apos;s installed where.

### Confirm the new disk is detected

Use `lsblk` to see all block devices:

```sh
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,MODEL
```

Example output with a new 4TB drive:

```sh
NAME                      SIZE TYPE MOUNTPOINT MODEL
sda                       3.6T disk            Samsung SSD 870
sdb                       476G disk            RS512GSSD310
├─sdb1                    1.1G part /boot/efi
├─sdb2                      2G part /boot
└─sdb3                  473.9G part
  └─ubuntu--vg-ubuntu--lv 466G lvm  /
```

If the disk doesn&apos;t show up, check `dmesg | tail -20` for detection messages. On hot-added disks (common on cloud servers), you may need to rescan the SCSI bus:

```sh
echo &quot;- - -&quot; | sudo tee /sys/class/scsi_host/host0/scan
```

Repeat for `host1`, `host2`, etc. until the disk appears.

&lt;Notice type=&quot;warning&quot; title=&quot;pvcreate is destructive&quot;&gt;
Running `pvcreate` on a disk will erase its partition table and all data. If there&apos;s anything on the disk you care about, [back up your disk with dd](/linux-dd-command-guide/) before proceeding. Double-check the device name. Running pvcreate on `/dev/sdb` (your OS disk) instead of `/dev/sda` will destroy your system.
&lt;/Notice&gt;

If you&apos;re building a [home server with multiple drives](/best-mini-pc-home-server/) or need a VPS with expandable block storage, &lt;a href=&quot;https://go.bitdoze.com/hetzner&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Hetzner Cloud Volumes&lt;/a&gt; support online resizing, a natural fit for LVM. You can also [benchmark your cloud server&apos;s disk performance](/benchmark-cloud-servers/) after setup to confirm you&apos;re getting the throughput you expect.

## Step 1: Identify the new drive with lsblk

Replace the older `lshw -C disk` approach with `lsblk`. It&apos;s installed everywhere, shows the block device tree, and is easier to read:

```sh
lsblk -o NAME,SIZE,TYPE,MOUNTPOINT,MODEL,SERIAL
```

Output:

```sh
NAME                      SIZE TYPE MOUNTPOINT MODEL              SERIAL
sda                       3.6T disk            Samsung SSD 870    S758NS0W807436T
sdb                       476G disk            RS512GSSD310       EB091502A000561
├─sdb1                    1.1G part /boot/efi
├─sdb2                      2G part /boot
└─sdb3                  473.9G part
  └─ubuntu--vg-ubuntu--lv 466G lvm  /
```

Here `/dev/sda` (3.6T Samsung SSD) is the new data drive, and `/dev/sdb` is the OS disk. The new drive has no partitions and isn&apos;t mounted, exactly what you want to see before creating a physical volume.

For detailed hardware info, `sudo lshw -C disk` still works, but `lsblk` gives you everything you need for LVM operations.

### Use stable device paths in VPS environments

&lt;Notice type=&quot;info&quot; title=&quot;VPS device name instability&quot;&gt;
On cloud/VPS servers (Hetzner Volumes, DigitalOcean block storage), `/dev/sdX` names can shift between reboots. Before running `pvcreate`, check the stable identifier:
&lt;/Notice&gt;

```sh
ls -la /dev/disk/by-id/
```

This shows persistent paths like `scsi-0HC_Volume_12345` that won&apos;t change. You can use these paths directly with `pvcreate`:

```sh
sudo pvcreate /dev/disk/by-id/scsi-0HC_Volume_12345
```

Once the PV is created, LVM resolves the disk through its own metadata, so this matters most for the initial `pvcreate` command, not for day-to-day operations.

## Step 2: Check existing physical volumes with pvs

Run `pvs` to see what LVM already knows about:

```sh
sudo pvs
```

Output:

```sh
  PV         VG        Fmt  Attr PSize    PFree
  /dev/sdb3  ubuntu-vg lvm2 a--  &lt;473.89g    0
```

This shows one physical volume (`/dev/sdb3`) in volume group `ubuntu-vg`, which is the OS disk. The new drive `/dev/sda` isn&apos;t listed yet. That&apos;s expected.

Column meanings:
- **PV**: physical volume device path
- **VG**: volume group it belongs to
- **PSize**: total size
- **PFree**: free space available for new logical volumes

## Step 3: Create a physical volume with pvcreate

Create a physical volume on the new disk:

```sh
sudo pvcreate /dev/sda
```

Output:

```sh
  Physical volume &quot;/dev/sda&quot; successfully created.
```

Verify the new PV is registered:

```sh
sudo pvs
```

Output:

```sh
  PV         VG        Fmt  Attr PSize    PFree
  /dev/sda             lvm2 ---    &lt;3.64t   &lt;3.64t
  /dev/sdb3  ubuntu-vg lvm2 a--  &lt;473.89g        0
```

`/dev/sda` now appears as an LVM physical volume with ~3.64 TB free. For a more detailed view:

```sh
sudo pvdisplay /dev/sda
```

&lt;Notice type=&quot;warning&quot; title=&quot;Double-check the device name&quot;&gt;
Running `pvcreate` on your OS disk will destroy it. Always verify with `lsblk` first. If the disk has leftover partition signatures, `pvcreate` may refuse. See the troubleshooting section below for the `wipefs` fix.
&lt;/Notice&gt;

If there&apos;s any data on this disk you need, [back it up with dd](/linux-dd-command-guide/) before this step. Once `pvcreate` runs, the old partition table is gone.

## Step 4: Create a volume group with vgcreate

Create a volume group named `mediavg` using the new physical volume:

```sh
sudo vgcreate mediavg /dev/sda
```

Output:

```sh
  Volume group &quot;mediavg&quot; successfully created
```

Use descriptive names for volume groups. `mediavg` tells you what it&apos;s for, unlike `vg0` which tells you nothing. You can add more physical volumes to this group later with `vgextend`.

## Step 5: Create a logical volume with lvcreate

Create a logical volume that uses all available space in the volume group:

```sh
sudo lvcreate -l +100%FREE -n medialv mediavg
```

Output:

```sh
  Logical volume &quot;medialv&quot; created.
```

Flag breakdown:
- **`-l +100%FREE`**: allocate all free space in the VG to this LV
- **`-n medialv`**: name the logical volume

If you want multiple volumes (e.g., separate LVs for media and backups), use specific sizes instead:

```sh
sudo lvcreate -L 2T -n medialv mediavg
sudo lvcreate -L 1.6T -n backuplv mediavg
```

For this guide, we&apos;ll use one LV with all the space.

## Step 6: Create an ext4 filesystem (with reserved block tuning)

Format the logical volume with ext4:

```sh
sudo mkfs.ext4 -m 0 /dev/mediavg/medialv
```

Output:

```sh
mke2fs 1.47.0 (5-Feb-2023)
Discarding device blocks: done
Creating filesystem with 976753664 4k blocks and 244195328 inodes
Filesystem UUID: 2d665675-4b2e-4a1f-9af6-4652e387d76e
Superblock backups stored on blocks:
        32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632,
        2654208, 4096000, 7962624, 11239424, 20480000, 23887872, 71663616,
        78675968, 102400000, 214990848, 512000000, 550731776, 644972544

Allocating group tables: done
Writing inode tables: done
Creating journal (262144 blocks): done
Writing superblocks and filesystem accounting information: done
```

&lt;Notice type=&quot;info&quot; title=&quot;Why -m 0 matters&quot;&gt;
By default, ext4 reserves 5% of the filesystem for the root user. On a 4TB data drive, that&apos;s roughly 200 GB wasted on space you&apos;ll never use. The `-m 0` flag sets reserved blocks to 0%, which is safe for pure data drives (media, backups, archives). If the drive will see heavy file creation and deletion, keep 1% with `-m 1` to avoid fragmentation when the drive is nearly full. You can adjust this after formatting with `sudo tune2fs -m 0 /dev/mediavg/medialv`.
&lt;/Notice&gt;

## Step 7: Mount the new filesystem

Create a mount point and mount the volume:

```sh
sudo mkdir -p /media/storage
sudo mount /dev/mediavg/medialv /media/storage
```

Verify the mount:

```sh
df -h /media/storage
```

Output:

```sh
Filesystem                   Size  Used Avail Use% Mounted on
/dev/mapper/mediavg-medialv  3.6T   28K  3.4T   1% /media/storage
```

The path `/dev/mapper/mediavg-medialv` is the device-mapper path that LVM creates. You can use either this or `/dev/mediavg/medialv`. They point to the same device.

For a tree view of all filesystems and their UUIDs:

```sh
lsblk -f
```

## Step 8: Make the mount persistent with fstab (using nofail)

Get the UUID of the new filesystem:

```sh
sudo blkid /dev/mediavg/medialv
```

Output:

```sh
/dev/mediavg/medialv: UUID=&quot;2d665675-4b2e-4a1f-9af6-4652e387d76e&quot; BLOCK_SIZE=&quot;4096&quot; TYPE=&quot;ext4&quot;
```

Add an entry to `/etc/fstab`:

```
UUID=2d665675-4b2e-4a1f-9af6-4652e387d76e /media/storage ext4 defaults,nofail 0 2
```

&lt;Notice type=&quot;error&quot; title=&quot;Always use nofail for non-root drives&quot;&gt;
Without `nofail`, if the drive fails, is removed, or LVM can&apos;t activate the logical volume at boot, your system will drop to emergency mode and refuse to boot. This is the most common footgun with fstab on data drives. The `nofail` option tells systemd to continue booting even if this mount fails. For drives that are slow to appear (external USB, some cloud volumes), also add `x-systemd.device-timeout=10s` to avoid a long hang.
&lt;/Notice&gt;

You can use either the UUID or the `/dev/mapper` path in fstab. Both are persistent under LVM:

```
/dev/mapper/mediavg-medialv /media/storage ext4 defaults,nofail 0 2
```

The mapper path is more human-readable, but UUID is the safer default if you ever move the disk between systems. If you hit a [kernel panic from a bad fstab entry](/fix-kernel-panic-unable-mount-root-fs/), boot to recovery mode and fix the line.

## Step 9 — Verify fstab before reboot with mount -a

&lt;Notice type=&quot;warning&quot; title=&quot;Never skip this step on a remote server&quot;&gt;
A typo in fstab can prevent your system from booting. On a remote VPS with no console access, that means reinstalling the OS. Always test with `mount -a` before rebooting.
&lt;/Notice&gt;

```sh
sudo mount -a
```

If this returns silently (no output), the fstab entry is valid. If you get an error, fix the entry before rebooting.

Confirm the mount is working:

```sh
df -h /media/storage
```

Output:

```sh
Filesystem                   Size  Used Avail Use% Mounted on
/dev/mapper/mediavg-medialv  3.6T   28K  3.4T   1% /media/storage
```

If `mount -a` produced no errors and `df` shows the filesystem, you&apos;re safe to reboot.

## Step 10 — Reboot and final verify

```sh
sudo reboot
```

After the system comes back up, verify the mount survived the reboot:

```sh
df -h /media/storage
```

Output:

```sh
Filesystem                   Size  Used Avail Use% Mounted on
/dev/mapper/mediavg-medialv  3.6T   28K  3.4T   1% /media/storage
```

Check the full block device tree:

```sh
lsblk -f
```

You should see `mediavg-medialv` mounted on `/media/storage` with the ext4 filesystem and UUID displayed.

If the mount didn&apos;t survive, check the troubleshooting section below — most likely the VG wasn&apos;t activated or there&apos;s a typo in fstab.

## Whole-disk vs partition — which approach?

This guide creates a physical volume directly on the raw disk (`/dev/sda`) without a partition table. This works fine and has advantages. Here&apos;s the trade-off:

| Approach | Pros | Cons |
|----------|------|------|
| **Whole-disk PV** | Simpler; `pvresize` works live if the block device grows; no partition table to manage | Other tools/OSes may show the disk as &quot;empty&quot; and offer to format it |
| **Single-partition PV** | `fdisk -l` clearly shows the disk is in use (type `8e` or GPT LVM flag) | Resizing requires partition manipulation, often a reboot or `partprobe` |

&lt;Accordion label=&quot;Do I need a partition table for LVM?&quot; group=&quot;faq&quot;&gt;
**Short answer: no.** LVM doesn&apos;t require a partition table. Using the whole disk as a PV is the simpler approach and works well in practice.

**When to use a partition anyway:**
- If you share this server with other admins, a partition prevents someone from accidentally thinking the disk is unused and reformatting it
- If you plan to boot from the disk (rare for a data drive), you need a partition table
- Some monitoring tools report unpartitioned disks as &quot;unused&quot; — a partition with the LVM flag makes the intent clear

**For solo operators and VPS volumes:** whole-disk PV is fine. It&apos;s especially good for Hetzner-style volumes that can be resized online, since `pvresize` on a whole disk is simpler than deleting and recreating a partition.

If you decide to use a partition, create a single Linux LVM partition (type `8e` in MBR, or `LVM` in GPT) that spans the entire disk, then run `pvcreate` on the partition (`/dev/sda1`) instead of the raw disk.
&lt;/Accordion&gt;

## How to extend your LVM storage later

One of LVM&apos;s biggest selling points is easy, live resizing. When you need more space, add another disk and extend the volume group — no downtime, no unmount:

```sh
# 1. Create a PV on the new disk
sudo pvcreate /dev/sdc

# 2. Add it to the existing volume group
sudo vgextend mediavg /dev/sdc

# 3. Extend the logical volume to use all free space
sudo lvextend -l +100%FREE /dev/mediavg/medialv

# 4. Grow the filesystem to fill the new space (works online, no unmount)
sudo resize2fs /dev/mediavg/medialv
```

Verify:

```sh
df -h /media/storage
```

&lt;Notice type=&quot;success&quot; title=&quot;Zero downtime storage expansion&quot;&gt;
LVM lets you add storage to a live system with no unmount, no reboot, and no service interruption. `resize2fs` grows the ext4 filesystem while it&apos;s mounted and serving data. That&apos;s the whole point of LVM.
&lt;/Notice&gt;

Once you have extra storage, you can [share your new storage over the network with NFS](/setup-nfs-linux/) or [set up a Samba share for Windows access](/setup-samba-linux/). If you&apos;re running containers that need lots of disk space, check out these [Docker containers for a home server](/docker-containers-home-server/). You can also [reclaim disk space from Docker overlay2](/clean-docker-overlay2-dir/) if `/var/lib/docker` is eating your root volume.

Shrinking a logical volume is also possible but requires unmounting the filesystem and running `fsck` first — see the LVM documentation for the `lvreduce` workflow. I&apos;d recommend expanding rather than shrinking whenever possible.

## Troubleshooting common LVM issues

### pvcreate fails: &quot;Can&apos;t open /dev/sda exclusively&quot;

The disk has an existing partition table, filesystem signature, or is mounted. Fix:

```sh
# Remove all signatures from the disk
sudo wipefs -a /dev/sda

# Or zero the first few MB for a clean slate
sudo dd if=/dev/zero of=/dev/sda bs=1M count=10

# Then retry
sudo pvcreate /dev/sda
```

### mount fails: &quot;unknown filesystem type &apos;LVM2_member&apos;&quot;

You&apos;re trying to mount the physical volume (the raw disk) instead of the logical volume. Use the LV path, not the disk path:

```sh
# Wrong:
sudo mount /dev/sda /media/storage

# Right:
sudo mount /dev/mediavg/medialv /media/storage
```

### LV not active after reboot

The volume group may not have been activated. Check and fix:

```sh
# Check VG status
sudo vgdisplay mediavg

# Activate the VG
sudo vgchange -ay mediavg

# Then mount
sudo mount /dev/mediavg/medialv /media/storage
```

### df shows old size after lvextend

You extended the logical volume but forgot to grow the filesystem:

```sh
sudo resize2fs /dev/mapper/mediavg-medialv
```

This works online (no unmount needed) for ext4.

### Boot hangs after fstab edit

&lt;Accordion label=&quot;Boot hangs after fstab edit&quot; group=&quot;troubleshooting&quot;&gt;
If the system hangs at boot after editing fstab, the entry is wrong or the `nofail` option is missing.

**Recovery steps:**

1. Boot into recovery mode (hold Shift during boot on BIOS systems, or select recovery in GRUB)
2. Select &quot;root — Drop to root shell prompt&quot;
3. Remount the root filesystem as read-write:
   ```sh
   mount -o remount,rw /
   ```
4. Edit fstab:
   ```sh
   nano /etc/fstab
   ```
5. Either fix the entry or comment it out with `#` to boot normally
6. Reboot:
   ```sh
   reboot
   ```

If you&apos;re on a remote VPS with no console access, most providers offer a rescue mode or VNC console. Use that to fix fstab remotely. See [fix kernel panic from a bad fstab entry](/fix-kernel-panic-unable-mount-root-fs/) for more recovery options.
&lt;/Accordion&gt;

### Disk not detected after hot-add

The kernel didn&apos;t rescan the SCSI bus. Force a rescan:

```sh
# Repeat for each host adapter until the disk appears
echo &quot;- - -&quot; | sudo tee /sys/class/scsi_host/host0/scan
echo &quot;- - -&quot; | sudo tee /sys/class/scsi_host/host1/scan
echo &quot;- - -&quot; | sudo tee /sys/class/scsi_host/host2/scan
```

Check with `lsblk` after each scan.

## Conclusion

You&apos;ve added a new drive to Ubuntu LVM and mounted it permanently. The sequence is always the same:

1. **Identify** the disk with `lsblk`
2. **Create** a physical volume with `pvcreate`
3. **Create** a volume group with `vgcreate`
4. **Create** a logical volume with `lvcreate`
5. **Format** with `mkfs.ext4 -m 0`
6. **Mount** and verify
7. **Add to fstab** with `defaults,nofail`
8. **Test** with `mount -a` before rebooting

The two safety lessons worth remembering: always add `nofail` to fstab for non-root drives, and always test with `mount -a` before rebooting a remote server.

From here, you can expand the volume group with `vgextend` when you need more space, [share the storage over NFS](/setup-nfs-linux/), [set up Samba for Windows clients](/setup-samba-linux/), or [mount cloud object storage as a filesystem](/s3-bucket-filesystem-vps/) for offsite backups.

Need a VPS with expandable block storage? &lt;a href=&quot;https://go.bitdoze.com/hetzner&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;Hetzner Cloud Volumes&lt;/a&gt; expand online with no downtime and work well with LVM.</content:encoded><category>linux</category><category>linux</category><category>ubuntu</category><category>lvm</category></item><item><title>How to Run Any Python App in Docker with Docker Compose</title><link>https://www.bitdoze.com/docker-run-python/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-run-python/</guid><description>Learn how to run any Python app in Docker with Docker Compose. Updated for 2026 with modern best practices, health checks, non-root users, and Cloudflare Tunnel SSL setup.</description><pubDate>Tue, 28 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

Running Python apps in Docker with Docker Compose is the quickest way to get from `main.py` to a reliable deployment on any VPS. This guide covers the full pipeline: `.dockerignore`, Dockerfile, `compose.yml`, start, verify, plus Cloudflare Tunnel SSL for free HTTPS. Updated for 2026 with Docker Compose V2 (no more hyphen), Python 3.13/3.14 availability, `uv` as a faster pip alternative, and Compose Watch for live reloading.

Whether you&apos;re containerizing a NiceGUI dashboard, a Streamlit app, or any Python project, the pattern is the same. I use this setup on Hetzner VPS boxes running Dokploy for multiple small Python apps, and it works reliably.

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS running Linux (Ubuntu or Debian). A Hetzner CX23 (EUR 4/mo) can run 3-5 small Python apps. [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) is my default. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) works as a budget alternative.&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose V2 installed. If you need a guide: [How To Install Docker &amp; Docker Compose for Ubuntu ARM Systems](https://www.bitdoze.com/install-docker-ubuntu-arm/)&lt;/li&gt;
&lt;li&gt;Basic familiarity with the terminal and a text editor.&lt;/li&gt;
&lt;li&gt;Recommended: [Dockge](https://www.bitdoze.com/dockge-install/) for GUI-based Docker Compose management. Makes starting, stopping, and editing compose files much easier.&lt;/li&gt;
&lt;li&gt;Recommended: A Cloudflare account with a domain (for free SSL via Tunnels).&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/7Nu7r8y_bDA&quot;
  label=&quot;How To Run Any Python App in Docker with Docker Compose&quot;
/&gt;

## Project structure overview

Before creating any files, here&apos;s what the final directory layout looks like:

```
my-python-app/
├── .dockerignore
├── Dockerfile
├── compose.yml
└── my-app/
    ├── requirements.txt
    └── main.py
```

Everything lives in one directory. The `my-app/` subfolder contains your actual Python code and dependencies. The Docker files sit at the project root.

## Create a `.dockerignore` file

Without a `.dockerignore`, Docker sends your entire project directory (including `.git`, `__pycache__`, `.venv`, `.env`) to the Docker daemon as build context. This slows builds and can leak secrets into the image.

Create `.dockerignore` in the project root:

```
**/__pycache__
**/.venv
**/.git
**/.env
**/.DS_Store
Dockerfile
compose.yml
README.md
```

That&apos;s it. Five lines prevents most common context-bloat issues.

## Create a Dockerfile

Here&apos;s the updated, production-ready Dockerfile:

```dockerfile
FROM python:3.12-slim

WORKDIR /app

ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

COPY ./my-app/requirements.txt /app
RUN pip install --no-cache-dir -r requirements.txt

COPY ./my-app /app

RUN addgroup --system app &amp;&amp; adduser --system --group app
USER app

CMD [&quot;python&quot;, &quot;main.py&quot;]
```

### Understanding the Dockerfile instructions

**`FROM python:3.12-slim`**: Uses the slim variant (~130MB) instead of the full image (~1GB). For most Python apps, slim has everything you need. The full image is only necessary if you need build tools like gcc for native extensions.

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t Use Alpine for Python&quot;&gt;
You might see `python:3.12-alpine` in other tutorials. Skip it. Alpine uses musl libc instead of glibc, which causes obscure build failures with Python packages that have C extensions (numpy, pandas, Pillow, cryptography). Build times are also longer because many packages need to compile from source. The extra ~80MB for `slim` saves hours of debugging.
&lt;/Notice&gt;

**`WORKDIR /app`**: Sets the working directory inside the container. All subsequent commands run from here.

**`ENV PYTHONDONTWRITEBYTECODE=1`**: Prevents Python from writing `.pyc` files to disk. No reason to cache bytecode in a container image.

**`ENV PYTHONUNBUFFERED=1`**: Forces stdout and stderr to be unbuffered. Without this, `docker compose logs` might show delayed or missing output from your app. For more on Docker environment variables, see [How to Use Environment Variables ARG and ENV in Docker](https://www.bitdoze.com/docker-env-vars/).

**`COPY ./my-app/requirements.txt /app`** then **`RUN pip install --no-cache-dir -r requirements.txt`**: Copies requirements first, then installs dependencies. This ordering is intentional: Docker caches layers, so if only your application code changes (not dependencies), the pip install layer is reused from cache and rebuilds are fast. The `--no-cache-dir` flag keeps pip&apos;s download cache out of the image.

**`COPY ./my-app /app`**: Copies the rest of your application code. This comes after pip install so code changes don&apos;t trigger a full dependency reinstall.

**`RUN addgroup --system app &amp;&amp; adduser --system --group app`** then **`USER app`**: Creates a non-root user and switches to it. Running containers as root is a security anti-pattern. If an attacker breaks out of the process, they have root on the container. The `app` user has minimal privileges.

**`CMD [&quot;python&quot;, &quot;main.py&quot;]`**: The default command. Overridable in `compose.yml` if you need a different entrypoint for different services.

&lt;Notice type=&quot;info&quot; title=&quot;Want Faster Builds?&quot;&gt;
&lt;a href=&quot;https://go.bitdoze.com/uv-get-start&quot;&gt;uv&lt;/a&gt; is a drop-in pip replacement that&apos;s 10-100x faster for dependency resolution and installation. To use it in your Dockerfile, replace the pip install line with:

```dockerfile
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
    uv pip install --system --no-cache -r requirements.txt
```

The cache mount means repeat builds skip downloading already-installed packages. For a full uv walkthrough, see [Getting Started with uv: Setting Up Your Python Project in 2026](https://www.bitdoze.com/uv-get-start/).
&lt;/Notice&gt;

## Create a `my-app` directory with your Python scripts

Now create the application code. Here are two common examples.

### NiceGUI example

Create `my-app/requirements.txt`:

```
nicegui
```

Create `my-app/main.py`:

```python
from nicegui import ui

ui.label(&apos;Hello NiceGUI!&apos;)

ui.run()
```

NiceGUI defaults to port `8080`. You can change it with `ui.run(port=9000)` if needed. Just make sure the port mapping in `compose.yml` matches.

For more on NiceGUI:
- [NiceGUI For Beginners: Build a UI to Python App in 5 Minutes](https://www.bitdoze.com/nicegui-get-started/)
- [How To Add Multiple Pages to NiceGUI](https://www.bitdoze.com/nicegui-pages/)

### Streamlit example

Create `my-app/requirements.txt`:

```
streamlit
```

Create `my-app/main.py`:

```python
import streamlit as st

st.title(&apos;Hello Streamlit!&apos;)
st.write(&apos;This is a minimal Streamlit app running in Docker.&apos;)
```

Streamlit uses port `8501` by default. Note: `streamlit hello` is a built-in demo command; real apps use `streamlit run main.py`.

For a full Streamlit + Cloudflare Tunnel deployment, see [Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/).

&lt;Notice type=&quot;info&quot; title=&quot;NiceGUI vs Streamlit?&quot;&gt;
Both are solid for building Python web UIs quickly. NiceGUI gives you more control over layout and events; Streamlit is faster for data dashboards. Compare them in [Streamlit vs. NiceGUI: Choose the Best Python Web Framework](https://www.bitdoze.com/streamlit-vs-nicegui/).
&lt;/Notice&gt;

## Create a Docker Compose file (`compose.yml`)

&lt;Tabs&gt;
&lt;Tab name=&quot;NiceGUI&quot;&gt;
```yaml
services:
  web:
    container_name: python-server
    command: python main.py
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./my-app:/app
    ports:
      - &quot;5021:8080&quot;
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD&quot;, &quot;python&quot;, &quot;-c&quot;, &quot;import urllib.request; urllib.request.urlopen(&apos;http://localhost:8080&apos;)&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 10s
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Streamlit&quot;&gt;
```yaml
services:
  web:
    container_name: python-server
    command: streamlit run main.py --server.headless true
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./my-app:/app
    ports:
      - &quot;5021:8501&quot;
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD&quot;, &quot;python&quot;, &quot;-c&quot;, &quot;import urllib.request; urllib.request.urlopen(&apos;http://localhost:8501&apos;)&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Key compose directives explained

- **`services:`**: Starts directly. No `version: &quot;3&quot;` needed (that key is obsolete and ignored in Compose V2).
- **`container_name: python-server`**: Gives the container a fixed name instead of an auto-generated one. Easier to reference in commands.
- **`command:`**: Overrides the Dockerfile&apos;s `CMD`. For NiceGUI it&apos;s `python main.py`; for Streamlit, `streamlit run main.py --server.headless true`.
- **`build:`**: Tells Compose to build the image from the local Dockerfile.
- **`volumes: ./my-app:/app`**: Bind-mounts your source code into the container. Code changes on the host are reflected immediately without rebuilding.
- **`ports: &quot;5021:8080&quot;`**: Maps host port 5021 to the container port (8080 for NiceGUI, 8501 for Streamlit). Access the app at `http://localhost:5021`.
- **`restart: unless-stopped`**: Restarts the container if it crashes, but not if you manually stop it. Good default for VPS-hosted apps.
- **`healthcheck:`**: Tells Docker to periodically check if the app is actually responding. Uses Python&apos;s `urllib` instead of `curl` because `curl` is not installed in `python:3.12-slim`. Once the healthcheck passes, `docker compose ps` shows the container as &quot;healthy&quot;.

&lt;Notice type=&quot;info&quot; title=&quot;Compose Watch for Development&quot;&gt;
Instead of bind mounts, you can use Docker Compose Watch for more granular file syncing. Add this to your `compose.yml`:

```yaml
develop:
  watch:
    - action: sync
      path: ./my-app
      target: /app
      ignore:
        - __pycache__/
        - &quot;*.pyc&quot;
    - action: rebuild
      path: ./requirements.txt
```

Then run `docker compose watch`. Code changes sync instantly; dependency changes trigger an automatic rebuild. It&apos;s optional — bind mounts still work fine — but Compose Watch gives you ignore patterns and different actions per path.
&lt;/Notice&gt;

## Start the Docker Compose stack

```sh
docker compose up -d --build
```

- **`docker compose`** (with a space): The V2 command. The old `docker-compose` (hyphen) was removed in April 2025.
- **`up -d`**: Starts containers in detached mode (background).
- **`--build`**: Rebuilds the image if the Dockerfile changed.

To stop the stack: `docker compose down`.

For a full reference on managing containers after initial setup, see [How To Update A Container With Docker Compose](https://www.bitdoze.com/updating-container-docker-compose/).

## Verify it works

Don&apos;t just assume it&apos;s running. Verify:

**1. Check container status:**

```sh
docker compose ps
```

Expect to see `Up` in the STATUS column. After 10-30 seconds (once the healthcheck passes), it should also show `healthy`.

```
NAME             STATUS                   PORTS
python-server    Up (healthy)             0.0.0.0:5021-&gt;8080/tcp
```

**2. Check logs:**

```sh
docker compose logs -f web
```

For NiceGUI, look for something like `NiceGUI is on http://0.0.0.0:8080`. For Streamlit, `You can now view your Streamlit app in your browser`. Press `Ctrl+C` to stop following.

**3. Test HTTP access:**

```sh
curl -s -o /dev/null -w &quot;%{http_code}&quot; http://localhost:5021
```

Expect `200`. If you get `000` or a connection refused, the app isn&apos;t listening on the expected port.

**4. Open in browser:** navigate to `http://&lt;your-vps-ip&gt;:5021`.

&lt;Notice type=&quot;success&quot; title=&quot;Healthy!&quot;&gt;
Once the healthcheck passes, Docker marks the container as &quot;healthy.&quot; This matters because Docker&apos;s restart policy and tools like Dokploy use health status to decide whether a container is actually working vs just running. An unhealthy container can be automatically restarted.
&lt;/Notice&gt;

For more Docker commands you&apos;ll use regularly, see [Top 50+ Docker Commands You MUST Know](https://www.bitdoze.com/docker-commands/).

## Add new PIP packages

When your app needs a new dependency:

1. Add the package to `my-app/requirements.txt`.
2. Rebuild:

   ```sh
   docker compose up -d --build
   ```

The `--build` flag rebuilds the image with the updated requirements. If you&apos;re using bind mounts and only the requirements changed, Docker&apos;s layer cache means only the pip install step re-runs.

For a deeper dive on updating running containers, see [How To Update A Container With Docker Compose](https://www.bitdoze.com/updating-container-docker-compose/).

## Add a domain with SSL using Cloudflare Tunnels

Cloudflare Tunnels give you free SSL without opening ports on your firewall or buying a certificate. Your app stays behind the tunnel; only Cloudflare&apos;s edge is exposed.

&lt;Notice type=&quot;info&quot; title=&quot;Already Have a Cloudflare Tunnel?&quot;&gt;
If you already have `cloudflared` running on your VPS, you just need to add a new hostname in the Cloudflare dashboard (Access → Tunnels → your tunnel → Configure) that points to `http://localhost:5021`. Skip to step 4.
&lt;/Notice&gt;

**Step 1: Install `cloudflared` on your VPS** (or run it as a Docker container).

**Step 2: Create a tunnel:**

```sh
cloudflared tunnel create my-python-app
```

This generates a tunnel ID and credentials file.

**Step 3: Configure the ingress rule.** Create or edit `~/.cloudflared/config.yml`:

```yaml
tunnel: my-python-app
credentials-file: /root/.cloudflared/&lt;tunnel-id&gt;.json

ingress:
  - hostname: app.example.com
    service: http://localhost:5021
  - service: http_status:404
```

Replace `&lt;tunnel-id&gt;` with the actual ID from step 2. Replace `app.example.com` with your domain.

**Step 4: Add the DNS CNAME.** In the Cloudflare dashboard, add a CNAME record for `app.example.com` pointing to `&lt;tunnel-id&gt;.cfargotunnel.com`.

**Step 5: Run the tunnel:**

```sh
cloudflared tunnel run my-python-app
```

Your Python app is now accessible at `https://app.example.com` with a valid SSL certificate managed by Cloudflare.

For an alternative reverse-proxy approach, see [Setup CloudPanel as Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/). If you&apos;re using Streamlit specifically, [Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/) has a more detailed walkthrough.

## Troubleshooting common issues

&lt;Accordion label=&quot;Port already in use&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;

**Error:** `bind: address already in use` or `port is already allocated`

**Cause:** Another process is using port 5021 on the host.

**Fix:**

```sh
ss -tlnp | grep 5021
```

This shows what&apos;s using the port. Either stop that process or change the host port in `compose.yml` (e.g., `&quot;5022:8080&quot;`).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Module not found&quot; group=&quot;troubleshooting&quot;&gt;

**Error:** `ModuleNotFoundError: No module named &apos;xyz&apos;`

**Cause:** The package isn&apos;t in `requirements.txt`, or the image wasn&apos;t rebuilt after adding it.

**Fix:**

```sh
docker compose exec web bash
pip list | grep xyz
```

If the package is missing, add it to `requirements.txt` and rebuild with `docker compose up -d --build`.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission denied on volume mounts&quot; group=&quot;troubleshooting&quot;&gt;

**Error:** `PermissionError: [Errno 13] Permission denied`

**Cause:** UID/GID mismatch between the host user and the container&apos;s `app` user.

**Fix:** Check ownership on the host:

```sh
ls -ln my-app/
```

If files are owned by a UID other than 1000 (the `app` user&apos;s UID in the Dockerfile), either change ownership on the host (`chown -R 1000:1000 my-app/`) or adjust the Dockerfile to match your host&apos;s UID.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Container exits immediately&quot; group=&quot;troubleshooting&quot;&gt;

**Error:** Container shows `Exited (1)` in `docker compose ps`

**Cause:** Python traceback on startup — usually a syntax error in `main.py` or a missing dependency.

**Fix:**

```sh
docker compose logs web
```

Read the traceback. Common culprits: import errors, missing files (check volume mount paths), wrong `command` in compose.yml.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Healthcheck always unhealthy&quot; group=&quot;troubleshooting&quot;&gt;

**Error:** Container stays &quot;starting&quot; then goes to &quot;unhealthy&quot;

**Cause:** The healthcheck URL or port doesn&apos;t match the app&apos;s listening port.

**Fix:**

1. Enter the container: `docker compose exec web bash`
2. Test the healthcheck manually: `python -c &quot;import urllib.request; urllib.request.urlopen(&apos;http://localhost:8080&apos;)&quot;`
3. If that fails, check what port the app is actually listening on: `python -c &quot;import socket; print(socket.gethostname())&quot;`
4. Make sure the healthcheck port in `compose.yml` matches the app&apos;s port.

&lt;/Accordion&gt;

For general cleanup commands when things go wrong, see [How to Cleanup All Docker Things](https://www.bitdoze.com/cleanup-all-docker-things/).

## Production hardening tips

Once your app works, a few extra steps make it production-ready:

**Resource limits** — prevent one container from starving others on a shared VPS:

```yaml
deploy:
  resources:
    limits:
      cpus: &apos;1&apos;
      memory: 512M
```

Add this under the `web` service in `compose.yml`.

&lt;Notice type=&quot;warning&quot; title=&quot;Running Multiple Containers?&quot;&gt;
Without resource limits, a Python app with a memory leak can consume all VPS RAM and crash other containers (or the host). Always set `deploy.resources.limits` on production VPS boxes. A Hetzner CX22 with 4GB RAM comfortably runs 3–5 small Python apps with 512MB limits each.
&lt;/Notice&gt;

**Non-root user** — already covered in the Dockerfile section. Don&apos;t skip it.

**Healthcheck** — already covered. Essential for Docker&apos;s restart policy to work correctly. Without it, Docker only knows if the process is running, not if the app is actually serving requests.

**Backups** — if your app uses a database or writes persistent data, mount a dedicated volume for it and back it up to S3-compatible storage. The bind mount (`./my-app:/app`) is for source code; don&apos;t store data there.

**Multi-stage builds** — if your app needs gcc or other build tools for native extensions (numpy, cryptography, etc.), use a multi-stage build to compile in one stage and copy only the runtime artifacts to the final slim image. Keeps the production image lean.

## Conclusions

Running Python apps in Docker with Docker Compose is straightforward once you have the right patterns. Here&apos;s what to take away:

- Use `python:3.12-slim` (not full, not Alpine) as your base image.
- Always include `.dockerignore`, non-root user, `PYTHONDONTWRITEBYTECODE`, and `PYTHONUNBUFFERED`.
- Use `docker compose` (space, not hyphen) — the old `docker-compose` V1 is gone.
- Drop the `version: &quot;3&quot;` from your compose files — it&apos;s obsolete.
- Add healthchecks so Docker knows if your app is actually working.
- Cloudflare Tunnels give you free SSL without opening firewall ports.
- Set resource limits when running multiple containers on one VPS.

Start with the simple NiceGUI example above, verify it works, then iterate. The total cost for a VPS + Docker + Cloudflare Tunnels stack is under €5/month — hard to beat for a self-hosted Python app with HTTPS.

For more Docker projects to self-host, see [Docker Containers for Your Home Server](https://www.bitdoze.com/docker-containers-home-server/).

&lt;Button text=&quot;Explore More Docker Tutorials&quot; link=&quot;/tag/docker/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>docker</category><category>python</category><category>docker-compose</category></item><item><title>Buzz by Block: Self-Host Your AI Workspace with Docker</title><link>https://www.bitdoze.com/buzz-block-docker-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/buzz-block-docker-setup/</guid><description>Self-host Buzz by Block with Docker Compose on your VPS. Step-by-step guide: key generation, relay setup, TLS with Caddy, and connecting the desktop app.</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Buzz by Block is an open-source, self-hostable workspace where humans and AI agents share the same channels, threads, DMs, git repos, and automated workflows. It&apos;s built by Block, Inc. (the company behind Square and Cash App), released under Apache 2.0 in July 2026, and has 13,700+ GitHub stars.

This guide walks you through running Buzz on your own VPS with Docker Compose. Every command is copy-pasteable. I&apos;ll call out the footguns before you step on them.

## What is Buzz by Block?

If you&apos;ve ever juggled Slack for chat, GitHub for code, Linear for tasks, and a pile of glue code to make them talk to each other, Buzz is trying to replace all of that with one workspace. One event log. One identity model. One search index.

Here&apos;s what it replaces in practice:

- **Slack/Teams**: channels, threads, DMs, reactions, media sharing
- **GitHub/GitLab**: git hosting, PR review, branch discussions, CI integration
- **Linear/Jira**: workflow automation with YAML triggers and approval gates
- **Discord**: voice huddles via WebSocket Opus relay (no external SFU needed)

The difference from Slack bots: agents in Buzz are first-class members. Each agent gets its own Nostr keypair, its own channel memberships, its own audit trail. Scoped by identity, not permission flags. You can connect Claude Code, Codex, Goose, or any agent speaking ACP (Agent Communication Protocol). Bring your own LLM keys. Buzz doesn&apos;t provide inference.

Under the hood, every message, reaction, commit, review, and workflow step is a cryptographically signed Nostr event on a relay you own. This is not blockchain, no token, no mining. It&apos;s signed events for identity and message integrity, stored in your Postgres database.

![Buzz by Block relay architecture diagram showing desktop app connecting to the Rust relay, which talks to Postgres, Redis, and MinIO](./assets/buzz-architecture.svg)

If you&apos;ve tried [self-hosted team chat platforms](https://www.bitdoze.com/chatto-self-hosted/) before, Buzz is different. It combines chat, git, workflows, and agents in one event log.

&lt;Notice type=&quot;info&quot; title=&quot;Pre-release software&quot;&gt;
Buzz is pre-1.0 with daily releases (v0.4.26 as of July 25, 2026). It works. Relay, channels, threads, DMs, canvases, search, workflows, git hosting, desktop app, and voice huddles are all functional. But expect rough edges. Check the [GitHub releases](https://github.com/block/buzz/releases) page for the latest version.
&lt;/Notice&gt;

## What you&apos;ll need to self-host Buzz

&lt;ListCheck&gt;

- A VPS with Docker and Docker Compose v2.24.4+ installed
- 2 vCPU minimum, 4 GB RAM recommended, 20-40 GB SSD
- A domain name pointing to your server (required for TLS/production use)
- Ports 3000 (relay), 80 and 443 (if using Caddy for TLS)
- 15-30 minutes
- Basic terminal comfort. No Nostr knowledge required.

&lt;/ListCheck&gt;

**VPS sizing:** The full stack runs Postgres 17, Redis 7, MinIO, and the Rust relay. At idle you&apos;re looking at roughly 1.5-2 GB RAM. Postgres will consume as much RAM as you give it for full-text search and caching. A [Hetzner Cloud](https://go.bitdoze.com/hetzner) CX22 (2 vCPU, 4 GB RAM, €3.99/mo) or CX32 (4 vCPU, 8 GB, €7.99/mo) works well. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option.

&lt;Notice type=&quot;warning&quot; title=&quot;Docker Compose version matters&quot;&gt;
Ubuntu 22.04 ships Docker Compose v2.5 via apt. That&apos;s too old. Buzz&apos;s TLS override uses the `!reset` YAML tag which requires v2.24.4+. Install Docker from the [official Docker repo](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository), not from Ubuntu&apos;s default apt sources. Verify with `docker compose version` before proceeding.
&lt;/Notice&gt;

If you&apos;ve done Docker Compose setups before, like [installing Umami Analytics](https://www.bitdoze.com/umami-analytics-install/) or [setting up Outline Wiki](https://www.bitdoze.com/outline-install/), the flow here is similar, just with more moving parts. If you prefer a managed approach, [self-hosted server panels like Coolify or Dokploy](https://www.bitdoze.com/best-self-hosted-panels/) can simplify deployment.

## How to self-host Buzz by Block with Docker Compose

### Step 1: Clone the repository and prepare your directory

The production Docker Compose files live in `deploy/compose/`, not the root of the repo. The root `docker-compose.yml` is for development only. Don&apos;t use it for your VPS.

```bash
git clone https://github.com/block/buzz.git
cd buzz/deploy/compose
ls -la
```

You should see these files:

```
compose.yml          # relay + postgres + redis + minio + minio-init
compose.caddy.yml    # optional: Caddy reverse proxy with auto TLS
compose.dev.yml      # optional: exposes dev ports (Adminer, MinIO console)
.env.example         # template for your configuration
run.sh               # management script (start/stop/upgrade/backup/add-member)
```

&lt;Notice type=&quot;info&quot; title=&quot;Production vs Dev compose files&quot;&gt;
Use `deploy/compose/` for your VPS deployment. The root `docker-compose.yml` at the repo root is meant for local development and has different configuration. The `run.sh` script in `deploy/compose/` handles everything you need.
&lt;/Notice&gt;

### Step 2: Generate secrets and encryption keys

Buzz needs several secrets. If you lose the relay private key or owner private key, your workspace is gone. Back these up immediately after generating them.

Run this block to generate all secrets at once:

```bash
# Generate all secrets at once
echo &quot;BUZZ_RELAY_PRIVATE_KEY=$(openssl rand -hex 32)&quot;
echo &quot;BUZZ_GIT_HOOK_HMAC_SECRET=$(openssl rand -hex 32)&quot;
echo &quot;POSTGRES_PASSWORD=$(openssl rand -hex 16)&quot;
echo &quot;REDIS_PASSWORD=$(openssl rand -hex 16)&quot;
echo &quot;BUZZ_S3_ACCESS_KEY=$(openssl rand -hex 16)&quot;
echo &quot;BUZZ_S3_SECRET_KEY=$(openssl rand -hex 16)&quot;
```

Copy the output — you&apos;ll paste it into `.env` in the next step.

&lt;Notice type=&quot;error&quot; title=&quot;Back up your keys immediately&quot;&gt;
Your `.env` file contains ALL your keys. If you lose it and haven&apos;t backed it up, you lose the workspace. Save it to a password manager or encrypted backup right after generating secrets. The relay private key (`BUZZ_RELAY_PRIVATE_KEY`) must never change — if you regenerate it, existing data becomes inaccessible.
&lt;/Notice&gt;

Now you need an **owner Nostr keypair**. The owner is the admin of the workspace. Here are two ways to generate one:

&lt;Tabs&gt;
&lt;Tab name=&quot;Desktop App (Recommended)&quot;&gt;

This method keeps the private key on your device — it never touches the server.

1. Download the [Buzz desktop app](https://github.com/block/buzz/releases/latest)
2. On first launch, click **&quot;Create a new identity key&quot;**
3. The app generates a Nostr keypair and stores it locally
4. Copy your **public key** (it starts with `npub1...`)
5. Convert the `npub` to hex — you can use this one-liner:

```bash
npx @cmdcode/nip19 decode npub1yourkeyhere
```

6. The hex output is what you set as `RELAY_OWNER_PUBKEY` (64-char hex, no `npub` prefix)

&lt;/Tab&gt;
&lt;Tab name=&quot;buzz-admin CLI&quot;&gt;

If you prefer to generate the key on the server:

```bash
# Run inside the relay container after first start
docker compose exec relay buzz-admin generate-key
```

This gives you a hex pubkey and private key. Save the private key somewhere safe — you&apos;ll need it for the desktop app.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Step 3: Configure the `.env` file

Copy the example and fill in your values:

```bash
cp .env.example .env
```

Here&apos;s every variable explained, grouped by category:

```bash
# ── Image tag ─────────────────────────────────────────────────
# Pin to a SHA tag for production (ghcr.io/block/buzz:sha-abc1234)
# Using :main pulls the latest build, which changes frequently
BUZZ_IMAGE=ghcr.io/block/buzz:main

# ── Domain config ─────────────────────────────────────────────
# Replace buzz.example.com with your actual domain
BUZZ_DOMAIN=buzz.example.com
RELAY_URL=wss://buzz.example.com
BUZZ_MEDIA_BASE_URL=https://buzz.example.com/media
BUZZ_MEDIA_SERVER_DOMAIN=buzz.example.com
BUZZ_CORS_ORIGINS=https://buzz.example.com

# ── Security ──────────────────────────────────────────────────
BUZZ_REQUIRE_AUTH_TOKEN=true          # require auth for connections
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true    # only whitelisted members can join
BUZZ_ALLOW_NIP_OA_AUTH=true           # allow NIP-OA auth
BUZZ_AUTO_MIGRATE=true                # MUST be true for fresh databases
BUZZ_GIT_CONFORMANCE_PROBE=true

# ── Owner pubkey ──────────────────────────────────────────────
# Your hex pubkey from Step 2 (64 chars, no 0x prefix)
# ⚠️  NOTE: NO &quot;BUZZ_&quot; prefix on this variable!
RELAY_OWNER_PUBKEY=your_64_char_hex_owner_pubkey

# ── Relay signing key ─────────────────────────────────────────
# Generated in Step 2. NEVER rotate this.
BUZZ_RELAY_PRIVATE_KEY=your_64_char_hex_relay_private_key

# ── Git HMAC secret ───────────────────────────────────────────
BUZZ_GIT_HOOK_HMAC_SECRET=your_64_char_hex_hmac_secret

# ── Database ──────────────────────────────────────────────────
POSTGRES_DB=buzz
POSTGRES_USER=buzz
POSTGRES_PASSWORD=your_random_postgres_password

# ── Redis ─────────────────────────────────────────────────────
REDIS_PASSWORD=your_random_redis_password

# ── S3 / MinIO ────────────────────────────────────────────────
BUZZ_S3_ACCESS_KEY=your_random_access_key
BUZZ_S3_SECRET_KEY=your_random_secret_key
BUZZ_S3_BUCKET=buzz-media

# ── Ports ─────────────────────────────────────────────────────
BUZZ_HTTP_PORT=3000
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443

# ── Logging ───────────────────────────────────────────────────
RUST_LOG=buzz_relay=info,buzz_db=info,buzz_auth=info,buzz_pubsub=info,tower_http=info
```

&lt;Notice type=&quot;warning&quot; title=&quot;RELAY_OWNER_PUBKEY has no BUZZ_ prefix&quot;&gt;
This is intentional and a common footgun. The variable is `RELAY_OWNER_PUBKEY`, not `BUZZ_RELAY_OWNER_PUBKEY`. If you add the `BUZZ_` prefix, it will be silently ignored and your owner identity won&apos;t work.
&lt;/Notice&gt;

&lt;Accordion label=&quot;What does BUZZ_AUTO_MIGRATE do?&quot; group=&quot;env-faq&quot;&gt;
When you start Buzz against a fresh Postgres database, the schema doesn&apos;t exist yet. `BUZZ_AUTO_MIGRATE=true` tells the relay to apply all pending migrations on startup. Without this, the relay will crash on first boot because it can&apos;t find the tables it needs. Always set this to `true` for new installations. It&apos;s safe to leave it on — migrations are idempotent.
&lt;/Accordion&gt;

### Step 4: Start the Docker stack

For initial testing (without TLS):

```bash
./run.sh start
```

This runs `docker compose up -d --wait` under the hood. It starts five containers:

| Container | What it does |
|-----------|-------------|
| **buzz-relay** | The Rust/Axum relay server. Handles WebSocket + REST. Verifies Schnorr signatures. Fans out events via Redis pub/sub. |
| **postgres:17-alpine** | Event store with monthly partitioning, full-text search (GIN index), channels, memberships, workflows, audit log. |
| **redis:7-alpine** | Pub/sub fan-out across relay instances, presence tracking (90s TTL), typing indicators (5s window). |
| **minio** | S3-compatible storage for uploaded media (Blossom protocol) and git packfiles. |
| **minio-init** | One-shot container that creates the `buzz-media` bucket. Runs once and exits. |

Check that everything is running:

```bash
./run.sh status
```

Verify the relay is healthy:

```bash
curl -fsS &quot;http://127.0.0.1:3000/_liveness&quot;
curl -fsS &quot;http://127.0.0.1:3000/_readiness&quot;
```

Both should return `200 OK`. If not, check the logs:

```bash
./run.sh logs relay
```

&lt;Notice type=&quot;success&quot; title=&quot;Verify your relay&quot;&gt;
After starting, run the liveness and readiness curl commands above. Both should return 200. If the relay container is restarting, check `./run.sh logs relay` for errors — the most common issue is a missing or malformed environment variable.
&lt;/Notice&gt;

The `run.sh` script has more useful commands:

```bash
./run.sh stop           # docker compose down (keeps data volumes)
./run.sh restart        # force-recreate relay container
./run.sh upgrade        # pull new image + restart + print backup reminders
./run.sh logs [svc]     # follow logs (default: relay)
./run.sh status         # compose ps
./run.sh config         # render merged compose config
./run.sh backup-hint    # print backup checklist
```

## Add TLS with Caddy (recommended for production)

Without TLS, your relay runs on `ws://` (unencrypted). That&apos;s fine for testing on localhost, but for production you need `wss://` with a valid certificate. Buzz includes Caddy as a compose overlay — it handles Let&apos;s Encrypt automatically.

**Step 1:** Create a DNS A record pointing your domain (e.g., `buzz.yourdomain.com`) to your VPS IP address.

**Step 2:** Wait for DNS to propagate. Check with:

```bash
dig +short buzz.yourdomain.com
```

**Step 3:** Start with TLS enabled:

```bash
BUZZ_COMPOSE_TLS=true ./run.sh start
```

This starts the Caddy container alongside everything else. Caddy automatically obtains a Let&apos;s Encrypt certificate, proxies WebSocket connections, and handles HTTP/2.

**Step 4:** Verify TLS is working:

```bash
curl -I https://buzz.yourdomain.com/_liveness
```

You should get a `200 OK` with a valid certificate. You can also open `https://buzz.yourdomain.com` in your browser — you&apos;ll see the cert is issued by Let&apos;s Encrypt.

&lt;Notice type=&quot;info&quot; title=&quot;DNS propagation takes time&quot;&gt;
After creating the A record, wait 5–15 minutes before starting with TLS. If DNS isn&apos;t ready when Caddy tries to issue the certificate, it will fail. Check with `dig buzz.yourdomain.com` first. If you need an alternative reverse proxy setup, see [CloudPanel as a reverse proxy for Docker](https://www.bitdoze.com/cloudpanel-setup-dockge/).
&lt;/Notice&gt;

Your `.env` file should already have the Caddy port variables set. If you need to change them:

```bash
CADDY_HTTP_PORT=80
CADDY_HTTPS_PORT=443
```

If you&apos;re not comfortable with Docker Compose overlays, [Coolify offers one-click deploys](https://www.bitdoze.com/coolify-install-heroku-alternative/) that can simplify the process.

## Connect the Buzz desktop app to your relay

The desktop app is a Tauri 2 application (React frontend, native shell) available for macOS, Linux, and Windows.

**Download** from [GitHub Releases](https://github.com/block/buzz/releases/latest):
- macOS: `.dmg`
- Linux: `.AppImage` or `.deb`
- Windows: `.exe`

&lt;Notice type=&quot;info&quot; title=&quot;Desktop app only — no web UI yet&quot;&gt;
Buzz is currently a desktop app only. There&apos;s no web UI. Mobile apps (Flutter) are in development. On headless Linux without a Secret Service (gnome-keyring), the app falls back to `0600` file permissions for key storage.
&lt;/Notice&gt;

**Connecting to your relay:**

1. Launch the desktop app
2. On first launch, click **&quot;Create a new identity key&quot;** — this generates a Nostr keypair stored locally on your device
3. Click **&quot;Join community&quot;** (or go to settings and change the relay URL)
4. Enter your relay URL:
   - With TLS: `wss://buzz.yourdomain.com`
   - Without TLS: `ws://your-server-ip:3000`
5. You&apos;re in. Since your pubkey matches `RELAY_OWNER_PUBKEY`, you have full admin access

**First things to try:**
- Create a channel (e.g., `#general`)
- Send a message, start a thread
- Try a DM with another member
- Upload an image (tests the MinIO/S3 setup)

## Add team members and your first AI agent

### Adding team members

Each team member needs their own Nostr keypair. They generate it in the desktop app (same as you did), then share their public key with you.

Add them from the server:

```bash
# Add a member (default role: member)
./run.sh add-member &lt;their_hex_pubkey&gt;

# Add an admin
./run.sh add-member &lt;their_hex_pubkey&gt; --role admin

# List all members
./run.sh list-members

# Remove a member
./run.sh remove-member &lt;their_hex_pubkey&gt;
```

Members share their pubkey by copying it from the desktop app. If they share an `npub1...` address, convert it to hex first:

```bash
npx @cmdcode/nip19 decode npub1theirkeyhere
```

### Connecting your first AI agent

Agents in Buzz are first-class members, not bots bolted on via API. Each agent gets its own Nostr keypair, its own channel memberships, and its own audit trail.

To connect an agent:

1. Generate a keypair for the agent:

```bash
docker compose exec relay buzz-admin generate-key
```

2. Add the agent as a member:

```bash
./run.sh add-member &lt;agent_hex_pubkey&gt;
```

3. Set `BUZZ_PRIVATE_KEY` in the agent&apos;s environment — this is the agent&apos;s private key for signing events.

4. The agent communicates via ACP (Agent Communication Protocol). Compatible agents include Claude Code, Codex, Goose, or any ACP-speaking agent.

&lt;Notice type=&quot;info&quot; title=&quot;Bring your own LLM keys&quot;&gt;
Buzz doesn&apos;t provide AI inference. You need your own API keys for whatever LLM provider your agents use (OpenAI, Anthropic, local models, etc.). The agent signs its own events with its Nostr key and calls the LLM through its own configured provider. If you want to build a custom agent, see [how to build your own AI agent with Mastra](https://www.bitdoze.com/build-ai-agent-mastra/). For multi-model routing, [Agent Router](https://go.bitdoze.com/agentrouter) gives unified access to Claude Code, OpenAI Codex, and Gemini CLI.
&lt;/Notice&gt;

For connecting a [self-hosted AI agent on Docker](https://www.bitdoze.com/hermes-agent-setup-guide/), the pattern is the same: generate a keypair, add the agent as a member, and configure its environment.

&lt;Accordion label=&quot;How do I convert npub to hex?&quot; group=&quot;agent-faq&quot;&gt;
Nostr uses two formats for public keys: `npub1...` (Bech32-encoded, human-readable) and raw hex (64 characters, what the relay needs). To convert:

```bash
# Using npx
npx @cmdcode/nip19 decode npub1yourkeyhere

# The output includes a &quot;data&quot; field — that&apos;s your hex pubkey
```

Alternatively, use a web tool like [nostr.com/npub2hex](https://nostr.com). The hex value is what you pass to `RELAY_OWNER_PUBKEY` and `./run.sh add-member`.
&lt;/Accordion&gt;

## Day-2 operations for your self-hosted Buzz workspace

### Backups

Your `.env` file contains all your keys. Back it up first, always.

```bash
# Print the backup checklist
./run.sh backup-hint
```

What to back up:

| Item | Why | How |
|------|-----|-----|
| `.env` | Contains all keys. Lose it = lose workspace. | Copy to password manager or encrypted storage |
| Postgres data volume | All events, channels, memberships, workflows | `pg_dump` or Docker volume snapshot |
| MinIO data volume | Uploaded media and git packfiles | Copy volume or use `mc mirror` to S3 |

Frequency: daily for production, weekly minimum for testing setups.

### Upgrades

```bash
./run.sh upgrade
```

This pulls the new image, restarts the relay, and prints backup reminders. Migrations run automatically if `BUZZ_AUTO_MIGRATE=true`.

For production, pin to a specific image tag instead of `:main`:

```bash
BUZZ_IMAGE=ghcr.io/block/buzz:sha-abc1234
```

Check the [releases page](https://github.com/block/buzz/releases) for stable tags.

### Monitoring

```bash
# Quick health check
./run.sh status

# Health endpoints
curl -fsS &quot;http://127.0.0.1:3000/_liveness&quot;
curl -fsS &quot;http://127.0.0.1:3000/_readiness&quot;

# Follow relay logs
./run.sh logs relay

# Follow database logs
./run.sh logs postgres
```

For more advanced log management, see [how to redirect Docker logs to a single file](https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/).

### Resource usage

Estimated idle RAM for the full stack:

| Component | RAM (idle) | Notes |
|-----------|-----------|-------|
| Postgres 17 | ~300-500 MB | Grows with data. GIN indexes on FTS can be heavy. |
| Redis 7 | ~50-100 MB | Lightweight. |
| MinIO | ~100-200 MB | Depends on media/git object volume. |
| buzz-relay | ~50-100 MB | Rust binary, efficient. |
| **Total** | **~500 MB-1 GB** | Plus OS overhead. 4 GB VPS recommended. |

## Troubleshooting common Buzz Docker issues

&lt;Accordion label=&quot;CHANGE_ME placeholders not replaced&quot; group=&quot;troubleshooting&quot;&gt;
`run.sh` checks for `CHANGE_ME` in your `.env` and refuses to start if any remain. Generate real secrets using the commands in Step 2 and replace every `CHANGE_ME` value.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;BUZZ_AUTO_MIGRATE not set&quot; group=&quot;troubleshooting&quot;&gt;
Fresh Postgres databases need schema migrations applied. If the relay crashes on first boot with table-not-found errors, make sure `BUZZ_AUTO_MIGRATE=true` is in your `.env`. You can also run migrations manually: `docker compose exec relay buzz-admin migrate`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;RELAY_OWNER_PUBKEY has wrong prefix&quot; group=&quot;troubleshooting&quot;&gt;
The variable is `RELAY_OWNER_PUBKEY`, NOT `BUZZ_RELAY_OWNER_PUBKEY`. The lack of `BUZZ_` prefix is intentional. If you add the prefix, the value is silently ignored and your owner identity won&apos;t be recognized. Check your `.env` file.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Key rotation breaks data&quot; group=&quot;troubleshooting&quot;&gt;
`BUZZ_RELAY_PRIVATE_KEY`, S3 secrets, and database passwords must remain stable across restarts. If you regenerate them, existing data becomes inaccessible. Never regenerate keys for an existing workspace. Back up `.env` immediately after generating secrets.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Docker Compose version too old&quot; group=&quot;troubleshooting&quot;&gt;
Buzz needs Docker Compose v2.24.4+. The TLS override uses the `!reset` YAML tag, which older versions don&apos;t support. On Ubuntu 22.04, the apt version is too old. Install from Docker&apos;s official repo: `curl -fsSL https://get.docker.com | sh`. Verify with `docker compose version`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Port 3000 already in use&quot; group=&quot;troubleshooting&quot;&gt;
Change `BUZZ_HTTP_PORT` in `.env` to an available port. Check what&apos;s using port 3000: `ss -tlnp | grep 3000`. For the TLS setup, Caddy handles ports 80/443 — make sure those are free too.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Desktop app can&apos;t connect to relay&quot; group=&quot;troubleshooting&quot;&gt;
Common causes:
1. **Firewall** — Make sure port 3000 (or your custom port) is open. For TLS, ports 80 and 443 must be open.
2. **Wrong URL scheme** — Use `wss://` for TLS, `ws://` for plain. Mixing them up is the most common mistake.
3. **Self-signed cert** — Caddy uses Let&apos;s Encrypt (valid cert). Self-signed certs won&apos;t work without extra config on the client.
4. **Relay not bound to 0.0.0.0** — Check `BUZZ_BIND_ADDR` if you set it.

Test from the server: `curl -fsS &quot;http://127.0.0.1:3000/_liveness&quot;`. If that works but the app can&apos;t connect, it&apos;s a firewall or DNS issue.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;MinIO bucket not created&quot; group=&quot;troubleshooting&quot;&gt;
The `minio-init` service creates the `buzz-media` bucket on first run. If it fails, media uploads will fail silently. Check: `docker compose logs minio-init`. Common cause: wrong MinIO credentials in `.env`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;No production rate limiter&quot; group=&quot;troubleshooting&quot;&gt;
Buzz defines rate limit tiers in code (human, agent-standard, agent-elevated, agent-platform) but the only implementation is a test stub (`AlwaysAllowRateLimiter`). There is NO production rate limiter. Don&apos;t expose an open relay to the public internet without additional protection — use firewall rules, Cloudflare, or VPN.
&lt;/Accordion&gt;

## Known rough edges (honest assessment)

Buzz is pre-1.0 software moving fast. Here&apos;s what&apos;s broken or incomplete as of July 2026:

&lt;Notice type=&quot;warning&quot; title=&quot;This is pre-release software&quot;&gt;
Buzz is evolving fast. Pin your image tag, back up before upgrades, and expect rough edges. Great for small teams comfortable with self-hosting; not yet ready for enterprise SSO requirements.
&lt;/Notice&gt;

- **No production rate limiter** — only a test stub exists. Don&apos;t expose an open relay publicly without extra protection.
- **Approval gates in workflows** are not fully wired (known limitation). The infrastructure exists but `request_approval` steps get marked as Failed instead of Waiting.
- **`send_dm` and `set_channel_topic` workflow actions** are stubbed — they don&apos;t work yet.
- **Mobile apps** are in development (Flutter). No mobile client today.
- **Git hosting** is functional but early. For mature CI/CD, keep GitHub/GitLab for now.
- **Single-community per relay** — one relay = one community. The URL IS the workspace.
- **Voice huddles** work but use a WebSocket Opus relay (no external SFU). Quality depends on your server bandwidth.

## Is self-hosting Buzz by Block right for you?

**Buzz makes sense when:**
- You have a small team (2–20 people) comfortable with Docker and Linux
- You want an agent-native workspace where humans and AI share the same context
- You care about data ownership and don&apos;t want vendor lock-in
- You&apos;re willing to tolerate pre-1.0 rough edges in exchange for a tool that&apos;s evolving fast

**Buzz doesn&apos;t make sense when:**
- You need enterprise SSO/SAML today
- You can&apos;t tolerate occasional breakage from rapid updates
- You just want Slack — use Slack
- You need a mature mobile app right now
- You need compliance certifications (SOC 2, HIPAA, etc.)

**Cost comparison for a 10-person team:**

| Stack | Monthly cost |
|-------|-------------|
| Slack Pro ($8.75/user) + GitHub Team ($4/user) + Linear Basic ($10/user) | ~$228/mo |
| Buzz on a Hetzner CX22 | €3.99/mo + your LLM API costs |

That&apos;s roughly $220/month saved, and you own the data. The tradeoff is you&apos;re the ops team.

&lt;Button text=&quot;Star Buzz on GitHub&quot; link=&quot;https://github.com/block/buzz&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

If you want to try it, start your Buzz workspace on a [Hetzner CX22](https://go.bitdoze.com/hetzner) for €3.99/month. You can have it running in 30 minutes with this guide. Tear it down if you don&apos;t like it. No vendor lock-in, no contracts.

## FAQ

&lt;Accordion label=&quot;Is Buzz by Block free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Buzz is Apache 2.0 open source. You pay for your own VPS and your own LLM API keys. There&apos;s no paid tier, no license key, no feature gating.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need to know Nostr to use Buzz?&quot; group=&quot;faq&quot;&gt;
No. The desktop app handles keypair generation. This guide covers the server-side setup. You&apos;ll interact with Nostr concepts (pubkeys, signed events) but the tooling abstracts most of it.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the minimum VPS to run Buzz?&quot; group=&quot;faq&quot;&gt;
2 vCPU and 2 GB RAM will work but it&apos;s tight. Recommended: 2–4 vCPU, 4 GB RAM, 40 GB SSD. A Hetzner CX22 (€3.99/mo) is a good starting point.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Buzz with OpenAI, Anthropic, or local models?&quot; group=&quot;faq&quot;&gt;
Yes. Buzz is model-agnostic. You bring your own LLM API keys for whatever provider your agents use. The relay doesn&apos;t do inference — agents handle that themselves.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is this blockchain? Do I need crypto?&quot; group=&quot;faq&quot;&gt;
No. Nostr uses cryptographic signatures (Schnorr/secp256k1) for identity and message integrity. No tokens, no mining, no blockchain. Just signed events in a Postgres database.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run multiple Buzz communities on one server?&quot; group=&quot;faq&quot;&gt;
Currently one community per relay. The URL is the workspace. Multi-community mode exists in code but isn&apos;t the default self-hosted setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I still need GitHub if Buzz has git hosting?&quot; group=&quot;faq&quot;&gt;
Buzz has git hosting with PR review and CI integration, but it&apos;s early. For mature CI/CD pipelines, keep GitHub or GitLab for now and use Buzz for communication and coordination.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I back up my Buzz workspace?&quot; group=&quot;faq&quot;&gt;
Back up three things: `.env` (keys!), the Postgres data volume, and the MinIO data volume. Run `./run.sh backup-hint` for a checklist. Daily backups for production, weekly minimum for testing.
&lt;/Accordion&gt;

Buzz sits next to personal assistants and coding agents in the wider stack. See [top AI GitHub repos](/top-ai-github-repos/) for OpenClaw, Hermes, Pi, OpenCode, and related tools.</content:encoded><category>self-hosting</category><category>buzz</category><category>docker</category><category>ai-agents</category></item><item><title>How to Migrate Astro to Bun on CloudFlare Pages (2026 Guide)</title><link>https://www.bitdoze.com/migrate-astro-bun/</link><guid isPermaLink="true">https://www.bitdoze.com/migrate-astro-bun/</guid><description>Migrate your Astro project from Node.js to Bun on CloudFlare Pages. Step-by-step guide with build commands, BUN_VERSION setup, verification &amp; troubleshooting.</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;

Swapping Node.js and npm for Bun on CloudFlare Pages can cut dependency install times by 10-30x and shave 15-30% off I/O-heavy builds. Bun is a supported build runtime on CloudFlare Pages, not an experiment. If you have an existing Astro static site on Node.js and npm, this guide covers the full migration. Whether you [built a free blog with Astro and Cloudflare](/build-astro-blog-free/) or [migrated from WordPress to Astro](/wordpress-to-astro-migration/), the Bun swap is the next step for faster deploys.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/RUBWS6zp2us&quot;
  label=&quot;How to Migrate Astro to Bun on CloudFlare&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Video Note&quot;&gt;The video above shows the original 2024 migration flow. The written guide below is fully updated for 2025 with current Bun versions, Cloudflare build system changes, and new troubleshooting guidance.&lt;/Notice&gt;

## Prerequisites and compatibility

### Astro version compatibility

This migration works the same way for Astro v3 through v7 on static (SSG) sites. Astro 7 added a Rust compiler and Rolldown (Vite 8), which means faster builds. You can read more about [Astro 7 build performance](/astro-7-faster-builds/) for details. The Bun migration steps themselves have not changed across Astro versions.

&lt;Notice type=&quot;info&quot; title=&quot;Static sites only&quot;&gt;This guide covers static (SSG) Astro sites. If you use SSR, read the next section first.&lt;/Notice&gt;

### SSR users: important caveat

If you use `@astrojs/cloudflare` for server-side rendering with Astro 6+, Cloudflare Pages is **no longer supported** for SSR deployments. As of `@astrojs/cloudflare` v13, only Cloudflare Workers is supported for on-demand rendering. Static sites are unaffected by this change.

&lt;Notice type=&quot;warning&quot; title=&quot;SSR on Cloudflare Pages no longer supported&quot;&gt;If you use `@astrojs/cloudflare` for SSR with Astro 6+, you must deploy to Cloudflare Workers, not Pages. Static sites are unaffected.&lt;/Notice&gt;

## 1. Install Bun

&lt;Tabs&gt;
&lt;Tab name=&quot;macOS / Linux (curl)&quot;&gt;
```sh
curl -fsSL https://bun.sh/install | bash
```
&lt;/Tab&gt;
&lt;Tab name=&quot;macOS (Homebrew)&quot;&gt;
```sh
brew install bun
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Windows (PowerShell)&quot;&gt;
```powershell
powershell -c &quot;irm bun.sh/install.ps1 | iex&quot;
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Via npm (universal)&quot;&gt;
```sh
npm install -g bun
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

After installing, verify it works:

```sh
bun --version
```

You should see `1.3.x` or later. If you get `command not found`, restart your terminal or run `source ~/.bashrc` / `source ~/.zshrc`.

&lt;Notice type=&quot;info&quot; title=&quot;Verify your install&quot;&gt;Run `bun --version`. You should see 1.3.x or later. If it is below 1.1.x, you will want to upgrade for Sharp image optimization compatibility.&lt;/Notice&gt;

You can check [Bun vs NPM, Yarn, PNPM, and Others](/bun-package-manager/) for a detailed comparison of Bun against other package managers.

## 2. Remove existing lock files

Remove the lockfile from whatever package manager you were using before:

```sh
# If you were using npm:
rm package-lock.json

# If you were using pnpm:
rm pnpm-lock.yaml

# If you were using Yarn:
rm yarn.lock
```

Bun creates its own lockfile: `bun.lock` (text format, default since ~v1.2) or `bun.lockb` (binary). This gets generated on the next `bun install`.

&lt;Notice type=&quot;warning&quot; title=&quot;Commit your lockfile&quot;&gt;Cloudflare Pages runs `bun install --frozen-lockfile` by default. If `bun.lock` or `bun.lockb` is not committed to your repo, your build will fail with a lockfile mismatch error.&lt;/Notice&gt;

## 3. Install dependencies with Bun

Run `bun install` to install your project&apos;s dependencies:

```sh
bun install
```

This reads your `package.json` and downloads packages much faster than npm (typically 10-30x for fresh installs). The biggest win is CI/CD dependency install time.

Sharp, Astro&apos;s image optimization library, works with Bun natively. It was broken in 2023-2024 but has been fixed since Bun ~1.1.x. No workarounds needed.

If you need to update packages to their latest versions later, see how to [update packages with Bun](/bun-update-packages/).

## 4. Test your Astro project locally

Start the dev server to make sure everything works:

```sh
bun run dev
```

Open `http://localhost:4321` and verify your pages load correctly. Then test the production build:

```sh
bun run build
```

The `dist/` directory should be created without errors.

### Understanding the `--bun` flag

There is a distinction most guides skip:

- `bun run dev` / `bun run build` runs Astro through Bun&apos;s **Node.js compatibility layer**. This is stable and recommended for most projects.
- `bun run --bun dev` / `bun run --bun build` runs Astro using the **actual Bun runtime**. Faster, but some integrations may have rough edges.

&lt;Notice type=&quot;info&quot; title=&quot;The --bun flag&quot;&gt;Start with `bun run build` (Node compat mode) for stability. If your project builds cleanly and you want more speed, try `bun run --bun build`. If you hit `ERR_HTTP_SOCKET_ASSIGNED` or other Node-specific errors, drop back to compat mode. It is still faster than npm.&lt;/Notice&gt;

If you want to squeeze more speed out of your builds, check the guides on [Astro 7 build performance](/astro-7-faster-builds/) and how to [optimize Astro build speed](/astro-ssg-build-optimization/).

## 5. Configure Cloudflare Pages for Bun

Log into the Cloudflare dashboard and go to your project&apos;s settings. You&apos;ll need to update the build command and optionally pin your Bun version. If you haven&apos;t set up Cloudflare Pages yet, see how to [deploy an Astro site on Cloudflare Pages](/deploy-astrojs-cloudflare/) first.

### Update the build command

Change your Cloudflare Pages build command to:

&lt;Tabs&gt;
&lt;Tab name=&quot;Recommended (Node compat)&quot;&gt;
```
bun run build
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Bun runtime (faster)&quot;&gt;
```
bun run --bun build
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

The recommended `bun run build` uses Bun&apos;s Node.js compatibility layer, which is stable and faster than npm. The `--bun` variant uses the native Bun runtime. It is faster but may not work with all integrations. Start with the recommended option.

### Pin your Bun version with BUN_VERSION

In Cloudflare Dashboard → Settings → Environment variables, add:

```
BUN_VERSION=1.3.14
```

Replace `1.3.14` with the latest stable Bun release. Pinning prevents surprise breakage when Cloudflare updates the default Bun version on their build image.

&lt;Notice type=&quot;error&quot; title=&quot;Avoid BUN_VERSION=latest&quot;&gt;Setting `BUN_VERSION=latest` causes a 403 error during build. Bun&apos;s installer script hits rate-limiting issues when fetching the version list through Cloudflare&apos;s build image (which uses `asdf` under the hood). Always pin a specific version like `1.3.14`.&lt;/Notice&gt;

### Cloudflare Pages build system versions (v2 vs v3)

Cloudflare Pages has three build system versions:

- **v3**: default for new projects. Bun 1.2.15, Node 22.16.0, Ubuntu 22.04.2.
- **v2**: default Bun 1.1.33, Node 18.17.1. Auto-migrating to v3 by February 2027.
- **v1**: deprecated. Auto-migrating to v3 by September 2026.

&lt;Notice type=&quot;info&quot; title=&quot;Check your build system&quot;&gt;New projects default to v3. If you are on v1 or v2, consider upgrading in Cloudflare Dashboard, Pages, Settings. v1 does not have Bun preinstalled, so you need v2 or v3.&lt;/Notice&gt;

Cloudflare Pages free tier gives you 500 builds/month, 1 concurrent build, and a 20-minute hard build timeout. Bun&apos;s faster installs help large projects stay under that timeout. No additional cost for using Bun vs Node.

## 6. Deploy to Cloudflare

Commit your changes and push to your Git repository:

```sh
git add .
git commit -m &quot;Migrate to Bun&quot;
git push
```

Cloudflare will detect the push and start building with the new Bun settings. You can watch the build progress in Cloudflare Dashboard → Pages → your project → Deployments.

## 7. Verify the deployment

Do not just assume it worked. Check these things:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Build log shows `bun install vX.X.X` (confirming Bun, not npm, ran the install)&lt;/li&gt;
&lt;li&gt;Build completes without errors&lt;/li&gt;
&lt;li&gt;Site loads correctly at your production URL&lt;/li&gt;
&lt;li&gt;No hydration errors or console warnings in browser DevTools&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

How to verify:

1. **Build log**: Cloudflare Dashboard → Pages → Deployments → click the latest build → read the log. Look for `bun install vX.X.X` near the top.
2. **Site check**: Visit your production URL and click through several pages.
3. **Console check**: Open browser DevTools → Console tab. Look for any errors or warnings that weren&apos;t there before.

```sh
# Quick HTTP check from your terminal:
curl -I https://your-site.pages.dev
```

You should get a `200 OK` response.

## Troubleshooting common issues

&lt;Accordion label=&quot;Build hangs on bun install&quot; group=&quot;troubleshooting&quot;&gt;
Bun install can intermittently hang on Cloudflare Pages. This is a known issue.

**Fix**: Set `SKIP_DEPENDENCY_INSTALL=true` as an environment variable in Cloudflare Dashboard, then change your build command to install Bun manually via npm:

```bash
npm install -g --allow-scripts=bun bun &amp;&amp; export PATH=&quot;$(npm prefix -g)/bin:$PATH&quot; &amp;&amp; bun --version &amp;&amp; bun install &amp;&amp; bun run build
```

This bypasses the preinstalled Bun and installs a fresh copy during the build.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;BUN_VERSION=latest causes 403 error&quot; group=&quot;troubleshooting&quot;&gt;
Setting `BUN_VERSION=latest` fails because Bun&apos;s installer script can&apos;t fetch the version list through Cloudflare&apos;s `asdf`-based build image.

**Fix**: Pin a specific Bun version instead:
```
BUN_VERSION=1.3.14
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;bun: command not found in build&quot; group=&quot;troubleshooting&quot;&gt;
Your Cloudflare Pages project is probably on build system v1, which doesn&apos;t have Bun preinstalled.

**Fix**: Go to Cloudflare Dashboard → Pages → Settings and upgrade to build system v2 or v3.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Sharp / image optimization errors&quot; group=&quot;troubleshooting&quot;&gt;
In 2023-2024, Sharp (Astro&apos;s image optimizer) didn&apos;t work with Bun. This has been resolved since Bun ~1.1.x.

**Fix**: Make sure you&apos;re running Bun 1.1.x or later. If you&apos;re on an older version, update your `BUN_VERSION` environment variable.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;ERR_HTTP_SOCKET_ASSIGNED or Node-specific errors&quot; group=&quot;troubleshooting&quot;&gt;
You&apos;re likely using the `--bun` flag, which runs Astro through the native Bun runtime instead of Node.js compatibility mode. Some integrations don&apos;t work cleanly with native Bun.

**Fix**: Remove the `--bun` flag. Use `bun run build` instead of `bun run --bun build`. You still get Bun&apos;s faster install times.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Lockfile conflicts / frozen lockfile errors&quot; group=&quot;troubleshooting&quot;&gt;
Cloudflare Pages runs `bun install --frozen-lockfile` by default. If your `bun.lock` or `bun.lockb` file isn&apos;t committed, the build fails.

**Fix**: Run `bun install` locally, then commit the lockfile:
```sh
git add bun.lock
git commit -m &quot;Add Bun lockfile&quot;
git push
```
&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Need the latest Bun on Cloudflare?&quot;&gt;
If the preinstalled Bun is too old and `BUN_VERSION` doesn&apos;t support the version you need, use this workaround. Set `SKIP_DEPENDENCY_INSTALL=true` as an environment variable, then use this build command:
&lt;/Notice&gt;

```bash
npm install -g --allow-scripts=bun bun &amp;&amp; export PATH=&quot;$(npm prefix -g)/bin:$PATH&quot; &amp;&amp; bun --version &amp;&amp; bun install &amp;&amp; bun run build
```

This installs a fresh Bun during the build. If your build is timing out, Bun&apos;s faster installs may help. The 20-minute build timeout is a hard limit. See how to [optimize Astro build speed](/astro-ssg-build-optimization/) for more tips.

## When not to migrate to Bun

&lt;Notice type=&quot;warning&quot; title=&quot;Consider skipping if...&quot;&gt;

- You use **Astro SSR with `@astrojs/cloudflare`** on Astro 6+. Cloudflare Pages no longer supports SSR, only Workers. This guide will not help.
- You rely on **Node.js-specific packages** that have not been tested with Bun.
- You use **Vite plugins that assume Node.js internals**. These may break under the `--bun` flag.
- Your project **already builds and deploys fine** with npm or pnpm. Consider whether the migration effort is worth it for your use case.

&lt;/Notice&gt;

## Conclusion

Bun on Cloudflare Pages is a supported runtime with a mature build system (v3) and proper version pinning. The migration is a one-time effort: swap your lockfile, install dependencies with `bun install`, update the build command to `bun run build`, and pin `BUN_VERSION` in Cloudflare Dashboard. You get faster dependency installs, faster builds for I/O-heavy projects, and a simpler local dev experience.

If you are starting a new project, you can [build a free blog with Astro and Cloudflare](/build-astro-blog-free/) with Bun from day one. If you are looking at alternative deployment platforms for static Astro sites, Bunny.net is a solid option. Here is how to [deploy Astro to Bunny.net](/deploy-astro-bunny-net/).</content:encoded><category>web-development</category><category>bun</category><category>astro</category><category>cloudflare</category></item><item><title>How To Add Multiple Pages to NiceGUI (2026 Guide)</title><link>https://www.bitdoze.com/nicegui-pages/</link><guid isPermaLink="true">https://www.bitdoze.com/nicegui-pages/</guid><description>Learn to add multiple pages to your NiceGUI app with @ui.page() routing, shared layouts, and SPA navigation. Updated for NiceGUI v3: sub_pages, APIRouter, and best practices.</description><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

# How To Add Multiple Pages to NiceGUI (2026 Guide)

NiceGUI (16k+ GitHub stars) is a Python UI framework for building web interfaces in Python, no JavaScript required. Most apps beyond a quick prototype need multiple pages with shared navigation: a header, a sidebar, and content that changes based on the URL.

This guide covers three approaches to NiceGUI page routing: the classic `@ui.page()` with a shared frame, SPA-style navigation with `ui.sub_pages`, and modular routing with `APIRouter`. Pick the one that matches your app&apos;s complexity.

&lt;Notice type=&quot;info&quot; title=&quot;Updated for NiceGUI v3.15&quot;&gt;
This guide was updated for NiceGUI v3.x (tested with v3.15.0). If you&apos;re migrating from v1.x or v2.x, check the official [v3 migration guide](https://zenodo.org/records/17259155). Key changes: Python 3.10+ required, `.tailwind` API removed, `ui.open()` replaced by `ui.navigate.to()`, and the auto-index shared client no longer exists.
&lt;/Notice&gt;

If you&apos;re new to NiceGUI, start with the [getting started with NiceGUI](/nicegui-get-started/) guide. If you&apos;re evaluating Python UI frameworks, the [Streamlit vs NiceGUI comparison](/streamlit-vs-nicegui/) or our roundup of the [best Python web frameworks](/best-python-web-frameworks/) can help you decide.

The video below shows the original approach using NiceGUI v1.x. The code patterns still apply. The `@ui.page()` + `@contextmanager` frame approach is still the idiomatic NiceGUI pattern. See the v3-specific notes throughout this guide.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/bW3ifL2hdfc&quot;
  label=&quot;How To Add Multiple Pages to NiceGUI&quot;
/&gt;

## Prerequisites

Before you start, make sure you have the following:

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;Python 3.10 or newer (`python3 --version` to check)&lt;/li&gt;
  &lt;li&gt;pip (comes with Python) or [setting up a Python project with uv](/uv-get-start/) for faster installs&lt;/li&gt;
  &lt;li&gt;A text editor or IDE (VS Code with the NiceGUI extension recommended for Tailwind autocomplete)&lt;/li&gt;
  &lt;li&gt;~10 minutes of time&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Install NiceGUI with version pinning:

```bash
pip install &quot;nicegui&gt;=3.0.0&quot;
```

Or with uv:

```bash
uv pip install &quot;nicegui&gt;=3.0.0&quot;
```

Verify the install:

```bash
pip show nicegui
```

You should see `Version: 3.x.x` in the output. If you see a version below 3.0, upgrade with `pip install --upgrade nicegui`.

## Project Structure for a NiceGUI Multi-Page App

Keeping your files organized from the start saves headaches when you add more pages later. Here&apos;s the folder layout we&apos;ll build:

```
my_nicegui_app/
├── main.py              # Entry point — starts the server
├── theme.py             # Shared layout frame (header, drawer, footer)
├── menu.py              # Navigation menu links
├── home_page.py         # Home page content
├── all_pages.py         # Route registration
├── pages/
│   ├── __init__.py      # Empty — makes pages a proper Python package
│   ├── title_generator.py
│   └── script_generator.py
├── requirements.txt
└── .gitignore
```

Each file has a single job. `theme.py` defines the shared layout. `menu.py` defines the navigation links. The `pages/` folder holds individual page content. `all_pages.py` wires up the routes. `main.py` starts it all.

&lt;Notice type=&quot;warning&quot; title=&quot;Starter repo caveat&quot;&gt;
The &lt;a href=&quot;https://github.com/bitdoze/nicegui-starter&quot;&gt;bitdoze/nicegui-starter&lt;/a&gt; repo targets NiceGUI v1.x. Use the code from this guide instead for v3.x compatibility.
&lt;/Notice&gt;

### Requirements and version pinning

Pin the major version in `requirements.txt`. Drop `numpy` unless you use it (the example pages here don&apos;t):

```
nicegui&gt;=3.0.0
```

Don&apos;t skip `pages/__init__.py`. It can be an empty file, but it&apos;s needed for proper Python packaging. Tools like PyInstaller and Nuitka require it.

## Approach 1: Page Routing with @ui.page() and a Shared Frame

This is the &quot;traditional&quot; NiceGUI page routing pattern. Each URL gets its own `@ui.page()` decorator, and they all share a layout via a `@contextmanager` frame. Use this when:

- You want independent page instances (each client gets a fresh UI tree)
- Pages have very different layouts
- You&apos;re building a simple multi-page site with a few routes

&lt;Notice type=&quot;info&quot;&gt;
The `@contextmanager` frame is still the idiomatic NiceGUI pattern for shared layouts. The official &lt;a href=&quot;https://github.com/zauberzeug/nicegui/blob/main/examples/modularization/theme.py&quot;&gt;modularization example&lt;/a&gt; uses the same approach.
&lt;/Notice&gt;

### Creating the shared layout (theme.py)

This file defines the page frame: header, sidebar drawer, footer, and the content area. Every page calls `theme.frame()` and puts its content inside.

```python
from contextlib import contextmanager

from menu import menu

from nicegui import ui


@contextmanager
def frame(navtitle: str):
    &quot;&quot;&quot;Custom page frame to share the same styling and behavior across all pages&quot;&quot;&quot;
    ui.colors(primary=&apos;#6E93D6&apos;, secondary=&apos;#53B689&apos;, accent=&apos;#111B1E&apos;, positive=&apos;#53B689&apos;)
    with ui.column().classes(&apos;absolute-center items-center h-screen no-wrap p-9 w-full&apos;):
        yield
    with ui.header() as header:
        ui.button(on_click=lambda: left_drawer.toggle(), icon=&apos;menu&apos;).props(&apos;flat color=white&apos;)
        ui.label(&apos;Getting Started&apos;).classes(&apos;font-bold&apos;)

    with ui.footer(value=False) as footer:
        ui.label(&apos;Footer&apos;)
    with ui.left_drawer().classes(&apos;bg-blue-100&apos;) as left_drawer:
        ui.label(&apos;Menu&apos;)
        with ui.column():
            menu()
    with ui.page_sticky(position=&apos;bottom-right&apos;, x_offset=20, y_offset=20):
        ui.button(on_click=footer.toggle, icon=&apos;contact_support&apos;).props(&apos;fab&apos;)
```

The `yield` statement is where page content gets inserted. The `@contextmanager` decorator makes this reusable. Every page calls `with theme.frame(&apos;Page Title&apos;):` and puts its UI elements inside the block.

Key points:
- `ui.colors()` sets the color scheme for the entire page
- `ui.header()` creates the top bar with a hamburger menu button
- `ui.left_drawer()` creates a slide-out sidebar with navigation
- `ui.footer()` creates an optional bottom bar (hidden by default, toggled by the FAB button)

### Building the navigation menu (menu.py)

```python
from nicegui import ui


def menu() -&gt; None:
    ui.link(&apos;Home&apos;, &apos;/&apos;).classes(&apos;text-black&apos;)
    ui.link(&apos;YouTube Titles&apos;, &apos;/youtube-title-generator/&apos;).classes(&apos;text-black&apos;)
    ui.link(&apos;YouTube Script Generator&apos;, &apos;/youtube-script/&apos;).classes(&apos;text-black&apos;)
```

Each `ui.link()` creates a clickable navigation link. The first argument is the display text, the second is the URL path. For programmatic navigation (e.g., after a form submit), use `ui.navigate.to(&apos;/new-page&apos;)` instead of links.

### Defining page content (pages/)

Each page file defines a function that uses the `theme.frame()` context manager. All UI creation happens inside the function, never at module level (this is a hard requirement in NiceGUI v3).

**pages/title_generator.py**

```python
import theme
from nicegui import ui


def title_generator():
    with theme.frame(&apos;YouTube Title Generator&apos;):
        ui.page_title(&apos;YouTube Title Generator&apos;)
        ui.markdown(&apos;# Title Generator&apos;)
        ui.markdown(&apos;Generate catchy titles for your YouTube videos.&apos;)
```

**pages/script_generator.py**

```python
import theme
from nicegui import ui


def script_generator():
    with theme.frame(&apos;YouTube Script Generator&apos;):
        ui.page_title(&apos;YouTube Script Generator&apos;)
        ui.markdown(&apos;# Script Generator&apos;)
        ui.markdown(&apos;Create video scripts with AI assistance.&apos;)
```

Don&apos;t forget `pages/__init__.py`. Just create an empty file. Without it, Python won&apos;t treat `pages` as a package.

### Registering routes (all_pages.py)

This file maps URL paths to page functions using `ui.page()` decorators:

```python
from nicegui import ui
from pages.title_generator import title_generator
from pages.script_generator import script_generator


def create() -&gt; None:
    @ui.page(&apos;/youtube-title-generator/&apos;)
    def title_page():
        title_generator()

    @ui.page(&apos;/youtube-script/&apos;)
    def script_page():
        script_generator()
```

The `create()` function is called once from `main.py` to register all routes before the server starts.

### Putting it together (main.py)

```python
import all_pages
import home_page
import theme

from nicegui import ui


@ui.page(&apos;/&apos;)
def index_page() -&gt; None:
    with theme.frame(&apos;Homepage&apos;):
        home_page.content()


all_pages.create()

ui.run(title=&apos;Getting Started With NiceGUI&apos;)
```

**Verify**: Run `python main.py`, then open `http://localhost:8080`. You should see the header with a hamburger menu, the sidebar with navigation links, and the home page content. Click each menu link to confirm navigation works.

&lt;Accordion label=&quot;Error: &apos;ui.page cannot be used in the global scope&apos;&quot; group=&quot;errors&quot;&gt;
This happens when you have UI creation at the module level alongside `@ui.page()` decorators. NiceGUI v3 removed the auto-index shared client. ALL UI must be inside `@ui.page()` functions. Move any top-level `ui.element()`, `ui.label()`, etc. calls inside a page function.
&lt;/Accordion&gt;

## Approach 2: SPA Navigation with ui.sub_pages

`ui.sub_pages` is the modern NiceGUI approach for apps where you want client-side navigation. The header and sidebar stay fixed, and only the content area swaps. No full page reload, no flash of unstyled content.

This is what most readers building dashboards or internal tools actually want.

### When to use sub_pages vs @ui.page()

&lt;Tabs&gt;
&lt;Tab name=&quot;sub_pages (SPA)&quot;&gt;
- Client-side content swap, no page reload
- Header and sidebar persist across navigation
- URL updates in the browser bar
- Best for dashboards and internal tools with consistent navigation
- Added in NiceGUI v2.22.0, stable in v3
&lt;/Tab&gt;
&lt;Tab name=&quot;@ui.page() (traditional)&quot;&gt;
- Full page reload per route
- Each page gets an independent client instance
- Pages can have completely different layouts
- Better for public-facing sites with distinct page designs
- Has been the standard approach since NiceGUI 1.x
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Complete sub_pages example

Here&apos;s a self-contained SPA app. Everything goes in `main.py` for clarity. In a real project, you&apos;d split the page functions into separate files:

```python
from nicegui import ui


def menu():
    ui.link(&apos;Home&apos;, &apos;/&apos;).classes(&apos;text-black&apos;)
    ui.link(&apos;YouTube Titles&apos;, &apos;/youtube-title-generator/&apos;).classes(&apos;text-black&apos;)
    ui.link(&apos;YouTube Script Generator&apos;, &apos;/youtube-script/&apos;).classes(&apos;text-black&apos;)


def home():
    ui.markdown(&apos;# Welcome&apos;)
    ui.markdown(&apos;Select a tool from the sidebar.&apos;)


def title_generator():
    ui.markdown(&apos;# Title Generator&apos;)
    ui.markdown(&apos;Generate catchy titles for your YouTube videos.&apos;)


def script_generator():
    ui.markdown(&apos;# Script Generator&apos;)
    ui.markdown(&apos;Create video scripts with AI assistance.&apos;)


@ui.page(&apos;/&apos;)
def root():
    ui.colors(primary=&apos;#6E93D6&apos;, secondary=&apos;#53B689&apos;, accent=&apos;#111B1E&apos;, positive=&apos;#53B689&apos;)
    with ui.header():
        ui.button(on_click=lambda: left_drawer.toggle(), icon=&apos;menu&apos;).props(&apos;flat color=white&apos;)
        ui.label(&apos;My App&apos;).classes(&apos;font-bold&apos;)
    with ui.left_drawer().classes(&apos;bg-blue-100&apos;) as left_drawer:
        with ui.column():
            menu()
    with ui.column().classes(&apos;p-4 w-full&apos;):
        ui.sub_pages({
            &apos;/&apos;: home,
            &apos;/youtube-title-generator/&apos;: title_generator,
            &apos;/youtube-script/&apos;: script_generator,
        })


ui.run(title=&apos;My App&apos;)
```

**Verify**: Run the app. Click menu links. The URL should change and the content area should swap without a full page reload. The header and sidebar remain fixed. Use your browser&apos;s back button to confirm history navigation works.

&lt;Accordion label=&quot;Why does sub_pages show a blank page?&quot; group=&quot;errors&quot;&gt;
Ensure the path dict keys in `ui.sub_pages()` match your menu link paths exactly, including trailing slashes. The root path must be `&apos;/&apos;`. Also make sure the menu `ui.link()` paths match the dictionary keys character for character.
&lt;/Accordion&gt;

## Approach 3: Modular Routing with APIRouter

For larger apps where you want to group related pages under a URL prefix and keep them in separate modules, `nicegui.APIRouter` works like FastAPI&apos;s `APIRouter`. This scales well when you have many feature groups and multiple developers.

### Grouping pages with shared URL prefixes

**api_pages.py**

```python
from nicegui import APIRouter, ui

router = APIRouter(prefix=&apos;/tools&apos;)


@router.page(&apos;/&apos;)
def tools_index():
    ui.markdown(&apos;# Tools&apos;)
    ui.markdown(&apos;Select a tool from the list.&apos;)


@router.page(&apos;/generator/{name}&apos;)
def generator(name: str):
    ui.markdown(f&apos;# Generator: {name}&apos;)
    ui.markdown(f&apos;This is the {name} generator page.&apos;)
```

**main.py**

```python
import api_pages
from nicegui import app, ui


@ui.page(&apos;/&apos;)
def index():
    ui.markdown(&apos;# Home&apos;)
    ui.link(&apos;Go to Tools&apos;, &apos;/tools/&apos;)


app.include_router(api_pages.router)

ui.run(title=&apos;APIRouter Example&apos;)
```

**Verify**: Run the app. Visit `/tools/` to confirm the tools index loads. Visit `/tools/generator/title` to confirm the dynamic path parameter works (you should see &quot;Generator: title&quot;).

&lt;Notice type=&quot;warning&quot; title=&quot;NiceGUI On Air limitation&quot;&gt;
APIRouter does not currently work with NiceGUI On Air (the hosted deployment service). Use Approach 1 or 2 if you plan to deploy via On Air.
&lt;/Notice&gt;

For another take on multi-page Python apps, see our guide on building a [multi-page website with FastHTML](/fasthtml-multiple-pages/).

## Which Approach Should You Use?

&lt;Tabs&gt;
&lt;Tab name=&quot;Simple app&quot;&gt;
**Use Approach 1** (`@ui.page()` + frame) when you have a few pages with different layouts, or you want each page to be an independent instance. Good for public-facing sites.
&lt;/Tab&gt;
&lt;Tab name=&quot;Dashboard / internal tool&quot;&gt;
**Use Approach 2** (`ui.sub_pages`), the recommended default for most readers. SPA-style navigation, persistent shell, fast client-side transitions. Use this for dashboards, admin panels, and internal tools.
&lt;/Tab&gt;
&lt;Tab name=&quot;Large app, many features&quot;&gt;
**Use Approach 3** (`APIRouter`) when you have many feature groups, need URL prefixes, and want to keep modules cleanly separated. Scales well for team development.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Verifying and Troubleshooting Your NiceGUI Pages

### How to verify all routes are registered

After starting the app:

```bash
python main.py
```

Check these things:

1. Open `http://localhost:8080`. The home page should load with header and navigation.
2. Click each menu link. Each page should render its content inside the shared frame.
3. Check the terminal for any import errors or warnings
4. Confirm the NiceGUI version: `pip show nicegui` (should be ≥ 3.0.0)

### Common errors and fixes

&lt;Accordion label=&quot;RuntimeError: &apos;ui.page cannot be used in the global scope&apos;&quot; group=&quot;errors&quot;&gt;
Move all UI creation inside `@ui.page()` functions. NiceGUI v3 removed the auto-index shared client. Global-scope UI elements are no longer allowed. If you have `ui.label(&apos;something&apos;)` at the top level of a file, wrap it in a page function.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;ImportError for theme or menu module&quot; group=&quot;errors&quot;&gt;
Run `python main.py` from the project root directory (where `theme.py` and `menu.py` live). Make sure `pages/__init__.py` exists (empty file is fine). If using a nested structure, check your `PYTHONPATH`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pages show a &apos;sad face&apos; error page&quot; group=&quot;errors&quot;&gt;
Check the terminal for the Python traceback. The page function raised an exception. Common causes: missing import, typo in an element name, or referencing a variable that doesn&apos;t exist in the page scope.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Navigation links don&apos;t work / 404&quot; group=&quot;errors&quot;&gt;
Verify route paths match exactly, including trailing slashes. `/youtube-title-generator/` is not the same as `/youtube-title-generator`. The paths in `ui.link()` must match the paths in `@ui.page()` or `ui.sub_pages()`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;response_timeout exceeded&quot; group=&quot;errors&quot;&gt;
Async page builders that take more than 3 seconds need a higher timeout: `@ui.page(&apos;/&apos;, response_timeout=10)`. This happens when pages do heavy computation or network calls during initialization.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Layout looks wrong after upgrading from v1/v2&quot; group=&quot;errors&quot;&gt;
NiceGUI v3 ships with Tailwind 4 (upgraded from Tailwind 3). Some utility classes behave differently, particularly `line-height`, `border` defaults, and spacing. Check the Tailwind 4 changelog if your layout shifted after upgrading.
&lt;/Accordion&gt;

## Deploying Your Multi-Page NiceGUI App

### Docker and reverse proxy

NiceGUI runs as a single uvicorn worker: one process handles all client connections. For production, put it behind a reverse proxy (Nginx, Caddy, or Traefik) and use Docker for reproducible deploys.

The official NiceGUI Docker image is `zauberzeug/nicegui`. For a complete guide on containerizing Python apps, see [deploy Python apps with Docker](/docker-run-python/).

Quick Docker Compose example:

```yaml
services:
  nicegui:
    image: zauberzeug/nicegui:latest
    ports:
      - &quot;8080:8080&quot;
    volumes:
      - .:/app
    environment:
      - PORT=8080
      - HOST=0.0.0.0
```

NiceGUI v3.10+ correctly reads `client.ip` behind reverse proxies. Make sure your proxy sets `X-Forwarded-For` and `X-Forwarded-Proto` headers.

### Production notes

Key things to know before running this in production:

- **Single worker process**: All clients share one process. Each `@ui.page()` connection holds its own UI tree in memory. For high-traffic apps, scale horizontally with Docker replicas behind a load balancer.
- **Shared state**: Each client gets its own UI instance. Use `app.storage` for per-user state or the `Event` class (v3.0+) for cross-client communication.
- **Self-hosting**: For affordable VPS hosting, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers plans starting at ~€4/month. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with KVM virtualization and NVMe storage.
- **Deployment platforms**: You can self-host with [Dokploy](/dokploy-install/) or check the [best self-hosted server panels](/best-self-hosted-panels/) for more options.

## Bonus Tips for NiceGUI v3

A few things worth knowing that don&apos;t fit neatly into the sections above:

- **Programmatic navigation**: Use `ui.navigate.to(&apos;/new-page&apos;)` — the old `ui.open()` was removed in v3.
- **Observable props**: `.props()`, `.classes()`, and `.style()` no longer need `.update()` calls. They&apos;re automatically reactive in v3.
- **Tailwind 4**: NiceGUI v3 ships Tailwind 4. If your classes look off after upgrading, check the Tailwind 4 changelog for breaking changes around line-height and border defaults.
- **VSCode NiceGUI extension**: Recommended for Tailwind class autocomplete. This replaces the removed `.tailwind` Python API.
- **`ui.keep_alive()`**: Prevents idle client disconnections (added in v3.11). Useful for long-lived dashboard sessions.

&lt;Accordion label=&quot;Quick v3 migration checklist&quot; group=&quot;faq&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;Replace `ui.open()` with `ui.navigate.to()`&lt;/li&gt;
  &lt;li&gt;Remove `.tailwind` API usage — use `.classes()` instead&lt;/li&gt;
  &lt;li&gt;Remove `.update()` calls after `.props()` / `.classes()` / `.style()`&lt;/li&gt;
  &lt;li&gt;Ensure Python 3.10 or newer&lt;/li&gt;
  &lt;li&gt;Pin `nicegui&gt;=3.0.0` in requirements&lt;/li&gt;
  &lt;li&gt;Move all UI inside `@ui.page()` functions (no global-scope elements)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Accordion&gt;

## Conclusion

Adding multiple pages to NiceGUI comes down to choosing the right pattern for your app:

- **`@ui.page()` + shared frame** for simple multi-page sites with distinct layouts
- **`ui.sub_pages`** for dashboards and internal tools with consistent navigation — this is the recommended default for most new projects
- **`APIRouter`** for large apps with many feature groups and team development

The `ui.sub_pages` approach is what I&apos;d reach for first on any new NiceGUI project. The client-side navigation feels faster, the persistent shell avoids layout flashing, and the code is simpler — everything in one `@ui.page(&apos;/&apos;)` handler with a path-to-function dictionary.

If you&apos;re building AI-powered tools on top of NiceGUI, check out our guide on [building AI tools with Python](/build-ai-agent-mastra/). For the broader ecosystem context, see the [best Python web frameworks](/best-python-web-frameworks/) roundup.

&lt;Button text=&quot;NiceGUI Official Documentation&quot; link=&quot;https://nicegui.io/documentation&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Frequently Asked Questions

&lt;Accordion label=&quot;Can I use NiceGUI with Tailwind CSS?&quot; group=&quot;faq&quot;&gt;
Yes. NiceGUI v3 ships with Tailwind 4. Use `.classes(&apos;your-tailwind-classes&apos;)` on any element. The old `.tailwind` Python API (e.g., `ui.element.tailwind.font_bold()`) was removed in v3 — use the string-based `.classes()` approach instead.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I share state between pages?&quot; group=&quot;faq&quot;&gt;
In v3, each `@ui.page()` creates a new client instance — state is not shared between users by default. Use `app.storage` for per-user persistent state. For cross-client communication (e.g., a dashboard showing live sensor data to all users), use the `Event` class introduced in v3.0.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is NiceGUI suitable for production?&quot; group=&quot;faq&quot;&gt;
Yes, but run it behind a reverse proxy (Nginx, Caddy, or Traefik). NiceGUI is a single-worker process — one uvicorn process handles all client connections. For high-traffic apps, scale horizontally with multiple Docker replicas behind a load balancer. Each client connection holds its own UI tree, so watch memory usage.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the difference between sub_pages and @ui.page()?&quot; group=&quot;faq&quot;&gt;
`@ui.page()` does a full page reload per route — the browser requests a new page from the server. `ui.sub_pages` swaps content client-side within a shared shell (header, sidebar stay fixed). Use `sub_pages` for dashboards and apps with consistent navigation. Use `@ui.page()` when pages need completely different layouts or independent client instances.
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>nicegui</category><category>python</category><category>ui-framework</category></item><item><title>CloudPanel Remote Backups to OneDrive &amp; Google Drive</title><link>https://www.bitdoze.com/cloudpanel-remote-backups/</link><guid isPermaLink="true">https://www.bitdoze.com/cloudpanel-remote-backups/</guid><description>Step-by-step guide to configure CloudPanel remote backups to OneDrive or Google Drive using Rclone. Includes restore steps, token expiry fixes, and troubleshooting tips.</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/24/03/cloudpanel-backup.png&quot;;

If you self-host your apps on CloudPanel and don&apos;t have remote backups, you&apos;re one compromised hosting account or ransomware hit away from losing everything. Local backups on the same VPS won&apos;t help when the server is gone.

[CloudPanel](https://www.cloudpanel.io/) is a free, open-source hosting panel and one of the [best self-hosted server panels](/best-self-hosted-panels/) available. It uses [Rclone](https://rclone.org/) under the hood for remote backups, supporting providers like Amazon S3, Dropbox, SFTP, and more. For some providers (DigitalOcean Spaces, Dropbox, SFTP), configuration happens directly in the UI. But for **OneDrive** and **personal Google Drive**, you need to go through the Rclone Custom Config path.

This guide covers the full setup for both providers, plus the restore steps and troubleshooting that most tutorials skip. If you prefer plugin-level backups for WordPress specifically, check [WordPress backup plugins](/best-free-wordpress-backup-plugins/) for an app-layer alternative.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ja3TZR083pA&quot;
  label=&quot;Setup CloudPanel Remote Backups to OneDrive or Google Drive&quot;
/&gt;

## What CloudPanel backs up (files, databases, and settings)

Before configuring anything, know what&apos;s actually included when CloudPanel runs a remote backup.

&lt;ListCheck&gt;

**Included in each backup:**
- Home directory of each site (all files)
- Site settings and vhost configuration
- Databases, backed up _before_ the file backup runs (since CloudPanel v2.4.0)

**Excluded by default:**
- `.ssh` directories
- `logs` directories
- `tmp` directories

&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Database-first backup&quot;&gt;
Since CloudPanel v2.4.0, databases are dumped before the remote backup archive is created. This means if a file copy fails halfway through, you still have a consistent database snapshot in the archive.
&lt;/Notice&gt;

You can also use the **Excludes** field in the CloudPanel UI to skip specific sites or paths. This is useful if you have large static asset directories that don&apos;t need daily off-site backup.

If you&apos;re hosting WordPress on CloudPanel, the backup includes everything: wp-content, uploads, database, and config. See how to [host WordPress sites on CloudPanel](/install-wordpress-on-ubuntu-arm/) for more on the setup.

## Prerequisites for CloudPanel remote backups

Make sure these are in place before starting the Rclone config.

### Check your instance timezone

CloudPanel docs explicitly state: the timezone must be correct for scheduled backups to run at the expected time.

Verify with:

```sh
timedatectl
```

If it&apos;s wrong, fix it:

```sh
sudo timedatectl set-timezone Europe/Bucharest
```

Replace `Europe/Bucharest` with your actual timezone.

&lt;Notice type=&quot;warning&quot; title=&quot;Wrong timezone = wrong backup times&quot;&gt;
If your server timezone is off, backups will run at unexpected hours. This matters if you have maintenance windows or want backups to run during low-traffic periods.
&lt;/Notice&gt;

### Create a target folder in OneDrive or Google Drive

Go to your cloud provider and create a dedicated folder for backups _before_ running Rclone config. CloudPanel and Rclone won&apos;t create it for you. If the folder doesn&apos;t exist, you&apos;ll get errors.

For this guide I&apos;ll use a folder called `dragos-cloudpanel-backups`.

**Storage costs to be aware of:**

| Provider | Free tier | Paid tier | Notes |
|----------|-----------|-----------|-------|
| Google Drive | 15 GB | 100 GB ~ $1.99/mo | Undocumented ~750 GB/day upload limit |
| OneDrive | 5 GB | 100 GB ~ $1.99/mo, 1 TB with M365 ~ $6.99/mo | Refresh token expires after 90 days inactivity |

Both count backups against your storage quota. Plan retention accordingly.

### Connect via SSH tunnel for browser authentication

Rclone needs a browser for OAuth, but your server is headless. The fix is an SSH tunnel that forwards port 53682 from the server to your local machine.

&lt;Notice type=&quot;info&quot; title=&quot;Tunnel only during setup&quot;&gt;
The SSH tunnel is only needed during the initial `rclone config` step. Day-to-day backups run without it.
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Mac / Linux&quot;&gt;
```sh
ssh -L localhost:53682:localhost:53682 username@your_server_ip
```
Replace `username` with your SSH user and `your_server_ip` with the server IP. This works in any terminal.
&lt;/Tab&gt;
&lt;Tab name=&quot;Windows (WSL)&quot;&gt;
If you have WSL installed, use the same command:
```sh
ssh -L localhost:53682:localhost:53682 username@your_server_ip
```
If you don&apos;t have WSL, use PuTTY: go to **Connection → SSH → Tunnels**, set Source port to `53682`, Destination to `localhost:53682`, click **Add**, then connect.
&lt;/Tab&gt;
&lt;/Tabs&gt;

For more details, see the [Rclone remote setup docs](https://rclone.org/remote_setup/).

If you haven&apos;t installed CloudPanel yet, [install CloudPanel on your server](/install-cloudpanel-host-nodejs/) first. Also make sure to [keep your CloudPanel installation up to date](/safely-update-cloudpanel/). Backup features have improved since v2.4.0.

## How to set up CloudPanel backup to OneDrive

### Run Rclone config for OneDrive

With the SSH tunnel open, SSH into your server and run:

```sh
rclone config
```

Follow the interactive prompts. The exact wording may differ depending on your Rclone version, but the choices and order are the same. Here&apos;s the full session:

```sh
root@cp-dg:~# rclone config
2024/03/14 13:27:43 NOTICE: Config file &quot;/root/.config/rclone/rclone.conf&quot; not found - using defaults
No remotes found - make a new one
n) New remote
s) Set configuration password
q) Quit config
n/s/q&gt; n
name&gt; remote
Type of storage to configure.
Enter a string value. Press Enter for the default (&quot;&quot;).
Choose a number from below, or type in your own value
 1 / 1Fichier
   \ &quot;fichier&quot;
 2 / Alias for an existing remote
   \ &quot;alias&quot;
 3 / Amazon Drive
   \ &quot;amazon cloud drive&quot;
 4 / Amazon S3 Compliant Storage Provider (AWS, Alibaba, Ceph, Digital Ocean, Dreamhost, IBM COS, Minio, Tencent COS, etc)
   \ &quot;s3&quot;
 5 / Backblaze B2
   \ &quot;b2&quot;
 6 / Box
   \ &quot;box&quot;
 7 / Cache a remote
   \ &quot;cache&quot;
 8 / Citrix Sharefile
   \ &quot;sharefile&quot;
 9 / Dropbox
   \ &quot;dropbox&quot;
10 / Encrypt/Decrypt a remote
   \ &quot;crypt&quot;
11 / FTP Connection
   \ &quot;ftp&quot;
12 / Google Cloud Storage (this is not Google Drive)
   \ &quot;google cloud storage&quot;
13 / Google Drive
   \ &quot;drive&quot;
14 / Google Photos
   \ &quot;google photos&quot;
15 / Hubic
   \ &quot;hubic&quot;
16 / In memory object storage system.
   \ &quot;memory&quot;
17 / Jottacloud
   \ &quot;jottacloud&quot;
18 / Koofr
   \ &quot;koofr&quot;
19 / Local Disk
   \ &quot;local&quot;
20 / Mail.ru Cloud
   \ &quot;mailru&quot;
21 / Microsoft Azure Blob Storage
   \ &quot;azureblob&quot;
22 / Microsoft OneDrive
   \ &quot;onedrive&quot;
23 / OpenDrive
   \ &quot;opendrive&quot;
24 / OpenStack Swift (Rackspace Cloud Files, Memset Memstore, OVH)
   \ &quot;swift&quot;
25 / Pcloud
   \ &quot;pcloud&quot;
26 / Put.io
   \ &quot;putio&quot;
27 / SSH/SFTP Connection
   \ &quot;sftp&quot;
28 / Sugarsync
   \ &quot;sugarsync&quot;
29 / Transparently chunk/split large files
   \ &quot;chunker&quot;
30 / Union merges the contents of several upstream fs
   \ &quot;union&quot;
31 / Webdav
   \ &quot;webdav&quot;
32 / Yandex Disk
   \ &quot;yandex&quot;
33 / http Connection
   \ &quot;http&quot;
34 / premiumize.me
   \ &quot;premiumizeme&quot;
35 / seafile
   \ &quot;seafile&quot;
Storage&gt; 22
** See help for onedrive backend at: https://rclone.org/onedrive/ **

OAuth Client Id
Leave blank normally.
Enter a string value. Press Enter for the default (&quot;&quot;).
client_id&gt;
OAuth Client Secret
Leave blank normally.
Enter a string value. Press Enter for the default (&quot;&quot;).
client_secret&gt;
Edit advanced config? (y/n)
y) Yes
n) No (default)
y/n&gt;
Remote config
Use auto config?
 * Say Y if not sure
 * Say N if you are working on a remote or headless machine
y) Yes (default)
n) No
y/n&gt;
If your browser doesn&apos;t open automatically go to the following link: http://127.0.0.1:53682/auth?state=LK_cdbrOrIT
Log in and authorize rclone for access
Waiting for code...
Got code
Choose a number from below, or type in an existing value
 1 / OneDrive Personal or Business
   \ &quot;onedrive&quot;
 2 / Root Sharepoint site
   \ &quot;sharepoint&quot;
 3 / Type in driveID
   \ &quot;driveid&quot;
 4 / Type in SiteID
   \ &quot;siteid&quot;
 5 / Search a Sharepoint site
   \ &quot;search&quot;
Your choice&gt; 1
Found 1 drives, please select the one you want to use:
0:  (personal) id=f9f661d3066d45ed
Chose drive to use:&gt; 0
Found drive &apos;root&apos; of type &apos;personal&apos;, URL: https://onedrive.live.com/?cid=f9f661d3066d45ed
Is that okay?
y) Yes (default)
n) No
y/n&gt;
--------------------
[remote]
type = onedrive
token = {&quot;access_token&quot;:&quot;eyJ0eXAiOiJKV1QiLCJub25jZSI6ImFRVH&quot;,&quot;token_type&quot;:&quot;Bearer&quot;,&quot;refresh_token&quot;:&quot;M.R3_BL2-&quot;,&quot;expiry&quot;:&quot;2024-03-14T14:33:11.976081855Z&quot;}
drive_id = f9f661d3066d45ed
drive_type = personal
--------------------
y) Yes this is OK (default)
e) Edit this remote
d) Delete this remote
y/e/d&gt;
Current remotes:

Name                 Type
====                 ====
remote               onedrive

e) Edit existing remote
n) New remote
d) Delete remote
r) Rename remote
c) Copy remote
s) Set configuration password
q) Quit config
e/n/d/r/c/s/q&gt; q
```

Key choices:

- **name&gt; remote** must be exactly `remote`. CloudPanel hardcodes this name.
- **Storage&gt; 22** is Microsoft OneDrive. You can also type `onedrive` on newer Rclone versions.
- **client_id / client_secret**: press Enter to use defaults.
- **Use auto config?**: say Yes. Rclone opens your browser via the SSH tunnel.
- **OneDrive Personal or Business**: choose the type that matches your account.

### Verify the Rclone configuration

Before touching the CloudPanel UI, test that Rclone can actually write to the remote:

```sh
touch /tmp/test-file
rclone copy /tmp/test-file remote:dragos-cloudpanel-backups/
```

Check your OneDrive folder. The test file should appear within seconds. If it does, delete it:

```sh
rclone delete remote:dragos-cloudpanel-backups/test-file
```

&lt;Notice type=&quot;success&quot; title=&quot;Test passed?&quot;&gt;
If the test file appears in your cloud folder, Rclone is configured correctly. If you get auth errors or &quot;directory not found&quot;, re-run `rclone config` and check the folder name.
&lt;/Notice&gt;

This step catches auth failures, missing folders, and permission issues before they silently break your real backups.

### Configure CloudPanel to use the Custom Rclone Config

1. Log in to CloudPanel
2. Go to **Admin Area → Backups**
3. Choose **Custom Rclone Config**
4. Set **Frequency** (daily, weekly), **Time**, **Retention Period** (days), and **Storage Directory** (the folder you created, e.g. `dragos-cloudpanel-backups`)
5. Optionally use the **Excludes** field to skip specific sites or paths
6. Click **Save**, then hit **Create Backup** to run one immediately

&lt;Picture src={imag1} alt=&quot;CloudPanel Custom Rclone Config backup settings&quot; /&gt;

After a few minutes (depending on site size), check OneDrive. You should see backup files appearing.

## How to set up CloudPanel backup to Google Drive

### Important: Google Workspace vs personal Google Drive

&lt;Notice type=&quot;warning&quot; title=&quot;Google Workspace required for native UI option&quot;&gt;
CloudPanel&apos;s built-in Google Drive option in the UI now requires a paid Google Workspace service account. If you have a personal Google account (the free one), you **must** use the Custom Rclone Config method described below. This is not a workaround. It&apos;s the official path for personal accounts.
&lt;/Notice&gt;

The steps below use the Custom Rclone Config approach, which works for both personal Google Drive and Google Workspace.

### Run Rclone config for Google Drive

```sh
rclone config
```

Full interactive session:

```sh
root@cp-dg:~/.config/rclone# rclone config
2024/03/14 13:54:24 NOTICE: Config file &quot;/root/.config/rclone/rclone.conf&quot; not found - using defaults
No remotes found - make a new one
n) New remote
s) Set configuration password
q) Quit config
n/s/q&gt; n
name&gt; remote
Type of storage to configure.
Enter a string value. Press Enter for the default (&quot;&quot;).
Choose a number from below, or type in your own value
 1 / 1Fichier
   \ &quot;fichier&quot;
 2 / Alias for an existing remote
   \ &quot;alias&quot;
 3 / Amazon Drive
   \ &quot;amazon cloud drive&quot;
 4 / Amazon S3 Compliant Storage Provider (AWS, Alibaba, Ceph, Digital Ocean, Dreamhost, IBM COS, Minio, Tencent COS, etc)
   \ &quot;s3&quot;
 5 / Backblaze B2
   \ &quot;b2&quot;
 6 / Box
   \ &quot;box&quot;
 7 / Cache a remote
   \ &quot;cache&quot;
 8 / Citrix Sharefile
   \ &quot;sharefile&quot;
 9 / Dropbox
   \ &quot;dropbox&quot;
10 / Encrypt/Decrypt a remote
   \ &quot;crypt&quot;
11 / FTP Connection
   \ &quot;ftp&quot;
12 / Google Cloud Storage (this is not Google Drive)
   \ &quot;google cloud storage&quot;
13 / Google Drive
   \ &quot;drive&quot;
14 / Google Photos
   \ &quot;google photos&quot;
15 / Hubic
   \ &quot;hubic&quot;
16 / In memory object storage system.
   \ &quot;memory&quot;
17 / Jottacloud
   \ &quot;jottacloud&quot;
18 / Koofr
   \ &quot;koofr&quot;
19 / Local Disk
   \ &quot;local&quot;
20 / Mail.ru Cloud
   \ &quot;mailru&quot;
21 / Microsoft Azure Blob Storage
   \ &quot;azureblob&quot;
22 / Microsoft OneDrive
   \ &quot;onedrive&quot;
23 / OpenDrive
   \ &quot;opendrive&quot;
24 / OpenStack Swift (Rackspace Cloud Files, Memset Memstore, OVH)
   \ &quot;swift&quot;
25 / Pcloud
   \ &quot;pcloud&quot;
26 / Put.io
   \ &quot;putio&quot;
27 / SSH/SFTP Connection
   \ &quot;sftp&quot;
28 / Sugarsync
   \ &quot;sugarsync&quot;
29 / Transparently chunk/split large files
   \ &quot;chunker&quot;
30 / Union merges the contents of several upstream fs
   \ &quot;union&quot;
31 / Webdav
   \ &quot;webdav&quot;
32 / Yandex Disk
   \ &quot;yandex&quot;
33 / http Connection
   \ &quot;http&quot;
34 / premiumize.me
   \ &quot;premiumizeme&quot;
35 / seafile
   \ &quot;seafile&quot;
Storage&gt; 13
** See help for drive backend at: https://rclone.org/drive/ **

Google Application Client Id
Setting your own is recommended.
See https://rclone.org/drive/#making-your-own-client-id for how to create your own.
If you leave this blank, it will use an internal key which is low performance.
Enter a string value. Press Enter for the default (&quot;&quot;).
client_id&gt;
OAuth Client Secret
Leave blank normally.
Enter a string value. Press Enter for the default (&quot;&quot;).
client_secret&gt;
Scope that rclone should use when requesting access from drive.
Enter a string value. Press Enter for the default (&quot;&quot;).
Choose a number from below, or type in your own value
 1 / Full access all files, excluding Application Data Folder.
   \ &quot;drive&quot;
 2 / Read-only access to file metadata and file contents.
   \ &quot;drive.readonly&quot;
   / Access to files created by rclone only.
 3 | These are visible in the drive website.
   | File authorization is revoked when the user deauthorizes the app.
   \ &quot;drive.file&quot;
   / Allows read and write access to the Application Data folder.
 4 | This is not visible in the drive website.
   \ &quot;drive.appfolder&quot;
   / Allows read-only access to file metadata but
 5 | does not allow any access to read or download file content.
   \ &quot;drive.metadata.readonly&quot;
scope&gt; 1
ID of the root folder
Leave blank normally.

Fill in to access &quot;Computers&quot; folders (see docs), or for rclone to use
a non root folder as its starting point.

Enter a string value. Press Enter for the default (&quot;&quot;).
root_folder_id&gt;
Service Account Credentials JSON file path
Leave blank normally.
Needed only if you want use SA instead of interactive login.

Leading `~` will be expanded in the file name as will environment variables such as `${RCLONE_CONFIG_DIR}`.

Enter a string value. Press Enter for the default (&quot;&quot;).
service_account_file&gt;
Edit advanced config? (y/n)
y) Yes
n) No (default)
y/n&gt;
Remote config
Use auto config?
 * Say Y if not sure
 * Say N if you are working on a remote or headless machine
y) Yes (default)
n) No
y/n&gt;
If your browser doesn&apos;t open automatically go to the following link: http://127.0.0.1:53682/auth?state=lJ52bPvVGaG
Log in and authorize rclone for access
Waiting for code...
Got code
Configure this as a team drive?
y) Yes
n) No (default)
y/n&gt;

--------------------
[remote]
scope = drive
token = {&quot;access_token&quot;:&quot;ya29.a0Ad52N3_J72wn8dG4c&quot;,&quot;token_type&quot;:&quot;Bearer&quot;,&quot;refresh_token&quot;:&quot;1//0czUgUBU95R8GCgYIARAAG-JK9klco7JLyA&quot;,&quot;expiry&quot;:&quot;2024-03-14T14:55:20.87391752Z&quot;}
--------------------
y) Yes this is OK (default)
e) Edit this remote
d) Delete this remote
y/e/d&gt;
Current remotes:

Name                 Type
====                 ====
remote               drive

e) Edit existing remote
n) New remote
d) Delete remote
r) Rename remote
c) Copy remote
s) Set configuration password
q) Quit config
e/n/d/r/c/s/q&gt; q
```

Key choices:

- **name&gt; remote**: same requirement. Must be exactly `remote`.
- **Storage&gt; 13** is Google Drive. You can also type `drive` on newer Rclone versions.
- **scope&gt; 1**: Full access. This is the default and what CloudPanel needs.
- **Configure this as a team drive?**: say No unless you&apos;re using a Shared Drive.
- **Use auto config?**: say Yes. Opens browser via SSH tunnel.

### Use your own Google Client ID (recommended)

&lt;Notice type=&quot;info&quot; title=&quot;Shared client_id works but has limits&quot;&gt;
Rclone&apos;s default shared client_id works for initial setup. Since Rclone v1.74.4, it warns when using the shared ID. Google may throttle or revoke shared IDs under heavy usage. Creating your own is free and recommended for production backups.
&lt;/Notice&gt;

Creating your own Client ID takes about 5 minutes:

1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a project (or use an existing one)
3. Enable the **Google Drive API**
4. Go to **APIs &amp; Services → Credentials**
5. Create **OAuth 2.0 Client ID** (Desktop application)
6. Copy the client_id and client_secret
7. Enter them during `rclone config` instead of pressing Enter for defaults

&lt;Button text=&quot;Create Your Own Google Client ID&quot; link=&quot;https://rclone.org/drive/#making-your-own-client-id&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;sm&quot; icon=&quot;arrow-right&quot; /&gt;

### Verify and configure CloudPanel

Run the same verification test as the OneDrive section:

```sh
touch /tmp/test-file
rclone copy /tmp/test-file remote:dragos-cloudpanel-backups/
```

Confirm the file appears in Google Drive, then delete it. After that, configure CloudPanel the same way: **Admin Area → Backups → Custom Rclone Config**. Set frequency, time, retention, and storage directory.

## How to restore CloudPanel backups from OneDrive or Google Drive

&lt;Notice type=&quot;warning&quot; title=&quot;No one-click restore&quot;&gt;
CloudPanel does not have one-click restore from remote backups. It&apos;s been a [feature request](https://feature-requests.cloudpanel.io/posts/27/one-click-restore-from-backup) since the early days. Restoring is a manual process.
&lt;/Notice&gt;

### Restoring files (small restores under 2 GB)

1. Download `backup.tar` from OneDrive or Google Drive
2. Upload it to the site&apos;s `tmp` folder using CloudPanel **File Manager**
3. Right-click the file → **Extract**
4. Copy the files to the correct locations

### Restoring large backups via SFTP and SSH

For backups over 2 GB, the File Manager may time out. Use SFTP instead:

1. Upload `backup.tar` via SFTP to the site user&apos;s `~/tmp/` directory
2. SSH as the site user
3. Extract:

```sh
tar xf ~/tmp/backup.tar
```

4. Move files to the correct directories

### Restoring databases via CLI

The backup archive contains SQL dumps of your databases. To restore a database:

```sh
clpctl db:restore --databaseName=your_database --filePath=/path/to/dump.sql
```

&lt;Notice type=&quot;warning&quot; title=&quot;Test before touching production&quot;&gt;
Always test restores on a staging environment first. The database must already exist. Create it in the CloudPanel UI if you&apos;re restoring to a fresh server.
&lt;/Notice&gt;

## Customizing the CloudPanel backup schedule

CloudPanel stores its backup schedule in `/etc/cron.d/clp`. The default looks like this:

```sh
15 3 * * * clp /usr/bin/bash -c &quot;/usr/bin/clpctl db:backup --ignoreDatabases=&apos;db1,db2&apos; --retentionPeriod=7&quot; &amp;&gt; /dev/null
15 4 * * * clp /home/clp/scripts/create_backup.sh &amp;&gt; /dev/null
```

Here&apos;s what each line does:

- **Line 1** runs database backups at 3:15 AM with a 7-day retention period. The `--ignoreDatabases` flag lets you exclude specific databases (replace `db1,db2` with real names or remove the flag).
- **Line 2** runs the full remote backup (files + databases) at 4:15 AM. This is the one that uploads to OneDrive/Google Drive via Rclone.

&lt;Notice type=&quot;info&quot; title=&quot;Frequency vs cron&quot;&gt;
The cron runs daily, but CloudPanel checks the **Frequency** setting in the UI to decide whether to actually execute the backup on a given day. If you set Frequency to &quot;Weekly&quot; in the UI, the cron still runs daily, but only performs the backup once per week.
&lt;/Notice&gt;

You can change the times to fit your schedule. Just keep the database backup before the full backup (line 1 earlier than line 2) so database dumps are fresh.

## Troubleshooting CloudPanel remote backups

&lt;Accordion label=&quot;Fixing token expiry (OneDrive 90-day limit)&quot; group=&quot;troubleshooting&quot;&gt;

OneDrive refresh tokens expire after 90 days of inactivity. If backups run at least once every 90 days, the token auto-refreshes and you&apos;ll never notice. But if backups stop running (disabled, server down, frequency too low), the token expires and uploads silently fail.

**Fix:** Re-authenticate Rclone:

```sh
rclone config reconnect remote:
```

This opens the browser via SSH tunnel (you&apos;ll need the tunnel open again). After re-authenticating, test with:

```sh
rclone lsd remote:dragos-cloudpanel-backups/
```

If it lists the directory, you&apos;re good.

**Prevention:** Set a calendar reminder every 60 days, or monitor backup logs for auth errors.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;&apos;Remote not found&apos; error&quot; group=&quot;troubleshooting&quot;&gt;

CloudPanel expects the Rclone remote to be named exactly `remote` (lowercase). If you named it something else during config, it won&apos;t work.

Check what you have:

```sh
rclone listremotes
```

If it shows anything other than `remote:`, rename it:

```sh
rclone config
```

Then use the **r) Rename remote** option.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Backup folder not found&quot; group=&quot;troubleshooting&quot;&gt;

The target folder must exist in OneDrive/Google Drive before you configure CloudPanel. If you see errors about missing paths:

1. Go to your cloud provider and create the folder manually
2. Verify Rclone can see it:

```sh
rclone lsd remote:
```

3. Re-run the backup from CloudPanel

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission errors on OneDrive Personal&quot; group=&quot;troubleshooting&quot;&gt;

OneDrive Personal has some API limitations compared to Business:

- `rclone cleanup` doesn&apos;t work (no version deletion)
- Hard delete is not supported
- Description field no longer supported (since Rclone v1.73.0)

These don&apos;t break backups. They just limit some management operations. Backups and restores work fine.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;SSH tunnel issues on Windows&quot; group=&quot;troubleshooting&quot;&gt;

If the SSH tunnel doesn&apos;t work from Windows Command Prompt or PowerShell:

1. **Use WSL** (Windows Subsystem for Linux). The `ssh -L` command works identically
2. **Use PuTTY**: go to **Connection → SSH → Tunnels**, set Source port to `53682`, Destination to `localhost:53682`, click **Add**, then connect
3. If port 53682 is blocked by a local firewall, temporarily allow it during setup. It&apos;s only needed for the initial `rclone config` authentication

&lt;/Accordion&gt;

## Final thoughts

Remote backups are not optional for self-hosting. The Custom Rclone Config path described here works reliably for both OneDrive and personal Google Drive. It&apos;s not a hack. It&apos;s the official approach when the native UI options don&apos;t cover your provider.

A few things to keep in mind:

- **Test your restores.** A backup you&apos;ve never restored from is not a backup. It&apos;s a hope. Download the archive, extract it, confirm the files are intact and the database imports cleanly.
- **Watch OneDrive token expiry.** It&apos;s the number one silent failure mode. Backups appear to run but nothing uploads.
- **Keep CloudPanel updated.** Backup features have improved since v2.4.0 (database-first backup, vhost inclusion). Security fixes matter too. Several privilege escalation CVEs were patched in v2.5.0.

Beyond backups, you should also [secure your CloudPanel server](/secure-cloudpanel/) and [secure your VPS](/crowdsec-secure-server/). If you&apos;re running WordPress, [optimize WordPress performance with CloudPanel](/cloudpanel-varnish-cache/) for faster page loads. And if you need a [backup strategy for self-hosted platforms](/dokploy-backups-cloudflare-r2/) beyond CloudPanel, that guide covers Cloudflare R2 with Dokploy.

If you&apos;re looking for an affordable VPS to host CloudPanel, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers reliable European servers starting at around €4.50/month. For budget-friendly alternatives, [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) provides KVM-based VPS with NVMe storage.</content:encoded><category>hosting</category><category>cloudpanel</category><category>rclone</category><category>backups</category></item><item><title>Perplexity AI Review 2026: Features, Pricing &amp; Promo Code</title><link>https://www.bitdoze.com/perplexity/</link><guid isPermaLink="true">https://www.bitdoze.com/perplexity/</guid><description>In-depth Perplexity AI review covering Pro features, pricing, Deep Research, AI models, and a $10 promo code. Is the AI answer engine worth it in 2026?</description><pubDate>Sun, 26 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

I&apos;ve been using [Perplexity.ai](https://go.bitdoze.com/perplexity) daily for over two years. In that time I&apos;ve canceled ChatGPT Plus, paused Claude Pro, and let Gemini Advanced lapse. Perplexity is the one AI subscription I keep paying. This review explains why.

Since I first wrote about it in early 2024, the platform has changed massively. It&apos;s grown from ~15 million monthly active users to over 45 million MAU, processing 780 million queries per month. The company raised $1.72 billion total and hit a $22.6 billion valuation. This isn&apos;t a niche tool anymore. It&apos;s a serious contender against Google for informational search.

The competitive landscape has shifted too. ChatGPT, Claude, Gemini, and DeepSeek all have search capabilities now. Perplexity needs to earn its place, and this review covers where it still does and where it doesn&apos;t.

&lt;Notice type=&quot;info&quot; title=&quot;What&apos;s changed since 2024&quot;&gt;
The original version of this article covered Perplexity&apos;s basic free and Pro tiers. Since then, the platform has added: **Deep Research** (reports in minutes from 100+ sources), a **$200/month Max tier**, the **Comet AI browser**, **Shopping/Buy with Pro**, **Spaces/Projects** for collaboration, and a full **Sonar API** for developers. Scroll to each section for details.
&lt;/Notice&gt;

## What is Perplexity? The AI answer engine changing search

Perplexity is an AI answer engine, not a chatbot and not a traditional search engine. You ask a question, it searches the web in real time, reads multiple sources, and synthesizes a cited answer. Every claim links back to its source.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/oE0JjmuaPPY&quot;
  label=&quot;Perplexity Video&quot;
/&gt;
This is completely different from how Google works (a list of 10 blue links you have to click through) and how ChatGPT works (a conversational response that may or may not have web grounding). Perplexity gives you the answer with inline citations so you can verify every claim.

The pipeline: **Your question, Perplexity searches the web, reads N sources, synthesizes a cited answer**. Simple concept, but execution quality is what keeps people paying.

The name comes from a language model metric: &quot;perplexity&quot; measures how well a model predicts text. Lower perplexity means better predictions. A fitting name for a product that makes AI predictions more grounded in reality.

&lt;Notice type=&quot;warning&quot; title=&quot;Publisher and legal context&quot;&gt;
Perplexity crawls websites to build its answers. In October 2024, News Corp (Dow Jones, NY Post) sued Perplexity for &quot;massive illegal copying&quot; of copyrighted content. Perplexity lost its bid to dismiss the case in August 2025. This is an ongoing legal risk worth knowing about. If you run a website and want to control which AI systems can access your content, see our guide on how to [block AI crawlers from scraping your site](/block-ai-crawlers).
&lt;/Notice&gt;

## Perplexity free vs Pro: which plan is right for you?

The free tier is genuinely useful. You can evaluate Perplexity without paying a cent and decide if it fits your workflow.

### Perplexity free tier: what you get

Free users in 2026 get:

- **Unlimited basic (Sonar) searches**: the default fast model handles straightforward questions
- **3 Pro Searches per day**: multi-step reasoning with tool use (searching, reading, synthesizing)
- **Limited Deep Research access**: enough to try it, not enough for heavy use
- **Basic focus modes**: narrow search to web, academic sources, or specific sites

Three Pro Searches per day is enough to evaluate whether the feature justifies paying. If you find yourself hitting that ceiling, that&apos;s your signal to upgrade.

### Perplexity Pro features: is the upgrade worth it?

Pro at $20/month unlocks:

- **Unlimited Pro Search** (with weekly fair-use limits, more on this below)
- **All AI models**: GPT-5.2, Claude Sonnet 4.6, Gemini 3.1 Pro, Nemotron 3 Super 120B, plus the full Sonar family
- **Image generation** with multiple engines
- **File uploads**: analyze PDFs, images, documents
- **$5/month API credits** (reported by users (see the API section for details))
- **Priority access during high traffic**

The cost argument is straightforward: Pro at $20/month gives you access to GPT-5.2 AND Claude Sonnet AND Gemini AND search-grounded answers. Subscribing to ChatGPT Plus ($20) and Claude Pro ($20) separately costs double and you still don&apos;t get Perplexity&apos;s citation-grounded search.

If you&apos;re a verified student or educator, Education Pro is $10/month with the same features via SheerID verification.

&lt;Button
  link=&quot;https://go.bitdoze.com/perplexity&quot;
  text=&quot;Get $10 OFF Perplexity Pro&quot;
/&gt;

## Perplexity Pro Search: multi-step reasoning with citations

The old &quot;Copilot&quot; branding is gone. What used to be called Copilot mode is now **Pro Search**: and it&apos;s better.

Pro Search does multi-step reasoning: it breaks down complex questions, searches multiple times, reads different sources, asks clarifying follow-up questions when needed, and produces a comprehensive cited answer. It&apos;s the feature that separates Perplexity from &quot;just another AI chatbot with a search button.&quot;

**How limits work in practice:**

| Tier | Pro Search limit |
|---|---|
| Free | 3 per day |
| Pro | Weekly limits (&quot;average use&quot;, exact number not published) |
| Max | Unlimited |

When you hit the Pro Search limit, the answer still comes back, but it uses basic Sonar search instead of multi-step reasoning. Not a hard block, which is better than hitting a wall.

My practical advice: save Pro Search for complex research questions. Basic Sonar search handles simple factual queries fine.

## AI models available on Perplexity (2026)

Perplexity runs a dual-layer model system. Understanding this is key to getting the most from the platform.

### Sonar family: Perplexity&apos;s proprietary models

The Sonar models are Perplexity&apos;s own, built on Llama and optimized for speed and citation grounding:

- **Sonar**: the default for most queries. Fast, grounded, good enough for 80% of searches
- **Sonar Pro**: more capable, handles complex queries
- **Sonar Reasoning Pro**: multi-step reasoning for harder problems
- **Sonar Deep Research**: dedicated model for the Deep Research feature (see below)

Sonar is what you&apos;ll use most. It&apos;s fast, the citations are reliable, and for factual questions it&apos;s hard to beat.

### Third-party models: GPT-5.2, Claude, Gemini and more

Pro and Max users can switch between third-party models:

| Model | Tier | Best for |
|---|---|---|
| GPT-5.2 (OpenAI) | Pro | General reasoning, coding |
| Claude Sonnet 4.6 (Anthropic) | Pro | Long-form writing, analysis |
| Claude Opus 4.6 (Anthropic) | Max | Complex reasoning, agentic tasks |
| Gemini 3.1 Pro (Google) | Pro | Multimodal, long context |
| Nemotron 3 Super 120B (NVIDIA) | Pro | Concise, fast responses |

**Which model for which task:** Use Sonar for factual questions with citations. Switch to GPT-5.2 or Claude Sonnet when you need deeper reasoning or longer-form writing. Gemini 3.1 Pro shines on multimodal queries (image analysis). Claude Opus on Max is for the hardest problems.

The Sonar family is based on Llama. If you&apos;re interested in [open-source alternatives to Claude and GPT](/best-open-source-llms-claude-alternative), the Sonar models are a good example of what fine-tuned Llama can do at scale.

**Writing Mode** still exists. It lets you chat directly with your chosen model without web search. Be aware: Writing Mode disables search entirely. Answers come from training data only, with no citations. It&apos;s useful for creative writing or brainstorming, but don&apos;t expect sourced answers.

## Perplexity Deep Research: reports in minutes

The biggest addition since this article was first published. Launched in February 2025, Deep Research performs dozens of searches, reads hundreds of sources, and produces a comprehensive report in 2-4 minutes.

**Benchmark performance:** 21.1% on Humanity&apos;s Last Exam, 93.9% on SimpleQA. Those scores matter because they show the model can handle both hard reasoning and factual accuracy.

**Access by tier:**
- Free: limited access (enough to try it)
- Pro: generous allocation for regular use
- Max: unlimited

**What it&apos;s good for:**
- Technology comparisons and stack decisions
- Market analysis for a new project
- Learning a new domain quickly
- Troubleshooting complex technical problems where you&apos;d normally have 20 browser tabs open

**What it&apos;s not good for:** vague prompts. &quot;Tell me about AI&quot; produces a generic report. &quot;Compare Docker vs Podman for self-hosting a PostgreSQL cluster on ARM with 4GB RAM&quot; produces something genuinely useful. Be specific.

&lt;Notice type=&quot;success&quot; title=&quot;Deep Research tip&quot;&gt;
Use Deep Research for technology evaluations and stack decisions. It reads 50+ sources and produces structured reports with citations. If you want to build your own multi-agent research pipeline instead, check out our guide on building a [multi-agent research team with Google ADK](/google-adk-multi-agent-search).
&lt;/Notice&gt;

## Image generation on Perplexity

Image generation is a Pro feature. The current engines (as of mid-2026):

- **GPT Image 1** (OpenAI): strong general-purpose generation
- **Seedream 4.5** (ByteDance): good for stylized and artistic images
- **Nano Banana** (Google): fast, lightweight generation

The image generation is convenient when you&apos;re already in a Perplexity conversation and need a quick illustration. For fine control, custom models, or batch generation, dedicated tools are still better. If you want to [generate AI images locally on Mac](/ai-images-mac) with more control, Flux and ComfyUI give you that.

## Perplexity Max: the $200/month power tier

Launched in July 2025, Max is Perplexity&apos;s premium tier at $200/month. It includes:

- **Unlimited Labs usage**: early experimental features
- **Early access to new products** (Comet browser launched to Max first)
- **Claude Opus 4.6 + o3-pro**: models not available on Pro
- **Brain memory system**: Perplexity remembers context across conversations
- **Priority support**

&lt;Notice type=&quot;info&quot; title=&quot;Who needs Max?&quot;&gt;
Max is worth it if you need Claude Opus for complex reasoning, unlimited Deep Research, or you spend 8+ hours/day in Perplexity. For 90% of individual users, Pro at $20/month covers everything. If you&apos;re a student or educator, Education Pro at $10/month is the better value.
&lt;/Notice&gt;

## Comet browser and Perplexity Computer: AI agents beyond search

Perplexity is expanding beyond search with two major products.

### Comet browser

Perplexity&apos;s own AI-first browser, built on Chromium. Comet Assistant can see web pages you&apos;re browsing, summarize emails, manage tabs, navigate websites, and execute multi-step workflows. It&apos;s an AI agent that lives in your browser.

Comet launched in July 2025 to Max subscribers first, then a waitlist. It&apos;s ambitious. Perplexity even made an unsolicited $34.5 billion bid to buy Google Chrome, capitalizing on the DOJ antitrust ruling. That bid probably won&apos;t succeed, but it shows where Perplexity sees itself heading.

### Perplexity Computer

An agentic AI system that coordinates multiple frontier models to complete multi-step desktop tasks: booking travel, filling forms, running research workflows. This is Perplexity&apos;s move into AI agents beyond search.

**Honest take:** Comet is more immediately useful. It&apos;s a real browser you can use today. Perplexity Computer is more ambitious but less proven. Both are evolving fast.

If you&apos;d rather build custom AI agents yourself, you can [build your own AI agent with Mastra](/build-ai-agent-mastra). For raw web data access, [AI-powered web scraping with BrightData](/brightdata-mcp-guide) ([Bright Data](https://go.bitdoze.com/brightdata)) gives you structured data at scale.

## Perplexity Chrome extension and security warning

The current official extension is **&quot;Perplexity - AI Search&quot;** on the Chrome Web Store.

What it does:
- Highlight text on any webpage and ask Perplexity about it
- Quick search from any tab
- Get definitions and explanations inline while browsing

&lt;Notice type=&quot;error&quot; title=&quot;Security warning&quot;&gt;
In June 2026, Microsoft discovered a **fake malicious Perplexity Chrome extension** that intercepted users&apos; searches and exfiltrated data. Only install the verified extension from the official Chrome Web Store. Double-check the publisher name and install count before adding any browser extension that handles your search queries.
&lt;/Notice&gt;

If you run a website, note that Perplexity crawls sites to build its answers. You may want to [block AI crawlers from scraping your site](/block-ai-crawlers) if you prefer to control access.

## Perplexity API: Sonar, Agent and Search APIs

The API has evolved far beyond the &quot;$5 monthly credits&quot; model from 2024. Perplexity now offers four distinct APIs:

**Sonar API**: search-grounded LLM responses. This is the API most developers will use.

| Model | Input (per 1M tokens) | Output (per 1M tokens) |
|---|---|---|
| Sonar | $1 | $1 |
| Sonar Pro | $3 | $15 |
| Sonar Deep Research | $2 | $8 |

**Agent API**: access to third-party models (OpenAI, Anthropic, Google, xAI) at provider rates with no markup.

**Search API**: raw web search results at $5 per 1,000 requests.

**Embeddings API**: for semantic search/RAG, $0.004-$0.05 per 1M tokens.

Official Python and TypeScript SDKs are available. There&apos;s also an MCP Server for Claude Desktop, Cursor, and VS Code.

&lt;Notice type=&quot;info&quot; title=&quot;API for self-hosters&quot;&gt;
If you&apos;re running your own AI infrastructure, the Sonar API and Search API can replace building your own web scraping pipeline. You get search-grounded responses via a clean API without managing crawling infrastructure. [TinyFish](https://go.bitdoze.com/tinyfish) is another option if you need web search, fetch, and browser APIs for AI agents.
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;curl&quot;&gt;
```bash
curl --request POST \
  --url https://api.perplexity.ai/chat/completions \
  --header &apos;accept: application/json&apos; \
  --header &apos;authorization: Bearer $PERPLEXITY_API_KEY&apos; \
  --header &apos;content-type: application/json&apos; \
  --data &apos;{
    &quot;model&quot;: &quot;sonar-pro&quot;,
    &quot;messages&quot;: [{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What are the latest developments in AI search?&quot;}],
    &quot;stream&quot;: false,
    &quot;web_search_options&quot;: {&quot;search_context_size&quot;: &quot;medium&quot;}
  }&apos;
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Python&quot;&gt;
```bash
pip install perplexityai
```

```python
from perplexity_ai import Perplexity

client = Perplexity(api_key=&quot;your-api-key&quot;)
response = client.chat.completions.create(
    model=&quot;sonar-pro&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;How do I set up Docker on Ubuntu 24.04?&quot;}],
)
print(response.choices[0].message.content)
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Pro subscribers reportedly get $5/month in API credits, though the official docs now describe a pay-as-you-go model. If you&apos;re new to APIs, our [getting started with AI programming](/ai-programming-beginners-guide) guide covers the basics.

## Perplexity Spaces, Projects and Shopping

Two features that didn&apos;t exist when this article was first published.

### Spaces and Projects

Customizable AI-powered collaboration hubs. Upload files, set custom AI instructions, invite collaborators, and search across your internal files plus the web. Enterprise users get app connectors for Google Drive, SharePoint, and similar services.

**File limits:** Pro gets 50 files per space, Max gets 500, Enterprise Max gets 5,000.

This is useful for teams doing research together. Everyone shares the same Perplexity context with uploaded docs and custom instructions.

### Shopping and Buy with Pro

Perplexity can search for products, compare them with AI recommendations, and complete purchases via &quot;Buy with Pro&quot; one-click checkout. Includes &quot;Snap to Shop&quot; visual search and Shopify integration. Free shipping on Buy with Pro orders.

**Caveat:** Buy with Pro is US-only. If you&apos;re outside the US, the product search and comparison features still work, but you can&apos;t check out through Perplexity.

## Perplexity pricing and $10 promo code

Here&apos;s the full pricing as of mid-2026:

| Plan | Price | Key features |
|---|---|---|
| Free | $0 | Unlimited basic search, 3 Pro Searches/day, limited Deep Research |
| Pro | $20/month | Unlimited Pro Search (weekly limits), all models, image gen, file uploads |
| Education Pro | $10/month | Pro features for verified students/educators (SheerID) |
| Max | $200/month | Everything in Pro + Claude Opus, unlimited Labs, Comet, Brain, priority support |
| Enterprise Pro | $40/seat/month | Team features, admin controls, app connectors |
| Enterprise Max | Custom | Everything + 5,000 files/space, SSO, dedicated support |

**The $10 promo:** You can get your first month of Pro for $10 instead of $20 using the referral link below. You get $10 off, I get $10 off.

&lt;Button
  link=&quot;https://go.bitdoze.com/perplexity&quot;
  text=&quot;Get $10 OFF Perplexity Pro&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
/&gt;

## Perplexity vs ChatGPT, Gemini and Claude: why it still matters

Every major AI tool now has search. ChatGPT searches the web. Claude has web search. Gemini is built on Google&apos;s index. DeepSeek has search. So why pay for Perplexity?

**Where Perplexity wins:**

1. **Citation transparency**: every claim is sourced with inline citations. ChatGPT and Gemini are improving, but Perplexity&apos;s citation density and source quality remain the best I&apos;ve used.
2. **Multi-model access**: one $20/month subscription gives you GPT-5.2 + Claude + Gemini + Sonar. Subscribing to each separately costs $60+/month.
3. **Research-first design**: Pro Search and Deep Research are purpose-built for research workflows, not bolted-on features.
4. **API value**: the Sonar API and Agent API at provider rates are unique in the market.

**Where Perplexity loses:**

&lt;Accordion label=&quot;When to use ChatGPT instead&quot; group=&quot;comparison&quot;&gt;
ChatGPT is better for general creative tasks, coding assistance with canvas, and when you need strong general reasoning without citations. Its plugin ecosystem is also more mature.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;When to use Claude instead&quot; group=&quot;comparison&quot;&gt;
Claude is better for long documents, nuanced writing, deep analysis of uploaded files, and when you need careful, methodical reasoning. If you&apos;re also exploring [AI coding tools and assistants](/ai-coading-tools), Claude&apos;s coding capabilities are excellent.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;When to use Gemini instead&quot; group=&quot;comparison&quot;&gt;
Gemini is better for multimodal tasks (image and video analysis), Google Workspace integration, and very long context windows (up to 2M tokens). If you live in Google&apos;s ecosystem, Gemini is the natural fit.
&lt;/Accordion&gt;

For coding-specific work, dedicated tools often beat general-purpose AI. See our guide to [AI coding tools and assistants](/ai-coading-tools) for a deeper comparison.

## Operator&apos;s notes: what I&apos;ve learned using Perplexity daily

Practical observations from over two years of daily use:

**What&apos;s great:**
- Citation quality is consistently high. I can verify claims quickly.
- Deep Research saves hours of manual tab management for tech evaluations
- Switching models mid-conversation is genuinely useful (start with Sonar, switch to Claude for writing)
- The Chrome extension is handy for quick context while reading articles

**What&apos;s annoying:**
- Pro Search limits are vague. &quot;Weekly limits for average use&quot; means you don&apos;t know where the ceiling is until you hit it
- Image generation quality varies wildly between engines
- Writing Mode confuses new users who expect citations but get training-data-only responses

**What breaks:**
- Occasional hallucinated citations. The source exists but doesn&apos;t actually say what Perplexity claims it does. Always click through and verify critical claims
- Shopping only works in the US
- Comet browser is still early and has bugs

&lt;ListCheck&gt;
**My daily Perplexity workflow:**
&lt;li&gt;Use basic Sonar search for quick factual questions&lt;/li&gt;
&lt;li&gt;Switch to Pro Search for multi-source research&lt;/li&gt;
&lt;li&gt;Use Deep Research for technology comparisons and stack decisions&lt;/li&gt;
&lt;li&gt;Switch to Claude Sonnet when I need long-form writing help&lt;/li&gt;
&lt;li&gt;Save Pro Search queries for complex problems. Don&apos;t waste them on simple lookups&lt;/li&gt;
&lt;li&gt;Always click through citations on anything I&apos;m going to act on or publish&lt;/li&gt;
&lt;/ListCheck&gt;

**Rate limit tip:** when you hit Pro Search limits, basic Sonar search still works fine for straightforward queries. Save Pro Search for the complex stuff.

**Cost tip:** if you&apos;re paying for both ChatGPT Plus ($20) and Claude Pro ($20), switching to Perplexity Pro alone saves $20/month and you still get access to both models through Perplexity&apos;s interface.

## Conclusion: is Perplexity worth it in 2026?

Yes, especially Pro at $20/month.

The core value proposition has only gotten stronger: citation-grounded AI search with multi-model access in one subscription. Deep Research alone justifies the subscription for anyone who does regular research. The fact that you get GPT-5.2, Claude, Gemini, and Sonar for $20/month (when subscribing to each separately costs $60+) makes it the best value in AI subscriptions right now.

The competitive landscape has changed. ChatGPT, Claude, and Gemini all have search now. But Perplexity&apos;s citation density and research-first design still set it apart for anyone who needs sourced, verifiable answers rather than conversational responses.

Max at $200/month is only for power users who need Claude Opus or unlimited Deep Research. For most people, Pro is the sweet spot.

The News Corp copyright lawsuit is worth watching. It could reshape how AI search engines interact with publishers. But it hasn&apos;t slowed Perplexity&apos;s growth so far.

If you&apos;re also exploring AI for development, check out our guide to [AI coding tools and assistants](/ai-coading-tools).

&lt;Notice type=&quot;info&quot; title=&quot;Quick verdict&quot;&gt;
Pro at $20/month is the sweet spot. You get GPT-5.2, Claude, Gemini, Deep Research, and citation-grounded search in one subscription. If you&apos;re paying for multiple AI subscriptions, consolidating to Perplexity Pro alone saves money.
&lt;/Notice&gt;

&lt;Button
  link=&quot;https://go.bitdoze.com/perplexity&quot;
  text=&quot;Try Perplexity Pro: $10 Off Your First Month&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
/&gt;</content:encoded><category>ai</category><category>perplexity</category><category>ai-search</category><category>answer-engine</category></item><item><title>How to Add a Back to Top Button on Carrd (2026 Guide)</title><link>https://www.bitdoze.com/carrd-back-to-top-button/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-back-to-top-button/</guid><description>Learn how to add a back to top button on Carrd with our step-by-step guide. Includes free copy-paste code, accessibility fixes, and Pro plan requirements.</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

[Carrd.co](https://go.bitdoze.com/carrd) is a popular option to [build a one-page website on a budget](/build-one-page-website-budget/), but long single-page sites have a usability gap: visitors end up scrolling a lot to get back to the top. A back to top button fixes that with a single Carrd Embed element and some copy-paste code.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

Some Carrd tutorials:

- [Add Sticky Header Carrd](/add-stickey-header-carrd/)
- [Add Carrd Cookie Notice](/add-cookie-notice-carrd/)
- [How To Add Pricing Table to Carrd.co](/carrd-add-pricing-table/)
- [Carrd.co Review](/carrd-review/)
- [How To Add Accordion FAQs Drop-Down to Carrd.co](/add-accordion-carrd/)
- [Carrd Mobile Responsive Navbar](/carrd-mobile-navbar/)

&gt; The complete list of Carrd plugins, themes, and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Prerequisites: what you need before you start

&lt;Notice type=&quot;warning&quot; title=&quot;Requires Carrd Pro Standard&quot;&gt;
This tutorial uses the Embed element, which requires [Carrd Pro Standard](https://go.bitdoze.com/carrd) ($19/yr) or Pro Plus ($49/yr). Free and Pro Lite plans do not support custom code embeds. See our [Carrd.co review](/carrd-review/) for a full plan comparison.
&lt;/Notice&gt;

Before you start, make sure you have:

- **Carrd Pro Standard ($19/yr) or Pro Plus ($49/yr).** The Embed element is not available on Free or Pro Lite plans. This is the most common reason the button &quot;doesn&apos;t work&quot; for people following tutorials online.
- **A Carrd site long enough to need it.** Back to top buttons work best for pages longer than about 4 screens (per NNGroup guidelines). For short Carrd pages, the button adds visual noise for no real benefit.

The embed won&apos;t preview in the Carrd builder. You have to publish the site to see it live.

Pair this button with a [sticky header](/add-stickey-header-carrd/) for easy navigation on long Carrd pages. Once you&apos;re done, you can also [add a custom domain to Carrd](/carrd-add-domain/) to give your site a professional look.

## How to add a back to top button on Carrd (step-by-step)

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/xnzKERC5SvY&quot;
  label=&quot;How to Add Back To Top Button on Carrd Website&quot;
/&gt;

### 1. Add an Embed element to your Carrd site

Open your Carrd site in the editor. Click the `+` button to add a new element and select **Embed**. Place it anywhere on the page. Its position in the element list doesn&apos;t affect where the button appears (that&apos;s controlled by CSS `position: fixed`).

Set these options:

- **Type:** Code
- **Style:** Hidden, Head

&lt;Picture src={imag1} alt=&quot;Carrd embed element settings showing Type: Code and Style: Hidden, Head for back to top button&quot; /&gt;

### 2. Copy and paste the back to top button code

Paste this complete code into the Embed element&apos;s code field:

```html
&lt;style&gt;
  @media (prefers-reduced-motion: no-preference) {
    html {
      scroll-behavior: smooth;
    }
  }

  :root {
    --button-color: #555;
    --button-hover-color: #333;
    --arrow-color: white;
  }

  #scroll-to-top {
    display: none;
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 99;
    width: 50px;
    height: 50px;
    background-color: var(--button-color);
    color: var(--arrow-color);
    border: none;
    outline: none;
    cursor: pointer;
    border-radius: 50%;
  }

  #scroll-to-top::before {
    content: &quot;\25b2&quot;;
    font-size: 24px;
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
  }

  #scroll-to-top:hover {
    background-color: var(--button-hover-color);
  }

  #scroll-to-top:focus-visible {
    outline: 2px solid var(--button-hover-color);
    outline-offset: 2px;
  }
&lt;/style&gt;

&lt;button id=&quot;scroll-to-top&quot; aria-label=&quot;Back to Top&quot;&gt;&lt;/button&gt;

&lt;script&gt;
  const scrollToTopBtn = document.getElementById(&quot;scroll-to-top&quot;);

  function toggleButton() {
    if (document.body.scrollTop &gt; 400 || document.documentElement.scrollTop &gt; 400) {
      scrollToTopBtn.style.display = &quot;block&quot;;
    } else {
      scrollToTopBtn.style.display = &quot;none&quot;;
    }
  }

  function scrollToTop() {
    window.scrollTo({ top: 0, behavior: &quot;smooth&quot; });
    document.body.focus();
  }

  scrollToTopBtn.addEventListener(&quot;click&quot;, scrollToTop);

  let ticking = false;
  window.addEventListener(&quot;scroll&quot;, () =&gt; {
    if (!ticking) {
      window.requestAnimationFrame(() =&gt; {
        toggleButton();
        ticking = false;
      });
      ticking = true;
    }
  });

  toggleButton();
&lt;/script&gt;
```

&lt;Notice type=&quot;info&quot; title=&quot;Embed limits&quot;&gt;
Carrd embeds have a 16,384 character limit. This code is roughly 1,200 characters, well within limits.
&lt;/Notice&gt;

**What changed from the original version:**

- `prefers-reduced-motion` CSS guard prevents smooth scrolling for users with motion sensitivity
- `aria-label=&quot;Back to Top&quot;` lets screen readers identify the button
- `:focus-visible` outline shows keyboard users where focus lands
- Replaced `window.onscroll` with throttled `addEventListener` using `requestAnimationFrame`. Fires once per frame instead of 60+ times per second.
- Replaced `document.documentElement.scrollTop = 0` with `window.scrollTo({ top: 0, behavior: &apos;smooth&apos; })`. Modern, cross-browser.
- `document.body.focus()` after scroll moves focus to the top for keyboard users.
- Scroll threshold increased from 200px to 400px. 200px triggers almost immediately on most screens.
- Removed inline `onclick`. Using `addEventListener` for cleaner separation.
- Initial `toggleButton()` call on load handles pages that load mid-scroll.
- Removed unnecessary `tabindex=&quot;0&quot;`. The `&lt;button&gt;` element is natively focusable.

## Customizing your Carrd back to top button

### Change button colors with CSS variables

The code uses three CSS custom properties in the `:root` block. Change these to match your site&apos;s design:

| Variable | What it controls | Default |
|----------|-----------------|---------|
| `--button-color` | Button background color | `#555` (dark gray) |
| `--button-hover-color` | Background on hover | `#333` (darker gray) |
| `--arrow-color` | Arrow icon color | `white` |

For example, to make a blue button: set `--button-color: #2563eb` and `--button-hover-color: #1d4ed8`.

Like this button, a [dark mode toggle](/carrd-dark-mode-toggle/) also uses the Carrd Embed element and CSS variables for customization.

### Adjust button size and position

The button is 50x50px by default, which exceeds the WCAG 2.5.5 (AAA) minimum touch target of 44x44px. To change it:

- **Width &amp; height:** Edit the `width` and `height` properties in `#scroll-to-top`
- **Arrow size:** Edit the `font-size` in `#scroll-to-top::before`
- **Position:** Edit the `bottom` and `right` properties in `#scroll-to-top` to control distance from the screen edges
- **z-index:** Default is `99`. If the button hides behind other Carrd elements (fixed headers, modals), try `9999`

### Add a text label to the button

&lt;Notice type=&quot;info&quot; title=&quot;UX tip&quot;&gt;
NNGroup recommends pairing the arrow icon with a text label like &quot;Back to Top&quot; for better usability. This matters on mobile, where the arrow alone can be ambiguous.
&lt;/Notice&gt;

To add a text label, replace the `::before` pseudo-element approach with inline text. Change the button HTML to:

```html
&lt;button id=&quot;scroll-to-top&quot; aria-label=&quot;Back to Top&quot;&gt;&amp;#9650; Top&lt;/button&gt;
```

And remove or comment out the `#scroll-to-top::before` CSS rule. Adjust the button `width` to `auto` and add some `padding` to fit the text.

## Accessibility &amp; WCAG compliance for your scroll to top button

The updated code above includes several accessibility fixes. Here&apos;s why they matter.

### Keyboard navigation and screen reader support

The button uses a native `&lt;button&gt;` element, which is focusable by default. The `aria-label=&quot;Back to Top&quot;` attribute gives screen readers a description to announce instead of reading an empty button.

After the page scrolls to the top, `document.body.focus()` moves keyboard focus to the top of the page. Without this, keyboard users stay stuck mid-page: they scroll visually to the top but their Tab key is still in the middle of the document.

The `:focus-visible` outline shows keyboard users exactly where focus is, without showing a ring on mouse clicks.

### Respecting reduced motion preferences

The `@media (prefers-reduced-motion: no-preference)` wrapper ensures that `scroll-behavior: smooth` only applies when the user hasn&apos;t requested reduced motion in their OS settings. For users with motion sensitivity, the page jumps instantly to the top instead of animating. This can prevent nausea and vertigo.

&lt;Notice type=&quot;warning&quot; title=&quot;Verify on your site&quot;&gt;
Carrd may inject its own scroll-related CSS. Test that the prefers-reduced-motion wrapper doesn&apos;t conflict with Carrd&apos;s smooth-scroll settings by enabling &quot;Reduce motion&quot; in your OS accessibility settings and checking that the button still works.
&lt;/Notice&gt;

## Verify: testing your back to top button

After publishing your Carrd site, run through this checklist:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Open the live site in an incognito/private window (avoids builder cookies)&lt;/li&gt;
&lt;li&gt;Scroll past 400px. The button appears at bottom-right.&lt;/li&gt;
&lt;li&gt;Click the button. Smooth scroll to top.&lt;/li&gt;
&lt;li&gt;Test on mobile (Chrome DevTools device toolbar or real device). Confirm tappable, doesn&apos;t overlap elements.&lt;/li&gt;
&lt;li&gt;Tab through the page with keyboard. Button should be reachable and activatable with Enter/Space.&lt;/li&gt;
&lt;li&gt;Check prefers-reduced-motion. Enable &quot;Reduce motion&quot; in OS settings, confirm instant jump instead of smooth scroll.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Make sure your button doesn&apos;t conflict with your [mobile responsive navbar](/carrd-mobile-navbar/).

## Troubleshooting: common back to top button issues on Carrd

&lt;Accordion label=&quot;Button doesn&apos;t appear after publishing&quot; group=&quot;troubleshooting&quot;&gt;
**Likely causes:**
- Embed Style is not set to &quot;Hidden&quot; + &quot;Head&quot;. Double-check both settings.
- Site is not published. The embed won&apos;t preview in the builder; you must publish.
- z-index conflict. Try increasing `z-index` to `9999` if Carrd elements (nav, modals) are covering the button.
- Carrd&apos;s responsive settings may hide the embed element in mobile view. Check for `display: none` in the mobile layout.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Button appears but doesn&apos;t scroll&quot; group=&quot;troubleshooting&quot;&gt;
**Likely causes:**
- JavaScript is blocked by a browser extension (ad blocker, NoScript)
- Safari below version 15.4 has partial support for `scroll-behavior: smooth`. The `window.scrollTo` call should still work, but test on your target browsers.
- Another embed or script on the page is throwing errors that break the JavaScript on the page
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Button overlaps footer content&quot; group=&quot;troubleshooting&quot;&gt;
**Fix:** Adjust the `bottom` CSS property in `#scroll-to-top` to increase the distance from the screen bottom, or add `margin-bottom` to your footer element.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Code won&apos;t save / embed limit exceeded&quot; group=&quot;troubleshooting&quot;&gt;
**Fix:** Carrd embeds have a 16,384 character limit. This code is roughly 1,200 characters, so it should fit fine. If you&apos;ve added a lot of other code to the same embed, try splitting CSS and JS into separate Embed elements: CSS in one (Hidden, Head) and JS in another (Hidden, Body End).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Button works on desktop but not mobile&quot; group=&quot;troubleshooting&quot;&gt;
**Likely causes:**
- Carrd&apos;s responsive settings may hide the embed element in mobile view. Check if the Embed element has `display: none` set in the mobile layout.
- The button might overlap with your [mobile responsive navbar](/carrd-mobile-navbar/) or other fixed elements. Adjust the `bottom` or `right` values.
- Test on a real device, not just DevTools. Some touch event behaviors differ.
&lt;/Accordion&gt;

## Alternative methods for adding a back to top button on Carrd

### Using IntersectionObserver instead of scroll events

Instead of listening to every scroll event, you can place a sentinel `&lt;div&gt;` at the top of the page and use `IntersectionObserver` to detect when it leaves the viewport. The button appears when the sentinel is no longer visible.

This approach is more performant (no scroll event handler), automatically adapts to screen size, and works with the browser&apos;s compositor thread. Browser support is 97%+ globally.

&lt;Notice type=&quot;info&quot; title=&quot;Pro tip&quot;&gt;
IntersectionObserver avoids firing on every scroll pixel. Use this if your Carrd site already has heavy embeds (analytics, chat widgets) that could cause jank.
&lt;/Notice&gt;

### Common Ninja back to top widget (no-code alternative)

Common Ninja offers a free Back to Top Button widget that embeds via a single script tag. It handles accessibility, animations, and customization through a visual editor. Works for non-technical users who prefer not to edit code.

### Carrd native Scroll Points (zero code)

Carrd has a built-in Scroll Point Control element that creates linkable page anchors. Place a Scroll Point named `top` at the very top of your page, then add a footer link to `#top`. No auto-show/hide behavior, but zero custom code required.

### Third-party Carrd plugins

The Carrd plugin ecosystem is growing. Sites like [carrdme.com](https://carrdme.com/) offer plugins for tabs, accordions, and other components that extend Carrd beyond its built-in features.

## Conclusion

Adding a back to top button to your Carrd site takes one Embed element and a few minutes. The code in this guide includes accessibility fixes (keyboard support, screen reader labels, reduced motion respect) that many online tutorials skip.

For a complete navigation experience, also add [smooth scroll and anchor links](/carrd-smooth-scroll/) to your Carrd site. You might also want a [floating menu](/carrd-floating-menu/) for additional navigation options.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category><category>accessibility</category><category>css</category></item><item><title>How to Use Groq&apos;s FREE API in Your Streamlit App</title><link>https://www.bitdoze.com/groq-api-mistral-streamlit/</link><guid isPermaLink="true">https://www.bitdoze.com/groq-api-mistral-streamlit/</guid><description>Learn how to integrate Groq&apos;s free API into your Streamlit app with Llama 3.1. Step-by-step code, error handling, rate limits, and deployment tips. Updated for 2026.</description><pubDate>Sat, 25 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/02/groq-mistral-streamlit.png&quot;;

Groq&apos;s LPU inference engine delivers some of the fastest text generation you can get from a cloud API, and the free tier gives you real access with no credit card. If you want to build an AI-powered Streamlit app without paying for API calls, this is the fastest path I know.

The original version of this article (March 2024) used Mistral models on Groq. That no longer works. Groq deprecated all Mistral models by July 2025. This updated guide uses **Llama 3.1 8B Instant** instead: same free tier, same Groq speed, and a model that&apos;s still actively supported. The core integration pattern hasn&apos;t changed, so most of what you learn here applies regardless of which Groq model you pick.

You can have a working AI-powered Streamlit app running in under 15 minutes.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/uika9hrAdro&quot;
  label=&quot;Integrate FREE Groq API and Mistral LLM into Your Streamlit App&quot;
/&gt;

## What changed since 2024: Mistral deprecation and new models

&lt;Notice type=&quot;warning&quot; title=&quot;Returning from the 2024 version?&quot;&gt;
Replace `model=&quot;mixtral-8x7b-32768&quot;` with `model=&quot;llama-3.1-8b-instant&quot;` in your code. Everything else, the SDK install, client setup, streaming pattern, still works the same way.
&lt;/Notice&gt;

Here&apos;s the timeline:

- **Feb 2024:** Groq launches with Mixtral and Mistral models available via API.
- **March 2025:** `mixtral-8x7b-32768` is deprecated and shut down.
- **July 2025:** The last Mistral model on Groq (`mistral-saba-24b`) is deprecated. No Mistral models remain on the platform.

If you&apos;re specifically looking for Mistral, see the [Mistral alternative section](#mistral-alternative-using-mistrals-own-api) below. Mistral AI runs their own API with a free tier.

### Current free-tier models (July 2026)

| Model ID | Best for | Free RPM | Free TPM | Speed |
|---|---|---|---|---|
| `llama-3.1-8b-instant` | High-volume prototyping | 30 | 6,000 | ~560 TPS |
| `llama-3.3-70b-versatile` | Quality output, complex tasks | 30 | 12,000 | ~280 TPS |
| `openai/gpt-oss-20b` | Reasoning, coding, tool use | 30 | 8,000 | ~1,000 TPS |
| `meta-llama/llama-4-scout-17b-16e-instruct` | Multimodal, long context | 30 | 30,000 | ~750 TPS |

For this tutorial, I default to `llama-3.1-8b-instant`. It&apos;s fast, cheap (if you ever hit the paid tier), and good enough for most prototyping work. If you need higher quality output, `llama-3.3-70b-versatile` is the upgrade, at the cost of lower rate limits. Check the [best open-source LLMs](/best-open-source-llms-claude-alternative/) for a broader comparison of what&apos;s available today.

Groq also now offers a **Developer Tier** (pay-as-you-go, credit card required) with roughly 10x the free-tier limits and access to their Batch API. For prototyping and small apps, the free tier is plenty.

If you&apos;re scaling up and need to compare model costs across providers, see [cheapest AI models for agent workflows](/best-cheap-models-hermes-agent/).

## Prerequisites: what you need to get started

&lt;ListCheck&gt;
- Python 3.10 or newer (the Groq SDK supports 3.7+, but Streamlit requires 3.10+ since v1.30)
- A Groq API key: sign up at [console.groq.com](https://console.groq.com), no credit card needed
- `pip` (Python package manager)
- A terminal and a text editor
- Basic Python knowledge (functions, loops, string handling)
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot;&gt;
No credit card is required for the Groq free tier. Sign up, grab your API key, and start making calls immediately.
&lt;/Notice&gt;

If you&apos;re completely new to Python and AI development, start with [getting started with AI programming](/ai-programming-beginners-guide/) first.

## Setting up the Groq Python SDK

Install the Groq SDK and `python-dotenv` for managing your API key:

```bash
pip install groq python-dotenv
```

Create a `.env` file in your project directory:

```bash
echo &apos;GROQ_API_KEY=gsk_your_api_key_here&apos; &gt; .env
```

Replace `gsk_your_api_key_here` with the key from [console.groq.com/keys](https://console.groq.com/keys).

Now write a quick verification script to confirm everything works:

```python
import os
from dotenv import load_dotenv
from groq import Groq

load_dotenv()

client = Groq(api_key=os.environ.get(&quot;GROQ_API_KEY&quot;))

completion = client.chat.completions.create(
    model=&quot;llama-3.1-8b-instant&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Say hello in one sentence.&quot;}],
    max_tokens=50,
)

print(completion.choices[0].message.content)
```

Run it:

```bash
python verify.py
```

&lt;Notice type=&quot;success&quot; title=&quot;Verify your setup&quot;&gt;
You should see a short greeting printed to the terminal, like: &quot;Hello! How can I assist you today?&quot; If you see output, your API key works and the SDK is installed correctly.
&lt;/Notice&gt;

&lt;Notice type=&quot;error&quot;&gt;
Common failures and fixes:
- **&quot;AuthenticationError: Invalid API Key&quot;**: Check that `GROQ_API_KEY` in your `.env` file matches the key at console.groq.com/keys. No trailing spaces.
- **&quot;ModuleNotFoundError: No module named &apos;groq&apos;&quot;**: Run `pip install groq` again. If using a virtual environment, make sure it&apos;s activated.
- **&quot;Python version not supported&quot;**: You need Python 3.10+. Run `python3 --version` to check.
&lt;/Notice&gt;

## How to use the Groq API with Python

The core pattern is straightforward: create a client, send a chat completion request, handle the streamed response.

Here&apos;s the request lifecycle:

```
Python Script → Groq SDK → Groq API (LPU) → Llama 3.1 8B → Streamed Response
```

The Groq SDK handles HTTP connection management, retries (2 automatic retries on transient errors), and timeout configuration. You don&apos;t need to manage any of that yourself for basic usage.

### Breaking down the API parameters

```python
completion = client.chat.completions.create(
    model=&quot;llama-3.1-8b-instant&quot;,
    messages=[
        {
            &quot;role&quot;: &quot;system&quot;,
            &quot;content&quot;: &quot;You are a YouTube expert who writes engaging titles.&quot;
        },
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;Install WordPress on Docker&quot;
        }
    ],
    temperature=0.5,
    max_tokens=1024,
    top_p=1,
    stream=True,
)
```

- **`model`**: The model ID. `llama-3.1-8b-instant` is the fast free-tier default.
- **`messages`**: A list of message objects. The `system` role sets behavior; the `user` role provides your prompt.
- **`temperature`** (0-2): Controls randomness. 0.5 gives focused but not rigid output. Use 0 for deterministic responses, 1+ for more creative ones.
- **`max_tokens`**: Maximum tokens in the response. 1024 is plenty for 10 YouTube titles. The old article used 5640, which was way too high.
- **`top_p`**: Alternative to temperature for nucleus sampling. 1 means no filtering. Leave it at 1 unless you have a specific reason to change it.
- **`stream`**: `True` streams chunks as they generate. This gives much better perceived latency in a UI.

&lt;Notice type=&quot;info&quot;&gt;
The Groq SDK auto-retries twice on transient errors (network timeouts, 5xx responses). You only need manual retry logic for rate limits (HTTP 429).
&lt;/Notice&gt;

### Complete Python script with error handling

```python
import os
from dotenv import load_dotenv
from groq import Groq

load_dotenv()

client = Groq(api_key=os.environ.get(&quot;GROQ_API_KEY&quot;))

try:
    completion = client.chat.completions.create(
        model=&quot;llama-3.1-8b-instant&quot;,
        messages=[
            {
                &quot;role&quot;: &quot;system&quot;,
                &quot;content&quot;: &quot;You are a YouTube expert creator who likes to write engaging titles for a keyword. You will provide 10 attention-grabbing YouTube titles on keywords specified by the user.&quot;
            },
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: &quot;Install WordPress on Docker&quot;
            }
        ],
        temperature=0.5,
        max_tokens=1024,
        top_p=1,
        stream=True,
    )

    for chunk in completion:
        content = chunk.choices[0].delta.content
        if content:
            print(content, end=&quot;&quot;)

except groq.RateLimitError:
    print(&quot;Rate limit hit. Wait a minute and try again.&quot;)
except groq.APIConnectionError:
    print(&quot;Network issue. Check your internet connection.&quot;)
except groq.APIStatusError as e:
    print(f&quot;API error {e.status_code}: {e.response}&quot;)
except Exception as e:
    print(f&quot;Unexpected error: {e}&quot;)
```

Run this and you&apos;ll see YouTube titles streamed to your terminal in real time. The speed is noticeable. Groq&apos;s LPU generates tokens significantly faster than most cloud APIs.

## Build a Streamlit app with Groq and Llama 3.1

Streamlit turns any Python function into a web UI with minimal boilerplate. If you&apos;ve tried [other Python UI frameworks](/best-python-web-frameworks/), you know Streamlit trades customization for speed of development. For a quick AI demo app, that&apos;s the right tradeoff. For a deeper comparison, see [Streamlit vs NiceGUI](/streamlit-vs-nicegui/).

Install Streamlit:

```bash
pip install streamlit
```

Create `app.py`:

```python
import os
from dotenv import load_dotenv
import streamlit as st
from groq import Groq

load_dotenv()

def get_groq_completions(user_content):
    client = Groq(api_key=os.environ.get(&quot;GROQ_API_KEY&quot;))

    completion = client.chat.completions.create(
        model=&quot;llama-3.1-8b-instant&quot;,
        messages=[
            {
                &quot;role&quot;: &quot;system&quot;,
                &quot;content&quot;: &quot;You are a YouTube expert creator who likes to write engaging titles for a keyword. You will provide 10 attention-grabbing YouTube titles on keywords specified by the user.&quot;
            },
            {
                &quot;role&quot;: &quot;user&quot;,
                &quot;content&quot;: user_content
            }
        ],
        temperature=0.5,
        max_tokens=1024,
        top_p=1,
        stream=True,
    )

    result = &quot;&quot;
    for chunk in completion:
        content = chunk.choices[0].delta.content
        if content:
            result += content
    return result

def main():
    st.title(&quot;YouTube Title Generator&quot;)
    st.write(&quot;Powered by Groq LPU + Llama 3.1 8B&quot;)

    user_content = st.text_input(&quot;Enter the keyword for YouTube titles:&quot;)

    if st.button(&quot;Generate Titles&quot;):
        if not user_content:
            st.warning(&quot;Please enter a keyword before generating titles.&quot;)
            return

        with st.spinner(&quot;Generating titles...&quot;):
            try:
                generated_titles = get_groq_completions(user_content)
                st.success(&quot;Titles generated successfully!&quot;)
                st.markdown(&quot;### Generated Titles:&quot;)
                st.text_area(&quot;&quot;, value=generated_titles, height=200)
            except Exception as e:
                st.error(f&quot;Error: {e}&quot;)

if __name__ == &quot;__main__&quot;:
    main()
```

Run it:

```bash
streamlit run app.py
```

&lt;Picture src={img1} alt=&quot;Streamlit YouTube Title Generator app running with Groq API and Llama 3.1 model&quot; /&gt;

The app opens at `http://localhost:8501`. Enter a keyword, click Generate Titles, and you&apos;ll see results appear in the text area.

A few things worth noting about the code above:

- **`st.spinner`** instead of `st.info`: shows a loading animation, which is better UX than a static message.
- **Error handling in the UI**: `st.error()` shows errors inline rather than crashing the app.
- **`load_dotenv()`**: loads the `.env` file automatically. For Streamlit Cloud deployment, use `st.secrets` instead (covered in the deployment section).

### Choosing the right Groq model for your app

The default `llama-3.1-8b-instant` works well for most prototyping. But Groq offers several models, and the right choice depends on your use case.

&lt;Tabs&gt;
&lt;Tab name=&quot;Llama 3.1 8B Instant&quot;&gt;
**Best for:** High-volume prototyping, fast iteration, simple generation tasks.

This is the speed demon. ~560 tokens per second on the free tier, 14,400 requests per day. Use this as your default unless you have a reason not to.

```python
model=&quot;llama-3.1-8b-instant&quot;,
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Llama 3.3 70B Versatile&quot;&gt;
**Best for:** Higher quality output, complex instructions, nuanced text.

The bigger model produces noticeably better output for tasks that require reasoning or nuance. The tradeoff: slower (~280 TPS), lower daily request limit (1,000 RPD), and you&apos;ll hit rate limits faster.

```python
model=&quot;llama-3.3-70b-versatile&quot;,
```
&lt;/Tab&gt;
&lt;Tab name=&quot;GPT-OSS 20B&quot;&gt;
**Best for:** Reasoning tasks, coding assistance, tool use.

OpenAI&apos;s open-weight model running on Groq hardware. Good at structured output and following complex instructions. ~1,000 TPS with 1,000 RPD on the free tier.

```python
model=&quot;openai/gpt-oss-20b&quot;,
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot;&gt;
Groq automatically caches repeated system prompts with a 50% token savings on cached portions. No setup required. It just works when your app sends the same system prompt across requests. This matters more when you graduate to the paid tier.
&lt;/Notice&gt;

## Handling rate limits and errors on the free tier

The original article had zero error handling. On the free tier, you will hit rate limits eventually, especially if you&apos;re testing rapidly or building something people actually use. Here&apos;s what you need to know.

### Free-tier limits per model

| Model | RPM | RPD | TPM | TPD |
|---|---|---|---|---|
| `llama-3.1-8b-instant` | 30 | 14,400 | 6,000 | 500,000 |
| `llama-3.3-70b-versatile` | 30 | 1,000 | 12,000 | 100,000 |
| `openai/gpt-oss-20b` | 30 | 1,000 | 8,000 | 200,000 |

**RPM** = requests per minute, **RPD** = requests per day, **TPM** = tokens per minute, **TPD** = tokens per day.

For a personal demo app, these limits are generous. For anything with real users, you&apos;ll outgrow them fast.

### Error handling pattern for Streamlit

```python
import groq

def get_groq_completions(user_content):
    client = Groq(api_key=os.environ.get(&quot;GROQ_API_KEY&quot;))

    try:
        completion = client.chat.completions.create(
            model=&quot;llama-3.1-8b-instant&quot;,
            messages=[
                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a YouTube expert...&quot;},
                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_content}
            ],
            temperature=0.5,
            max_tokens=1024,
            stream=True,
        )

        result = &quot;&quot;
        for chunk in completion:
            content = chunk.choices[0].delta.content
            if content:
                result += content
        return result

    except groq.RateLimitError:
        return &quot;⏳ Rate limit hit. Please wait about a minute and try again.&quot;
    except groq.APIConnectionError:
        return &quot;🔌 Network error. Check your internet connection.&quot;
    except groq.APIStatusError as e:
        return f&quot;⚠️ API error ({e.status_code}): Please try again later.&quot;
```

The key thing: show the user a message they can act on. A cryptic stack trace in a Streamlit app helps nobody.

&lt;Notice type=&quot;warning&quot;&gt;
The free tier is generous for prototyping and personal use, but has hard limits. If you hit them regularly, Groq&apos;s Developer Tier is pay-as-you-go with roughly 10x higher limits. No commitment required.
&lt;/Notice&gt;

### What the rate limit headers tell you

Groq&apos;s API response includes headers you can inspect programmatically:

- `x-ratelimit-remaining-requests`: Requests left in the current window
- `x-ratelimit-remaining-tokens`: Tokens left in the current window
- `x-ratelimit-reset-tokens`: Time until the token limit resets

For a simple Streamlit app, you don&apos;t need to parse these. But if you&apos;re building something more complex (queue-based processing, multi-user apps), monitoring these headers lets you implement proactive backoff instead of waiting for a 429 error.

## Deploying your Streamlit app

Once your app works locally, you&apos;ll want to deploy it. Two main paths:

&lt;Tabs&gt;
&lt;Tab name=&quot;Streamlit Community Cloud (Free)&quot;&gt;
Streamlit Community Cloud hosts public apps for free. It&apos;s the fastest path to a public URL.

**Steps:**

1. Push your code to a GitHub repository. Include:
   - `app.py`
   - `requirements.txt` (see below)
   - `.env` is NOT committed. Add your API key via Streamlit secrets instead

2. Go to [share.streamlit.io](https://share.streamlit.io) and sign in with GitHub.

3. Click &quot;New app&quot; → select your repo, branch, and `app.py` file.

4. Before deploying, click &quot;Advanced settings&quot; → paste your secrets in TOML format:

```toml
GROQ_API_KEY = &quot;gsk_your_api_key_here&quot;
```

5. In your code, replace `os.environ.get(&quot;GROQ_API_KEY&quot;)` with `st.secrets[&quot;GROQ_API_KEY&quot;]` for the Streamlit Cloud deployment (or keep both — `st.secrets` falls back to env vars).

**`requirements.txt`:**

```
groq
python-dotenv
streamlit
```

**Limitations to know about:**
- ~1 GB RAM limit
- Apps sleep after 12 hours of inactivity. First visitor sees a &quot;waking up&quot; screen
- Only 1 private app (unlimited public apps)
- No custom domains — you&apos;re stuck on `yourapp.streamlit.app`
- GitHub required for deploy

For a quick demo or portfolio piece, these limits are fine. For anything with regular traffic, deploy on a VPS instead.
&lt;/Tab&gt;
&lt;Tab name=&quot;VPS with Docker&quot;&gt;
Running on a VPS gives you full control: no sleep, no RAM limits, custom domain, and you can run multiple apps on the same server.

**Create a `Dockerfile`:**

```dockerfile
FROM python:3.12-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .

EXPOSE 8501
CMD [&quot;streamlit&quot;, &quot;run&quot;, &quot;app.py&quot;, &quot;--server.port=8501&quot;, &quot;--server.address=0.0.0.0&quot;]
```

**`docker-compose.yml`:**

```yaml
services:
  app:
    build: .
    ports:
      - &quot;8501:8501&quot;
    environment:
      - GROQ_API_KEY=${GROQ_API_KEY}
    restart: unless-stopped
```

**Deploy:**

```bash
# Set your API key
export GROQ_API_KEY=gsk_your_api_key_here

# Build and run
docker compose up -d
```

For TLS and a custom domain, put this behind a reverse proxy. I&apos;ve covered how to [deploy your Streamlit app on a VPS behind Cloudflare Tunnels](/streamlit-deploy-vps-cloudflare/) in detail. For general Docker patterns, see [how to run Python apps in Docker](/docker-run-python/).

For VPS hosting, I use [Hetzner Cloud](https://go.bitdoze.com/hetzner) — a CX22 (2 vCPU, 4 GB RAM) is more than enough for a Streamlit app and costs around €4/month. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another solid budget option with NVMe storage.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot;&gt;
Community Cloud is fine for demos and portfolio pieces. For anything with real users, the 1 GB RAM limit and 12-hour sleep timer will cause problems. A cheap VPS is worth the few euros per month.
&lt;/Notice&gt;

## Mistral alternative: using Mistral&apos;s own API

&lt;Notice type=&quot;info&quot;&gt;
Mistral models are no longer available on Groq. If you specifically need Mistral, their own API (La Plateforme) has a free &quot;Experiment&quot; tier.
&lt;/Notice&gt;

If you arrived at this article looking for Mistral specifically, Mistral AI runs their own API at [console.mistral.ai](https://console.mistral.ai). The free &quot;Experiment&quot; tier gives you access to their current models including Mistral Small and Mistral Nemo.

Quick example using the `mistralai` Python SDK:

```bash
pip install mistralai python-dotenv
```

```python
import os
from dotenv import load_dotenv
from mistralai import Mistral

load_dotenv()

client = Mistral(api_key=os.environ.get(&quot;MISTRAL_API_KEY&quot;))

completion = client.chat.complete(
    model=&quot;mistral-small-latest&quot;,
    messages=[
        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;You are a YouTube expert who writes engaging titles.&quot;},
        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;Install WordPress on Docker&quot;}
    ],
    temperature=0.5,
    max_tokens=1024,
)

print(completion.choices[0].message.content)
```

The pattern is similar to Groq. The main difference: Mistral&apos;s API runs on their own infrastructure, so you won&apos;t get Groq&apos;s LPU speed advantage. But the models themselves are competitive, especially for European data residency requirements.

## Conclusion and next steps

Groq&apos;s free API plus Streamlit gives you a working AI app in minutes with no credit card and no infrastructure to manage. The integration is straightforward: install the SDK, send chat completions, display results.

Compared to the 2024 version of this article:
- The model changed from Mixtral to Llama 3.1 8B Instant (Mixtral was deprecated)
- Error handling is now included (the original had none)
- Rate limits are documented (the original didn&apos;t mention them)
- Deployment options cover both free Community Cloud and Docker on a VPS

Where to go from here:

- **Try different models** — swap `llama-3.1-8b-instant` for `llama-3.3-70b-versatile` and compare output quality
- **Add chat history** — use `st.session_state` to maintain conversation context across interactions
- **Structured outputs** — use Groq&apos;s `response_format` with JSON schema to get clean data instead of parsing text (useful for the YouTube title generator — imagine getting back a JSON array of titles)
- **Build something more complex** — try [building an AI research squad with Streamlit](/agno-squad/) or [build a full AI agent](/build-ai-agent-mastra/)
- **Run models locally** — if privacy matters, you can [run LLMs locally with Ollama](/ollama-docker-install/) and skip the cloud API entirely

&lt;Button text=&quot;Get Your Free Groq API Key&quot; link=&quot;https://console.groq.com&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Is Groq API really free?**
Yes. The free tier requires no credit card and gives you access to multiple models including Llama 3.1 8B, Llama 3.3 70B, and GPT-OSS 20B. Each model has its own rate limits (requests per minute, tokens per day), but for personal and prototyping use, they&apos;re generous.

**Why did Mistral models disappear from Groq?**
Groq&apos;s model partnerships evolved. Mixtral was deprecated in March 2025, and the last Mistral model (`mistral-saba-24b`) was removed in July 2025. Llama and GPT-OSS models replaced them on the platform. If you need Mistral specifically, use their own API at [console.mistral.ai](https://console.mistral.ai).

**Can I use Groq for production apps?**
The free tier works for prototyping and small internal tools. For production traffic with real users, you&apos;ll hit rate limits quickly. Groq&apos;s Developer Tier is pay-as-you-go with significantly higher limits and access to the Batch API at 50% off standard pricing.

**How does Groq compare to OpenAI API for speed?**
Groq&apos;s LPU hardware consistently delivers faster inference speeds than most cloud APIs. Llama 3.1 8B on Groq runs at ~560 tokens per second. Exact comparisons depend on model size and provider, but for open-source models, Groq is among the fastest options available.

&lt;/Accordion&gt;</content:encoded><category>ai</category><category>groq</category><category>streamlit</category><category>llm</category></item><item><title>30+ Best Python Web Frameworks for 2026 (Compared)</title><link>https://www.bitdoze.com/best-python-web-frameworks/</link><guid isPermaLink="true">https://www.bitdoze.com/best-python-web-frameworks/</guid><description>Discover the 30+ best Python web frameworks for 2026. Compare Django, FastAPI, Flask, Litestar, and more to find the perfect framework for your next project.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

The Python web framework landscape has shifted significantly since 2024. For the first time, FastAPI has overtaken Django and Flask in developer adoption. Nobody saw that coming three years ago. The &quot;pure Python&quot; UI category (Reflex, NiceGUI, Flet, Gradio) has exploded from a niche experiment to a serious option for production apps. Rust-powered tooling is reshaping what&apos;s possible for Python performance.

This guide covers 30+ frameworks organized by category, with version info, GitHub stars, and honest recommendations for when to use each. Whether you&apos;re building a REST API, a data dashboard, an AI demo, or a full-stack app, there&apos;s a Python framework that fits. This article will help you pick the right one.

&lt;Notice type=&quot;info&quot; title=&quot;2026 Update&quot;&gt;
According to the [JetBrains Python Developers Survey 2024](https://lp.jetbrains.com/python-developers-survey-2024/), FastAPI reached 38% usage, surpassing Django (35%) and Flask (34%) for the first time. FastAPI grew from 21% in 2021 to 38% in 2024, a clear upward trajectory reflecting the shift toward async, type-safe API development.
&lt;/Notice&gt;

## Types of Python web frameworks

Python web frameworks fall into four main categories in 2026. The distinction between &quot;async frameworks&quot; and &quot;API frameworks&quot; has become important enough to split them: most modern API work targets ASGI directly, while pure async networking frameworks serve a different, lower-level purpose.

- **Full-stack frameworks**: Include ORM, templating, auth, admin panels: everything you need for a server-rendered web application. Django is the dominant choice here.
- **API frameworks**: Optimized for building REST and GraphQL APIs with automatic docs, type validation, and dependency injection. FastAPI, Litestar, and Django Ninja lead this category.
- **Asynchronous frameworks**: Lower-level async networking frameworks for building real-time apps, WebSocket servers, or custom protocols on top of Python&apos;s asyncio.
- **UI and data app frameworks**: &quot;Pure Python&quot; frameworks that let you build web UIs without writing JavaScript. Streamlit, Gradio, Reflex, and NiceGUI are the major players here.

Each category serves a different kind of project. Pick the category first, then pick the framework within it.

## Advantages of Using Python Web Frameworks

Python web frameworks cut development time by handling the repetitive parts of web development. Here&apos;s what you get out of the box:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Rapid development: pre-built components for routing, templating, auth, and database access mean you ship faster&lt;/li&gt;
&lt;li&gt;Built-in security: protection against CSRF, XSS, SQL injection, and other common web vulnerabilities&lt;/li&gt;
&lt;li&gt;Scalability: most frameworks handle increased traffic through async support, caching layers, and connection pooling&lt;/li&gt;
&lt;li&gt;Large community: popular frameworks have thousands of answered questions, production-tested patterns, and maintained extensions&lt;/li&gt;
&lt;li&gt;Rich ecosystem: ORMs, migration tools, admin panels, API docs generators, and testing utilities that plug in cleanly&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Best Python Web Frameworks for 2026

### Full-stack frameworks

Full-stack frameworks provide a complete toolkit for server-rendered web applications: ORM, templating, authentication, admin interface, and form handling. If you want one framework to handle everything, this is the category.

- **[Django](https://www.djangoproject.com/)** (5.2 LTS, 88,189 ★): The heavyweight of Python web development. Django 5.2 LTS (released April 2025) adds composite primary keys and automatic model imports in the shell. LTS support runs until April 2028. Django 6.0 (released December 2025) added template partials, built-in CSP headers, and background tasks. These features support the HTMX+Django pattern that&apos;s gaining traction for solo developers who want to avoid a separate JavaScript frontend. **[Django Repo](https://github.com/django/django)**

- **[Pyramid](https://trypyramid.com)** (2.1): Pyramid&apos;s philosophy is &quot;start small, finish big.&quot; It gives you routing and templating out of the box but lets you swap in your own database layer, auth system, and template engine. Good fit when Django feels like too much and Flask feels like too little. **[Pyramid Repo](https://github.com/Pylons/pyramid)**

- **[TurboGears](https://turbogears.org)** (2.5.0): Starts as a single-file app and scales to a full-stack solution. Built on top of several other components (SQLAlchemy, ToscaWidgets, Genshi/Mako). Note: verify Python 3.10+ support before committing; check the release notes for 2.5.0. **[TurboGears Repo](https://github.com/TurboGears/tg2)**

- **[Masonite](https://docs.masoniteproject.com/)** (v5): A developer-centric framework with an MVC architecture inspired by Laravel. Masonite v5 is published as `masonite-framework` on PyPI.

&lt;Notice type=&quot;warning&quot; title=&quot;Masonite repo change&quot;&gt;
The original Masonite 4.x repository (`MasoniteFramework/masonite`) is archived and no longer maintained. Masonite 5 is published under `masonite-framework` on PyPI. If you&apos;re starting a new project, make sure you&apos;re installing from the correct package.
&lt;/Notice&gt;

- **[Emmett](https://github.com/emmett-framework/emmett)**: A full-stack async framework created by the developer behind Granian (the Rust-based HTTP server). Emmett integrates tightly with Granian for deployment and supports async/await throughout its ORM and template layer. Still relatively small community-wise, but worth watching if you want a modern async alternative to Django.

### API frameworks

This is the fastest-growing category and the one with the most active development. API frameworks are optimized for building REST and GraphQL APIs with automatic OpenAPI docs, Pydantic-based validation, and dependency injection.

&lt;Notice type=&quot;info&quot; title=&quot;FastAPI leads the market&quot;&gt;
FastAPI usage hit 38% in the 2024 JetBrains survey (up from 25% in 2022), making it the #1 Python web framework by adoption. The Stack Overflow 2025 survey confirms the trend: FastAPI at 14.8% globally among all web frameworks, Flask at 14.4%. This category is where most new Python web projects start.
&lt;/Notice&gt;

- **[FastAPI](https://fastapi.tiangolo.com/)** (0.115.x, 100,813 ★): The framework that changed Python web development. Built on Starlette and Pydantic v2, FastAPI generates interactive API docs (Swagger + ReDoc) from your type hints. The `[standard]` extra installs Uvicorn and other essentials:

```bash
pip install &quot;fastapi[standard]&quot;
# Start dev server
fastapi dev main.py
# Visit http://localhost:8000/docs for auto-generated API docs
```

FastAPI&apos;s release cadence has slowed as the project matures, but it&apos;s production-stable and battle-tested. **[FastAPI Repo](https://github.com/fastapi/fastapi)**

- **[Flask](https://flask.palletsprojects.com/)** (3.1.3, 71,998 ★): The original Python microframework, still going strong. Flask 3.0 dropped Python 3.7 support; 3.1 added async improvements. It&apos;s WSGI-based (not ASGI), which matters for deployment choices. Flask&apos;s extension ecosystem is massive. There&apos;s a package for nearly everything. Best when you want full control over your stack. **[Flask Repo](https://github.com/pallets/flask)**

- **[Litestar](https://litestar.dev/)** (8,356 ★): The fastest-growing ASGI framework in 2025-2026. Litestar positions itself as &quot;FastAPI with more built-in structure.&quot; It includes DTOs (data transfer objects), built-in dependency injection, GraphQL support, WebSocket handlers, and a CLI for scaffolding:

```bash
pip install litestar
litestar new my-app
cd my-app &amp;&amp; litestar run
# Visit /schema for auto-generated OpenAPI docs
```

Litestar is not claiming to be &quot;better&quot; than FastAPI. It offers different trade-offs. If you want more opinionated defaults and built-in features, Litestar reduces boilerplate. If you prefer maximum flexibility and community size, FastAPI is the safer bet. **[Litestar Repo](https://github.com/litestar-org/litestar)**

- **[Django Ninja](https://django-ninja.dev/)** (9,148 ★): Brings FastAPI-style ergonomics into Django. Uses Pydantic v2, Python type hints, and async support, but lives inside your existing Django project with access to Django&apos;s ORM, admin, and middleware. The best choice when you want to add a modern API to a Django app without switching frameworks. **[Django Ninja Repo](https://github.com/vitalik/django-ninja)**

- **[Falcon](https://falconframework.org/)** (4.0.2, ~9,500 ★): A bare-metal Python web API framework focused on raw performance. Falcon 4.0 added ASGI support alongside its existing WSGI mode. Zero dependencies. Used by LinkedIn, OpenStack, and other high-traffic systems. Best when you need maximum throughput with minimum overhead. **[Falcon Repo](https://github.com/falconry/falcon)**

- **[Sanic](https://sanic.dev/)** (25.3, ~18,000 ★): A web server and framework built for speed with async/await support. Sanic uses a `YY.M` versioning convention now. Version 25.3 was released March 2025. Requires Python 3.10+. **[Sanic Repo](https://github.com/sanic-org/sanic)**

- **[Quart](https://quart.palletsprojects.com/)** (0.20.0): Maintained by the Pallets organization (the same team behind Flask), Quart is an ASGI framework with the same API as Flask. If you want async support but already know Flask&apos;s API, Quart is the migration path. Flask extensions can run on Quart with minimal changes. **[Quart Repo](https://github.com/pallets/quart)**

- **[BlackSheep](https://www.neoteroi.dev/blacksheep/)** (2,351 ★): A high-performance ASGI framework inspired by Flask and ASP.NET Core. Features clean dependency injection, automatic OpenAPI docs, and excellent type hints support. Smaller community but actively maintained. **[BlackSheep Repo](https://github.com/Neoteroi/BlackSheep)**

- **[Esmerald](https://esmerald.dev/)**: A modular ASGI framework designed for both APIs and full applications. Heavy focus on modularity, scalability, and pluggable architecture. Still growing in adoption, but the design is solid for complex projects that need structure. **[Esmerald Repo](https://github.com/dymmond/esmerald)**

&lt;Tabs&gt;
&lt;Tab name=&quot;FastAPI&quot;&gt;
**Install:** `pip install &quot;fastapi[standard]&quot;`
**Type system:** Pydantic v2
**DI:** Built-in (`Depends()`)
**GraphQL:** Via Strawberry extension
**Server:** ASGI only (Uvicorn/Granian)
**Maintained by:** Sebastián Ramírez
**Best for:** REST APIs, async APIs, microservices
&lt;/Tab&gt;
&lt;Tab name=&quot;Litestar&quot;&gt;
**Install:** `pip install litestar`
**Type system:** Pydantic v2 + msgspec
**DI:** Built-in (more structured than FastAPI)
**GraphQL:** Built-in support
**Server:** ASGI only (Uvicorn/Granian)
**Maintained by:** Litestar Organization
**Best for:** Structured APIs, larger projects needing conventions
&lt;/Tab&gt;
&lt;Tab name=&quot;Django Ninja&quot;&gt;
**Install:** `pip install django-ninja`
**Type system:** Pydantic v2
**DI:** Django dependency system + Ninja
**GraphQL:** Not built-in (use graphene-django)
**Server:** WSGI or ASGI (Django&apos;s choice)
**Maintained by:** Vitaliy Kucheryaviy
**Best for:** Adding APIs to existing Django projects
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Asynchronous frameworks

These are lower-level async networking frameworks. They&apos;re not specifically built for REST APIs (that&apos;s what the API frameworks above are for) but for building real-time applications, WebSocket servers, custom protocols, or anything that benefits from non-blocking I/O.

- **[AIOHTTP](https://docs.aiohttp.org/)** (3.11.18, ~15,000 ★): Both an async HTTP client and server framework. In practice, most developers use AIOHTTP as an HTTP client (for making async requests) rather than as a web framework. The server side (`aiohttp.web`) is solid but less commonly used for new projects. **[AIOHTTP Repo](https://github.com/aio-libs/aiohttp)**

- **[Starlette](https://www.starlette.io/)**: A lightweight ASGI toolkit, not a full framework. FastAPI is built on top of Starlette. You&apos;d use Starlette directly when you want raw ASGI handling without FastAPI&apos;s opinionated layer on top. Good for custom middleware, background tasks, or WebSocket-only services. **[Starlette Repo](https://github.com/encode/starlette)**

- **[Tornado](https://www.tornadoweb.org)** (6.4.2): A Python web framework and async networking library, originally developed at FriendFeed (now part of Facebook). Tornado usage has declined to 2% in the JetBrains survey, but it&apos;s still maintained and still works well for long-polling, WebSockets, and custom TCP servers. **[Tornado Repo](https://github.com/tornadoweb/tornado)**

- **[Robyn](https://robyn.tech/)** (7,322 ★): A Python web framework with a Rust runtime. Robyn compiles its core server loop in Rust via PyO3, giving it near-compiled-language performance while keeping Python for your application code. Supports Python 3.13. If you need maximum throughput from a Python web server, Robyn is worth benchmarking against your workload. **[Robyn Repo](https://github.com/sparckles/Robyn)**

&lt;Notice type=&quot;warning&quot; title=&quot;UvLoop is not a web framework&quot;&gt;
UvLoop was previously listed in this section. It&apos;s actually an ultra-fast drop-in replacement for Python&apos;s asyncio event loop (built with Cython and libuv). It powers frameworks like Sanic under the hood, but it&apos;s infrastructure, not a web framework. Use it as a performance optimization for any asyncio-based framework: `pip install uvloop`.
&lt;/Notice&gt;

### UI and data app frameworks

This category barely existed when the original article was written. In 2026, &quot;pure Python&quot; web UI frameworks are one of the fastest-growing areas in the Python ecosystem. Reflex, Flet, NiceGUI, and Taipy collectively went from around 10,000 to over 80,000 GitHub stars in two years. These frameworks let you build complete web applications without writing JavaScript.

&lt;Notice type=&quot;success&quot; title=&quot;Pure Python UI boom&quot;&gt;
The &quot;pure Python UI&quot; category has exploded: Reflex (28,667 ★), Flet (16,372 ★), NiceGUI (16,052 ★), and Taipy (19,333 ★) collectively went from ~10k stars to 80k+ in two years. Gradio alone has 43,191 stars. If you&apos;re a Python developer who doesn&apos;t want to touch JavaScript, this is the category to watch.
&lt;/Notice&gt;

- **[Streamlit](https://streamlit.io/)** (~36,000 ★): The standard for building data apps and ML dashboards in Python. Streamlit 1.52+ added `st.datetime_input`, dynamic tabs, and advanced theming with Google Fonts. Requires Python 3.9+. Check the [Streamlit vs Taipy](https://www.bitdoze.com/streamlit-vs-taipy/) comparison if you need more enterprise features. You can also [deploy Streamlit on a VPS](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/) for production use. **[Streamlit Repo](https://github.com/streamlit/streamlit)**

- **[Gradio](https://www.gradio.app/)** (43,191 ★): The dominant framework for ML model demos and AI app interfaces. Backed by Hugging Face. Gradio 5 focused on production readiness; Gradio 6 is in development. If you&apos;re building a demo for a machine learning model or an AI application, Gradio is the default choice: it integrates directly with Hugging Face Spaces for free hosting. **[Gradio Repo](https://github.com/gradio-app/gradio)**

- **[Reflex](https://reflex.dev/)** (28,667 ★): Formerly called Pynecone. Reflex compiles your Python code to a React frontend with a Next.js backend. Version 0.8.x brought 2-3x faster builds with Vite+Rolldown and 60+ built-in UI components. The `reflex deploy` command handles deployment, or you can self-host. **[Reflex Repo](https://github.com/reflex-dev/reflex)**

- **[Flet](https://flet.dev/)** (16,372 ★): Build interactive multi-user web, mobile, and desktop apps in Python. Flet 1.0 Alpha (June 2025) is a major rewrite with a custom Python runtime, permissions control, and faster rebuilds. Cross-platform is Flet&apos;s differentiator: the same code runs on web, iOS, Android, Windows, macOS, and Linux. **[Flet Repo](https://github.com/flet-dev/flet)**

- **[NiceGUI](https://nicegui.io/)** (16,052 ★): A user interface framework for building web applications with Python only. Version 2.x added 3D scene rendering, camera controls, and native window events. See the [Streamlit vs NiceGUI comparison](https://www.bitdoze.com/streamlit-vs-nicegui/) for when to pick each. Check the [NiceGUI beginner&apos;s guide](https://www.bitdoze.com/nicegui-get-started/) to get started. **[NiceGUI Repo](https://github.com/zauberzeug/nicegui)**

- **[Taipy](https://www.taipy.io/)** (19,333 ★): Enterprise-focused data application framework. Taipy 4.x targets production data pipelines with scenario management, data node orchestration, and job scheduling. See the [Streamlit vs Taipy](https://www.bitdoze.com/streamlit-vs-taipy/) comparison for details. **[Taipy Repo](https://github.com/Avaiga/taipy)**

- **[Dash (Plotly)](https://plotly.com/dash/)** (3.0.4): A framework for building data visualization apps on top of Flask, Plotly.js, and React.js. Dash 3.0 was a major release. Ideal for building analytical dashboards with highly customized interactive charts in pure Python, no JavaScript required. **[Dash Repo](https://github.com/plotly/dash)**

- **[Panel](https://panel.holoviz.org/)**: A high-level dashboarding solution from the HoloViz ecosystem. Panel works with Bokeh, Matplotlib, HoloViews, and many other Python plotting libraries. Good fit when you&apos;re already in the scientific Python ecosystem. **[Panel Repo](https://github.com/holoviz/panel)**

- **[ReactPy](https://reactpy.dev/)** (8,144 ★): Create interactive web applications in Python using a component model inspired by React. ReactPy lets you write JSX-like Python code that renders to the DOM. Smaller community but interesting for developers who want the React mental model without leaving Python. **[ReactPy Repo](https://github.com/reactive-python/reactpy)**

- **[Anvil](https://anvil.works/)**: A platform for building full-stack web apps with nothing but Python. Client-side and server-side code are both Python. Anvil handles hosting and deployment. Note: Anvil is a commercial platform with a free tier. It&apos;s not fully open-source. **[Anvil Repo](https://github.com/anvil-works/anvil-runtime)**

&lt;Tabs&gt;
&lt;Tab name=&quot;Streamlit&quot;&gt;
**Best for:** Data apps, ML dashboards, internal tools
**Install:** `pip install streamlit`
**Pros:** Fastest time-to-dashboard, huge community, free hosting
**Cons:** Limited customization, state management quirks
&lt;/Tab&gt;
&lt;Tab name=&quot;Gradio&quot;&gt;
**Best for:** ML model demos, AI app interfaces
**Install:** `pip install gradio`
**Pros:** HuggingFace integration, free hosting on Spaces, API auto-generated
**Cons:** Less flexible for general-purpose UIs
&lt;/Tab&gt;
&lt;Tab name=&quot;Reflex&quot;&gt;
**Best for:** Full-stack Python web apps, no JavaScript
**Install:** `pip install reflex`
**Pros:** 60+ components, compiles to React, full control
**Cons:** Larger learning curve, newer community
&lt;/Tab&gt;
&lt;Tab name=&quot;NiceGUI&quot;&gt;
**Best for:** General web UIs, IoT dashboards, internal tools
**Install:** `pip install nicegui`
**Pros:** Simple API, 3D scenes, camera support, active dev
**Cons:** Smaller ecosystem than Streamlit
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Microframeworks

Minimal, near-zero-dependency frameworks for small projects, embedded use, or situations where you want full control over every component.

- **[Bottle](https://bottlepy.org/)** (0.13.3): A single-file micro web framework with no dependencies outside the Python standard library. Bottle is still maintained (last release April 2025). Good for embedded systems, quick prototypes, or situations where adding dependencies is a problem. **[Bottle Repo](https://github.com/bottlepy/bottle)**

- **[CherryPy](https://cherrypy.dev/)** (18.10.0): A minimalist Python web framework that lets you build web applications the same way you build any other object-oriented Python program. CherryPy includes its own production-ready, thread-pooled HTTP server. Still maintained (last release June 2024). **[CherryPy Repo](https://github.com/cherrypy/cherrypy)**

### Legacy and historical frameworks

These frameworks have historical significance and some still run in production, but they&apos;re not recommended for new projects in 2026.

&lt;Accordion group=&quot;legacy&quot; label=&quot;Why are these in a separate section?&quot; expanded=&quot;false&quot;&gt;
These frameworks were important in Python&apos;s web history. Some still receive maintenance releases. But for new projects in 2026, the frameworks above offer better performance, modern Python support, active communities, and current documentation. If you&apos;re maintaining an existing app on one of these, that&apos;s fine. Just don&apos;t start new projects with them.
&lt;/Accordion&gt;

- **[Zope](https://www.zope.dev/)** &amp; **BlueBream**: Zope is historically important for its component architecture, which influenced many later frameworks. Zope 5.13 was released March 2025. BlueBream 1.0 was released in 2011 and is essentially frozen. If you need Zope&apos;s component model, consider modern alternatives first. **[Zope Repo](https://github.com/zopefoundation/Zope)**

- **[web2py](http://www.web2py.com)**: The web2py documentation itself says: &quot;not recommended for new projects, consider using py4web instead.&quot; JetBrain&apos;s survey shows 3% usage, flat since 2021. If you&apos;re on web2py, look at py4web as the migration path.

- **[CubicWeb](https://www.cubicweb.org)** (4.10.0): Built on Semantic Web principles with an emphasis on reusability through components and explicit data models. Version 4.10.0 was released April 2025, but the community is extremely niche. Only relevant if you specifically need semantic web / linked data capabilities.

- **[web.py](https://webpy.org/)**: A web framework that&apos;s as simple as it is powerful, but effectively frozen. Last release 0.62 was in November 2020. Occasional commits still land, but there&apos;s no active development. If you&apos;re using web.py for something simple, Bottle is a better maintained alternative.

- **[Hug](https://hugapi.github.io/hug/)**: Last meaningful release was v2.6.1 in February 2020. The original `hug.rest` domain may no longer resolve. The project now lives on GitHub Pages. 187 open issues with no resolution. Don&apos;t use this for new projects; FastAPI covers the same use case with active maintenance.

## Django vs Flask vs FastAPI: which one should you choose?

This is the most-searched comparison in the Python web space, and for good reason: Django, Flask, and FastAPI remain the three most production-proven options. The choice depends on what you&apos;re building.

&lt;Notice type=&quot;info&quot; title=&quot;The 2026 landscape&quot;&gt;
JetBrains 2024 survey: FastAPI 38%, Django 35%, Flask 34%. Stack Overflow 2025: FastAPI 14.8%, Flask 14.4% globally among all web frameworks. FastAPI has the momentum, but Django and Flask have the deep ecosystem and battle-tested track records. All three are production-grade.
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Django&quot;&gt;
**Best for:** Full-stack web applications, admin-heavy apps, content sites, anything needing ORM + auth + admin
**Python version:** 3.10+
**Key dependency:** Django ORM (built-in)
**Install:** `pip install django==5.2.*`
**Strengths:** Complete ecosystem, admin panel, ORM, massive community, LTS releases
**Weaknesses:** Monolithic architecture, slower for pure API work, template-first mindset
**Quick start:**
```bash
pip install django==5.2.*
django-admin startproject myproject
cd myproject
python manage.py runserver
# Visit http://localhost:8000/
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Flask&quot;&gt;
**Best for:** Lightweight APIs, microservices, when you want full control over your stack
**Python version:** 3.8+
**Key dependency:** Werkzeug, Jinja2
**Install:** `pip install flask`
**Strengths:** Minimal, flexible, huge extension ecosystem, easy to learn
**Weaknesses:** WSGI only (no native async), no built-in ORM or admin
**Quick start:**
```bash
pip install flask
```
```python
from flask import Flask
app = Flask(__name__)

@app.route(&quot;/&quot;)
def hello():
    return &quot;Hello, World!&quot;
```
```bash
flask run
# Visit http://localhost:5000/
```
&lt;/Tab&gt;
&lt;Tab name=&quot;FastAPI&quot;&gt;
**Best for:** REST/GraphQL APIs, async APIs, microservices, type-safe codebases
**Python version:** 3.8+
**Key dependency:** Pydantic v2, Starlette
**Install:** `pip install &quot;fastapi[standard]&quot;`
**Strengths:** Auto OpenAPI docs, async native, type hints, fast (literally)
**Weaknesses:** Younger ecosystem, no built-in ORM or admin, ASGI-only
**Quick start:**
```bash
pip install &quot;fastapi[standard]&quot;
```
```python
from fastapi import FastAPI
app = FastAPI()

@app.get(&quot;/&quot;)
def hello():
    return {&quot;message&quot;: &quot;Hello, World!&quot;}
```
```bash
fastapi dev main.py
# Visit http://localhost:8000/docs for auto-generated API docs
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

**Quick decision guide:**

- **Need an ORM, admin panel, and user auth out of the box?** Django.
- **Building a REST API with automatic docs and async support?** FastAPI.
- **Want minimal overhead and full control over every component?** Flask.
- **Already in Django but want FastAPI-style API ergonomics?** Django Ninja (don&apos;t switch frameworks, add it).
- **Solo developer who doesn&apos;t want a JavaScript frontend?** Django + HTMX or Streamlit/NiceGUI.

The HTMX + Django pattern is worth noting: Django 6.0 adds template partials specifically to support this approach. For solo operators who want to build server-rendered apps without maintaining a React/Vue frontend, Django + HTMX is a practical, low-maintenance stack.

## Deployment recommendations for Python web frameworks

Once you&apos;ve picked a framework, you need to deploy it. Here&apos;s the practical guide for the bitdoze audience: self-hosted on a VPS with Docker or bare metal.

&lt;Notice type=&quot;success&quot; title=&quot;Self-hosting saves money&quot;&gt;
A $5-10/month VPS from [Hetzner Cloud](https://go.bitdoze.com/hetzner) or [DigitalOcean Droplets](https://go.bitdoze.com/do) can handle substantial traffic for most Python web applications. Managed platforms with equivalent resources typically cost $50-100+/month. If you&apos;re running a side project, startup MVP, or internal tool, self-hosting on a VPS is the cost-effective choice. Budget options like [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) start even lower, and [Vultr](https://go.bitdoze.com/vultr) offers global regions if you need to deploy closer to your users.
&lt;/Notice&gt;

**Django:** Gunicorn (WSGI) behind Nginx or Caddy. For Docker-based deployment, use [Dokploy for self-hosting](https://www.bitdoze.com/dokploy-install/). It handles reverse proxy, SSL, and deployment from Git. Django 5.2 on a $5 VPS with 1-2 GB RAM is enough for most projects.

**FastAPI:** Uvicorn or Granian (ASGI server). Granian is a Rust-based HTTP server that shows 2-4x faster throughput than Uvicorn in published benchmarks (results vary by workload). Put it behind Nginx or Caddy for SSL and static files:

```bash
pip install fastapi granian
granian app:app --interface asgi --port 8000
```

**Flask:** Gunicorn (WSGI) behind a reverse proxy. Flask doesn&apos;t natively support ASGI, but Quart (maintained by the same Pallets team) gives you Flask&apos;s API with async support.

**Streamlit:** Reverse proxy with Nginx + systemd service, or Docker. See the full guide on how to [deploy Streamlit on a VPS](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/). You can also [run Python apps in Docker](https://www.bitdoze.com/docker-run-python/) for isolated, reproducible deployments.

**Gradio:** Free hosting on Hugging Face Spaces for demos. For production, self-host behind Nginx with systemd or Docker.

**Reflex:** Built-in `reflex deploy` command for Reflex Cloud (has a free tier). Self-hosting requires the standard VPS setup.

For managing your Python projects, I recommend [setting up Python projects with uv](https://www.bitdoze.com/uv-get-start/). It&apos;s significantly faster than pip and handles virtual environments cleanly. You can also [deploy Python projects with Dokploy and uv](https://www.bitdoze.com/dokploy-python-railpack-uv/) for a full CI/CD pipeline.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;VPS provisioned (1-2 GB RAM minimum, 2+ GB recommended for Django/FastAPI)&lt;/li&gt;
&lt;li&gt;Python 3.10+ installed (use uv or pyenv for version management)&lt;/li&gt;
&lt;li&gt;Reverse proxy configured (Nginx, Caddy, or Traefik)&lt;/li&gt;
&lt;li&gt;SSL/TLS certificates (Caddy auto-provisions, or use Certbot with Nginx)&lt;/li&gt;
&lt;li&gt;Systemd service or Docker Compose for process management&lt;/li&gt;
&lt;li&gt;Firewall rules (allow only 80, 443, and your SSH port)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

For VPS management, check the [best self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) if you want a GUI for managing your deployments.

## Choosing the right Python web framework

The &quot;right&quot; framework depends on what you&apos;re building, who&apos;s building it, and how much complexity you want to manage. Here&apos;s the decision matrix I use:

&lt;Tabs&gt;
&lt;Tab name=&quot;Building an API&quot;&gt;
**Recommendation:** FastAPI or Litestar
**Why:** FastAPI is the market leader with the largest community, auto-generated docs, and Pydantic v2 validation. Litestar is the rising alternative with more built-in structure (DTOs, DI, GraphQL). Both are ASGI and perform well.
**Solo operator default:** FastAPI. More tutorials, more Stack Overflow answers, more production examples.
&lt;/Tab&gt;
&lt;Tab name=&quot;Full-stack app&quot;&gt;
**Recommendation:** Django or Django + HTMX
**Why:** Django gives you ORM, admin, auth, and templating out of the box. With HTMX, you can build interactive UIs without a separate JavaScript frontend. Django 5.2 LTS is supported until 2028.
**Solo operator default:** Django + HTMX. Lowest maintenance burden for a solo developer.
&lt;/Tab&gt;
&lt;Tab name=&quot;AI/ML demo&quot;&gt;
**Recommendation:** Gradio or Streamlit
**Why:** Gradio is purpose-built for ML model demos and integrates directly with Hugging Face Spaces (free hosting). Streamlit is better for data dashboards and broader data science tools.
**Solo operator default:** Gradio for ML demos, Streamlit for data dashboards.
&lt;/Tab&gt;
&lt;Tab name=&quot;Data dashboard&quot;&gt;
**Recommendation:** Streamlit or Dash
**Why:** Streamlit is fastest to prototype with. Dash gives more control over chart customization and is built on Plotly. For internal tools, Streamlit&apos;s simplicity wins.
**Solo operator default:** Streamlit.
&lt;/Tab&gt;
&lt;Tab name=&quot;Pure Python UI&quot;&gt;
**Recommendation:** NiceGUI or Reflex
**Why:** NiceGUI is simpler to start with and has a clean API. Reflex compiles to React and gives you a full-stack app with a real frontend build step. Pick NiceGUI for quick tools, Reflex for production web apps.
**Solo operator default:** NiceGUI for internal tools, Reflex for user-facing apps.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Accordion group=&quot;faq&quot; label=&quot;What if I&apos;m just starting out?&quot; expanded=&quot;false&quot;&gt;
Start with Flask or FastAPI. Flask teaches you the fundamentals of web development (routing, templates, request/response cycle) with minimal magic. FastAPI teaches you modern Python patterns (type hints, Pydantic, async) that transfer to the rest of the ecosystem. Both have excellent official tutorials. Build a small project (a URL shortener, a todo API, a blog) and you&apos;ll learn enough to evaluate other frameworks.
&lt;/Accordion&gt;

&lt;Accordion group=&quot;faq&quot; label=&quot;Can I switch frameworks later?&quot; expanded=&quot;false&quot;&gt;
It depends on the switch. Going from Flask to FastAPI is relatively smooth: both are lightweight, and the routing patterns are similar. Going from Django to FastAPI is harder because Django&apos;s ORM, admin, and middleware are deeply integrated. Django Ninja gives you FastAPI-style APIs inside Django, which is often better than switching entirely. The key insight: keep your business logic separate from your framework code. If your core logic is in plain Python modules (not tied to framework decorators or ORM models), switching becomes much easier.
&lt;/Accordion&gt;

### Other factors to consider

**Project size and scope**: Full-stack frameworks (Django) are better for larger applications. Microframeworks (Flask, Bottle) suit smaller projects. API frameworks (FastAPI, Litestar) are purpose-built for services.

**Learning curve**: Django has the steepest initial learning curve but the most comprehensive documentation. Flask is the easiest to start with. FastAPI sits in between: the type hints and Pydantic model concepts take some getting used to, but the auto-generated docs pay off immediately.

**Community and support**: Django (88k stars), FastAPI (101k stars), and Flask (72k stars) have the largest communities. Smaller frameworks like Litestar and NiceGUI are growing fast but have fewer answered questions available.

**Long-term viability**: Django has been around since 2005 and has LTS releases with 3+ years of support. FastAPI and Flask are both actively maintained with strong funding and community backing. Newer frameworks carry more risk. Evaluate the maintainer&apos;s track record and funding model.

**Performance**: For raw API throughput, ASGI frameworks (FastAPI, Litestar, Sanic, Robyn) outperform WSGI frameworks (Flask, Django) under concurrent load. But for most applications, the framework choice matters less than your database queries and caching strategy.

## Conclusion

Python web development in 2026 is more diverse than ever. FastAPI has overtaken Django and Flask in adoption, but all three remain production-grade choices for different kinds of projects. The &quot;pure Python&quot; UI explosion (Gradio, Streamlit, Reflex, NiceGUI) means Python developers can build full web applications without writing JavaScript. Rust-powered tooling (Robyn, Granian, uv) is pushing the performance ceiling higher.

The best framework is the one that fits your project, your team, and your deployment constraints. If you&apos;re a solo operator on a VPS, FastAPI or Flask give you the least friction. If you need an admin panel and ORM, Django is still the answer. If you&apos;re building an AI demo, Gradio is the default. Pick one, build something this weekend, and deploy it.

&lt;Button text=&quot;Deploy Your First Python App&quot; link=&quot;/docker-run-python/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>web-development</category><category>python</category><category>python-web-frameworks</category><category>django</category></item><item><title>Build Your Own AI Agent with Mastra (Files, Web, Browser)</title><link>https://www.bitdoze.com/build-ai-agent-mastra/</link><guid isPermaLink="true">https://www.bitdoze.com/build-ai-agent-mastra/</guid><description>A step-by-step guide to building a lean AI assistant with the Mastra framework: file tools, shell, live web search, browser automation, and persistent memory. Runs on OpenRouter with free model options. Full code on GitHub.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I have been building AI assistants on and off for the last year. I started with a Discord bot on Agno (wrote about that [here](/create-your-own-ai-agent/)), and it was fine, but I kept running into the same wall. Every framework wanted me to think in its terms. Define agents this way. Wire tools that way. Memory goes here, not there. The boilerplate kept getting heavier than the actual logic.

So when I found [Mastra](https://mastra.ai/), I was curious more than convinced. Another TypeScript agent framework. But the pitch was different: one file to define an agent, tools are just functions with Zod schemas, memory and storage are pluggable, and there is a built-in Studio UI for chatting with your agent and inspecting traces. No YAML, no DAG editor, no 200-line config file.

I ported my assistant over and open-sourced it. The result is a lean, focused agent that can search the web, read and write files, run shell commands, browse JavaScript-heavy pages, and remember what you told it across sessions. No bloat, no 15-agent circus. This article walks through how I built it, step by step, so you can build your own or just clone mine and start tinkering.

&lt;Button text=&quot;View the Code on GitHub&quot; link=&quot;https://github.com/bitdoze/mastra-assistant&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

## What the assistant actually does

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/FdMQAyzsbbI&quot;
  label=&quot;I Built an AI System with 156M Tokens - Here&apos;s How&quot;
/&gt;

Before the how, here is the what. The agent can:

- Read, write, edit, and search files in a local workspace, and run shell commands (with approval)
- Search the live web and fetch clean page content through [TinyFish](https://go.bitdoze.com/tinyfish)
- Drive a real Chromium browser to navigate JS-rendered pages, click, type, and extract data
- Fetch YouTube video metadata and transcripts for research
- Discover trending GitHub repos and pull repo details with README content
- Remember things across sessions using a local embedder (no embedding API key)
- Run on a schedule (a daily news digest workflow that researches AI, DevOps, self-hosting, and Hacker News, then writes a Markdown summary)
- Get edited at runtime through the Mastra Studio UI, no redeploy needed

No Discord bot, no video pipelines, no social posting. Just research and coding assistance. The repo on GitHub has the full picture.

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://nodejs.org/&quot;&gt;Node.js 22+&lt;/a&gt; installed&lt;/li&gt;
&lt;li&gt;An &lt;a href=&quot;https://openrouter.ai/keys&quot;&gt;OpenRouter API key&lt;/a&gt; (hundreds of models, free tiers available)&lt;/li&gt;
&lt;li&gt;A TinyFish API key (free web search and fetch, &lt;a href=&quot;https://go.bitdoze.com/tinyfish&quot;&gt;no credit card&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Chromium, only if you want browser automation&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Let me break down the model layer, because you have real choices here.

The default is [OpenRouter](https://openrouter.ai). It is a model gateway that gives you access to 300+ models behind one API key, including free options like Google Gemini 2.5 Flash, Meta Llama 3.3 70B, and Qwen 3 Coder. If you want to start without spending anything, that works. Paid models are pay-per-token and cheap. Browse the full list at [openrouter.ai/models](https://openrouter.ai/models).

If you prefer flat-rate pricing, [OpenCode Go](/opencode-go-plan/) is an alternative. For $10 a month you get 16 models behind one API key (Grok 4.5, Kimi K3, DeepSeek V4 Pro, GLM-5.2, MiniMax M3, and others). I covered it in detail in the [OpenCode Go review](/opencode-go-plan/). You can swap between providers by changing one environment variable.

&lt;Button text=&quot;Browse OpenRouter Models&quot; link=&quot;https://openrouter.ai/models&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Try OpenCode Go ($5 First Month)&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

The web layer is [TinyFish](/tinyfish-ai-agents-web-search/). Search and fetch are free, and fetch renders pages in a real Chromium instance so JavaScript-heavy docs sites come back readable. I wrote a whole article on why this matters for coding agents. Short version: most modern docs are SPAs, and raw HTML fetching returns empty shells. TinyFish handles that for you.

&lt;Button text=&quot;Get a free TinyFish API key&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Quick start

If you want to skip ahead and poke at a working version first, clone the repo and run the setup.

```bash
git clone https://github.com/bitdoze/mastra-assistant.git
cd mastra-assistant
npm install
cp .env.example .env
# fill in your keys, then:
npm run dev
```

Open `http://localhost:4111` and you will see Mastra Studio. Click into the `assistant` agent and start chatting.

The rest of this article explains how that code is put together, so you can build your own from scratch or modify mine.

## Step 1: Create the project

Scaffold a new Mastra project:

```bash
npx create-mastra@latest my-agent
cd my-agent
npm install
```

This gives you a working skeleton with a sample weather agent. Delete the sample files. We are going to build from a clean slate.

## Step 2: Install the dependencies

Here is what the project depends on. You do not need all of these on day one, but I will explain what each does as we add the corresponding feature.

```bash
npm install @mastra/core @mastra/memory @mastra/libsql @mastra/fastembed \
  @mastra/agent-browser @mastra/observability @mastra/duckdb \
  @mastra/editor @mastra/loggers @mastra/evals \
  @tiny-fish/sdk ws zod
```

For browser automation, also install Chromium:

```bash
npx playwright-core install chromium
```

## Step 3: Define the agent

This is the core of the whole project. One file, one agent. Here is the structure I use, adapted from my repo at `src/mastra/agents/assistant.ts`:

```typescript
import { Agent } from &quot;@mastra/core/agent&quot;;
import { memory } from &quot;../memory&quot;;
import { workspace } from &quot;../workspaces&quot;;
import { browser } from &quot;../browsers&quot;;
import { tinyfishSearch } from &quot;../tools/tinyfish-search&quot;;
import { tinyfishFetch } from &quot;../tools/tinyfish-fetch&quot;;
import { fetchYoutubeMetadata } from &quot;../tools/youtube-metadata&quot;;
import { fetchYoutubeTranscript } from &quot;../tools/youtube-transcript&quot;;
import { githubTrendingRepos } from &quot;../tools/github-trending&quot;;
import { githubRepo } from &quot;../tools/github-repo&quot;;

const AGENT_MODEL =
  process.env.AGENT_MODEL ?? &quot;google/gemini-2.5-flash&quot;;

export const assistant = new Agent({
  id: &quot;assistant&quot;,
  name: &quot;Assistant&quot;,
  instructions: () =&gt; {
    const now = new Date();
    const iso = now.toISOString().split(&quot;T&quot;)[0];
    const year = String(now.getUTCFullYear());
    return `TODAY IS ${iso}. THE CURRENT YEAR IS ${year}. Use ${year} in all web searches.

You are a general-purpose coding and research assistant.

You can read, write, and edit files, run shell commands, search the web,
fetch YouTube transcripts, browse GitHub repos, and drive a browser.
Prefer doing real work with tools over guessing.
Cite URLs when answering from the web.`;
  },
  model: AGENT_MODEL,
  memory,
  workspace,
  browser,
  tools: {
    tinyfishSearch,
    tinyfishFetch,
    fetchYoutubeMetadata,
    fetchYoutubeTranscript,
    githubTrendingRepos,
    githubRepo,
  },
});
```

A few things worth noting.

The `instructions` field is a function, not a string. It gets resolved on every call, so the current date injected into the system prompt is always fresh. I learned this the hard way after my agent kept searching for things using last year&apos;s date and getting stale results. Models are bad at knowing what year it is. Tell them.

The `model` field takes a string in `provider/model` format. `google/gemini-2.5-flash` routes through OpenRouter. If you use OpenAI directly, it would be `openai/gpt-4o`. Mastra supports dozens of providers out of the box. You can also use [OpenCode Go](https://opencode.ai/go) by setting `AGENT_MODEL=opencode-go/glm-5.2` — see the [OpenCode Go guide](/opencode-go-plan/) for the full model list.

`memory`, `workspace`, and `browser` are optional. You can start with just `instructions`, `model`, and `tools`, and add the rest as you go. I cover each one below.

## Step 4: Wire up the Mastra instance

The agent is useless on its own. You need to register it with a `Mastra` instance, which is the thing that runs the server, the Studio UI, storage, and auth. Create `src/mastra/index.ts`:

```typescript
import { Mastra } from &quot;@mastra/core/mastra&quot;;
import { PinoLogger } from &quot;@mastra/loggers&quot;;
import { LibSQLStore } from &quot;@mastra/libsql&quot;;
import { assistant } from &quot;./agents/assistant&quot;;
import { auth } from &quot;./auth&quot;;

export const mastra = new Mastra({
  agents: { assistant },
  storage: new LibSQLStore({
    url: process.env.DATABASE_URL ?? &quot;file:./mastra.db&quot;,
  }),
  logger: new PinoLogger({ name: &quot;Mastra&quot;, level: &quot;info&quot; }),
  server: {
    host: &quot;0.0.0.0&quot;,
    port: 4111,
    auth,
  },
});
```

That is the minimum. Storage uses LibSQL (SQLite), which means no external database to set up. The file is created automatically on first run. Run `bun run dev` and you have a working agent server with a chat UI.

## Step 5: Add memory

Out of the box, the agent has no memory between conversations. Every thread starts fresh. For a real assistant, that is not good enough. Mastra&apos;s `Memory` class handles two things: working memory (a persistent scratchpad of user facts) and semantic recall (vector search over past messages).

Here is my `src/mastra/memory.ts`:

```typescript
import { Memory } from &quot;@mastra/memory&quot;;
import { LibSQLVector } from &quot;@mastra/libsql&quot;;
import { fastembed } from &quot;@mastra/fastembed&quot;;

export const memory = new Memory({
  vector: new LibSQLVector({
    url: process.env.DATABASE_URL ?? &quot;file:./mastra.db&quot;,
  }),
  embedder: fastembed,
  options: {
    semanticRecall: {
      topK: 3,
      messageRange: 2,
    },
    workingMemory: {
      enabled: true,
      scope: &quot;resource&quot;,
      template: `# User Profile
## Identity
- Name:
- Timezone:
## Preferences
- Communication Style:
- Coding Conventions:
## Session State
- Active Task:
- Decisions Made:`,
    },
  },
});
```

The part I want to highlight is `embedder: fastembed`. This runs the embedding model locally through ONNX Runtime (bge-small-en-v1.5). No OpenAI embedding API key, no per-token cost, nothing leaving your machine. The model downloads on first use, about 130MB. After that, semantic recall is free.

Working memory is scoped to `resource`, which means it persists across all threads for a given user. The agent fills in that template over time: your name, your timezone, your preferences. Next time you talk to it, even in a new thread, it remembers.

&lt;Notice type=&quot;info&quot; title=&quot;If you want hosted embeddings instead&quot;&gt;
Swap `fastembed` for `new ModelRouterEmbeddingModel(&apos;openai/text-embedding-3-small&apos;)` and add an embedding provider key. Local is fine for me, but if you are running on a tiny VPS without CPU headroom, hosted embeddings are faster.
&lt;/Notice&gt;

## Step 6: Add web search, YouTube, and GitHub tools

This is where TinyFish comes in. Tools in Mastra are just functions with Zod input and output schemas. For when to keep tools native vs plug in MCP servers (and the RAM cost of stdio MCP), see [Mastra tools vs MCP](/mastra-tools-vs-mcp/). Here is the search tool, from `src/mastra/tools/tinyfish-search.ts`:

```typescript
import { createTool } from &quot;@mastra/core/tools&quot;;
import { z } from &quot;zod&quot;;
import { getTinyFish } from &quot;./tinyfish-client&quot;;

export const tinyfishSearch = createTool({
  id: &quot;tinyfish_search&quot;,
  description:
    &quot;Search the live web and return ranked results (title, snippet, url). Use for factual questions or finding pages to read.&quot;,
  inputSchema: z.object({
    query: z.string().describe(&quot;Search query. site: and -site: operators supported.&quot;),
    location: z.string().optional().describe(&quot;Country code for geo-targeting, e.g. US, GB.&quot;),
  }),
  outputSchema: z.object({
    results: z.array(
      z.object({
        title: z.string(),
        snippet: z.string(),
        url: z.string(),
        domain: z.string(),
      }),
    ),
  }),
  execute: async (input) =&gt; {
    const client = getTinyFish();
    const res = await client.search.query({ query: input.query });
    return {
      results: res.results.map((r) =&gt; ({
        title: r.title,
        snippet: r.snippet,
        url: r.url,
        domain: r.site_name,
      })),
    };
  },
});
```

The fetch tool is similar but calls `client.fetch.getContents()` with up to 10 URLs and returns clean Markdown. The `description` field matters more than you think. That is what the model reads to decide whether to use the tool. Be specific about when to reach for it.

The shared client lives in `tinyfish-client.ts` and just wraps the SDK:

```typescript
import { TinyFish } from &quot;@tiny-fish/sdk&quot;;

let client: TinyFish | null = null;

export function getTinyFish(): TinyFish {
  if (!client) {
    client = new TinyFish(); // reads TINYFISH_API_KEY from env
  }
  return client;
}
```

Both tools are free. Search is rate-limited to 30 requests per minute, fetch to 150 URLs per minute. For a personal assistant that is more than enough.

### YouTube tools

Two YouTube tools let the agent pull video metadata and transcripts without leaving the conversation. `fetch-youtube-metadata` grabs title, channel, duration, view count, and thumbnail from a URL. `fetch-youtube-transcript` pulls the full transcript or captions. Useful for research — the agent can watch a video summary and cite it in its answers.

### GitHub tools

Two GitHub tools give the agent access to the open source ecosystem. `github_trending_repos` discovers trending repos from the last N days, filtered by language if you want. `github_repo` pulls repo details and the full README content. The agent uses these for research — finding new tools, checking what is popular, reading documentation without navigating away.

## Step 7: Add a workspace for files and shell

The `workspace` field gives the agent a sandboxed filesystem and shell access. Mastra handles this through the `Workspace` class. Here is a simplified version of my `src/mastra/workspaces.ts`:

```typescript
import {
  Workspace,
  LocalFilesystem,
  LocalSandbox,
  WORKSPACE_TOOLS,
} from &quot;@mastra/core/workspace&quot;;

export const workspace = new Workspace({
  id: &quot;default&quot;,
  name: &quot;Default Workspace&quot;,
  filesystem: new LocalFilesystem({
    basePath: &quot;./workspace&quot;,
  }),
  sandbox: new LocalSandbox({ workingDirectory: &quot;./workspace&quot; }),
  bm25: true,
  tools: {
    enabled: true,
    [WORKSPACE_TOOLS.FILESYSTEM.WRITE_FILE]: {
      requireApproval: true,
      requireReadBeforeWrite: true,
    },
    [WORKSPACE_TOOLS.FILESYSTEM.DELETE]: {
      enabled: false,
    },
    [WORKSPACE_TOOLS.SANDBOX.EXECUTE_COMMAND]: {
      requireApproval: true,
      maxOutputTokens: 5000,
    },
  },
});
```

This adds file tools (read, write, edit, grep, list) and a shell tool (run commands). Files written to the workspace are immediately executable because the filesystem and sandbox point at the same directory.

Notice the safety rails. Writes require approval and a read-before-write check. The delete tool is disabled entirely. Shell commands require approval by default. You can turn approvals off with `REQUIRE_COMMAND_APPROVAL=false` in `.env` for trusted local setups, but I would not do that on a shared server.

You can also grant the agent access to other directories on your machine through `ALLOWED_DIRECTORIES`. I use this to let the agent work across multiple projects. Containment stays on; it just gets a bigger yard.

## Step 8: Add browser automation

The `browser` field enables a local Playwright instance that the agent can drive. Each conversation thread gets its own isolated browser. Sixteen tools: navigate, snapshot, click, type, scroll, screenshot, evaluate JavaScript, and more.

```typescript
import { AgentBrowser } from &quot;@mastra/agent-browser&quot;;

const headless = process.env.BROWSER_HEADLESS !== &quot;false&quot;;
const cdpUrl = process.env.BROWSER_CDP_URL;

export const browser = new AgentBrowser(
  cdpUrl
    ? { headless, cdpUrl, scope: &quot;shared&quot; }
    : { headless, scope: &quot;thread&quot; },
);
```

The browser is optional. If you do not pass it to the agent, the browser tools simply do not exist. I keep it on because the agent sometimes needs to navigate a docs site that requires JavaScript, or click through a login flow to reach content behind auth.

There is a live screencast that streams to Studio over WebSocket, so you can watch the agent click around in real time. That is weirdly fun to watch.

## Step 9: Add a research skill

Skills are reusable workflows that the agent picks up automatically. Create a folder under `workspace/skills/research/` with a `SKILL.md`:

```markdown
---
name: research
version: 1.0.0
---

# Research workflow
1. Search the web with `tinyfish_search` for the topic.
2. Pick the top 3-5 results.
3. Fetch each with `tinyfish_fetch` to get clean content.
4. Synthesize a summary with sources cited.
```

The agent reads skill files on the next request and applies them when relevant. You can add more skills for recurring tasks — code review checklists, content research patterns, whatever you find yourself repeating.

## Step 10: Add a scheduled workflow

A daily news digest workflow runs on a schedule, researches the web, and writes a Markdown summary to the workspace. Mastra auto-registers it on boot.

```typescript
import { createWorkflow, createStep } from &quot;@mastra/core/workflows&quot;;
import { z } from &quot;zod&quot;;

const digestStep = createStep({
  id: &quot;generate-digest&quot;,
  inputSchema: z.object({ topic: z.string().optional() }),
  outputSchema: z.object({ ok: z.boolean(), path: z.string().optional() }),
  execute: async ({ inputData, mastra }) =&gt; {
    const today = new Date().toISOString().split(&quot;T&quot;)[0];
    const agent = mastra.getAgent(&quot;assistant&quot;);

    const result = await agent.generate(
      `Research today&apos;s top stories (${today}) on ${
        inputData.topic ??
        &quot;AI news, DevOps updates, self-hosting, trending GitHub repos, and Hacker News&quot;
      }. Summarize the top stories as markdown with sources.`,
      { memory: { thread: `digest-${today}`, resource: &quot;workflow&quot; } },
    );

    return { ok: true, path: `workspace/digests/news-${today}.md` };
  },
});

export const newsDigest = createWorkflow({
  id: &quot;news-digest&quot;,
  inputSchema: z.object({ topic: z.string().optional() }),
  outputSchema: z.object({ ok: z.boolean(), path: z.string().optional() }),
  schedule: {
    cron: &quot;30 7 * * *&quot;,
    timezone: process.env.AGENT_TIMEZONE ?? &quot;Europe/Bucharest&quot;,
    inputData: {},
  },
})
  .then(digestStep)
  .commit();
```

You can pause and resume schedules from Studio or the API. The workflow reuses the same agent, so it has access to all the same tools. The digests land in `workspace/digests/news-YYYY-MM-DD.md`.

## Step 11: Configure environment variables

Here is the `.env` file with the keys you need:

```env
# Model provider (OpenRouter by default)
OPENROUTER_API_KEY=your-openrouter-key
AGENT_MODEL=google/gemini-2.5-flash

# Web search and fetch (free)
TINYFISH_API_KEY=sk-tinyfish-your-key

# GitHub API (recommended, raises rate limits)
GITHUB_TOKEN=your-github-token

# Storage
TURSO_DATABASE_URL=file:./mastra.db

# Auth tokens for Studio login
ADMIN_API_KEY=your-admin-token

# Optional
# AGENT_TIMEZONE=Europe/Bucharest
# ALLOWED_DIRECTORIES=/home/me/projects/other-app
# REQUIRE_COMMAND_APPROVAL=false
```

To use OpenCode Go instead, swap the provider variables:

```env
OPENCODE_API_KEY=your-opencode-go-key
AGENT_MODEL=opencode-go/glm-5.2
```

Mastra detects the provider from the key prefix. You can mix and match — use OpenRouter for the main agent and OpenCode Go for a judge model, or whatever combination makes sense for your budget. The [OpenCode Go guide](/opencode-go-plan/) has the full model list and pricing breakdown.

## Step 12: Run it

```bash
npm run dev
```

Open `http://localhost:4111` to access Mastra Studio.

Studio gives you:

- A chat interface to talk to the agent
- A traces view to inspect every tool call, token count, and latency
- A workspace browser to see the files the agent has access to
- A schedules view to manage workflows
- An editor tab to modify the agent&apos;s instructions and tools at runtime

The Editor tab is worth pausing on. It lets you change the agent&apos;s system prompt and tool definitions through the UI, with draft, publish, and archived versioning stored in the database. No code changes, no redeploy. I use this to tweak the agent&apos;s behavior during the day without restarting the server.

&lt;Accordion label=&quot;What does the Editor actually let me change?&quot; group=&quot;faq&quot;&gt;
The agent&apos;s instructions (the system prompt) and its tool list. Saves create versioned drafts in the database. You can publish a draft to make it live, or roll back to an archived version. If you want to lock the agent down, set `editor: false` on the agent constructor. If you want to allow only prompt edits, use `editor: { instructions: true }`.
&lt;/Accordion&gt;

## Deploy on a VPS

Running locally is fine for testing, but a real assistant should be available 24/7. I deploy mine on a Hetzner VPS behind Caddy as a reverse proxy.

&lt;Button text=&quot;Get €20 Hetzner Credit&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;

A CX22 (2 vCPU, 4GB RAM) handles this fine. The agent itself is not resource-heavy. The browser is the only thing that eats memory, and only when it is actively running.

### Build and run with pm2

Build the production bundle:

```bash
npm run build
```

This produces a self-contained server in `.mastra/output/`. Then use pm2 to keep it running:

```bash
npm install -g pm2
pm2 start .mastra/output/index.mjs --name mastra-assistant
pm2 save
pm2 startup
```

That last command generates the init script so pm2 starts on boot. Check logs with `pm2 logs mastra-assistant`.

### Reverse proxy with Caddy

Caddy handles TLS automatically. Add this to your Caddyfile:

```caddy
your-domain.com {
    reverse_proxy localhost:4111
}
```

One thing that tripped me up: behind a reverse proxy, set `MASTRA_AUTO_DETECT_URL=true` in your `.env`. Without it, Studio tries to call `0.0.0.0:4111` from the browser, which does not work. That flag makes Studio use the browser&apos;s origin instead.

### Run on macOS

Same build and pm2 steps work on a Mac Mini or MacBook. If you want to access Studio from other devices on your network without port forwarding, install [Tailscale](https://tailscale.com):

```bash
brew install tailscale
sudo tailscale up
```

Studio is then available at `http://your-mac:4111` from any device on your tailnet.

## What I learned

A few observations from running this for a while.

The single-agent approach works. The model picks the right tool on its own most of the time. You do not need five specialized agents to get useful work done. Start with one agent and a focused set of tools. Add complexity only when you actually need it.

Local embeddings are slower than hosted ones on the first run (the model has to download, about 130MB), but after that they work fine for a personal assistant. Free, nothing leaves your machine. If you are building something with many concurrent users, switch to hosted embeddings to keep latency down.

Browser automation is powerful but expensive in memory and time. I keep it on but the agent reaches for it rarely. Most web tasks are handled by search and fetch. The browser is for when a site needs JavaScript or interaction to render.

## How TinyFish powers the whole pipeline

I want to spend more time on the web layer because it is the piece that made the biggest difference. Before TinyFish, I had written and discarded three different scraping setups. BeautifulSoup wrappers, Playwright scripts with custom selectors, even a half-finished DOM parser that tried to extract article text from raw HTML. Each one worked for about two weeks before a site changed its layout or added a bot wall and everything broke.

The real unlock was realizing that my agents did not need raw HTML at all. They needed clean, structured text they could reason over. TinyFish search returns ranked results with titles, snippets, and URLs. TinyFish fetch takes those URLs, up to 10 at a time, renders the pages in a real Chromium instance (so JavaScript-heavy docs sites actually work), strips out navigation, ads, scripts, and clutter, and returns clean Markdown. That is it. That is the whole interface.

![Mastra and TinyFish Agent Pipeline Diagram](../../assets/images/26/06/mastra-tinyfish-diagram.webp)

### What the daily pipeline actually looks like

Every morning at 7:30 AM, a scheduled cron fires. It triggers research agents that scan for news across AI, DevOps, self-hosting, trending GitHub repos, and Hacker News. Each agent calls `tinyfish_search` with targeted queries, picks the top results, and passes those URLs to `tinyfish_fetch` for parallel retrieval. Within seconds, the agent has the full text of up to 10 live web pages sitting in its context window.

From there, the agents do real work with that content. They verify technical claims against live documentation before drafting articles. They pull release notes and changelogs to check if a tool&apos;s feature list is current. They cross-reference multiple sources to catch inaccuracies. The daily digest lands in the workspace as a Markdown file with sources cited, and nobody touched a keyboard.

The video pipeline works the same way. A scheduled job fetches live documentation and release notes via TinyFish, verifies technical accuracy, and generates a fully animated video script that gets rendered through HyperFrames. The agent goes from a raw search query to a published article and rendered video with zero manual intervention.

### Why this matters for agents specifically

Most web search APIs return snippets, 160 characters of context per result. That is enough to decide whether a link is relevant, but not enough to reason over. You end up in a loop: search, click, read, go back, search again. For a human that is normal. For an agent, it wastes tokens and time on navigation instead of thinking.

TinyFish fetch solves this by returning full-page Markdown in a single call. The agent gets the actual content, not a teaser. It can answer questions, summarize, fact-check, and synthesize across multiple sources without bouncing between pages. And because the output is Markdown, not HTML, you are not paying for navbars, footers, and cookie banners in your token budget.

The speed matters too. Fetching 10 pages in parallel keeps the agent loop latency low. A research task that would take me 30 minutes of tab-hopping takes the agent about 15 seconds. I measured it. The bottleneck is the LLM inference, not the web retrieval.

### What I would tell someone building this

Do not write custom scrapers for AI agents. Scraping and web extraction are solved problems. Give your agents high-level primitives: search that returns ranked results, and fetch that returns clean text. Keep the tool definitions simple. It reduces prompt tokens, prevents agents from getting lost in raw HTML, and means you never have to debug a broken CSS selector at 2 AM.

I wrote a full article on [why TinyFish matters for coding agents](/tinyfish-ai-agents-web-search/) if you want the technical deep-dive on the API, the cookbook projects, and how to wire it into other frameworks.

## Where to go from here

- [Clone the full project](https://github.com/bitdoze/mastra-assistant) and adapt it. The README covers all tools, workflows, skills, and deployment.
- Read the [Mastra documentation](https://mastra.ai/docs/) for the full API. I covered the pieces I use, but there is more (evals, multi-agent workflows, RAG pipelines).
- Add media tools next: [image agent with Kie.ai](/mastra-image-agent-kie-ai/) and [voice cloning TTS with Fish Audio](/mastra-fish-audio-tts/).
- Choosing native `createTool` vs MCP servers: [Mastra tools vs MCP](/mastra-tools-vs-mcp/).
- If you want a different take on building a Discord AI bot, my [Agno guide](/create-your-own-ai-agent/) covers a Python-based approach with team orchestration and a different memory system.
- For the web search layer, the [TinyFish guide](/tinyfish-ai-agents-web-search/) goes deeper into the API, the cookbook projects, and how to wire it into other agents like Hermes and Pi.
- For the model layer, [OpenRouter](https://openrouter.ai/models) has 300+ models including free tiers. The [OpenCode Go review](/opencode-go-plan/) covers the flat-rate alternative with 16 models at $10/month.
- If you want to compare this approach to other always-on assistants, see the [OpenClaw setup guide](/clawdbot-setup-guide/) and the [OpenCode setup guide](/opencode-setup-guide/).
- For the wider open-source AI map (frameworks, memory, gateways, assistants), see [top AI GitHub repos](/top-ai-github-repos/).

&lt;Button text=&quot;Browse OpenRouter Models&quot; link=&quot;https://openrouter.ai/models&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Try OpenCode Go ($5 First Month)&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Get a free TinyFish API key&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;success&quot; title=&quot;You have an agent now&quot;&gt;
One file for the agent, a handful of tool functions, and a Mastra instance to tie it together. Clone the repo, fill in two API keys, and you are chatting with an assistant that can read your files, search the web, browse GitHub, and remember what you told it.
&lt;/Notice&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>mastra</category><category>self-hosted</category></item><item><title>Bunny.net Review 2026: Honest Take After 1 Year (Cheaper Than Cloudflare)</title><link>https://www.bitdoze.com/bunny-net-review/</link><guid isPermaLink="true">https://www.bitdoze.com/bunny-net-review/</guid><description>Running Bunny.net CDN + Stream in production for 12 months. $0.01/GB bandwidth, free video transcoding, 119 PoPs. Here&apos;s what I like and what bugs me.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

I have been running sites on [Bunny.net](https://go.bitdoze.com/bunny) for over a year: CDN in front of WordPress, plus their Stream product for video. Before that I was on Cloudflare free and briefly on CloudFront. CloudFront&apos;s pricing made me grumpy (bandwidth *and* per-request fees?), and Cloudflare free is hard to beat on price, but I wanted real cache control and video without duct-taping three vendors together.

Bunny fixed most of that. Not all of it. After twelve months in production, here is what still holds up and what still bugs me.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You can test everything in this review yourself. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card required and get a full 14-day free trial.
&lt;/Notice&gt;

## What Bunny.net actually is


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/3CNMmhbC-fw&quot;
  label=&quot;I’ve Used Bunny.net for 5 Years – Here’s My Honest Review&quot;
/&gt;

Bunny.net started life as a CDN (they used to be called BunnyCDN) but has since grown into a full edge platform. Think of it as a smaller, cheaper alternative to the AWS stack of CloudFront + S3 + MediaConvert, except everything lives under one dashboard and the pricing doesn&apos;t require a spreadsheet to decode.

![Bunny.net main dashboard interface](../../assets/images/26/04/bunny-main-interface.webp)

The company is based in Slovenia, which means they fall under EU data protection laws. They run 119+ edge locations across 82 countries, with a network capacity over 250 Tbps.

Here&apos;s everything they offer right now:

| Service | What it does | Starting price |
|---------|-------------|----------------|
| **Bunny CDN** | Content delivery network | $0.01/GB (EU/NA) |
| **Bunny Stream** | Video hosting and streaming | $0.01/GB storage + delivery |
| **Bunny Storage** | Edge-replicated object storage | $0.01/GB (single region) |
| **Bunny Optimizer** | Image/CSS/JS optimization | $9.50/mo per site |
| **Bunny DNS** | Scriptable DNS with monitoring | Free (included) |
| **Bunny Shield** | WAF, DDoS, bot protection | Free tier available |
| **Bunny Database** | SQLite-compatible global database | $0.30/billion reads |
| **Edge Scripting** | Serverless functions (Deno) | $0.20/million requests |
| **Magic Containers** | Global Docker deployment | Pay per CPU/memory usage |

That&apos;s a lot of products for a company that started as &quot;just a CDN.&quot; Let me go through the ones that matter most.

## Bunny CDN: the core product

This is what most people sign up for, and it&apos;s still the strongest part of the platform.

![Bunny CDN pull zone interface](../../assets/images/26/04/bunny-cdn-interface.webp)

### How it works

You create a &quot;pull zone&quot; that sits in front of your origin server. Bunny caches your content across their 119 PoPs and serves it from whichever location is closest to your visitor. Setup takes about 5 minutes: point your pull zone at your origin, update your DNS, and you&apos;re running.

### My experience with WordPress

I&apos;ve been using Bunny CDN with my WordPress sites and the setup was painless. They have a WordPress plugin that configures everything automatically, but even without it, you just swap your asset URLs to the Bunny pull zone hostname and you&apos;re done.

What I noticed right away was the drop in origin server load. With Perma-Cache enabled, my VPS went from handling every single request to barely seeing any traffic at all. Page load times improved across the board, especially for visitors outside Europe where my server sits. The TTFB (Time to First Byte) for cached assets dropped to under 30ms for most locations.

If you&apos;re running WordPress on a budget VPS (Hetzner, DigitalOcean, etc.), adding Bunny CDN in front of it is probably the single best performance upgrade you can make for the money.

### Performance

Bunny claims 24ms average global latency, and from my testing, that checks out for EU and NA traffic. Asia-Pacific varies more, somewhere between 30-60ms depending on the specific country. For context, Cloudflare typically hits 15-20ms (they have more PoPs), but Bunny is faster than CloudFront in most regions outside the US.

The real performance win is their Perma-Cache feature. Regular CDN caching expires and needs to re-fetch from your origin. Perma-Cache stores your content on edge storage permanently, so you get a near-100% cache hit ratio. Your origin server barely gets touched.

### Key features

&lt;ListCheck&gt;
- **Perma-Cache**: Permanent edge storage for near-100% cache hit ratio
- **SmartEdge routing**: Sends users to the fastest PoP, not just the nearest one
- **Edge Rules**: Custom logic for redirects, headers, caching, and security
- **Real-time analytics**: Traffic monitoring with raw log access
- **Instant purge**: Cache clearing propagates in seconds
- **Free SSL**: Let&apos;s Encrypt certificates with one click
- **DDoS protection**: Built into every plan, no extra charge
- **SafeHop**: Configurable origin timeouts and automatic retry logic
&lt;/ListCheck&gt;

### CDN pricing

Bunny runs two network tiers:

&lt;Tabs&gt;
&lt;Tab name=&quot;Standard network (119 PoPs)&quot;&gt;

Region-based pricing with full global coverage:

| Region | Price per GB |
|--------|-------------|
| Europe &amp; North America | $0.01 |
| Asia &amp; Oceania | $0.03 |
| South America | $0.045 |
| Middle East &amp; Africa | $0.06 |

Good for: Most websites, apps, and projects that need worldwide reach.

&lt;/Tab&gt;
&lt;Tab name=&quot;Volume network (10 PoPs)&quot;&gt;

Single flat rate for high-bandwidth projects:

| Tier | Price per GB |
|------|-------------|
| First 500 TB | $0.005 |
| 500 TB - 1 PB | $0.004 |
| 1 PB - 2 PB | $0.002 |
| 2 PB+ | Contact them |

Good for: Video platforms, large file distribution, anything bandwidth-heavy.

&lt;/Tab&gt;
&lt;/Tabs&gt;

To put this in perspective: 5 TB of EU/NA traffic on Bunny costs about $50. The same on CloudFront runs $425+. On Fastly, over $600. That&apos;s not a typo.

## Bunny Stream: video without the headaches

![Bunny Stream video library interface](../../assets/images/26/04/bunny-stream-interface.webp)

This is the other service I use regularly, and it&apos;s the main reason I&apos;m not on Vimeo or Wistia anymore. I wrote a full [step-by-step guide to setting up Bunny Stream](/bunny-stream-guide/) if you want the hands-on walkthrough. Here&apos;s the overview.

### What you get

Upload a video, Bunny transcodes it into multiple resolutions (240p through 1080p, with 4K available), replicates it across their storage regions, and serves it through the CDN with an embedded player. The player is customizable (colors, controls, language) or you can pull the raw HLS stream URLs and use your own player.

### Why I moved away from Vimeo

Cost. Vimeo&apos;s pricing for video hosting adds up fast once you have more than a handful of videos. Bunny Stream charges for storage ($0.01/GB) and delivery bandwidth. Transcoding is free. The player is free. DRM is included if you need it.

For 300 GB of stored video with 50 GB of monthly traffic and a single replication point, you&apos;re looking at roughly $3.50/month. Try getting that number from any traditional video platform.

### Stream features

&lt;ListCheck&gt;
- **Free transcoding**: No per-minute encoding costs
- **Adaptive bitrate**: HLS streaming across multiple quality levels
- **Custom player**: Full control over colors, controls, watermarks, language
- **DRM protection**: Enterprise-grade, blocks downloads and screen recording
- **Token authentication**: Control exactly who can watch your videos
- **Hotlink protection**: Block unauthorized embedding on other sites
- **TUS resumable uploads**: Large uploads that survive connection drops
- **API-first design**: Full REST API with webhook support
- **AI content tagging**: Automatic categorization of video content
&lt;/ListCheck&gt;

### Stream vs traditional cloud providers

| Feature | Bunny Stream | Traditional (AWS etc.) |
|---------|-------------|----------------------|
| Encoding | Free | ~$0.02/minute |
| Storage | $0.01/GB | ~$0.02/GB |
| CDN delivery | From $0.005/GB | From $0.085/GB |
| Player | Included free | Requires external integration |
| DRM | Included | Extra charges |
| Setup complexity | Minutes | Hours to days |

## Other Bunny.net services

I don&apos;t use all of these daily, but here&apos;s what each one does and who it&apos;s meant for.

&lt;Accordion label=&quot;Bunny Storage&quot; group=&quot;services&quot;&gt;

![Bunny Storage file manager interface](../../assets/images/26/04/bunny-storage-interface.webp)

Edge-replicated object storage across up to 15 regions. Think S3, but your files get automatically copied to multiple locations worldwide.

**Pricing:**

| Setup | Price per GB |
|-------|-------------|
| Single region | $0.01 |
| Two regions | $0.02 |
| Three regions | $0.025 |
| Each additional | +$0.005 |

No egress fees when delivering through Bunny CDN, no API request charges, and automatic global replication. Bunny reports 41ms average global latency compared to 131ms for AWS S3 in EU Central. The distributed architecture makes that number plausible.

Upload via FTP, SFTP, HTTP API, or their web file manager.

I put together a detailed [Bunny Storage vs S3 vs Backblaze B2 comparison](/bunny-storage-vs-s3-vs-backblaze/) if you want to see how the pricing stacks up against other providers. With their new S3-compatible API, you can also [mount a Bunny bucket as a filesystem on a VPS](/s3-bucket-filesystem-vps/) using ZeroFS or JuiceFS.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bunny Optimizer&quot; group=&quot;services&quot;&gt;

Automatic image optimization, WebP/AVIF conversion, and CSS/JS minification. Connect your site and it handles everything on the fly without plugins or code changes.

**Pricing:** $9.50/month per website. Flat rate, unlimited requests and optimizations.

**What it does:**

- Compresses images by up to 80%
- Converts to WebP based on browser support
- Resizes images per device type
- Minifies CSS and JavaScript

If you&apos;re already running image optimization through your build process (like I do with Astro), this may be redundant. But for WordPress sites or anything serving unoptimized images, the Core Web Vitals improvement is noticeable.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bunny DNS&quot; group=&quot;services&quot;&gt;

Free DNS hosting with some extras you don&apos;t see elsewhere: scriptable DNS records (write actual routing logic in code), built-in health monitoring for A records, load balancing, and geographic routing. Runs on 36+ anycast PoPs with sub-20ms latency in most regions.

If you&apos;re already on Cloudflare DNS, there&apos;s no strong reason to migrate unless you need the scripting capability. But if you&apos;re using Bunny CDN anyway, having DNS in the same dashboard simplifies things.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bunny Shield (WAF and security)&quot; group=&quot;services&quot;&gt;

Their security product, launched fairly recently. Combines a WAF, DDoS mitigation, bot detection, rate limiting, and upload scanning under one roof.

**Pricing tiers:**

| Feature | Basic (Free) | Advanced ($9.50/mo) | Business ($99/mo) |
|---------|-------------|--------------------|--------------------|
| WAF rules | 71 built-in | 255 built-in | 255 built-in |
| Custom WAF rules | No | 10 | 25 |
| DDoS protection | Yes | Yes | Yes |
| Bot mitigation | Basic | Advanced | Advanced |
| Rate limiting | No | Yes | Yes |

The free tier covers basic WAF rules and DDoS protection, which is already more than many CDNs include by default. Paid tiers add custom rules, smarter bot detection, and rate limiting per IP or path.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Edge Scripting&quot; group=&quot;services&quot;&gt;

Serverless functions running on Deno, deployed across all 119 edge locations. Write TypeScript or JavaScript, push to GitHub, and it goes live globally in under 100ms.

**Pricing:** $0.20 per million requests + $0.02 per 1,000 seconds of CPU time.

This competes with Cloudflare Workers and Deno Deploy. The Deno runtime gives you TypeScript support out of the box, and since Deno is open source, you avoid vendor lock-in.

Useful for: A/B testing, auth middleware, API gateways, request routing, or any situation where compute needs to live close to the user.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bunny Database&quot; group=&quot;services&quot;&gt;

A SQLite-compatible database service that runs on Bunny&apos;s global network. You create a database, pick a primary region, and optionally add read replicas in any of their 41 available regions. When nobody&apos;s querying it, the database spins down and you only pay for storage.

It uses the libSQL protocol, so you connect with official SDKs for TypeScript/JavaScript, Go, Rust, and .NET. There&apos;s also a plain HTTP API if your stack doesn&apos;t have an SDK yet.

**Pricing (pay-as-you-go):**

| Resource | Cost |
|----------|------|
| Reads | $0.30 per billion rows |
| Writes | $0.30 per million rows |
| Storage | $0.10 per GB per active region/month |

Currently in public preview and free to use while it lasts.

**Best suited for:** Product catalogs, user profiles, app configuration, metadata filtering, and other read-heavy workloads. Since it&apos;s built on SQLite, it&apos;s not the right pick for write-heavy transactional systems, but for most web applications the read/write ratio makes it a good fit.

Pairs well with Edge Scripting or Magic Containers for a full edge-native stack. I put together a hands-on walkthrough on [building a todo app with TanStack Start, Bunny Database, and Drizzle ORM](/tanstack-start-bunny-database-drizzle/), and another on [using Astro DB with Bunny Database](/astro-db-bunny-database/), if you want to see it wired into a real app.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Magic Containers&quot; group=&quot;services&quot;&gt;

Their newest and most ambitious product. Deploy Docker containers across 40+ global locations with AI-powered auto-scaling. No Kubernetes setup, no DevOps overhead.

Each container runs fully isolated (not crammed into shared processes like some serverless platforms), with NVMe storage and high-frequency CPUs. Their pitch is 5x cost savings over traditional edge hosting, because AI only provisions resources where and when traffic actually demands them.

**Use cases:** API services, game servers, DNS clusters, real-time apps, image processing, microservices.

Still fairly new, so it&apos;s less battle-tested than something like AWS ECS or Fly.io. But the concept is solid, especially if you&apos;re currently babysitting Docker deployments on VPS instances.

&lt;/Accordion&gt;

## The dashboard and developer experience

One thing I keep coming back to is how clean the dashboard is. After spending time in the AWS Console (which feels like it was designed to confuse people) and Cloudflare&apos;s increasingly cluttered interface, Bunny&apos;s dashboard feels straightforward.

Everything is where you&apos;d expect it. Pull zone settings, analytics, storage management, video libraries. The real-time analytics actually update in real time, and the raw log explorer is genuinely useful when you&apos;re debugging cache misses or weird routing behavior.

The API is well-documented and consistent across services. If you&apos;re automating deployments or building integrations, you won&apos;t spend hours fighting undocumented edge cases.

## What I like

&lt;ListCheck&gt;
- **Pricing transparency**: No hidden fees, no per-request charges, no surprise bills
- **$1 monthly minimum**: Small projects cost basically nothing to run
- **14-day free trial**: No credit card needed to start
- **Perma-Cache**: Near-100% cache hit ratio without complicated configuration
- **All-in-one platform**: CDN, storage, video, database, DNS, and security in one place
- **Support quality**: 5-minute average first response, 3-hour average resolution, 24/7
- **Pay-as-you-go**: Scale down to zero when you don&apos;t need it
- **EU-based company**: GDPR compliance built in, data stays where you want it
- **Overcharge protection**: Set monthly bandwidth limits to avoid bill surprises
&lt;/ListCheck&gt;

## What bugs me

&lt;ListCheck&gt;
- **Smaller network than Cloudflare**: 119 PoPs vs 300+, noticeable in some Asian regions
- **No free CDN tier**: Cloudflare&apos;s free plan is tough to compete with for hobby projects
- **Optimizer per-site pricing**: $9.50/month per site adds up when you manage multiple properties
- **Magic Containers still maturing**: Less proven than AWS ECS or Fly.io for production workloads
- **No built-in web analytics**: You&apos;ll still need a separate analytics tool
- **Shield UI needs polish**: The WAF rule editor feels basic next to Cloudflare&apos;s
&lt;/ListCheck&gt;

## Bunny.net vs the competition

Here&apos;s how Bunny compares against the CDNs you&apos;re likely considering:

| Feature | Bunny.net | Cloudflare | CloudFront | Fastly |
|---------|-----------|-----------|------------|--------|
| CDN pricing (EU/NA) | $0.01/GB | Free tier / $0.05/GB | $0.085/GB | $0.12/GB |
| 5 TB cost (EU/NA) | ~$50 | Free* / ~$250 | $425+ | $600+ |
| Global PoPs | 119 | 300+ | 450+ | 72 |
| Avg. global latency | 24ms | ~15ms | ~27ms | ~29ms |
| Video streaming | Built-in | No | Separate service | No |
| Object storage | Built-in | R2 | S3 (separate billing) | No |
| Database | Built-in (SQLite) | D1 | DynamoDB (separate) | No |
| Free SSL | Yes | Yes | Yes | Yes |
| DDoS protection | Included | Included | AWS Shield | Included |
| Free tier | 14-day trial | Yes (generous) | 12-month trial | No |
| Min. monthly cost | $1 | $0 | ~$1 | $50 |

Cloudflare&apos;s free tier is unbeatable if all you need is basic CDN caching. But once you start adding storage, video hosting, and compute, the cost comparison shifts fast. Bunny&apos;s advantage is having everything bundled at straightforward per-GB rates.

&lt;Notice type=&quot;info&quot; title=&quot;About this comparison&quot;&gt;
  Performance numbers come from CDNPerf independent testing (March 2025). Pricing is from each provider&apos;s public pricing page. Your actual results will depend on your visitors&apos; locations and origin server setup.
&lt;/Notice&gt;

## Who should use Bunny.net

**Good fit for:**

- Small to medium sites that need CDN features beyond Cloudflare&apos;s free tier
- Anyone hosting video content (courses, tutorials, marketing videos) who wants off Vimeo or Wistia pricing
- Developers who want CDN + storage + compute in one dashboard
- WordPress sites looking for easy CDN integration and image optimization
- Businesses in the EU that care about data sovereignty

**Probably not the best fit for:**

- Hobby projects with zero budget (Cloudflare&apos;s free plan covers that)
- Large enterprises with complex multi-cloud architectures (the AWS ecosystem goes deeper)
- Sites that need 300+ PoPs for minimum latency in every corner of the world

## Frequently asked questions

&lt;Accordion label=&quot;Is Bunny.net good for WordPress?&quot; group=&quot;faq&quot;&gt;

From personal experience, yes. I&apos;ve been running Bunny CDN with WordPress and the difference in load times was obvious from day one. They have a WordPress plugin that handles CDN integration automatically, and combined with Bunny Optimizer ($9.50/mo) for image compression and JS/CSS minification, it&apos;s one of the simpler CDN setups for WordPress. You can also pair it with Varnish and Redis on your server for a full caching stack. The whole setup took me about 10 minutes.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Bunny.net compare to Cloudflare?&quot; group=&quot;faq&quot;&gt;

Cloudflare has a better free tier and more edge locations. Bunny has cheaper paid bandwidth, built-in video streaming, and simpler pricing once you&apos;re past the free tier. If Cloudflare&apos;s free plan already does what you need, there&apos;s no urgent reason to switch. If you need video hosting or predictable bandwidth pricing at scale, Bunny makes more financial sense.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Bunny Stream for online courses?&quot; group=&quot;faq&quot;&gt;

For most course creators, yes. You get adaptive bitrate streaming, DRM protection (blocks downloads and screen recording), token authentication, and a customizable player. The main thing you lose compared to Vimeo is their built-in course platform integrations, but if you&apos;re running your own LMS, Bunny&apos;s API handles embedding without issues.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What payment methods does Bunny.net accept?&quot; group=&quot;faq&quot;&gt;

Credit cards (Visa, Mastercard, American Express), PayPal, and cryptocurrency. The $1 monthly minimum means you can run small projects without committing to much.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny.net work with static site generators?&quot; group=&quot;faq&quot;&gt;

Yes, and it works well. You can deploy your static site to Bunny Storage and serve it through Bunny CDN for a fully edge-hosted setup. I run my Astro site this way. Perma-Cache plus edge storage means your site loads from the nearest PoP every time, no origin server needed.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a free trial?&quot; group=&quot;faq&quot;&gt;

14 days, no credit card required, full access to all features. After the trial, the $1 monthly minimum kicks in, which honestly covers most small projects without any noticeable cost.

&lt;/Accordion&gt;

## My take after using it in production

Bunny.net sits in a spot that most CDN providers miss. It&apos;s cheaper than the big cloud providers by a wide margin, more capable than most budget CDNs, and the combined platform (CDN + storage + video + database + DNS + security) means you&apos;re not duct-taping five separate services together.

The video streaming alone saved me a meaningful amount compared to what I was paying for Vimeo. CDN performance lands within reach of providers charging 5-10x more. And the support team actually responds in minutes, which still catches me off guard after years of waiting 48 hours for AWS to acknowledge a ticket.

It&apos;s not flawless. The network is smaller than Cloudflare&apos;s, the security dashboard could use more work, and the newer products like Magic Containers and Edge Scripting are still finding their legs. But for the price, I haven&apos;t found a better combination of features and performance.

If you&apos;re currently overpaying for CloudFront, want video hosting without Vimeo&apos;s pricing, or just need a CDN that doesn&apos;t require an AWS certification to set up, Bunny.net is worth trying.

&lt;Button text=&quot;Try Bunny.net Free for 14 Days&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;</content:encoded><category>hosting</category><category>cdn</category><category>video-streaming</category></item><item><title>Clone Your Voice with Fish Audio in 2 Minutes (2026 Guide)</title><link>https://www.bitdoze.com/fish-audio-clone-voice/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-audio-clone-voice/</guid><description>Clone your voice from 15 seconds of audio on Fish Audio&apos;s free tier. Step-by-step with recording tips, emotion tags, screenshots, and mistakes that ruined my first try.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import fishAudioInterface from &quot;@assets/images/26/07/fish-audio-inerface.webp&quot;;
import fishAudioClone from &quot;@assets/images/26/07/fish-audio-clone-voice.webp&quot;;

I clone my voice on Fish Audio for YouTube. First clean sample to working clone took about two minutes. The first attempt was trash because I recorded with a ceiling fan running. Here is the exact path that worked, plus what I would redo.

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## What you need

- A Fish Audio account (free tier works)
- 10-15 seconds of clear audio of yourself speaking
- A quiet room (background noise hurts quality)
- A decent microphone (your laptop mic works, a USB mic is better)

&lt;Notice type=&quot;info&quot; title=&quot;Before you start&quot;&gt;
Fish Audio voice cloning is free on the free tier. You do not need a paid plan to clone your voice. The clone can speak in 83 languages once created.
&lt;/Notice&gt;

## Step 1: Record your voice sample

Record yourself reading a paragraph clearly for about 15 seconds. Here is what I used:

&gt; &quot;The quick brown fox jumps over the lazy dog. I am recording this sample to create a voice clone that I can use for my video projects. Speaking naturally and clearly helps the AI capture my voice characteristics.&quot;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/1qnuGqTKGL0&quot;
  label=&quot;Clone Your Voice with Fish Audio and Mastra)
&quot;
/&gt;

### Recording tips

**Keep it clean.** Record in a quiet room. Close windows, turn off fans, silence your phone. Background noise gets baked into the clone and makes it sound worse.

**Speak naturally.** Do not read in a monotone or try to sound like a news anchor. Talk like you normally would in a conversation. The clone will match your natural cadence, so give it your real voice.

**Use a good microphone if you have one.** A USB condenser mic like the Blue Yeti or Audio-Technica AT2020 produces cleaner results than a laptop microphone. That said, I tested with my laptop mic and the clone was still usable.

**Aim for 15 seconds.** Fish Audio needs at least 10 seconds, but 15 seconds gives the model more to work with. Do not record for five minutes thinking more is better. It is not. Short, clean samples produce better clones.

**Avoid reading lists or numbers.** Natural paragraph text works best. Lists and numbers have unusual prosody that can confuse the model.

## Step 2: Upload to Fish Audio

1. Go to [Fish Audio](https://go.bitdoze.com/fish-audio) and sign in
2. Click on **Voice** in the left sidebar
3. Click **Create Voice** or the **+** button
4. Upload your audio file (MP3, WAV, or M4A)
5. Add a name for your voice (I used &quot;My Voice&quot;)
6. Add a description (optional, but helps you find it later)
7. Click **Create**

The upload and processing takes about 30 seconds to 2 minutes. Fish Audio processes the audio, extracts your voice characteristics, and builds a model from it.

![Cloned voice in Fish Audio showing waveform and language settings](../../assets/images/26/07/fish-audio-clone-voice.webp)

## Step 3: Test your clone

Once the clone is ready, go to the TTS generation page:

1. Select your cloned voice from the voice dropdown
2. Type a test sentence (something different from your recording)
3. Click **Generate**
4. Listen to the output

Test with a few different types of text:

- A normal sentence to check basic quality
- A question to check intonation rise
- A longer paragraph to check consistency
- Something emotional to check range

If the clone sounds off, re-record with a cleaner sample and try again. The quality of the input audio is the biggest factor in how good the clone sounds.

![Fish Audio TTS interface with emotion controls and model selection](../../assets/images/26/07/fish-audio-inerface.webp)

## Step 4: Add emotion tags

Fish Audio&apos;s emotion tags are what set it apart from other TTS tools. Once your clone is working, you can add tags to control delivery:

- `(excited)` — upbeat, energetic
- `(sad)` — slower, lower pitch
- `(whisper)` — quiet, intimate
- `(angry)` — sharp, forceful
- `(serious)` — firm, measured
- `(happy)` — warm, positive

Place the tag at the start of the section you want to affect:

```
(excited) I just got the promotion I have been working toward for two years!
(serious) But I need to think carefully about whether to accept it.
(whisper) Between you and me, I already made up my mind.
```

### Emotion tag tips

**Do not overdo it.** One or two tags per paragraph is enough. Too many tags make the output sound unnatural.

**Place tags at natural break points.** Put them at the start of sentences or clauses, not in the middle of a word.

**Experiment.** The same text with different tags produces very different results. Try a few variations before committing to a final version.

**Combine with punctuation.** Exclamation marks, ellipses, and question marks work with emotion tags to shape delivery.

## Step 5: Generate and download

Once you are happy with the output:

1. Click **Generate** to create the audio
2. Listen to the full output
3. If it sounds right, click **Download** to save as MP3 or WAV
4. Import into your video editor, podcast tool, or project

You can generate as many variations as you want. Try different phrasings, different tag placements, and different texts until the output matches what you need.

&lt;Accordion label=&quot;How long does voice cloning take?&quot; group=&quot;faq&quot;&gt;
About 30 seconds to 2 minutes. Fish Audio processes your audio sample, extracts voice characteristics, and builds a model. The actual time depends on server load, but it is usually under two minutes.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I clone someone else&apos;s voice?&quot; group=&quot;faq&quot;&gt;
You should only clone voices you have permission to use. Fish Audio requires that you have the right to clone any voice you upload. Cloning someone else&apos;s voice without consent may violate their terms of service and could have legal consequences.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I improve my clone after creating it?&quot; group=&quot;faq&quot;&gt;
You can create a new clone with a better audio sample. There is no way to &quot;fine-tune&quot; an existing clone. If the quality is not what you want, record a cleaner sample and create a new voice.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How many languages can my clone speak?&quot; group=&quot;faq&quot;&gt;
Fish Audio supports 83 languages. Your clone can generate speech in any of these languages. Quality varies by language. Major languages like English, Chinese, Japanese, French, and German work well. Less common languages may have some accent artifacts.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is my voice data safe?&quot; group=&quot;faq&quot;&gt;
Fish Audio uses standard encryption for voice data. They do not claim perpetual rights over your voice (unlike some competitors). That said, read the terms of service before uploading sensitive audio. For commercial use, paid plans include proper licensing.
&lt;/Accordion&gt;

## Common mistakes

**Recording in a noisy room.** Background noise, echo, and room reverb all get captured in the clone. Record in the quietest room you can find.

**Speaking too slowly or unnaturally.** If you read like a robot, your clone will sound like a robot. Speak the way you normally talk.

**Using too many emotion tags.** Two or three tags per paragraph is fine. Ten tags per paragraph makes the output sound choppy and unnatural.

**Not testing enough.** Generate several variations with different text before deciding the clone is good or bad. One bad output does not mean the clone is broken.

**Expecting perfection.** AI voice cloning is good, not perfect. The clone will sound like you on a good day, not exactly like you in every situation. For most content creation purposes, that is good enough.

## What I would do differently

If I were starting over, I would:

1. Record in a treated room or closet (less echo)
2. Use my USB mic instead of the laptop mic
3. Record a few different samples and test each one before picking the best
4. Start with no emotion tags and add them gradually

The clone I have now works well for my YouTube videos. My wife could not tell the difference in a blind test, which is good enough for me. But the first attempt was not great because I recorded in a room with a ceiling fan running. Clean input matters.

&lt;Button text=&quot;Clone Your Voice Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Related articles

- [Fish Audio review 2026](/fish-audio-review/) — pricing, free API, what is good and what is not
- [Add voice cloning TTS to Mastra with Fish Audio](/mastra-fish-audio-tts/) — use your clone from a Mastra agent (`fish_tts` tool)
- [Fish Audio vs ElevenLabs](/fish-audio-vs-elevenlabs/) — if you are leaving ElevenLabs
- [Fish Audio vs MiniMax comparison](/fish-audio-vs-minimax/) — another AI voice alternative
- [Text-to-Speech with uv](/uv-text-to-speech-script/) — run TTS locally from the command line

**Lee en espanol:** [Resena Fish Audio](/es/resena-fish-audio/) | [Fish Audio vs ElevenLabs](/es/fish-audio-vs-elevenlabs/) | [Clonar Tu Voz](/es/fish-audio-clonar-voz/) | [Fish Audio vs MiniMax](/es/fish-audio-vs-minimax/)</content:encoded><category>ai</category><category>fish-audio</category><category>voice-cloning</category><category>text-to-speech</category></item><item><title>Fish Audio Review 2026: Free AI Voice Cloning That Sounds Human</title><link>https://www.bitdoze.com/fish-audio-review/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-audio-review/</guid><description>I cloned my voice from a 15-second clip and use it for all my YouTube videos. Fish Audio review covering S2.1 Pro, emotion tags, 83 languages, and the free API tier.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import fishAudioInterface from &quot;@assets/images/26/07/fish-audio-inerface.webp&quot;;
import fishAudioClone from &quot;@assets/images/26/07/fish-audio-clone-voice.webp&quot;;

I clone my voice in Fish Audio from about 15 seconds of audio and use it on YouTube. I came over from ElevenLabs because the bill kept climbing and multilingual support was not good enough for my mix of English and Romanian. A few weeks in, I am still on Fish Audio. Here is the honest version: what works, what is rough, and who should skip it.

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## What is Fish Audio

Fish Audio is a voice AI platform that does text-to-speech, voice cloning, speech-to-text, and a handful of other audio tasks. You can use it through their web app with no code, or through their REST API and Python SDK if you want to build something on top of it.

The thing that got my attention was the emotion control. Most TTS tools give you a flat, monotone output unless you spend time tweaking SSML tags. Fish Audio lets you mark sections of text with emotion tags like `(excited)` or `(whisper)` and the voice actually changes delivery. It sounds like a small thing, but it makes a real difference when you are narrating a 10-minute video and need the tone to shift.

![Fish Audio TTS interface with emotion controls and S2.1 Pro model selection](../../assets/images/26/07/fish-audio-inerface.webp)

They have over 2 million community-uploaded voices in their library. You can pick a voice someone else created, or clone your own from about 10 to 15 seconds of audio. The cloning quality is solid. Not perfect, but good enough that my wife could not tell the difference in a blind test between my real voice and the clone in a video narration.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/1qnuGqTKGL0&quot;
  label=&quot;Clone Your Voice with Fish Audio and Mastra)
&quot;
/&gt;

## The models

Fish Audio runs three main TTS models:

- **S2.1 Pro** — their current production model. Better quality, lower latency, and higher throughput than the previous generation. This is the one I use.
- **S2 Pro** — the previous generation. Still solid, supports multi-speaker and natural language expression control.
- **S1** — the older model that uses parenthesis-based emotion tags like `(happy)` or `(sad)`.

Here is the part that surprised me: they made S2.1 Pro free for developers. Same model that powers their paid tier, free API access, 83 languages, no hard usage cap. You just set `model: &quot;s2.1-pro-free&quot;` in your API call and you are on S2.1 Pro. If you are building an app or testing TTS in a project, this is a good deal.

&lt;Notice type=&quot;info&quot; title=&quot;Free S2.1 Pro API&quot;&gt;
Fish Audio made their best model free for developers. Set `model: &quot;s2.1-pro-free&quot;` and you get the same S2.1 Pro quality at no cost. No hard usage cap. Same endpoint as the paid version.
&lt;/Notice&gt;

## What Fish Audio actually does well

**Voice cloning is fast.** I recorded about 15 seconds of myself reading a paragraph, uploaded it, and had a working clone in under two minutes. ElevenLabs requires 60 seconds of audio for cloning, and the clone sits behind their $22/month tier. Fish Audio does it faster and cheaper.

![Cloned voice in Fish Audio showing waveform and language settings](../../assets/images/26/07/fish-audio-clone-voice.webp)

**Emotion control works.** The tag system takes some getting used to. You cannot just sprinkle `(excited)` everywhere and expect good results. But once you figure out where to place them, the output sounds noticeably more natural than flat TTS. I use it for YouTube narration and the difference is obvious.

**Multilingual support is strong.** Fish Audio handles 83 languages. I tested it with mixed English and Romanian content and the pronunciation was clean. No robotic accent bleed that you get with some other tools. If you create content in multiple languages, this is where Fish Audio pulls ahead of ElevenLabs.

**The community voice library is large.** Two million voices means you will probably find something close to what you need without cloning anything. You can search by language, style, and use case. Some of the community voices are surprisingly good.

**Pricing is reasonable.** The free tier gives you enough to test properly. Paid plans start at around $15/month with pay-as-you-go pricing. No credit expiry nonsense. You pay for what you use.

&lt;Button text=&quot;Get Started with Fish Audio&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Where it falls short

**The web app can be slow.** Generating long audio through the browser interface takes a while. The API is faster, but if you are a non-technical user who just wants to paste text and get audio, the web experience could be better.

**Emotion tags have a learning curve.** The documentation explains what tags are available, but not much about placement strategy. I had to experiment for about an hour before I got results I was happy with. A few example scripts with before/after audio would help.

**Voice library quality varies.** Two million voices is a lot, and not all of them are good. The search and filtering could be improved. I spent more time than I wanted browsing through mediocre voices before finding ones I liked.

**No desktop app.** It is web-only or API. If you want a native app for offline work, you are out of luck. There is a mobile app on Google Play, but I have not tested it.

## Fish Audio vs ElevenLabs

This is the comparison most people want to know about. I have used both, so here is how they stack up:

| Feature | Fish Audio | ElevenLabs |
|---------|-----------|------------|
| Voice cloning audio needed | 10-15 seconds | 60+ seconds |
| Cheapest plan with cloning | Free / $15/mo | $22/mo |
| Languages | 83 | 32 |
| Emotion control | Tag-based | Limited |
| Community voices | 2 million+ | Large library |
| English voice quality | Very good | Best in class |
| API pricing | $15/million chars | ~$30/million chars |
| Free tier | Yes | Yes (no cloning) |

ElevenLabs still has the edge on raw English voice quality. If you only produce English content and budget is not a concern, ElevenLabs is hard to beat. But for multilingual work, emotion control, and cost, Fish Audio is the better pick. The free S2.1 Pro API makes it even more compelling for developers.

One thing worth noting: ElevenLabs updated their terms of service to claim perpetual rights over voice data. Fish Audio does not have that clause. If you are cloning your own voice, read the fine print.

&lt;Notice type=&quot;warning&quot; title=&quot;Voice data ownership&quot;&gt;
Before cloning your voice on any platform, read the terms of service. ElevenLabs claims perpetual, royalty-free rights over voice data. Fish Audio&apos;s terms are more standard. This matters if you are using your own voice commercially.
&lt;/Notice&gt;

## Pricing

Fish Audio keeps pricing straightforward:

| Plan | Price | What you get |
|------|-------|-------------|
| Free | $0 | Limited generations, access to community voices, S2.1 Pro free API |
| Starter | ~$15/month | More generations, voice cloning, priority processing |
| Pro | ~$45/month | Higher limits, commercial rights, faster processing |
| Enterprise | Custom | Dedicated support, SLA, custom integrations |

The free tier is enough to test voice cloning and generate a few audio clips. If you are producing content regularly, the Starter plan covers most use cases. The pay-as-you-go model means you are not losing credits at the end of the month.

For developers, the free S2.1 Pro API is hard to argue with. Same model quality as the paid tier, no hard usage cap. If you are building a product that needs TTS, start here.

## How to get started

1. Go to [Fish Audio](https://go.bitdoze.com/fish-audio) and create a free account
2. Pick a voice from the community library or clone your own
3. Paste your text, select emotions where needed, and generate
4. Download the audio or use the API in your project

For voice cloning, record yourself reading a paragraph clearly for about 15 seconds. Upload the audio, wait a minute or two, and your clone is ready. Test it with a few different scripts before committing to a paid plan.

If you want to use the API, their documentation at [docs.fish.audio](https://docs.fish.audio/overview/capabilities) covers the Python SDK and REST endpoints. The `s2.1-pro-free` model is a good starting point.

&lt;Accordion label=&quot;How does voice cloning work?&quot; group=&quot;faq&quot;&gt;
Fish Audio analyzes a short audio clip (10-15 seconds) to capture your voice characteristics: tone, pitch, speaking style, and rhythm. It builds a model from that clip that can then generate speech in your voice from any text input. The clone can speak in 83 languages, though quality varies by language.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Fish Audio free?&quot; group=&quot;faq&quot;&gt;
Yes, there is a free tier with limited generations and access to community voices. The S2.1 Pro API is also free for developers with no hard usage cap. Paid plans start at around $15/month for higher limits and voice cloning features.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Fish Audio safe?&quot; group=&quot;faq&quot;&gt;
Fish Audio uses standard encryption and does not claim perpetual rights over your voice data (unlike some competitors). That said, read the terms of service before uploading sensitive audio. For commercial use of cloned voices, paid plans include proper licensing.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Fish Audio vs ElevenLabs: which should I pick?&quot; group=&quot;faq&quot;&gt;
Fish Audio is better for multilingual content, emotion control, and budget-conscious projects. ElevenLabs edges ahead on raw English voice quality. If you create content in multiple languages or want the free API, go with Fish Audio. If you only do English narration and want the absolute best quality, ElevenLabs is worth the extra cost.
&lt;/Accordion&gt;

## Who should use Fish Audio

**Content creators** producing videos, podcasts, or audiobooks in multiple languages. The emotion tags make narration sound less robotic, and the pricing will not wreck your budget.

**Developers** building apps that need TTS. The free S2.1 Pro API is generous and the documentation is decent. Python SDK and REST endpoints are both available.

**Teams** that need a consistent brand voice across content. Clone one voice, use it everywhere. The multilingual support means you can localize without hiring voice actors for each language.

**Anyone testing AI voice tools.** The free tier is enough to get a real feel for the platform before spending money.

## Who should look elsewhere

If you only produce English content and want the absolute highest voice fidelity, ElevenLabs is still the benchmark. If you need a desktop app for offline work, Fish Audio does not have one. And if you want a tool that works perfectly out of the box with no experimentation, the emotion tag system will frustrate you at first.

## Final thoughts

I came for cheaper cloning and stayed for emotion tags and the free API. Clone quality is good enough for YouTube. The web app is still slower than it should be, library search is messy, and tags take practice. For my use case, none of that outweighs the price and multilingual win.

Want to try it? Free tier, 15 seconds of audio, about two minutes of waiting. If you are comparing alternatives, I also wrote [Fish Audio vs ElevenLabs](/fish-audio-vs-elevenlabs/) and a [Fish Audio vs MiniMax comparison](/fish-audio-vs-minimax/).

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Related&quot;&gt;
If you are interested in building TTS scripts locally, check out [Text-to-Speech with uv: Create Audio from Text in Python](/uv-text-to-speech-script/) for a guide on running TTS from the command line.
&lt;/Notice&gt;

## Related articles

- [Add voice cloning TTS to Mastra with Fish Audio](/mastra-fish-audio-tts/) — wire `fish_tts` into Mastra agents for video narration
- [Fish Audio vs ElevenLabs 2026](/fish-audio-vs-elevenlabs/) — side-by-side quality, cloning, and real yearly cost
- [Clone your voice with Fish Audio in 2 minutes](/fish-audio-clone-voice/) — walkthrough with screenshots and recording tips
- [Fish Audio vs MiniMax comparison](/fish-audio-vs-minimax/) — English vs Chinese quality, pricing, free API

**Lee en espanol:** [Resena Fish Audio](/es/resena-fish-audio/) | [Fish Audio vs ElevenLabs](/es/fish-audio-vs-elevenlabs/) | [Clonar Tu Voz](/es/fish-audio-clonar-voz/) | [Fish Audio vs MiniMax](/es/fish-audio-vs-minimax/)</content:encoded><category>ai</category><category>fish-audio</category><category>voice-cloning</category><category>text-to-speech</category></item><item><title>Fish Audio vs ElevenLabs 2026: I Switched and Saved $84/Year</title><link>https://www.bitdoze.com/fish-audio-vs-elevenlabs/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-audio-vs-elevenlabs/</guid><description>Six months on ElevenLabs, then Fish Audio. Side-by-side on English quality, cloning (15s vs 60s), 83 languages, emotion tags, and real monthly cost.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import fishAudioInterface from &quot;@assets/images/26/07/fish-audio-inerface.webp&quot;;
import fishAudioClone from &quot;@assets/images/26/07/fish-audio-clone-voice.webp&quot;;

I used ElevenLabs for about six months before switching to Fish Audio. Not because ElevenLabs is bad. It is not. Multilingual support was not enough for my English/Romanian mix, and the bill was getting silly for a solo creator. After running both on the same scripts, here is what actually differed.

## The short version

Fish Audio wins on multilingual content, emotion tags, cloning speed, and price. ElevenLabs still wins raw English fidelity and has a cleaner UI. If you only ship English and quality is everything, stay on ElevenLabs. Everyone else should try Fish first.

| Feature | Fish Audio | ElevenLabs |
|---------|-----------|------------|
| Voice cloning audio needed | 10-15 seconds | 60+ seconds |
| Cheapest plan with cloning | Free / $15/mo | $22/mo |
| Languages | 83 | 32 |
| Emotion control | Tag-based | Limited |
| Community voices | 2 million+ | Large library |
| English voice quality | Very good | Best in class |
| API pricing | $15/million chars | ~$30/million chars |
| Free tier | Yes | Yes (no cloning) |
| Voice data rights | Standard | Perpetual license claim |

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Voice quality

This is the comparison that matters most, and it is close.

ElevenLabs produces the most realistic English voices I have heard from any TTS tool. The intonation, the micro-pauses, the way it handles emphasis, it sounds human. If you are producing English-only narration for a podcast or audiobook, ElevenLabs is the benchmark.

Fish Audio is close. Not identical, but close enough that most listeners would not notice the difference in a YouTube video or training course. Where Fish Audio pulls ahead is emotion control. You can tag sections of text with emotions like `(excited)`, `(whisper)`, or `(serious)` and the voice changes delivery mid-sentence. ElevenLabs does not have this. You get one tone per generation, and if you want to shift emotion, you generate separate clips and edit them together.

For multilingual content, Fish Audio wins clearly. I tested both with mixed English and Romanian narration. Fish Audio handled the language transitions cleanly. ElevenLabs had noticeable accent bleed when switching between languages, and the Romanian pronunciation was off on several words.

![Fish Audio TTS interface with emotion controls and model selection](../../assets/images/26/07/fish-audio-inerface.webp)

## Voice cloning

Fish Audio clones faster and with less audio. You need about 10 to 15 seconds of clear speech. Upload it, wait about two minutes, and you have a working clone. The quality is good. I used my own clone for a week of YouTube narration and nobody noticed it was AI.

ElevenLabs has two cloning modes. Instant Voice Cloning needs about 10 seconds of audio but is only available on paid plans starting at $6/month (Starter), and the quality is decent but not great. Professional Voice Cloning needs 30+ minutes of audio and produces near-perfect results, but it is only on the $22/month Creator plan and above.

Here is the practical difference: if you want to clone your voice quickly and cheaply, Fish Audio does it in under two minutes for free. If you want the absolute highest fidelity clone and you are willing to record 30 minutes of audio and pay $22/month, ElevenLabs Professional Voice Cloning is better.

![My cloned voice in Fish Audio showing waveform and language settings](../../assets/images/26/07/fish-audio-clone-voice.webp)

&lt;Notice type=&quot;warning&quot; title=&quot;Voice data ownership&quot;&gt;
ElevenLabs updated their terms of service to claim &quot;perpetual, irrevocable, royalty-free&quot; rights over voice data uploaded to their platform. Fish Audio does not have this clause. If you are cloning your own voice for commercial use, read both platforms&apos; terms carefully.
&lt;/Notice&gt;

## Pricing

This is where the gap gets wider.

### ElevenLabs pricing

| Plan | Price | Characters/month | Cloning |
|------|-------|-----------------|---------|
| Free | $0 | 10,000 | No |
| Starter | $6/mo | 30,000 | Instant only |
| Creator | $22/mo | 121,000 | Professional |
| Pro | $99/mo | 600,000 | Professional |
| Scale | $299/mo | 1,800,000 | Professional |

Credits do not roll over. If you do not use them, they disappear. The Creator plan at $22/month is the minimum for Professional Voice Cloning.

### Fish Audio pricing

| Plan | Price | What you get |
|------|-------|-------------|
| Free | $0 | Limited generations, community voices, free S2.1 Pro API |
| Starter | ~$15/mo | More generations, voice cloning, priority processing |
| Pro | ~$45/mo | Higher limits, commercial rights |
| Enterprise | Custom | SLA, dedicated support |

Fish Audio uses pay-as-you-go pricing. No credit expiry. The free S2.1 Pro API gives developers the same model quality as the paid tier with no hard usage cap.

For a creator producing about 2 hours of audio per month, the cost comparison works out to roughly:

- **ElevenLabs Creator plan**: $22/month for 121,000 characters
- **Fish Audio Starter plan**: ~$15/month with similar or better output

That is about $84/year savings. Over a few years, it adds up.

&lt;Button text=&quot;Get Started with Fish Audio&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Language support

Fish Audio supports 83 languages. ElevenLabs supports 32.

The raw number is not the whole story. What matters is how well each platform handles the languages you actually need. For major languages like English, French, German, Spanish, and Japanese, both are solid. For less common languages, Fish Audio generally has better coverage and more natural pronunciation.

I create content in English and Romanian. Fish Audio handles both well. ElevenLabs Romanian is usable but has noticeable issues with certain vowel sounds and word stress patterns. If you create content in Asian languages, Fish Audio has stronger support for Chinese, Japanese, and Korean.

## Emotion and expression

Fish Audio uses a tag system. You insert tags like `(excited)`, `(sad)`, `(whisper)`, or `(angry)` directly into your text, and the voice changes delivery for that section. It takes some practice to get right, but the results are worth it.

ElevenLabs has &quot;emotional&quot; voices in their library, but you cannot control emotion within a single generation. You pick a voice that sounds a certain way and it stays that way throughout. If your script shifts from serious to upbeat, you need to generate separate clips and stitch them together in post.

For narration work where tone shifts matter, like YouTube videos, audiobooks, or training content, Fish Audio&apos;s tag system is a significant advantage.

## API and developer experience

Both platforms have solid APIs. ElevenLabs has been around longer and has more third-party integrations. Their documentation is thorough and the JavaScript/Python SDKs are mature.

Fish Audio&apos;s API is clean and straightforward. The REST endpoints are well-documented, and the Python SDK works as expected. The free S2.1 Pro API is a strong draw for developers testing TTS in their apps. Set `model: &quot;s2.1-pro-free&quot;` and you are running the same model as paying customers.

One practical difference: Fish Audio&apos;s API pricing at $15/million characters is about half of ElevenLabs&apos; ~$30/million characters. For high-volume applications, this difference is significant.

## Who should pick Fish Audio

**Multilingual creators.** If you produce content in more than one language, Fish Audio handles it better and cheaper.

**Budget-conscious creators.** The free tier is functional, voice cloning is free, and the paid plans are cheaper than ElevenLabs.

**Developers.** The free S2.1 Pro API and lower per-character pricing make Fish Audio the better choice for building TTS into products.

**Anyone who wants emotion control.** The tag system lets you shape delivery within a single generation, which ElevenLabs cannot do.

## Who should pick ElevenLabs

**English-only creators who want the best quality.** ElevenLabs English voices are still the benchmark. If you only produce English content and quality is your top priority, it is worth the extra cost.

**Teams that need a polished interface.** ElevenLabs has a more refined web app and better third-party integrations.

**Enterprise users.** ElevenLabs has more enterprise features, including HIPAA compliance and custom SSO.

## My recommendation

I switched from ElevenLabs to Fish Audio and I am not going back. The voice quality is close enough for my YouTube content, the multilingual support is better, and I am saving about $84/year. The emotion tags took some getting used to, but now I cannot imagine going back to flat TTS.

If you only produce English content and money is not a concern, ElevenLabs is still the better tool. For everyone else, Fish Audio is the better value.

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Accordion label=&quot;Can I use Fish Audio and ElevenLabs together?&quot; group=&quot;faq&quot;&gt;
Yes. Some creators use ElevenLabs for their primary English narration voice and Fish Audio for multilingual versions or secondary characters. Both platforms export standard audio files, so mixing them in post-production is straightforward.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which has better voice cloning?&quot; group=&quot;faq&quot;&gt;
Fish Audio clones faster (10-15 seconds vs 60+ seconds) and is cheaper (free vs $22/month for professional cloning). ElevenLabs Professional Voice Cloning produces slightly higher fidelity results but requires 30+ minutes of audio. For quick clones, Fish Audio is the better choice. For studio-grade clones, ElevenLabs has an edge.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is ElevenLabs worth the extra cost?&quot; group=&quot;faq&quot;&gt;
For English-only content where maximum voice quality matters, yes. For multilingual content, budget-conscious projects, or developers building TTS into apps, no. Fish Audio gives you more for less in those cases.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about voice data ownership?&quot; group=&quot;faq&quot;&gt;
ElevenLabs claims perpetual, irrevocable, royalty-free rights over voice data in their terms of service. Fish Audio does not have this clause. If you are cloning your own voice, this difference matters. Read both platforms&apos; terms before uploading sensitive audio.
&lt;/Accordion&gt;

## Related articles

- [Fish Audio review 2026](/fish-audio-review/) — full product review, free API, pros and cons
- [Clone your voice with Fish Audio](/fish-audio-clone-voice/) — 2-minute walkthrough with screenshots
- [Add voice cloning TTS to Mastra with Fish Audio](/mastra-fish-audio-tts/) — agent integration with emotion tags and API
- [Fish Audio vs MiniMax comparison](/fish-audio-vs-minimax/) — if you are also weighing MiniMax Speech
- [Text-to-Speech with uv](/uv-text-to-speech-script/) — run TTS locally from the command line

**Lee en espanol:** [Resena Fish Audio](/es/resena-fish-audio/) | [Fish Audio vs ElevenLabs](/es/fish-audio-vs-elevenlabs/) | [Clonar Tu Voz](/es/fish-audio-clonar-voz/) | [Fish Audio vs MiniMax](/es/fish-audio-vs-minimax/)</content:encoded><category>ai</category><category>fish-audio</category><category>elevenlabs</category><category>voice-cloning</category></item><item><title>Fish Audio vs MiniMax 2026: Which AI Voice Tool Wins?</title><link>https://www.bitdoze.com/fish-audio-vs-minimax/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-audio-vs-minimax/</guid><description>Side-by-side Fish Audio vs MiniMax on cloning, English vs Chinese quality, pricing, and free API. I ran the same YouTube scripts through both.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import fishAudioInterface from &quot;@assets/images/26/07/fish-audio-inerface.webp&quot;;
import fishAudioClone from &quot;@assets/images/26/07/fish-audio-clone-voice.webp&quot;;

I clone my voice in Fish Audio from a short clip and use it on YouTube. When MiniMax&apos;s Speech-02 started topping the Artificial Analysis Speech Arena leaderboard, I ran the same scripts through both tools to see if I should switch. Spoiler: I mostly stayed on Fish Audio, but MiniMax wins a few specific cases.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Voice quality and naturalness comparison&lt;/li&gt;
&lt;li&gt;Voice cloning speed and fidelity&lt;/li&gt;
&lt;li&gt;Emotion and expression controls&lt;/li&gt;
&lt;li&gt;Language support and multilingual performance&lt;/li&gt;
&lt;li&gt;Developer API and pricing&lt;/li&gt;
&lt;li&gt;Which platform fits different use cases&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## The short version

Fish Audio wins on library size, developer pricing, free API tier, and language coverage (83 languages). MiniMax sounds great, especially in Chinese, and its sound tags for laughs and breaths are fun for expressive narration. If you are multilingual or cost-sensitive, start with Fish. If Chinese quality is the whole job, try MiniMax first.

| Feature | Fish Audio | MiniMax |
|---------|-----------|---------|
| Latest model | S2.1 Pro | Speech 2.8 (HD/Turbo) |
| Languages | 83 | 40+ |
| Voice cloning audio needed | 10-15 seconds | 10 seconds |
| Community voices | 2 million+ | 300+ |
| Emotion control | Tags like (excited), (whisper) | Tags + sound effects (laughs), (breath) |
| API pricing | $15/million chars | $60-100/million chars |
| Free tier | Yes, with free S2.1 Pro API | Limited free usage |
| Best for | Multilingual, budget-conscious | Chinese content, expressive narration |

&lt;Button text=&quot;Try Fish Audio Free&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Voice quality

Both platforms produce natural-sounding speech. The differences are subtle but real.

Fish Audio&apos;s S2.1 Pro model handles English well. The output sounds clean, the pacing is natural, and the emotion tags let you shift tone within a generation. I use it for YouTube narration and the quality is good enough that most viewers do not notice it is AI.

MiniMax Speech 2.8 HD focuses on high-fidelity narration. According to Artificial Analysis, their Speech-02 model ranks at or near the top of the leaderboard for voice quality. The HD variant produces polished output suitable for audiobooks and professional voiceovers. The Turbo variant trades some quality for speed, which is better for real-time applications.

For Chinese content, MiniMax has an edge. Their models were built with strong Chinese language support from the start, and the pronunciation and rhythm in Mandarin are more natural than most competitors. If you create Chinese content, MiniMax is worth testing first.

For English and other European languages, the difference is less clear. Both produce good results. I would recommend generating the same script on both platforms and comparing the output side by side.

## Voice cloning

Both platforms clone voices from short audio samples. The process is similar, but the details differ.

**Fish Audio** needs 10 to 15 seconds of clear audio. Upload it, wait about two minutes, and the clone is ready. The quality is good for content creation. My clone sounds close enough to my real voice that listeners cannot tell the difference in a YouTube video.

**MiniMax** needs about 10 seconds of audio. The cloning process takes about 30 seconds. Their Speech 2.5 announcement claims the model can &quot;flawlessly replicate a person&apos;s unique accent, speaking style, and emotional tone&quot; across languages. The cross-lingual cloning preserves vocal characteristics when switching between languages, which is useful for multilingual content.

One practical difference: MiniMax deletes unused cloned voices after 7 days. If you clone a voice and do not use it, you will need to re-clone. Fish Audio keeps your clones as long as your account is active.

![Cloned voice in Fish Audio showing waveform and language settings](../../assets/images/26/07/fish-audio-clone-voice.webp)

## Emotion and expression controls

This is where the platforms diverge in interesting ways.

**Fish Audio** uses emotion tags. You insert tags like `(excited)`, `(sad)`, `(whisper)`, or `(angry)` into your text, and the voice changes delivery for that section. The system is simple and effective. You can shift tone within a single generation without editing multiple clips together.

**MiniMax** has emotion tags too, but also supports sound tags and interjection tags. These add non-verbal vocal expressions:

- `(laughs)` — adds laughter
- `(chuckle)` — subtle laugh
- `(breath)` — audible breathing
- `(sighs)` — a sigh
- `(clear-throat)` — throat clearing
- `(gasp)` — surprised intake of breath

These sound tags make narration feel more human, especially in storytelling or character-driven content. A breath between paragraphs or a chuckle after a casual line changes how the listener experiences the audio.

MiniMax also supports pause markers with `&lt;#x#&gt;` syntax, where x is the pause duration in seconds. This gives you precise control over pacing without relying on punctuation tricks.

If you need basic emotion control, both platforms work. If you need granular control over non-verbal sounds and pauses, MiniMax has more options.

![Fish Audio TTS interface with emotion controls and model selection](../../assets/images/26/07/fish-audio-inerface.webp)

## Language support

Fish Audio supports 83 languages. MiniMax supports 40+.

The raw numbers favor Fish Audio, but what matters is how well each platform handles the languages you actually need. Here is what I found:

**For English**: Both are solid. Fish Audio and MiniMax produce clean, natural English output.

**For Chinese**: MiniMax is stronger. Their models were optimized for Chinese from the beginning, and the Mandarin output sounds more natural.

**For European languages**: Both handle major languages well (French, German, Spanish, Portuguese, Italian). Fish Audio has better coverage for less common European languages.

**For Asian languages**: MiniMax has strong support for Japanese, Korean, and Vietnamese. Fish Audio covers these too, but MiniMax&apos;s Asian language support is more polished.

If you create content in one or two major languages, both platforms work. If you need wide language coverage across many different languages, Fish Audio has the advantage.

## Developer API

Both platforms offer REST APIs for text-to-speech. The developer experience differs in a few ways.

### Fish Audio API

- REST endpoints and Python SDK
- Free S2.1 Pro API (set `model: &quot;s2.1-pro-free&quot;`)
- Pricing: $15/million characters
- Documentation at docs.fish.audio
- Supports streaming and batch generation

### MiniMax API

- REST API at `/v1/t2a_v2`
- Two model variants: `speech-2.8-hd` and `speech-2.8-turbo`
- Pricing: $60/million chars (Turbo), $100/million chars (HD)
- Available through MiniMax directly or third-party providers (Replicate, fal.ai)
- Supports streaming, subtitle timestamps, and async long-form workflows

The pricing difference is significant. Fish Audio at $15/million characters is about 4x cheaper than MiniMax Turbo at $60/million characters, and about 7x cheaper than MiniMax HD at $100/million characters.

For developers building TTS into products, Fish Audio&apos;s free S2.1 Pro API is hard to beat. You get the same model quality as paying customers with no hard usage cap. MiniMax does not have an equivalent free tier for their best models.

&lt;Notice type=&quot;info&quot; title=&quot;Developer tip&quot;&gt;
If you are building a product that needs TTS, start with Fish Audio&apos;s free S2.1 Pro API. Set `model: &quot;s2.1-pro-free&quot;` in your API call. Same quality as the paid tier, no cost.
&lt;/Notice&gt;

## Pricing comparison

| | Fish Audio | MiniMax |
|---|-----------|---------|
| Free tier | Yes, with S2.1 Pro free API | Limited |
| Paid plans | From ~$15/mo | Pay-as-you-go |
| API pricing | $15/million chars | $60-100/million chars |
| Voice cloning cost | Free | $3 per voice (via Replicate) |
| Credit expiry | None | Varies by provider |

Fish Audio is cheaper at every level. The free tier is more generous, the paid plans cost less, and the API pricing is significantly lower. For high-volume applications, the cost difference adds up fast.

MiniMax&apos;s pricing through third-party providers like Replicate may differ from their direct pricing. Check the specific provider&apos;s rates before committing.

&lt;Button text=&quot;Get Started with Fish Audio&quot; link=&quot;https://go.bitdoze.com/fish-audio&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Voice library

Fish Audio has over 2 million community-uploaded voices. MiniMax has about 300 official voices.

The size difference matters when you are looking for a specific voice style. With 2 million voices, you can search by language, accent, age, gender, and use case. Finding something close to what you need without cloning is realistic on Fish Audio.

MiniMax&apos;s 300 voices are curated and generally high quality. You are less likely to find a mediocre voice in their library. But the selection is smaller, so you might not find the exact style you want.

If you prefer to browse and pick from a large selection, Fish Audio wins. If you prefer a smaller, curated set of reliable voices, MiniMax works.

## Who should pick Fish Audio

**Budget-conscious creators.** The free tier and low API pricing make Fish Audio the cheaper option at every usage level.

**Multilingual creators.** 83 languages vs 40+ means better coverage for less common languages.

**Developers.** The free S2.1 Pro API and lower per-character pricing make Fish Audio the better choice for building TTS into products.

**Anyone who wants a large voice library.** Two million voices means you will probably find what you need without cloning.

## Who should pick MiniMax

**Chinese content creators.** MiniMax&apos;s Chinese language support is the best I have heard.

**Creators who need sound tags.** The ability to add laughs, breaths, sighs, and other non-verbal sounds makes narration feel more human.

**Teams already in the MiniMax ecosystem.** If you use MiniMax&apos;s other AI products (video, music), staying in the same ecosystem simplifies things.

**Users who need precise pause control.** The `&lt;#x#&gt;` syntax gives you exact control over pacing.

## My take

I use Fish Audio and I am staying with it. The pricing is better, the language coverage is wider, and the free API is useful for my side projects. MiniMax is a strong platform, especially for Chinese content and expressive narration, but the 4-7x price difference on API usage is hard to justify for my use case.

If I were creating Chinese content or needed the sound tag system for character-driven narration, I would seriously consider MiniMax. For everything else, Fish Audio is the better value.

&lt;Accordion label=&quot;Is MiniMax Speech-02 better than Fish Audio S2.1 Pro?&quot; group=&quot;faq&quot;&gt;
It depends on the use case. MiniMax Speech-02 ranks highly on voice quality benchmarks and has strong Chinese language support. Fish Audio S2.1 Pro has wider language coverage, lower pricing, and a free API tier. For most users, the difference in English voice quality is small enough that pricing and language support matter more.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use both platforms together?&quot; group=&quot;faq&quot;&gt;
Yes. Both export standard audio files (MP3, WAV). You can generate different sections of a project on different platforms and combine them in your editor. Some creators use MiniMax for Chinese narration and Fish Audio for English.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which has better voice cloning?&quot; group=&quot;faq&quot;&gt;
Both clone from about 10-15 seconds of audio with similar quality. MiniMax claims better cross-lingual cloning (preserving your voice across languages). Fish Audio keeps clones permanently while MiniMax deletes unused clones after 7 days. Test both with your own voice to see which sounds better to you.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is MiniMax free?&quot; group=&quot;faq&quot;&gt;
MiniMax has limited free usage. Their best models (Speech 2.8 HD) cost $100/million characters. Through third-party providers like Replicate, you may get small free tiers. Fish Audio&apos;s free S2.1 Pro API is more generous for developers.
&lt;/Accordion&gt;

## Related articles

- [Fish Audio review 2026](/fish-audio-review/) — full product review, pricing, free API tier
- [Fish Audio vs ElevenLabs](/fish-audio-vs-elevenlabs/) — the comparison most people want first
- [Clone your voice with Fish Audio](/fish-audio-clone-voice/) — 2-minute walkthrough with screenshots
- [Add voice cloning TTS to Mastra with Fish Audio](/mastra-fish-audio-tts/) — wire Fish Audio into Mastra agents for narration
- [Text-to-Speech with uv](/uv-text-to-speech-script/) — run TTS locally from the command line

**Lee en espanol:** [Resena Fish Audio](/es/resena-fish-audio/) | [Fish Audio vs ElevenLabs](/es/fish-audio-vs-elevenlabs/) | [Clonar Tu Voz](/es/fish-audio-clonar-voz/) | [Fish Audio vs MiniMax](/es/fish-audio-vs-minimax/)</content:encoded><category>ai</category><category>fish-audio</category><category>minimax</category><category>voice-cloning</category></item><item><title>How to Install WordPress with Docker Compose: Full Stack Guide</title><link>https://www.bitdoze.com/install-wordpress-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/install-wordpress-docker/</guid><description>Install WordPress with Docker Compose. Complete stack with MySQL 8.4, phpMyAdmin, Cloudflare SSL, automatic backups, and Redis caching. Production-ready setup guide.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/02/docker-wp-access.png&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

This guide walks you through installing WordPress with Docker Compose, covering a complete production-ready stack: MySQL 8.4 LTS, phpMyAdmin, automated database backups, optional Redis object caching, and Cloudflare Tunnels for SSL. Everything runs in five containers managed by a single `compose.yaml` file on any Linux VPS or [Mini PC home server](https://www.bitdoze.com/best-mini-pc-home-server/).

You&apos;ll need a Linux VPS. I recommend [Hetzner](https://go.bitdoze.com/hetzner) for the best price-to-performance ratio in Europe, or [Hostinger](https://go.bitdoze.com/hostinger-vps) if you prefer NVMe storage at a budget price. If you are running WooCommerce, you can also deploy the [Woo Admin product dashboard](https://www.bitdoze.com/woocommerce-admin-dashboard/) alongside WordPress in Docker for faster product management.

Here&apos;s what you get by following this guide:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;WordPress container (pinned PHP version, persistent volume)&lt;/li&gt;
&lt;li&gt;MySQL 8.4 LTS database (supported until 2032)&lt;/li&gt;
&lt;li&gt;phpMyAdmin for database management&lt;/li&gt;
&lt;li&gt;Automated database backups with rotation&lt;/li&gt;
&lt;li&gt;Redis object cache for faster database queries&lt;/li&gt;
&lt;li&gt;SSL via Cloudflare Tunnels (free plan)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&gt; I&apos;ll use `latest` for some image tags below, but you can pin exact versions if you prefer predictable deploys. The one exception is MySQL. More on that in Step 3.

## WordPress Docker Compose Stack: Step-by-Step Setup

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/m3FNd_7MSGQ&quot;
  label=&quot;Install WordPress in a Docker Container with Docker Compose&quot;
/&gt;

### 1. Prerequisites for your WordPress Docker stack

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A Linux VPS with 2GB+ RAM (4GB recommended for WordPress + Redis + phpMyAdmin)&lt;/li&gt;
&lt;li&gt;Docker Engine 24+ and Docker Compose V2 (the &lt;code&gt;docker compose&lt;/code&gt; plugin, not standalone &lt;code&gt;docker-compose&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A domain name pointed at your server (or use direct IP access)&lt;/li&gt;
&lt;li&gt;A Cloudflare account (free plan) for Tunnels and SSL&lt;/li&gt;
&lt;li&gt;Port 5010 and 5011 available (or pick your own)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Docker Compose V2 syntax&quot;&gt;
This guide uses Docker Compose V2 syntax. The standalone `docker-compose` (V1) is deprecated and no longer receives updates. All examples use `compose.yaml` naming. If you&apos;re still on V1, upgrade with `apt install docker-compose-plugin` or check your distro&apos;s docs.
&lt;/Notice&gt;

Dockge is optional but recommended. It gives you a web UI to manage your compose stacks without SSH-ing into the server. Check [Dockge Install - Docker Compose Manager for Self-Hosting](https://www.bitdoze.com/dockge-install/) for the full setup. If you prefer a home server over a VPS, have a look at the [best Mini PCs for home server](https://www.bitdoze.com/best-mini-pc-home-server/). An [ASUS DC510](https://go.bitdoze.com/asus-dc510) works well for this kind of stack.

Familiarize yourself with these [essential Docker commands](https://www.bitdoze.com/docker-commands/) before moving on. They&apos;ll help with troubleshooting.

### 2. Create the project directory and config files

Docker bind-mounts behave badly when the source file doesn&apos;t exist. Docker creates a directory instead of a file, and your PHP config won&apos;t load. Create the config files first:

```sh
# Navigate to where the stack will live
cd /opt/stacks/wordpress

# Create the config directory and empty files
mkdir -p config
touch ./config/wp_php.ini
touch ./config/pma_php.ini
touch ./config/pma_config.php

# Set ownership so containers can write to volumes
# WordPress runs as UID 1000 inside the container
chown -R 1000:1000 ./config
```

&lt;Notice type=&quot;warning&quot; title=&quot;File permissions matter&quot;&gt;
If you skip creating these files first, Docker will create them as directories, and your PHP config won&apos;t load. After the first `docker compose up`, also check ownership of the data directories: `chown -R 1000:1000 ./wp-app ./db_data ./backups` to avoid permission denied errors inside WordPress.
&lt;/Notice&gt;

Verify the files were created correctly:

```sh
ls -la ./config/
```

You should see regular files (`-rw-r--r--`), not directories (`drwxr-xr-x`).

### 3. Docker Compose file: WordPress, MySQL 8.4 LTS and phpMyAdmin

Here&apos;s the full `compose.yaml` with all five services. I&apos;ll explain the key decisions after the code.

&lt;Tabs&gt;
&lt;Tab name=&quot;Base Stack (4 services)&quot;&gt;

```yaml
services:
  wp:
    image: wordpress:php8.3
    restart: unless-stopped
    ports:
      - 5010:80
    volumes:
      - ./config/wp_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./wp-app:/var/www/html
    environment:
      WORDPRESS_DB_HOST: wp-db:3306
      WORDPRESS_DB_NAME: &quot;${DB_NAME}&quot;
      WORDPRESS_DB_USER: &quot;${DB_USER}&quot;
      WORDPRESS_DB_PASSWORD: &quot;${DB_PASSWORD}&quot;
      WORDPRESS_TABLE_PREFIX: &quot;wp_&quot;
      WORDPRESS_CONFIG_EXTRA: |
        define(&apos;FS_METHOD&apos;, &apos;direct&apos;);
    depends_on:
      wp-db:
        condition: service_healthy
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost/wp-admin/install.php&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  wp-db:
    image: mysql:8.4
    volumes:
      - ./db_data:/var/lib/mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: &quot;${DB_ROOT_PASSWORD}&quot;
      MYSQL_DATABASE: &quot;${DB_NAME}&quot;
      MYSQL_USER: &quot;${DB_USER}&quot;
      MYSQL_PASSWORD: &quot;${DB_PASSWORD}&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;mysqladmin&quot;, &quot;ping&quot;, &quot;-h&quot;, &quot;localhost&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

  pma:
    image: phpmyadmin:latest
    ports:
      - 5011:80
    volumes:
      - ./config/pma_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./config/pma_config.php:/etc/phpmyadmin/config.user.inc.php
    restart: unless-stopped
    environment:
      PMA_HOST: wp-db
      PMA_PORT: 3306
      MYSQL_ROOT_PASSWORD: &quot;${DB_ROOT_PASSWORD}&quot;
      UPLOAD_LIMIT: 100M
    depends_on:
      - wp-db

  wp-db-backup:
    image: tiredofit/db-backup:4.1
    volumes:
      - ./backups:/backup
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: wp-db
      DB_NAME: &quot;${DB_NAME}&quot;
      DB_USER: &quot;${DB_USER}&quot;
      DB_PASS: &quot;${DB_PASSWORD}&quot;
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: ZSTD
      CONTAINER_ENABLE_MONITORING: &quot;false&quot;
    depends_on:
      - wp-db
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Full Stack with Redis (5 services)&quot;&gt;

```yaml
services:
  wp:
    build:
      context: .
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - 5010:80
    volumes:
      - ./config/wp_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./wp-app:/var/www/html
    environment:
      WORDPRESS_DB_HOST: wp-db:3306
      WORDPRESS_DB_NAME: &quot;${DB_NAME}&quot;
      WORDPRESS_DB_USER: &quot;${DB_USER}&quot;
      WORDPRESS_DB_PASSWORD: &quot;${DB_PASSWORD}&quot;
      WORDPRESS_TABLE_PREFIX: &quot;wp_&quot;
      WORDPRESS_CONFIG_EXTRA: |
        define(&apos;FS_METHOD&apos;, &apos;direct&apos;);
        define(&apos;WP_REDIS_HOST&apos;, &apos;redis-wp&apos;);
        define(&apos;WP_REDIS_PORT&apos;, 6379);
        define(&apos;WP_REDIS_DATABASE&apos;, 0);
    depends_on:
      wp-db:
        condition: service_healthy
      redis-wp:
        condition: service_started
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost/wp-admin/install.php&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M

  wp-db:
    image: mysql:8.4
    volumes:
      - ./db_data:/var/lib/mysql
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: &quot;${DB_ROOT_PASSWORD}&quot;
      MYSQL_DATABASE: &quot;${DB_NAME}&quot;
      MYSQL_USER: &quot;${DB_USER}&quot;
      MYSQL_PASSWORD: &quot;${DB_PASSWORD}&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;mysqladmin&quot;, &quot;ping&quot;, &quot;-h&quot;, &quot;localhost&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    deploy:
      resources:
        limits:
          memory: 1G
        reservations:
          memory: 512M

  pma:
    image: phpmyadmin:latest
    ports:
      - 5011:80
    volumes:
      - ./config/pma_php.ini:/usr/local/etc/php/conf.d/conf.ini
      - ./config/pma_config.php:/etc/phpmyadmin/config.user.inc.php
    restart: unless-stopped
    environment:
      PMA_HOST: wp-db
      PMA_PORT: 3306
      MYSQL_ROOT_PASSWORD: &quot;${DB_ROOT_PASSWORD}&quot;
      UPLOAD_LIMIT: 100M
    depends_on:
      - wp-db

  wp-db-backup:
    image: tiredofit/db-backup:4.1
    volumes:
      - ./backups:/backup
    restart: unless-stopped
    environment:
      DB_TYPE: mysql
      DB_HOST: wp-db
      DB_NAME: &quot;${DB_NAME}&quot;
      DB_USER: &quot;${DB_USER}&quot;
      DB_PASS: &quot;${DB_PASSWORD}&quot;
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: ZSTD
      CONTAINER_ENABLE_MONITORING: &quot;false&quot;
    depends_on:
      - wp-db

  redis-wp:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - ./redis_data:/data
    deploy:
      resources:
        limits:
          memory: 256M
        reservations:
          memory: 128M
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;error&quot; title=&quot;Do NOT use mysql:latest&quot;&gt;
`mysql:latest` now tracks MySQL 9.x Innovation releases, short-lived versions with only ~3 months of support per minor release. For WordPress in production, use `mysql:8.4` (LTS, supported until April 2032). The `mysql:lts` tag also works as a moving LTS pointer.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;MySQL Innovation vs LTS&quot;&gt;
Since July 2024, MySQL uses a dual release track. **Innovation** releases (9.x) ship new features quarterly but have a short support window. Fine for testing, bad for production. **LTS** releases (8.4, future 9.7) get 5 years of premier support plus 3 years of extended support. Always use LTS for anything that stores data you care about.
&lt;/Notice&gt;

Key decisions in this compose file:

- **`wordpress:php8.3`** instead of `wordpress:latest`: pins PHP to 8.3, which is the WordPress-recommended version. The `latest` tag also ships PHP 8.3 as of mid-2025, but pinning the tag avoids surprises when the default changes.
- **`mysql:8.4`**: the current LTS release, supported until 2032.
- **`redis:7-alpine`**: pinned version, Alpine-based for a smaller image (~30MB vs ~130MB).
- **`tiredofit/db-backup:4.1`**: pinned major version. This image is migrating to `nfrastack/container-db-backup`. The old one still works fine but watch for the new release.
- **`COMPRESSION: ZSTD`**: the new default in db-backup, faster compression and decompression than GZ.
- **`FS_METHOD: direct`**: tells WordPress to write files directly instead of using FTP, which doesn&apos;t work in Docker.
- **Health checks**: MySQL has a `mysqladmin ping` check, WordPress has a `curl` check. The `depends_on: condition: service_healthy` means WordPress waits for MySQL to be actually ready, not just started.
- **Resource limits**: keeps each container from eating all your RAM. Adjust based on your VPS size.

For production, consider [Docker Compose secrets](https://www.bitdoze.com/docker-compose-secrets/) instead of `.env` files. They&apos;re more secure and don&apos;t leave credentials in shell history or process listings.

### 4. Configure the .env file and security keys

Create a `.env` file in the same directory as your `compose.yaml`:

```sh
DB_NAME=&apos;wordpress&apos;
DB_USER=&apos;wp&apos;
DB_PASSWORD=&apos;use-a-strong-random-password-here&apos;
DB_ROOT_PASSWORD=another-strong-random-password
```

Now add the WordPress security salts. These are 8 cryptographic keys that WordPress uses to encrypt cookies and authentication tokens. The official Docker image generates unique random SHA1 hashes from whatever values you provide. It&apos;s a free security upgrade.

&lt;Notice type=&quot;success&quot; title=&quot;Free security upgrade&quot;&gt;
Adding WordPress security salts costs nothing and makes session hijacking significantly harder. The Docker image reads these environment variables and writes the corresponding `define()` constants into `wp-config.php` on first boot.
&lt;/Notice&gt;

Add these to your `.env` file:

```sh
WORDPRESS_AUTH_KEY=&apos;put-unique-phrase-here&apos;
WORDPRESS_SECURE_AUTH_KEY=&apos;put-unique-phrase-here&apos;
WORDPRESS_LOGGED_IN_KEY=&apos;put-unique-phrase-here&apos;
WORDPRESS_NONCE_KEY=&apos;put-unique-phrase-here&apos;
WORDPRESS_AUTH_SALT=&apos;put-unique-phrase-here&apos;
WORDPRESS_SECURE_AUTH_SALT=&apos;put-unique-phrase-here&apos;
WORDPRESS_LOGGED_IN_SALT=&apos;put-unique-phrase-here&apos;
WORDPRESS_NONCE_SALT=&apos;put-unique-phrase-here&apos;
```

Generate real random values from the official WordPress salt generator:

&lt;Button text=&quot;Generate WP Salts →&quot; link=&quot;https://api.wordpress.org/secret-key/1.1/salt/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

Replace each `put-unique-phrase-here` with the generated values. Don&apos;t reuse these across installations.

If you&apos;re using Dockge, you can add these as environment variables in the stack config instead of a `.env` file. For production setups, look at [Docker Compose secrets](https://www.bitdoze.com/docker-compose-secrets/) or the `_FILE` environment variable variants the WordPress image supports (e.g., `WORDPRESS_DB_PASSWORD_FILE=/run/secrets/wp-db-password`).

### 5. Start WordPress in Docker

If you&apos;re using Dockge, save the compose file and click **Start**. Otherwise:

```sh
docker compose up -d
```

Watch the logs to catch any startup errors:

```sh
docker compose logs -f
```

Wait about 30 seconds, then check that all containers are healthy:

```sh
docker compose ps
```

You should see all services with `Up` status. If you included health checks, the `wp-db` service should show `(healthy)` after a few seconds, and the `wp` service after about 30 seconds.

&lt;Notice type=&quot;info&quot; title=&quot;Startup order matters&quot;&gt;
The `depends_on: condition: service_healthy` on the `wp` service means WordPress won&apos;t start until MySQL passes its health check. This prevents the common &quot;Error establishing a database connection&quot; race condition you get with plain `depends_on`.
&lt;/Notice&gt;

**If something fails:**

- Port 5010 or 5011 already in use: `lsof -i :5010` to find what&apos;s occupying it, then change the port mapping
- MySQL health check keeps failing: `docker compose logs wp-db` (look for authentication or config errors)
- WordPress can&apos;t connect to DB: verify the `.env` values match between the `wp` and `wp-db` services

### 6. Configure Cloudflare Tunnels for SSL

Cloudflare Tunnels give you SSL and DDoS protection without opening ports on your firewall or managing certificates.

&lt;Notice type=&quot;info&quot; title=&quot;Updated dashboard path&quot;&gt;
The Cloudflare dashboard path has changed. Navigate to **Zero Trust → Networks → Tunnels**, not the old &quot;Access → Tunnels&quot; path that older guides reference.
&lt;/Notice&gt;

In the Cloudflare Zero Trust dashboard:

1. Go to **Zero Trust → Networks → Tunnels**
2. Select your tunnel (or create one with `cloudflared`)
3. Add a hostname mapping your domain to `http://localhost:5010`
4. Save — Cloudflare handles SSL automatically

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel setup&quot; /&gt;

You can add a second hostname for phpMyAdmin on a subdomain (e.g., `pma.yourdomain.com`) pointing to `http://localhost:5011`. I&apos;d recommend this over exposing port 5011 directly.

Verify the tunnel works:

```sh
curl -I https://yourdomain.com
```

You should get a `200` or `301` response with `cf-ray` and `server: cloudflare` headers.

**502 Bad Gateway?** WordPress container isn&apos;t running or the port mapping is wrong. Run `docker compose ps` and check that the `wp` service is `Up`.

&gt; You can also use [Traefik v3 as a reverse proxy](https://www.bitdoze.com/traefik-proxy-docker/) if you prefer managing SSL yourself, or [CloudPanel with Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/) for a different reverse proxy approach.

### 7. Complete the WordPress installation

Open your domain in the browser (or `http://your-server-ip:5010` if you haven&apos;t set up Cloudflare yet). You&apos;ll see the WordPress installation wizard:

&lt;Picture src={imag2} alt=&quot;WordPress Docker Setup&quot; /&gt;

Choose your language, create an admin account, and you&apos;re in. After that, configure permalinks under **Settings → Permalinks** (I use &quot;Post name&quot; for most sites) and start adding themes and plugins.

### 8. Customize PHP settings for WordPress in Docker

Edit `./config/wp_php.ini` to tune PHP for WordPress:

```ini
file_uploads = On
memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 300
max_input_time = 1000
```

Bump `memory_limit` to `512M` and `upload_max_filesize` to `128M` if you&apos;re running WooCommerce or uploading large media files.

After editing, restart the WordPress container:

```sh
docker compose restart wp
```

&lt;Notice type=&quot;info&quot; title=&quot;PHP version in the WordPress image&quot;&gt;
The `wordpress:latest` image ships PHP 8.2 as of mid-2025. If you followed this guide, you&apos;re using `wordpress:php8.3` which is the WordPress-recommended minimum. The `wordpress:php8.4` tag is also available and fully supported by WordPress 6.7+. Check your version with: `docker exec wp php -v`
&lt;/Notice&gt;

## Database management and backups

### 9. Access phpMyAdmin for database management

Access phpMyAdmin at `http://your-server-ip:5011` (or via a Cloudflare tunnel subdomain). Log in with the database credentials from your `.env` file — the `DB_USER` and `DB_PASSWORD` values.

The `UPLOAD_LIMIT: 100M` in the compose file lets you import larger database dumps through the phpMyAdmin UI.

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t expose phpMyAdmin publicly&quot;&gt;
phpMyAdmin gives full access to your database. In production, firewall off port 5011 and only access it through a Cloudflare Tunnel with an Access policy, or use SSH tunneling: `ssh -L 5011:localhost:5011 your-server-ip`.
&lt;/Notice&gt;

### 10. Verify automatic database backups

The `wp-db-backup` container runs on a schedule defined by `DB_BACKUP_INTERVAL: 720` (every 12 hours) and cleans up backups older than `DB_CLEANUP_TIME: 72000` minutes (~50 days).

Check the backup directory:

```sh
ls -ltr ./backups/
```

You should see files like:

```
-rw------- 1 10000 10000  495 Jul 17 09:26 mysql_wordpress_wp-db_20250717-092619.sql.zst
-rw------- 1 10000 10000   87 Jul 17 09:26 mysql_wordpress_wp-db_20250717-092619.sql.zst.sha1
lrwxrwxrwx 1 10000 10000   44 Jul 17 09:26 latest-mysql_wordpress_wp-db -&gt; mysql_wordpress_wp-db_20250717-092619.sql.zst
```

&lt;Notice type=&quot;info&quot; title=&quot;Backup image migration&quot;&gt;
The `tiredofit/db-backup` image is migrating to `nfrastack/container-db-backup`. The current image (pinned at 4.1) still works fine. Watch for the new release if you&apos;re setting this up after mid-2026. The compression extension changed from `.sql.gz` to `.sql.zst` (ZSTD is faster than GZ).
&lt;/Notice&gt;

For full site backups (files + database), pair this with a WordPress backup plugin — see [Best Free WordPress Backup Plugins](https://www.bitdoze.com/best-free-wordpress-backup-plugins/) for options that handle themes, plugins, and uploads too.

### 11. How to restore a database backup

&lt;Notice type=&quot;warning&quot; title=&quot;Test your restores&quot;&gt;
A backup you can&apos;t restore is not a backup. Run through this procedure at least once after initial setup to make sure it works.
&lt;/Notice&gt;

To restore from a compressed backup:

```sh
# For ZSTD-compressed backups (new default)
zstd -d ./backups/latest-mysql_wordpress_wp-db -c | docker exec -i wp-db mysql -u &quot;${DB_USER}&quot; -p&quot;${DB_PASSWORD}&quot; &quot;${DB_NAME}&quot;

# For GZ-compressed backups (if you haven&apos;t updated compression)
zcat ./backups/latest-mysql_wordpress_wp-db | docker exec -i wp-db mysql -u &quot;${DB_USER}&quot; -p&quot;${DB_PASSWORD}&quot; &quot;${DB_NAME}&quot;
```

To verify backup integrity before restoring, check the SHA1 sidecar file:

```sh
cd ./backups
sha1sum -c mysql_wordpress_wp-db_20250717-092619.sql.zst.sha1
```

After restoring, open WordPress admin and confirm your posts and pages are present.

**Common errors:**

- &quot;Access denied&quot; — wrong credentials or missing quotes around the password
- &quot;Unknown database&quot; — the `DB_NAME` in the restore command doesn&apos;t match the backup
- &quot;ERROR 2006 (HY000)&quot; — MySQL server has gone away, the dump is too large; increase `max_allowed_packet` in MySQL config

## Optional performance improvements

### 12. Add Redis Object Cache to WordPress in Docker

&lt;Notice type=&quot;error&quot; title=&quot;Redis requires a PHP extension&quot;&gt;
The official WordPress Docker image does NOT include the Redis PHP extension. Adding the `redis-wp` service to your compose file is not enough — you must also install the `phpredis` extension or use the `Predis` pure-PHP library. Without this, the Redis Object Cache plugin will show &quot;Not connected.&quot;
&lt;/Notice&gt;

There are three ways to add Redis support. I recommend the custom Dockerfile approach — it&apos;s the cleanest.

&lt;Tabs&gt;
&lt;Tab name=&quot;Custom Dockerfile (Recommended)&quot;&gt;

Create a `Dockerfile` in the same directory as your `compose.yaml`:

```dockerfile
FROM wordpress:php8.3
RUN pecl install redis &amp;&amp; docker-php-ext-enable redis
```

Then change the `wp` service in your compose file from `image: wordpress:php8.3` to:

```yaml
wp:
  build:
    context: .
    dockerfile: Dockerfile
```

The `WORDPRESS_CONFIG_EXTRA` in the full stack compose file (Tab 2 in Step 3) already includes the Redis host and port defines. Rebuild with:

```sh
docker compose up -d --build
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Pre-built Image&quot;&gt;

Use `fazalfarhan01/wordpress-redis` — a community image that bundles the Redis extension:

```yaml
wp:
  image: fazalfarhan01/wordpress-redis:php8.3
```

No Dockerfile needed, but you&apos;re trusting a third-party image. Check the Docker Hub page for the latest tags.

&lt;/Tab&gt;
&lt;Tab name=&quot;Predis (No Build Required)&quot;&gt;

The [Redis Object Cache](https://wordpress.org/plugins/redis-cache/) plugin supports the `Predis` pure-PHP library as an alternative to the `phpredis` extension. Install it via Composer inside the container:

```sh
docker exec wp bash -c &quot;curl -sS https://getcomposer.org/installer | php &amp;&amp; php composer.phar require predis/predis&quot;
```

Then in the plugin settings, switch the client to &quot;Predis.&quot; This is slower than the native extension but requires no image customization.

&lt;/Tab&gt;
&lt;/Tabs&gt;

After activating the Redis Object Cache plugin (by Till Krüss), go to **Settings → Redis** in WordPress admin and click **Enable Object Cache**. The status should show &quot;Connected.&quot;

**Not connecting?** Check that:
1. The Redis container is running: `docker compose ps redis-wp`
2. The host/port in `WP_REDIS_HOST` / `WP_REDIS_PORT` match the service name and port
3. The PHP Redis extension is actually installed: `docker exec wp php -m | grep redis`

&gt; For maximum performance, combine Redis caching with Varnish and Cloudflare — see [How to Speed Up WordPress with Cloudflare, Varnish and Redis](https://www.bitdoze.com/speed-up-wordpress-with-cloudflare-varnish-and-redis/).

## Production hardening

### 13. Docker health checks for production

Health checks are already configured in the compose file from Step 3. Here&apos;s what they do:

- **MySQL (`wp-db`)**: Runs `mysqladmin ping` every 10 seconds. After 5 failed checks, the container is marked unhealthy. This prevents WordPress from connecting before MySQL is ready.
- **WordPress (`wp`)**: Curls the install page every 30 seconds. Confirms the web server and PHP are responding.

Monitor health status:

```sh
docker compose ps
```

All services should show `(healthy)` in the STATUS column. If a service is `(unhealthy)`, check its logs: `docker compose logs &lt;service-name&gt;`.

The `restart: unless-stopped` policy means containers auto-restart on failure or server reboot, but stay stopped if you manually stop them.

### 14. WP-CLI container for WordPress maintenance

The official `wordpress:cli` image gives you command-line access to WordPress without installing anything extra. Run commands against your existing WordPress container:

```sh
docker run -it --rm \
  --volumes-from wp \
  --network container:wp \
  wordpress:cli \
  wp plugin list
```

&lt;Accordion label=&quot;Common WP-CLI commands&quot; group=&quot;wpcli&quot; expanded=&quot;false&quot;&gt;

**Plugin management:**
```sh
# List installed plugins
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin list

# Update all plugins
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin update --all

# Deactivate a plugin
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp plugin deactivate plugin-name
```

**User management:**
```sh
# List users
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp user list

# Reset a user password
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp user update admin --user_pass=newpassword
```

**Database operations:**
```sh
# Export database
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp db export /var/www/html/backup.sql

# Search and replace URLs (useful after domain changes)
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp search-replace &apos;http://old-domain.com&apos; &apos;https://new-domain.com&apos; --skip-columns=guid
```

**Core updates:**
```sh
# Check current version
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp core version

# Update WordPress core
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp core update
```

&lt;/Accordion&gt;

The `FS_METHOD: direct` define in `WORDPRESS_CONFIG_EXTRA` is required for WP-CLI (and WordPress itself) to write files in Docker without FTP.

**&quot;Error: This does not seem to be a WordPress install&quot;?** You&apos;re missing `--volumes-from wp` or the container name is wrong. Check with `docker compose ps`.

### 15. Security hardening checklist

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;MySQL 8.4 LTS pinned (not &lt;code&gt;mysql:latest&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;WordPress security salts set in &lt;code&gt;.env&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FS_METHOD: direct&lt;/code&gt; in &lt;code&gt;WORDPRESS_CONFIG_EXTRA&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Port 5011 (phpMyAdmin) firewalled off — access only via Cloudflare Tunnel or SSH&lt;/li&gt;
&lt;li&gt;Cloudflare Access policy in front of phpMyAdmin subdomain&lt;/li&gt;
&lt;li&gt;Database passwords are strong random strings, not dictionary words&lt;/li&gt;
&lt;li&gt;WordPress table prefix changed from &lt;code&gt;wp_&lt;/code&gt; if you&apos;re paranoid (set in &lt;code&gt;WORDPRESS_TABLE_PREFIX&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;XML-RPC disabled if you&apos;re not using Jetpack (add to &lt;code&gt;.htaccess&lt;/code&gt; or use a plugin)&lt;/li&gt;
&lt;li&gt;Resource limits set in compose file to prevent runaway containers&lt;/li&gt;
&lt;li&gt;Regular backup restores tested (not just backup creation)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Already covered&quot;&gt;
Most of these are already handled by following Steps 3-6 of this guide. This checklist is here for reference and for when you&apos;re auditing your setup later.
&lt;/Notice&gt;

Monitor your server to detect anomalies early — see [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/) for setting up resource monitoring with tools like Beszel or Netdata.

### 16. MariaDB as a MySQL alternative

MariaDB is fully compatible with WordPress, has a lighter memory footprint, and is recommended alongside MySQL in the WordPress Hosting Handbook. Many self-hosters prefer it.

To swap, change one line in your compose file:

```yaml
# Replace this:
  wp-db:
    image: mysql:8.4

# With this:
  wp-db:
    image: mariadb:11.4
```

&lt;Notice type=&quot;info&quot; title=&quot;Same env vars, same everything&quot;&gt;
MariaDB uses the same environment variables as MySQL (`MYSQL_ROOT_PASSWORD`, `MYSQL_DATABASE`, etc.). No other changes needed — just swap the image tag. MariaDB 11.4 LTS is supported until May 2029.
&lt;/Notice&gt;

### 17. Updating WordPress in Docker

Two strategies depending on how much control you want:

**Strategy 1: Self-managing (default)**

WordPress auto-updates itself inside the volume. This is the default behavior — WordPress checks for updates and applies them without you touching Docker. Simple, but your infrastructure isn&apos;t immutable.

**Strategy 2: Pinned version (recommended for production)**

Pin the WordPress image version in your compose file, disable auto-updates, and control when you update:

```yaml
wp:
  image: wordpress:php8.3:6.8
```

Add to `WORDPRESS_CONFIG_EXTRA`:

```php
define(&apos;WP_AUTO_UPDATE_CORE&apos;, false);
```

When you&apos;re ready to update, change the version tag and redeploy:

```sh
docker compose pull
docker compose up -d
```

Your `wp-app` volume persists all WordPress files, themes, plugins, and uploads. The container is just the runtime — your data lives on the host.

**&quot;Another update is in progress&quot;?** This is a stuck transient. Clear it with WP-CLI:

```sh
docker run -it --rm --volumes-from wp --network container:wp wordpress:cli wp option delete core_updater.lock
```

### 18. What&apos;s Next

Your WordPress Docker stack is running. A few things to do from here:

- **Close firewall ports** — if you&apos;re using Cloudflare Tunnels, block ports 5010 and 5011 at the firewall level so only the tunnel can reach them. Access is only through your domain.
- **Set up monitoring** — [monitor your server and Docker resources](https://www.bitdoze.com/sever-monitoring/) to catch CPU spikes, disk fill-ups, and container restarts before they become problems.
- **Explore self-hosted panels** — if you want a broader management interface, check the [best self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) for options beyond Dockge.
- **Master Docker commands** — bookmark these [essential Docker commands](https://www.bitdoze.com/docker-commands/) for troubleshooting containers, cleaning up disk space, and managing images.
- **Install themes and plugins** — WordPress is ready for your content. Start with a lightweight theme and add only the plugins you need.

## Conclusion

You now have a production-ready WordPress stack running in Docker with MySQL 8.4 LTS (supported until 2032), phpMyAdmin for database management, automated backups with ZSTD compression, optional Redis object caching, and SSL through Cloudflare Tunnels — all from a single `compose.yaml` file.

The most important next step is testing your backups. A backup you&apos;ve never restored is a gamble, not a strategy. Run through the restore procedure in Step 11 at least once, then set a calendar reminder to test it quarterly.

If something breaks, `docker compose logs` is your best friend. Most issues come down to port conflicts, permission errors, or MySQL not being ready when WordPress tries to connect — all of which the health checks in this setup are designed to catch.</content:encoded><category>wordpress</category><category>self-hosted</category><category>docker</category><category>docker-compose</category></item><item><title>Zcode Review 2026: Free AI Coding Agent With Goal Mode (vs Cursor)</title><link>https://www.bitdoze.com/zcode-ai-review/</link><guid isPermaLink="true">https://www.bitdoze.com/zcode-ai-review/</guid><description>Hands-on Zcode review: free desktop AI coding agent with Goal Mode for long tasks, multi-model support, and Bot Channel for phone control. How it stacks up vs Cursor.</description><pubDate>Fri, 24 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import { Image } from &quot;astro:assets&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

import zcodeInterface from &quot;../../assets/images/26/02/zcode-interface.webp&quot;;
import zcodeModelsPanel from &quot;../../assets/images/26/02/zcode-apikey-models-panel-en.webp&quot;;
import zcodePermissions from &quot;../../assets/images/26/02/zcode-agent-permissions-en.webp&quot;;
import zcodeAgentsCall from &quot;../../assets/images/26/02/zcode-agents-call-en.webp&quot;;
import zcodeSkillList from &quot;../../assets/images/26/02/zcode-skill-list-en.webp&quot;;
import zcodeMcp from &quot;../../assets/images/26/02/zcode-mcp.webp&quot;;
import zcodePluginDiscover from &quot;../../assets/images/26/02/zcode-plugin-discover-en.webp&quot;;
import zcodeWelcome from &quot;../../assets/images/26/07/zcode-welcome-workspace.webp&quot;;
import zcodeAgentFramework from &quot;../../assets/images/26/07/zcode-agent-framework.png&quot;;
import zcodeExecutionModes from &quot;../../assets/images/26/07/zcode-execution-modes.png&quot;;
import zcodeGoalMode from &quot;../../assets/images/26/07/zcode-goal-mode.png&quot;;
import zcodeRemoteControl from &quot;../../assets/images/26/07/zcode-remote-control.png&quot;;
import zcodeBotChannel from &quot;../../assets/images/26/07/zcode-bot-channel.png&quot;;

I wanted a free desktop coding agent that could chew through long tasks without me babysitting it. That is basically Zcode: download it, hook up a model, and let Goal Mode keep going until the job is done. Bot Channels put the same session on your phone. Multi-model is supported if you do not want to live on GLM alone.

Zcode is the official desktop app from [Z.AI](https://go.bitdoze.com/glm), the folks behind GLM. File manager, terminal, Git, browser preview, and agent chat sit in one window. Open a project, tell it what you need, and it reads files, writes code, runs commands, and commits.

I have been on it since launch. v3.2 finally feels stable enough for real work. Cursor and Windsurf still feel like editors with chat stapled on. Zcode is the opposite: the agent is the product, the UI just follows. Z.AI calls it an &quot;Agentic Development Environment,&quot; which is marketing language, but it matches how the app actually behaves.

&lt;Notice type=&quot;info&quot; title=&quot;Official Z.AI Tool&quot;&gt;

Zcode is now the official development environment from Z.AI for GLM-5.2. It works with GLM Coding Plans and gives you **~1.5x the effective quota** when using Zcode compared to other tools. New users get a **5-day free trial** with 5M tokens/day (3M GLM-5.2 + 2M GLM-5-turbo).

&lt;/Notice&gt;

## What Zcode does


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/SJ6cA_fE5_Y&quot;
  label=&quot;Zcode v3: The Official Z.AI Coding Environment with GLM-5.2&quot;
/&gt;


You interact through natural language, and the agent handles file operations, terminal commands, and browser previews. Here&apos;s the workspace you see when you open it:

&lt;Image src={zcodeWelcome} alt=&quot;Zcode workspace showing task list, file tree, terminal, and agent chat&quot; /&gt;

&lt;ListCheck&gt;

- **Zcode Agent**: The default agent built for GLM-5.2, with long-task planning, multi-turn context, and continuous code changes across files.
- **Goal Mode**: Set a verifiable session objective with `/goal` — the agent keeps iterating until the goal is verified complete.
- **Execution Modes**: Five modes from &quot;ask before everything&quot; to &quot;full access&quot; — cycle through them with Shift+Tab.
- **Multi-Model Support**: GLM-5.2, Claude, GPT, Kimi K2.5, DeepSeek, or any compatible model.
- **Bot Channel**: Connect WeChat or Feishu to control Zcode tasks from your phone&apos;s chat app.
- **Remote Control**: Scan a QR code to connect your phone to the desktop session and keep tasks moving.
- **AGENTS.md Memory**: Project-level instructions file that Zcode reads at the start of every task.

&lt;/ListCheck&gt;

## Installation

Zcode is available as a desktop app for macOS, Windows, and Linux (beta).

### macOS

1. Download the DMG from [zcode.z.ai](https://zcode.z.ai/en/docs/install)
2. Open the DMG file
3. Drag **Zcode.app** to your **Applications** folder
4. Launch from Launchpad

### Windows

1. Download the installer from [zcode.z.ai](https://zcode.z.ai/en/docs/install)
2. Double-click the installer
3. Follow the setup wizard

### Linux (Beta)

Linux builds are available through the [Feishu beta group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=dabi8911-b0f9-41d8-8090-1b47c058a198). Both x64 and ARM64 AppImages are provided.

&lt;Button
  text=&quot;Download Zcode&quot;
  link=&quot;https://zcode.z.ai/en/docs/install&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

## Configuring AI providers

Zcode supports multiple providers. You&apos;re not locked into a single service.

### Supported providers

&lt;Image src={zcodeModelsPanel} alt=&quot;Zcode models configuration panel with AI provider settings&quot; /&gt;

| Provider | Models Available | How to Connect |
| --- | --- | --- |
| **Z.AI / BigModel** | GLM-5.2, GLM-5, GLM-4.7, GLM-4.6 | Built-in, bind your Zhipu account |
| **Anthropic** | Claude Sonnet 4.5, Opus 4.5, etc. | Enter Anthropic API key |
| **OpenRouter** | 100+ models | Set base URL to `https://openrouter.ai/api`, add API key |
| **Moonshot** | Kimi K2.5, Kimi K2-Turbo | Base URL: `https://api.moonshot.cn/anthropic` |
| **DeepSeek** | DeepSeek Chat | Base URL: `https://api.deepseek.com/anthropic` |
| **Custom** | Any OpenAI/Anthropic-compatible API | Add provider name, base URL, and API key |

&lt;Tabs&gt;
&lt;Tab name=&quot;OpenRouter Setup&quot;&gt;

1. Go to [openrouter.ai](https://openrouter.ai), create an account, and generate an API key
2. In Zcode, go to **Agents Settings &gt; Models**
3. Click **Add Provider**
4. Set name to &quot;OpenRouter&quot;
5. Set API Base URL to `https://openrouter.ai/api`
6. Enter your API key
7. Enable the toggle

&lt;/Tab&gt;
&lt;Tab name=&quot;Anthropic Setup&quot;&gt;

1. Get an API key from [console.anthropic.com](https://console.anthropic.com)
2. In Zcode, go to **Agents Settings &gt; Models**
3. Select &quot;Anthropic&quot; from the provider list
4. Enable the toggle
5. Enter your API key

&lt;/Tab&gt;
&lt;Tab name=&quot;DeepSeek Setup&quot;&gt;

1. Get an API key from DeepSeek
2. In Zcode, go to **Agents Settings &gt; Models**
3. Click **Add Provider**
4. Set name to &quot;deepseek&quot;
5. Set Base URL to `https://api.deepseek.com/anthropic`
6. Enter your API key
7. Add model name &quot;deepseek-chat&quot;

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;success&quot; title=&quot;GLM Coding Plan Benefits&quot;&gt;

If you use a [GLM Coding Plan](https://go.bitdoze.com/glm), Zcode consumes quota at a discounted rate — roughly **1.5x the effective allowance**. Off-peak hours (20 hours/day) use only 0.67x quota, and peak hours (14:00–18:00) use 2x instead of the usual 3x.

&lt;/Notice&gt;

## Zcode Agent

The agent is the core of the experience. Zcode Agent is built in-house and deeply adapted for GLM-5.2. It handles complex project understanding, long-task planning, and continuous code changes across multiple files.

&lt;Image src={zcodeAgentFramework} alt=&quot;Zcode Agent new task interface showing model picker, context, and execution modes&quot; /&gt;

### Adding context

Click the **+** button at the bottom-left of the chat input to add context:

- **Attachments**: Upload screenshots, documents, and requirement material
- **@ mentions**: Reference files in the workspace so the agent can locate relevant code
- **# conversations**: Link a past conversation to bring its context into the current task
- **/ commands**: Call saved prompts to reuse a fixed workflow
- **$$ skills**: Invoke reusable skill playbooks

### Project instructions

Zcode reads an `AGENTS.md` file from your project root and loads it into every conversation. Put your project conventions, architecture notes, and coding standards here.

Two locations are supported:

| Source | Path | Scope |
| --- | --- | --- |
| User global | `~/.zcode/AGENTS.md` | All projects |
| Workspace | `AGENTS.md` in project root | Current project |

&lt;Notice type=&quot;info&quot; title=&quot;AGENTS.md replaces CLAUDE.md&quot;&gt;

Zcode uses `AGENTS.md` instead of `CLAUDE.md`. If you have an existing Claude Code project, Zcode copies `CLAUDE.md` content into `AGENTS.md` during onboarding. After that, only `AGENTS.md` is read.

&lt;/Notice&gt;

## Execution modes

Five modes control how much freedom the agent gets. Press **Shift + Tab** while the chat input is focused to cycle through them.

&lt;Image src={zcodeExecutionModes} alt=&quot;Zcode execution modes selector showing Default, Confirm, Auto Edit, Plan, and Full Access&quot; /&gt;

| Mode | What Happens | Good For |
| --- | --- | --- |
| **Default Mode** | Standard task strategy with balanced confirmations | Everyday development, routine edits |
| **Confirm Before Changes** | Asks before every file edit or command | Critical code, production configs |
| **Auto Edit** | Edits files automatically, commands still need confirmation | Routine iteration with fewer interruptions |
| **Plan Mode** | Creates a plan first, then implements after approval | Complex multi-step tasks |
| **Full Access** | Minimal interruptions, agent proceeds continuously | Low-risk tasks where you want speed |

## Goal Mode

This is one of the newer features and I&apos;ve found it genuinely useful for long tasks. Type `/goal &lt;objective&gt;` and the agent keeps iterating toward the goal. After each round, it verifies whether the goal is met. If not, it continues automatically — no need to type &quot;continue&quot; every few minutes.

&lt;Image src={zcodeGoalMode} alt=&quot;Goal Mode showing the agent iterating toward a verifiable objective with progress tracking&quot; /&gt;

Good use cases for Goal Mode:

- &quot;Refactor the whole module and keep the tests passing&quot;
- &quot;Fix all TypeScript compile errors&quot;
- &quot;Get this page&apos;s Lighthouse performance score above 90&quot;

The summary panel in the top-right shows goal status, elapsed time, total tokens, and iteration count. You can pause, replace, or clear the goal at any time.

For long goal-driven tasks, pair Goal Mode with **Full Access** or **Auto Edit** to minimize interruptions and keep iterations flowing.

## Bot Channel

Bot Channel connects WeChat and Feishu to your Zcode workspace. After setup, you can message the bot from your phone&apos;s chat app to check task progress, send instructions, and keep the agent moving.

&lt;Image src={zcodeBotChannel} alt=&quot;Bot Channel overview showing WeChat and Feishu integration options&quot; /&gt;

Setting up Feishu:

1. Open **Bots** settings in Zcode&apos;s left sidebar
2. Click **Create Bot** and select Feishu
3. Scan the QR code — Zcode creates the Feishu app automatically
4. Send `/bind pairing-code` in the Feishu conversation
5. The bot is ready to use

WeChat works similarly — scan to sign in and bind automatically. DingTalk, Discord, and WeCom support are coming in later versions.

## Remote Control

Remote Control lets your phone connect to the current desktop workspace. Scan a QR code, and your phone shows a live view of the session. You can read agent responses, send new instructions, and confirm or stop long-running tasks.

&lt;Image src={zcodeRemoteControl} alt=&quot;Remote Control entry point showing QR code dialog for mobile connection&quot; /&gt;

The desktop stays as the runtime — the phone only handles display and text input. One phone connection per session. For longer-lived access from chat apps, use Bot Channel instead.

## Skills system

Skills are Markdown files that teach the agent how to handle specific tasks. They work like reusable playbooks.

&lt;Image src={zcodeSkillList} alt=&quot;Zcode skills management panel showing installed user and plugin skills&quot; /&gt;

| Type | Storage | Scope |
| --- | --- | --- |
| **User Skills** | `~/.claude/skills/` | All projects |
| **Plugin Skills** | Installed via plugins | Auto-available |
| **Project Skills** | `.claude/skills/` | Current project only |

## MCP services

Zcode uses the Model Context Protocol to extend what agents can do. Three built-in MCP services cover common needs:

&lt;Image src={zcodeMcp} alt=&quot;Zcode MCP services configuration with visual understanding, web search, and web reader&quot; /&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Visual Understanding&quot;&gt;

**zai-mcp-server**: Lets the agent analyze images. Get an API token from [open.bigmodel.cn](https://open.bigmodel.cn), add your `Z_AI_API_KEY` in the MCP settings, and set `Z_AI_MODE` to `ZHIPU`.

&lt;/Tab&gt;
&lt;Tab name=&quot;Web Search&quot;&gt;

**web-search-prime**: Gives the agent real-time internet access. Add your API key to the `Authorization` header as `Bearer your_api_key`.

&lt;/Tab&gt;
&lt;Tab name=&quot;Web Reader&quot;&gt;

**web-reader**: Parses web pages and pulls out structured content. Same auth setup as web search.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Plugin system

Plugins add new commands, agents, MCP connections, language servers, and more. Zcode ships with access to the Claude Code plugin marketplace, so you can install community plugins you&apos;re already familiar with.

&lt;Image src={zcodePluginDiscover} alt=&quot;Zcode plugin marketplace showing available plugins for agents, commands, MCP, and LSP&quot; /&gt;

| Plugin Type | What It Adds |
| --- | --- |
| **Agent** | Specialized agents with domain workflows |
| **Command** | Custom `/` slash commands |
| **MCP** | External tool and data source connections |
| **LSP** | Language servers for code navigation and type checking |
| **Skill** | Behavior guidelines for the agent |
| **Hook** | Auto-execute actions on specific events |

## Version control

Zcode handles versioning at two levels.

**Conversation-level rollback**: Every message creates a checkpoint. Review all file changes after any interaction in a multi-file diff view, undo only the last interaction&apos;s changes, or jump back to the state after any specific message.

**Built-in Git panel**: View modified files with status markers, write commit messages and commit with one click, switch branches, and browse commit history.

## Zcode vs other AI coding tools

| Feature | Zcode | Cursor | Claude Code CLI | Windsurf |
| --- | --- | --- | --- | --- |
| **Type** | Standalone ADE | IDE (VS Code fork) | Terminal CLI | IDE (VS Code fork) |
| **Official Z.AI tool** | Yes | No | No | No |
| **Multi-Model** | GLM, Claude, GPT, Kimi, DeepSeek, custom | Multiple providers | Claude only | Multiple providers |
| **Goal Mode** | Yes, auto-iterate until verified | No | No | No |
| **Bot Channel** | WeChat, Feishu | No | No | No |
| **Skills System** | Yes, reusable Markdown playbooks | No | No | No |
| **Plugin Marketplace** | Agents, commands, MCP, LSP | Extensions | No | Extensions |
| **Chat Versioning** | Conversation checkpoints + rollback | No | No | No |
| **Remote Dev (Mobile)** | QR code + Bot Channel | No | No | No |
| **AGENTS.md** | Auto-loaded per project | .cursorrules | CLAUDE.md | .windsurfrules |

## Getting started in 5 minutes

**Step 1**: Download and install Zcode from [zcode.z.ai](https://zcode.z.ai/en/docs/install)

**Step 2**: Open the app and click &quot;Connect&quot; to set up your AI provider. If you have a [GLM Coding Plan](https://go.bitdoze.com/glm), bind it for the best experience with GLM-5.2 and the 1.5x quota benefit.

**Step 3**: Open a project folder in Zcode.

**Step 4**: Type `/init` or create an `AGENTS.md` file in your project root with your conventions.

**Step 5**: Start chatting. Ask the agent to explain your codebase, fix a bug, or build a new feature.

&lt;Button
  text=&quot;Get Started with Zcode&quot;
  link=&quot;https://zcode.z.ai/en/docs/install&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

## GLM Coding Plan pricing

Z.AI offers GLM Coding Plans that work with Zcode. The plans give you access to GLM-5.2 and other GLM models with generous token allowances.

&lt;Notice type=&quot;success&quot; title=&quot;Exclusive Benefits for Zcode Users&quot;&gt;

- **5-day free trial** for new Zcode users: 3M tokens/day of GLM-5.2 + 2M tokens/day of GLM-5-turbo (5M total)
- **~1.5x effective quota** for GLM Coding Plan subscribers using Zcode — off-peak hours consume only 0.67x quota
- These benefits run through **July 31, 2026**

&lt;/Notice&gt;

&lt;Button
  text=&quot;Get GLM Coding Plan (10% OFF)&quot;
  link=&quot;https://go.bitdoze.com/glm&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

## Pros and cons

**What I like:**

- GLM-5.2 integration is deep — the agent, model, and execution workflow are tuned together
- Goal Mode saves me from typing &quot;continue&quot; every 5 minutes on long tasks
- Bot Channel means I can check on tasks from WeChat without opening my laptop
- The 1.5x quota benefit with GLM Coding Plans makes it cheaper to run than alternatives
- Linux support (beta) — wasn&apos;t there at launch

**What I don&apos;t:**

- The plugin ecosystem is still small compared to Cursor&apos;s extension marketplace
- Some MCP services need Zhipu/BigModel API keys specifically
- The learning curve is real — agents, skills, plugins, MCP, Goal Mode, execution modes. There&apos;s a lot to figure out.
- Bot Channel only supports WeChat and Feishu right now (Discord and Slack coming later)

## Frequently asked questions

&lt;Accordion label=&quot;Is Zcode free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

Zcode itself is free to download and use. You pay for the AI models you connect — either through Z.AI&apos;s GLM Coding Plans, OpenRouter credits, Anthropic API usage, or whichever provider you choose. New users get a 5-day free trial with 5M tokens/day.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need Claude Code to use Zcode?&quot; group=&quot;faq&quot;&gt;

No. Zcode supports multiple AI providers. You can use GLM-5.2, models through OpenRouter, the Anthropic API directly, Moonshot (Kimi), DeepSeek, or any service that speaks the OpenAI or Anthropic protocol.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the GLM Coding Plan quota benefit?&quot; group=&quot;faq&quot;&gt;

GLM Coding Plan subscribers using Zcode get roughly 1.5x the effective token allowance. Off-peak hours (20 hours/day) consume only 0.67x quota per token. Peak hours (14:00–18:00) consume 2x instead of the usual 3x. This means the same plan lasts longer in Zcode than in other tools.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Zcode work on Linux?&quot; group=&quot;faq&quot;&gt;

Yes, Linux is now supported in beta. Both x64 and ARM64 AppImages are available. You can join the Linux beta group through the Feishu link on the download page.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Zcode with local models?&quot; group=&quot;faq&quot;&gt;

Yes. Zcode supports any OpenAI or Anthropic-compatible API endpoint. Point it at a local inference server (like Ollama or vLLM) and it&apos;ll work with whatever model you&apos;re running.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Zcode compare to Cursor?&quot; group=&quot;faq&quot;&gt;

Cursor is a VS Code fork with AI editing features. Zcode is a standalone environment built around agent interactions. Zcode has Goal Mode, Bot Channel, specialized agents, a skills system, and conversation-level version rollback. Cursor has a more familiar IDE experience and a larger community.

&lt;/Accordion&gt;

## Wrapping up

Zcode has come a long way since its initial launch. The v3.2 update added Linux support, Goal Mode, Bot Channels for WeChat and Feishu, and a bunch of stability fixes. As Z.AI&apos;s official coding environment for GLM-5.2, it gets the deepest integration with their models and the quota benefits that come with it.

The Goal Mode feature alone has changed how I handle long tasks — set it and walk away. Come back to a completed result. The Bot Channel is useful for checking on progress from your phone without needing to be at your desk.

If you&apos;re already using GLM models or want to try them, Zcode is the way to go. The 5-day free trial and 1.5x quota benefit make it worth testing.

&lt;Button
  text=&quot;Try Zcode Free&quot;
  link=&quot;https://zcode.z.ai/en/docs/install&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;</content:encoded><category>ai</category><category>zcode</category><category>ai-coding</category><category>glm</category></item><item><title>How to Create a Carrd Mobile Responsive Navbar (3 Methods)</title><link>https://www.bitdoze.com/carrd-mobile-navbar/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-mobile-navbar/</guid><description>Learn how to create a Carrd mobile responsive navbar with a hamburger menu. Covers custom HTML/CSS, native Carrd elements, and plugins. No-code options included.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

A Carrd mobile responsive navbar is one of the first things you&apos;ll want to add if your site has more than one section. Carrd&apos;s built-in header options work fine for desktop, but they don&apos;t give you a hamburger menu on smaller screens, which is table stakes for any modern site.

This guide covers three approaches: a custom HTML/CSS embed (cheapest, most control), native Carrd elements (no code at all), and third-party plugins (drop-in features). Each has trade-offs in cost, complexity, and flexibility. If you haven&apos;t picked Carrd yet, you can read [our Carrd.co review](https://www.bitdoze.com/carrd-review/) or try it free below.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd Free (7-Day Pro Trial)&quot; /&gt;

## Some Carrd tutorials

Before we dig in, here are related Carrd customization guides that pair well with a responsive navbar:



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/PkZ7CYfKxSg&quot;
  label=&quot;How to Create A Carrd.co Mobile Responsive Navbar&quot;
/&gt;
- [Add a Sticky Header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/) makes the navbar stay visible on scroll
- [Add a Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/) is a different navigation pattern worth considering
- [Add a Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/) is an alternative layout for nav-heavy sites
- [Add Smooth Scroll and Anchor Links to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/) pairs directly with anchor-based nav links

## What you&apos;ll need

&lt;Notice type=&quot;info&quot; title=&quot;Carrd Plans&quot;&gt;
Carrd&apos;s plan tiers matter here. **Pro Standard** ($19/year) unlocks the Embed element you need for Method 1. **Pro Plus** ($49/year) unlocks Visibility settings for Method 2. Method 3 plugins work on Pro Standard plus the plugin cost. All paid plans include a **7-day free trial** with no credit card. Carrd also runs 40% off Black Friday sales if you want to time your upgrade.

Read more: [Carrd plans and pricing](https://carrd.co/docs/pro/plans) | [Full Carrd review](https://www.bitdoze.com/carrd-review/)
&lt;/Notice&gt;

| Plan | Price | Sites | Embeds | Visibility Settings |
|---|---|---|---|---|
| Free | $0 | 3 | No | No |
| Pro Lite | $9/yr | 3 | No | No |
| **Pro Standard** | **$19/yr** | 10 | **Yes** | No |
| **Pro Plus** | **$49/yr** | 25 | Yes | **Yes** |

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;A Carrd account (free to start)&lt;/li&gt;
  &lt;li&gt;Pro Standard ($19/yr) for Method 1 and Method 3, or Pro Plus ($49/yr) for Method 2&lt;/li&gt;
  &lt;li&gt;A Carrd site with at least 2-3 sections set up&lt;/li&gt;
  &lt;li&gt;Browser with dev tools for testing responsiveness&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Which approach is right for you?

Quick decision guide. Pick the path that matches your situation:

&lt;Tabs&gt;
&lt;Tab name=&quot;Custom Code&quot;&gt;
**Best for**: Full control, lowest cost, animated hamburger toggle.

- **Plan needed**: Pro Standard ($19/yr)
- **Difficulty**: Medium (copy-paste code, minor customization)
- **What you get**: Animated hamburger-to-X toggle, smooth open/close transition, full CSS control, sticky header compatible
- **What you don&apos;t**: Changes require editing code, no visual preview in editor
&lt;/Tab&gt;
&lt;Tab name=&quot;Native Elements&quot;&gt;
**Best for**: No code at all, visual editing in Carrd&apos;s builder.

- **Plan needed**: Pro Plus ($49/yr)
- **Difficulty**: Easy-Medium (more steps, but all point-and-click)
- **What you get**: Edit everything in the Carrd editor, visual preview, no embed code to manage
- **What you don&apos;t**: No animated toggle, not naturally sticky, more setup steps
&lt;/Tab&gt;
&lt;Tab name=&quot;Plugins&quot;&gt;
**Best for**: Extra features like multi-level dropdowns without writing code.

- **Plan needed**: Pro Standard ($19/yr) + plugin cost
- **Difficulty**: Easy (install and configure)
- **What you get**: Feature-rich navbars, multi-level menus, maintained by plugin author
- **What you don&apos;t**: Ongoing cost dependency, less customization than hand-written code
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Method 1: Custom HTML/CSS embed (code-based Carrd navbar)

This is the approach I use. It gives you the most control over the Carrd hamburger menu behavior and styling, and it only needs Pro Standard ($19/year). The trade-off is you&apos;re editing HTML/CSS directly.

### How the checkbox hack works

The toggle mechanism uses a hidden checkbox and a `&lt;label&gt;` element. When you click the label (styled as a hamburger icon), it toggles the checkbox. CSS then targets the sibling menu using `#menu-toggle:checked ~ .menu`. The `~` is the general sibling combinator, meaning &quot;select `.menu` that is a sibling after the checked checkbox.&quot;

It&apos;s a pure-CSS toggle. No JavaScript needed for the open/close itself. The JS only handles closing the menu when you click a nav link or the close button.

The checkbox hack is a dated pattern (it has accessibility trade-offs we&apos;ll cover below), but it works reliably in Carrd&apos;s embed environment where you can&apos;t easily inject a `&lt;button&gt;` toggle with proper ARIA state management.

### Step 1: Set up the container

In Carrd&apos;s editor, add a **Container** element with 2 columns. Split it roughly 25%/75%:

- **First column**: Your logo (an Image or Text element)
- **Second column**: The embed widget (next step)

Choose a background color for the container that matches your header design. This won&apos;t affect the navbar code itself.

### Step 2: Add an embed widget

In the second column, add an **Embed** element. Name it something like `navbar-embed`.

&lt;Notice type=&quot;info&quot; title=&quot;Embed Placement Tip&quot;&gt;
Carrd supports 4 embed placement options: **Inline** (default, code goes where the element sits), **Hidden → Head** (code goes in `&lt;head&gt;`), **Hidden → Body Top**, and **Hidden → Body End**.

For best results, split your code into two embeds: put the `&lt;style&gt;` block in a **Hidden → Head** embed (prevents flash of unstyled content), and the HTML + JS in an **Inline** embed. If you want to keep it simple with one embed, the Inline approach works. It&apos;s what the original tutorial used.

See the [Carrd embedding docs](https://carrd.co/docs/building/embedding-custom-code) for details.
&lt;/Notice&gt;

### Step 3: Add the HTML, CSS and JavaScript

Below is the complete updated code. It fixes several bugs from the original version, adds accessibility attributes, uses a smooth `max-height` transition instead of `display: none/block`, and scopes the CSS reset to avoid breaking Carrd&apos;s built-in styles.

&lt;Tabs&gt;
&lt;Tab name=&quot;Complete code&quot;&gt;

**If using two embeds (recommended):** Put the `&lt;style&gt;` block in a Hidden → Head embed, and the rest in an Inline embed. **If using one embed:** Paste everything below into a single Inline embed.

```html
&lt;style&gt;
  :root {
    --mbm-main-font: inherit;
    --mbm-font-size-base: 18px;
    --primary-color: #200eed;
    --secondary-color: #fff;
  }

  /* Scoped reset — only affects nav and its children */
  nav, nav * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  /* Navigation styling (desktop) */
  nav {
    position: relative;
    color: var(--secondary-color);
    background-color: var(--primary-color); /* blue header */
    padding: 15px;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    font-family: var(--mbm-main-font);
    font-size: var(--mbm-font-size-base);
  }

  .menu {
    list-style: none;
    display: flex;
  }

  .menu li a {
    display: block;
    color: var(--secondary-color);
    text-decoration: none;
    padding: 10px 15px;
    position: relative;
  }

  .menu li a::after {
    content: &quot;&quot;;
    position: absolute;
    bottom: 2px;
    left: 50%;
    width: 0;
    height: 2px;
    background-color: var(--secondary-color);
    transition: width 0.3s ease-in-out;
  }

  .menu li a:hover::after {
    width: 100%;
    left: 0;
  }

  /* Hamburger styling */
  .hamburger {
    display: none;
    cursor: pointer;
  }

  .hamburger .bar {
    display: block;
    width: 25px;
    height: 3px;
    background-color: var(--secondary-color);
    margin: 5px 0;
  }

  /* Hide checkbox */
  #menu-toggle {
    display: none;
  }

  /* Close button — hidden by default */
  .close-button {
    display: none;
    position: absolute;
    top: 50%;
    right: 20px;
    transform: translateY(-50%);
    background: none;
    border: none;
    font-size: 22px;
    line-height: 1;
    color: var(--secondary-color);
    cursor: pointer;
    z-index: 1001;
    padding: 5px;
  }

  /* Mobile styles */
  @media (max-width: 768px) {
    .hamburger {
      display: block;
    }

    .menu {
      position: absolute;
      width: 100%;
      top: 100%;
      left: 0;
      background-color: var(--primary-color);
      text-align: center;
      padding: 20px 0;
      z-index: 999;
      flex-direction: column; /* not in a row — stacked vertically */
      max-height: 0;
      overflow: hidden;
      opacity: 0;
      transition: max-height 0.4s ease-in-out, opacity 0.3s ease-in-out;
    }

    .menu li {
      width: 100%;
    }

    .menu li a {
      padding: 15px;
      border-bottom: 1px solid rgba(255, 255, 255, 0.1);
    }

    .menu li:last-child a {
      border-bottom: none;
    }

    /* Show menu when checkbox is checked */
    #menu-toggle:checked ~ .menu {
      max-height: 500px;
      opacity: 1;
    }

    /* Show close button + hide hamburger when open */
    #menu-toggle:checked ~ .close-button {
      display: block;
    }

    #menu-toggle:checked ~ .hamburger {
      visibility: hidden;
    }
  }
&lt;/style&gt;

&lt;nav role=&quot;navigation&quot; aria-label=&quot;Main navigation&quot;&gt;
  &lt;input type=&quot;checkbox&quot; id=&quot;menu-toggle&quot; /&gt;
  &lt;label for=&quot;menu-toggle&quot; class=&quot;hamburger&quot; aria-label=&quot;Toggle menu&quot;&gt;
    &lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;
    &lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;
    &lt;span class=&quot;bar&quot;&gt;&lt;/span&gt;
  &lt;/label&gt;
  &lt;ul class=&quot;menu&quot; role=&quot;menubar&quot;&gt;
    &lt;li&gt;&lt;a href=&quot;#&quot;&gt;Home&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#about&quot;&gt;About&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#testimonials&quot;&gt;Testimonials&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#contact&quot;&gt;Contact&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;
  &lt;button type=&quot;button&quot; class=&quot;close-button&quot; aria-label=&quot;Close menu&quot;&gt;×&lt;/button&gt;
&lt;/nav&gt;

&lt;script&gt;
  // Close menu when close button is clicked
  document
    .querySelector(&quot;.close-button&quot;)
    .addEventListener(&quot;click&quot;, function () {
      document.getElementById(&quot;menu-toggle&quot;).checked = false;
    });

  // Close menu when a nav link is clicked
  document.querySelectorAll(&quot;.menu a&quot;).forEach(function (link) {
    link.addEventListener(&quot;click&quot;, function () {
      document.getElementById(&quot;menu-toggle&quot;).checked = false;
    });
  });
&lt;/script&gt;
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Code walkthrough&quot;&gt;

**CSS custom properties** (`:root` block):
- `--primary-color`: Background color of the mobile dropdown menu
- `--secondary-color`: Text and icon color
- `--mbm-font-size-base`: Base font size for menu items
- Change these to match your site&apos;s design

**Scoped reset** (`nav, nav *`):
The reset only applies to the nav element and its children, so it won&apos;t mess with Carrd&apos;s spacing on other elements.

**Desktop styles** (`.menu`, `.hamburger`):
On screens wider than 768px, the menu displays as a horizontal flex row. The hamburger is hidden (`display: none`).

**Mobile styles** (`@media max-width: 768px`):
- Hamburger becomes visible
- Menu switches to a full-width dropdown with `max-height: 0` (hidden)
- When checkbox is checked (`#menu-toggle:checked ~ .menu`), `max-height` expands to 500px with a smooth transition
- Close button appears in the top-right corner of the dropdown

**JavaScript**:
- Closes the menu (unchecks the checkbox) when the close button or any nav link is clicked
- No external libraries, no dependencies

**Key values to adjust**:
- `top: calc(15px + 6em)` controls where the dropdown appears. Adjust `6em` to match your header height
- `z-index: 999` keeps the menu on top of other elements
- `768px` breakpoint. Change this if your site needs a different mobile breakpoint

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Customizing the navbar

Quick reference for common changes:

- **Colors**: Edit `--primary-color` and `--secondary-color` in the `:root` block
- **Font**: Set `--mbm-main-font` to a font-family value (e.g., `&apos;Inter&apos;, sans-serif`)
- **Menu items**: Replace the `&lt;li&gt;&lt;a href=&quot;...&quot;&gt;` entries with your own section links
- **Breakpoint**: Change `768px` in the `@media` query to target a different screen width
- **Dropdown position**: Adjust `top: calc(15px + 6em)` to match your actual header height
- **Transition speed**: Change `0.4s` in `transition: max-height 0.4s ease-in-out`

If your nav links point to sections on the same page, add [smooth scrolling for Carrd anchor links](https://www.bitdoze.com/carrd-smooth-scroll/) for a polished UX.

### Accessibility notes

&lt;Notice type=&quot;warning&quot; title=&quot;Accessibility Trade-off&quot;&gt;
The checkbox hack has inherent limitations: it doesn&apos;t manage focus properly, and screen readers may interpret the label as a checkbox rather than a button toggle. The updated code includes `role=&quot;navigation&quot;`, `aria-label` attributes, and a `&lt;button&gt;` close element. This helps, but can&apos;t fully solve the checkbox-hack limitation.

For production sites with strict accessibility requirements, consider a `&lt;button&gt;`-based toggle with JavaScript (which is hard to do inside Carrd&apos;s embed constraints) or use Method 2.

For more context: [CSS &amp; JavaScript Requirements for Accessible Components, Smashing Magazine](https://www.smashingmagazine.com/2021/06/css-javascript-requirements-accessible-components/)
&lt;/Notice&gt;

## Method 2: Native Carrd elements (no-code responsive navigation)

If you don&apos;t want to touch any code, Carrd&apos;s built-in elements can do the job. The trick is using **Visibility settings** to show different navigation elements on desktop vs. mobile. Desktop gets a row of buttons; mobile gets a hamburger icon that jumps to a full-screen menu section.

&lt;Notice type=&quot;info&quot; title=&quot;Pro Plus Required&quot;&gt;
Visibility settings (Desktop Only / Mobile Only) require **Pro Plus** at $49/year. This is the most expensive option, but the trade-off is zero code and full visual editing in Carrd&apos;s builder.
&lt;/Notice&gt;

### How it works

Carrd lets you set element visibility per device type. You create two versions of your navigation (one for desktop, one for mobile) and Carrd shows the right one based on screen size.

### Setting up the desktop navigation

1. In your header container, add a **Buttons** element
2. Set **Layout** to **Row** (horizontal button layout)
3. Set **Visibility** to **Desktop Only**
4. Add your nav link buttons (Home, About, Contact, etc.). Each links to a section using `#section-name`

### Creating the mobile hamburger toggle

1. Add an **Icon** element to the same container (choose a hamburger/menu icon from Carrd&apos;s icon library)
2. Set the icon&apos;s **URL** to `#menu` (this will link to a section you create next)
3. Set **Visibility** to **Mobile Only**

### Building the mobile menu section

1. Add a **Section Break** at the point in your page where you want the mobile menu to appear. Label it `menu` (this creates the `#menu` anchor target)
2. Inside that section, add a **Buttons** element with **Layout: Column** (vertical buttons)
3. Add your nav link buttons — same links as the desktop version
4. Add a close **Icon** (X symbol) with its URL set to `browser:back` — this uses Carrd&apos;s built-in URL type to go back in browser history, effectively closing the menu
5. Add a **Header Marker Control** so the navigation header appears on every &quot;page&quot; of your Carrd site

For reference, Carrd supports these [URL types](https://carrd.co/docs/building/url-types) in buttons and icons: `#section-name`, `browser:back`, `section:next`, `section:previous`, and `browser:top`.

A free template implementing this approach is available: [Mobile navbar free Carrd template](https://carrd.co/buy/190a78e4d1467cca) by Jason Leow (requires Pro Plus).

### Pros, cons and plan requirements

**Pros:**
- No code at all — everything is edited visually in Carrd&apos;s builder
- Changes are immediate in the editor preview
- No external dependencies

**Cons:**
- Requires Pro Plus ($49/year) for Visibility settings
- More setup steps (7 major steps vs. 3 for the code approach)
- Not naturally sticky — needs extra work to stay fixed on scroll
- No animated hamburger-to-X transition
- The `browser:back` close behavior can be unreliable if the user navigated to the page from an external link

For a different no-code navigation pattern, also check out [how to add a floating menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/).

## Method 3: Carrd navigation plugins (no-code, extra features)

If you want a polished navbar without writing code and the native approach feels too limiting, third-party plugins fill the gap. They&apos;re paid but well-maintained and designed specifically for Carrd.

### Nav Bar plugin

The [Nav Bar plugin](https://navbar.carrd.co/) from Jason&apos;s Plugins adds a responsive hamburger menu to your Carrd site. It handles the mobile toggle, styling, and layout — you just configure your menu items. Works on Pro Standard ($19/year) plus the plugin cost.

### Mega Nav Bar

The [Mega Nav Bar](https://plugins.carrd.co/#meganavbar) adds multi-level dropdown support. This is overkill for a simple 4-link nav, but if your Carrd site has a lot of pages or a complex information hierarchy, it&apos;s the cleanest option without building it yourself.

### Free Visibility Control plugin

The [Visibility Control plugin](https://visibility-control.carrd.co/) is free and uses a `visCtrl()` JavaScript function to show/hide native Carrd elements based on device. This effectively replicates Pro Plus Visibility settings on a Pro Standard plan — useful if you want Method 2&apos;s approach but don&apos;t want to pay for Pro Plus.

Other useful Carrd plugins include the [Carrd Tabs plugin](https://go.carrdme.com/tabs) for tabbed content and the [Carrd Accordion plugin](https://go.carrdme.com/accordion) for FAQ sections.

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Browse Carrd Plugins &amp; Themes&quot; /&gt;

## Method comparison: which Carrd navbar approach is right for you?

| Approach | Plan Required | Cost | Code Required | Visual Preview | Sticky Support | Multi-Level | Animated Toggle |
|---|---|---|---|---|---|---|---|
| Custom Code (Method 1) | Pro Standard | $19/yr | Yes | No | Yes (manual CSS) | Yes (manual) | Yes |
| Native Elements (Method 2) | Pro Plus | $49/yr | No | Yes | No (needs extra work) | No | No |
| Nav Bar Plugin (Method 3) | Pro Standard + plugin | $19/yr + plugin | No | Limited | Yes | No | Yes |
| Mega Nav Bar (Method 3) | Pro Standard + plugin | $19/yr + plugin | No | Limited | Yes | Yes | Yes |
| Visibility Control (free) | Pro Standard | $19/yr | Minimal (JS) | Yes | No | No | No |

**My default**: Method 1 (custom code) if you&apos;re comfortable copy-pasting HTML. It&apos;s the cheapest, most flexible, and only option that supports an animated hamburger toggle. Use Method 2 if you never want to see code and don&apos;t mind paying for Pro Plus. Use Method 3 if you need multi-level dropdowns or want a maintained plugin.

## How to make your Carrd navbar sticky

The custom code approach (Method 1) works with a sticky header. If you want your navbar to stay pinned at the top of the viewport as users scroll, follow the [sticky header tutorial for Carrd](https://www.bitdoze.com/add-stickey-header-carrd/).

The native approach (Method 2) doesn&apos;t natively support sticky positioning — you&apos;d need additional custom CSS to make it work, which somewhat defeats the purpose of the no-code approach.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Need Carrd Pro? Start Here&quot; /&gt;

## Troubleshooting common Carrd mobile navbar issues

&lt;Accordion label=&quot;Menu appears behind other elements (z-index fix)&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;

The code uses `z-index: 999` on the `.menu` element. If other Carrd elements (containers, overlays, or other embeds) have higher z-index values, the menu will render behind them.

**Fix**: Increase the z-index value in the `.menu` CSS. Try `z-index: 9999`. If that doesn&apos;t work, use your browser&apos;s dev tools (right-click → Inspect) to find the z-index of the overlapping element and set yours higher.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu doesn&apos;t open on iOS Safari&quot; group=&quot;troubleshooting&quot;&gt;

iOS Safari can be finicky with the checkbox hack. The `&lt;label&gt;` element that triggers the checkbox sometimes doesn&apos;t register taps properly.

**Fixes**:
- Add `cursor: pointer` to the `.hamburger` label (this forces iOS to treat it as tappable)
- Make sure no parent element has `overflow: hidden` that could clip the tap target
- Test on a real iOS device — Carrd&apos;s mobile preview doesn&apos;t catch all Safari quirks

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu position breaks with sticky header&quot; group=&quot;troubleshooting&quot;&gt;

The dropdown position uses `top: calc(15px + 6em)`. This is hardcoded and assumes a specific header height. If you&apos;ve added a sticky header, the values may not line up.

**Fix**: Adjust the `6em` value to match your actual header height. Use browser dev tools to measure the header and tweak the `calc()` value until the dropdown sits directly below it. See the [sticky header tutorial](https://www.bitdoze.com/add-stickey-header-carrd/) for the combined setup.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;CSS conflicts with Carrd&apos;s built-in styles&quot; group=&quot;troubleshooting&quot;&gt;

The updated code uses a scoped reset (`nav, nav *`) instead of the original universal `*` selector, which should prevent most conflicts. But if you still see spacing issues with Carrd&apos;s native elements, the embed&apos;s CSS may be leaking.

**Fix**: Check if your `&lt;style&gt;` block uses any universal selectors. If you split the CSS into a separate Hidden → Head embed, make sure all selectors are scoped to `nav` or its children. Avoid targeting global elements like `body`, `html`, or `*`.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Close button or menu links not working&quot; group=&quot;troubleshooting&quot;&gt;

If the close button (X) doesn&apos;t close the menu, or clicking a nav link doesn&apos;t close it either, the JavaScript may be running before the DOM is ready.

**Fix**: Wrap the JS in a `DOMContentLoaded` event:

```javascript
document.addEventListener(&quot;DOMContentLoaded&quot;, function () {
  document
    .querySelector(&quot;.close-button&quot;)
    .addEventListener(&quot;click&quot;, function () {
      document.getElementById(&quot;menu-toggle&quot;).checked = false;
    });

  document.querySelectorAll(&quot;.menu a&quot;).forEach(function (link) {
    link.addEventListener(&quot;click&quot;, function () {
      document.getElementById(&quot;menu-toggle&quot;).checked = false;
    });
  });
});
```

Also verify the close button is a direct child of `&lt;nav&gt;` (not inside `&lt;ul&gt;`) as shown in the updated code.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Menu items not clickable on Android&quot; group=&quot;troubleshooting&quot;&gt;

Older Android browsers (pre-Chrome 90) sometimes have issues with the checkbox hack combined with `display: none/block` toggling.

**Fix**: The updated code already uses `max-height` transition instead of `display` toggle, which is more reliable on Android. If you&apos;re still seeing issues on older devices, add `position: relative` and `z-index: 1` to the `.menu li a` elements.

&lt;/Accordion&gt;

## Testing your Carrd responsive navbar

&lt;Notice type=&quot;success&quot; title=&quot;Testing Tip&quot;&gt;
Always test on real devices. Carrd&apos;s mobile preview in the editor doesn&apos;t catch all edge cases, especially iOS Safari quirks and Android touch behavior.
&lt;/Notice&gt;

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;Publish the site and test on a real phone (not just Carrd&apos;s mobile preview)&lt;/li&gt;
  &lt;li&gt;Hamburger toggle opens AND closes the menu&lt;/li&gt;
  &lt;li&gt;Each menu link closes the menu and navigates to the correct section&lt;/li&gt;
  &lt;li&gt;Close button (X) closes the menu&lt;/li&gt;
  &lt;li&gt;Resize desktop browser from 1920px down to 320px — verify the breakpoint at 768px&lt;/li&gt;
  &lt;li&gt;Test with sticky header applied simultaneously (if using one)&lt;/li&gt;
  &lt;li&gt;Test on iOS Safari, Chrome for Android, and desktop Chrome/Firefox&lt;/li&gt;
  &lt;li&gt;Run a Lighthouse accessibility audit — check for ARIA warnings&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Do I need coding skills to add a mobile navbar to Carrd?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. Method 2 (native Carrd elements) and Method 3 (plugins) require zero code. Method 1 (custom embed) requires copy-pasting HTML/CSS into Carrd&apos;s Embed element — no programming knowledge needed, just follow the steps.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What Carrd plan do I need for a responsive navbar?&quot; group=&quot;faq&quot;&gt;
It depends on the method:
- **Method 1** (custom code): Pro Standard at $19/year — you need the Embed element
- **Method 2** (native elements): Pro Plus at $49/year — you need Visibility settings
- **Method 3** (plugins): Pro Standard at $19/year + the plugin cost

All paid plans include a [7-day free trial](https://go.bitdoze.com/carrd) with no credit card required.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use this navbar with a Carrd sticky header?&quot; group=&quot;faq&quot;&gt;
Yes, but it works best with Method 1 (custom code). The [sticky header tutorial for Carrd](https://www.bitdoze.com/add-stickey-header-carrd/) pairs directly with the custom code approach — you just need to adjust the `top` value in the dropdown CSS to match your header height. The native approach (Method 2) doesn&apos;t support sticky positioning without additional custom CSS.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about sidebar menus for Carrd?&quot; group=&quot;faq&quot;&gt;
If a top navbar doesn&apos;t fit your design, a sidebar navigation menu is another option. It works better for sites with many sections or a dashboard-style layout. See [how to add a sidebar menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/) for a step-by-step guide. You can also explore a [Carrd popup modal](https://www.bitdoze.com/carrd-popup-modal/) for overlay-style navigation.
&lt;/Accordion&gt;

## More Carrd customizations

If you&apos;re building out your Carrd site beyond the navbar, these guides cover other common additions:

- [Add a Back to Top Button on Carrd](https://www.bitdoze.com/carrd-back-to-top-button/)
- [Add a Cookie Notice to Carrd](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [Add a Pricing Table to Carrd](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Add a Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add a Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/)
- [Add a WhatsApp Chat Button to Carrd](https://www.bitdoze.com/carrd-whatsapp-button/)
- [Add Countdown Timer Styling to Carrd](https://www.bitdoze.com/carrd-countdown-styling/)
- [Add a Custom Domain to Carrd](https://www.bitdoze.com/carrd-add-domain/)</content:encoded><category>web-development</category><category>carrd</category><category>navbar</category><category>responsive-design</category></item><item><title>CloudPanel as Reverse Proxy: Docker &amp; Dockge Setup Guide</title><link>https://www.bitdoze.com/cloudpanel-setup-dockge/</link><guid isPermaLink="true">https://www.bitdoze.com/cloudpanel-setup-dockge/</guid><description>Set up CloudPanel as a reverse proxy for Docker with Dockge. Step-by-step guide with Nginx vhost config, SSL, firewall rules, and security hardening.</description><pubDate>Thu, 23 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import imag1 from &quot;../../assets/images/24/02/cp-add-proxy-1.png&quot;;
import imag2 from &quot;../../assets/images/24/02/cp-add-proxy-2.png&quot;;
import imag3 from &quot;../../assets/images/24/02/cp-open-ports.png&quot;;
import imag4 from &quot;../../assets/images/24/01/dockge-add.png&quot;;

[CloudPanel](https://www.cloudpanel.io/) is a lightweight hosting panel that handles PHP, Node.js, and Python apps. Its reverse proxy feature lets you route traffic to Docker containers running on the same VPS. Pair it with [Dockge](/dockge-install/), a [Dockge Docker compose manager](/dockge-install/) for organizing and deploying your Docker stacks, and you get one server that handles both traditional hosted sites and containerized apps.

If you only need a reverse proxy for Docker containers, tools like Nginx Proxy Manager or Traefik are simpler. But if you also want to host PHP or Node.js sites alongside Docker, CloudPanel is a solid fit. There are also other [self-hosted server panels](/best-self-hosted-panels/) worth comparing.

&lt;Notice type=&quot;info&quot; title=&quot;Tested with&quot;&gt;
CloudPanel CE v2.5.3 · Dockge v1.5.0 · Docker 29.x · Ubuntu 24.04 LTS
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/BuoyvbDVBe0&quot;
  label=&quot;Setup CloudPanel As Reverse Proxy with Docker and Dockge&quot;
/&gt;

## 1. Prerequisites

Before you start, make sure you have:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS running Ubuntu 24.04 LTS (or Ubuntu 22.04, Debian 12)&lt;/li&gt;
&lt;li&gt;Minimum 1 core, 2 GB RAM, 10 GB disk&lt;/li&gt;
&lt;li&gt;A domain name with DNS access (Cloudflare, Hetzner DNS, etc.)&lt;/li&gt;
&lt;li&gt;SSH access to the server&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Both ARM and x86_64 servers are supported.

## 2. Create a VPS server

I recommend [Hetzner](https://go.bitdoze.com/hetzner) for EU-based hosting (good price-to-performance ratio). [Hostinger](https://go.bitdoze.com/hostinger-vps) is a solid budget KVM option. For global presence, DigitalOcean and Vultr work fine. For more details check this [Hetzner Review](https://www.wpdoze.com/hetzner-cloud-review/) and you can check also: [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/)

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

Ubuntu 24.04 LTS is the recommended OS for new deployments.

## 3. Update the VPS server

Always update before installing anything:

```bash
apt update &amp;&amp; apt -y upgrade &amp;&amp; apt -y install curl wget sudo
```

Verify the OS version:

```bash
cat /etc/os-release
```

You should see `VERSION_ID=&quot;24.04&quot;` (or `22.04` / `12` for Debian).

## 4. Install CloudPanel

&gt; I have also created a course that will help you get going with CloudPanel if you are a beginner, check **[CloudPanel Setup Course](https://webdoze.net/courses/cloudpanel-setup/)**

CloudPanel supports multiple database engines. Pick the one you prefer:

&lt;Tabs&gt;
&lt;Tab name=&quot;MariaDB 10.11&quot;&gt;
```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;6eac061df80f08b75224fcd7fce2f115e201696d8a6122e31abf7259a813b462 install.sh&quot; | \
sha256sum -c &amp;&amp; sudo CLOUD=hetzner DB_ENGINE=MARIADB_10.11 bash install.sh
```
&lt;/Tab&gt;
&lt;Tab name=&quot;MySQL 8.4&quot;&gt;
```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;6eac061df80f08b75224fcd7fce2f115e201696d8a6122e31abf7259a813b462 install.sh&quot; | \
sha256sum -c &amp;&amp; sudo CLOUD=hetzner DB_ENGINE=MYSQL_8.4 bash install.sh
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Installer checksum changes&quot;&gt;
The installer checksum changes with each CloudPanel release. If verification fails, grab the latest checksum from the [official install docs](https://www.cloudpanel.io/docs/v2/getting-started/other/).
&lt;/Notice&gt;

Replace `CLOUD=hetzner` with your provider (or remove it for generic installs). Available database engines: `MARIADB_10.11`, `MARIADB_11.4`, `MYSQL_8.0`, `MYSQL_8.4`. Installation takes about 5-10 minutes.

CloudPanel supports PHP, Node.js, and Python sites. If you need to [host Node.js apps with CloudPanel](/install-cloudpanel-host-nodejs/), the same install works.

**Verify:**

```bash
systemctl status cloudpanel
```

The service should show `active (running)`. You can access the admin at `https://serverIP:8443`.

**If checksum fails:** the installer script was updated since publication. Fetch the latest from [CloudPanel install docs](https://www.cloudpanel.io/docs/v2/getting-started/other/).

## 5. Secure CloudPanel immediately

&lt;Notice type=&quot;error&quot; title=&quot;Do this within minutes of install&quot;&gt;
CloudPanel has had multiple security issues, including privilege escalation bugs patched in v2.5.0-v2.5.2. CVE-2025-15241 (open redirect in the admin panel) was fixed in v2.5.2. Unpatched instances are easy targets for bots. Three things you must do immediately after install.
&lt;/Notice&gt;

**1. Create the admin account now.** Bots scan for unconfigured CloudPanel instances and can create the admin user before you do. Open `https://serverIP:8443` and set up your admin account within minutes.

**2. Restrict port 8443 to your IP.** Use CloudPanel&apos;s firewall or UFW from the command line:

```bash
# Allow 8443 only from your IP (replace with your actual IP)
ufw allow from YOUR_IP_ADDRESS to any port 8443
ufw deny 8443
```

Or do this through CloudPanel admin: **Admin Area &gt; Security &gt; Add Rule** to whitelist your IP on port 8443.

**3. Update CloudPanel to the latest version:**

```bash
clp-update
```

This patches CVE-2025-15241 (open redirect in `/admin/users` via Referer header manipulation) and privilege escalation vulnerabilities fixed in v2.5.0-v2.5.2.

**Verify:**

```bash
clp-version
```

Should show v2.5.2 or later.

For comprehensive hardening, see how to [secure your CloudPanel server](/secure-cloudpanel/) and [keep CloudPanel updated](/safely-update-cloudpanel/) regularly.

## 6. Create an admin subdomain

To access CloudPanel behind a proper domain with SSL, create a DNS A record for a subdomain (e.g., `panel.example.com`) pointing to your server IP. If you use Cloudflare, the proxy (orange cloud) works fine here.

Then add the subdomain in CloudPanel admin under **Settings** to secure the admin area.

**Verify:** Visit `https://panel.example.com`. You should see the CloudPanel login page with a valid SSL certificate.

## 7. Install Docker and Docker Compose

The old Docker install method (hardcoding `jammy` in the apt source) breaks on Ubuntu 24.04. Use the current official method with dynamic codename detection:

&lt;Notice type=&quot;info&quot; title=&quot;Works on Ubuntu 22.04 and 24.04&quot;&gt;
This method detects your Ubuntu version automatically. No need to hardcode the release name.
&lt;/Notice&gt;

```bash
sudo apt update
sudo apt install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

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

sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```

For a full reference of Docker commands, check [essential Docker commands](/docker-commands/). Everything is also explained in the [install Docker on Ubuntu](/install-docker-ubuntu-arm/) guide.

**Verify:**

```bash
sudo docker run hello-world
sudo systemctl status docker
docker compose version
```

`docker compose version` should print something like `Docker Compose version v2.x.x`. If it fails, you likely installed the deprecated standalone `docker-compose` instead of `docker-compose-plugin`.

**Failure mode:** If `docker compose` is not found, remove the old standalone package and install the plugin:

```bash
sudo apt remove docker-compose
sudo apt install docker-compose-plugin
```

## 8. Install Dockge

Dockge will be installed under a CloudPanel site&apos;s `htdocs` directory. The advantage: CloudPanel&apos;s rClone backups automatically capture Dockge and all your Docker stacks together. The tradeoff: Dockge&apos;s official recommendation is `/opt/stacks` and `/opt/dockge`. Both approaches work. The htdocs approach gives you backup coupling with CloudPanel, while `/opt/stacks` keeps things in standard paths.

&gt; If you have CloudPanel external backup activated you will backup all the apps and Dockge at once. See [CloudPanel remote backups](/cloudpanel-remote-backups/) for setup.

&lt;Notice type=&quot;info&quot; title=&quot;Dockge is actively maintained&quot;&gt;
After a quiet period (the maintainer prioritized Uptime Kuma 2.0), Dockge v1.5.0 was released in March 2025 with security fixes including the disabled-by-default console. The project has 23k+ GitHub stars and is not abandoned.
&lt;/Notice&gt;

### 8.1 Create a reverse proxy site in CloudPanel

Under **Sites &gt; Add Site** choose **Create a Reverse Proxy**.

&lt;Picture src={imag1} alt=&quot;CloudPanel Create a Reverse Proxy&quot; /&gt;

Add the domain you want to use for Dockge, create a user with a password, and set the port. I use port `5000` (host) which maps to Dockge&apos;s internal port `5001`. You can customize this.

&lt;Picture src={imag2} alt=&quot;CloudPanel Create a Reverse Proxy&quot; /&gt;

### 8.2 Create directories for Dockge

SSH into your server and navigate to the CloudPanel site directory:

```sh
cd /home/&lt;user&gt;/htdocs/&lt;website&gt;/
# Example:
cd /home/bitdoze-dockge/htdocs/dockge.bitdoze.com/
```

Replace `&lt;user&gt;` and `&lt;website&gt;` with the values from step 8.1.

Create the directories:

```sh
mkdir dockge-stacks
mkdir dockge
```

- **dockge**: Dockge&apos;s own data and compose file
- **dockge-stacks**: where all your Docker app stacks will live

&lt;Notice type=&quot;info&quot; title=&quot;Backup advantage&quot;&gt;
Storing stacks under CloudPanel&apos;s htdocs means CloudPanel&apos;s rClone backups include all your Docker apps automatically. If you prefer Dockge&apos;s official `/opt/stacks` layout, adjust the volume mounts in the compose file accordingly.
&lt;/Notice&gt;

### 8.3 Deploy Dockge with Docker Compose

Create a `compose.yaml` file inside the `dockge` directory:

```yaml
services:
  dockge:
    image: louislam/dockge:1
    restart: unless-stopped
    ports:
      - 5000:5001  # Host:Container, customize host port as needed
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/app/data
      - /home/&lt;user&gt;/htdocs/&lt;website&gt;/dockge-stacks:/home/&lt;user&gt;/htdocs/&lt;website&gt;/dockge-stacks
    environment:
      # Tell Dockge where to find the stacks
      - DOCKGE_STACKS_DIR=/home/&lt;user&gt;/htdocs/&lt;website&gt;/dockge-stacks
      # Set file ownership for stack files (optional but recommended)
      # - PUID=1000
      # - PGID=1000
      # Console is disabled by default since v1.5.0 for security.
      # Enable only if you understand the risk:
      # - DOCKGE_ENABLE_CONSOLE=true
```

Replace the paths and port with your actual values. Here is a concrete example:

```yaml
services:
  dockge:
    image: louislam/dockge:1
    restart: unless-stopped
    ports:
      - 5000:5001
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/app/data
      - /home/bitdoze-dockge/htdocs/dockge.bitdoze.com/dockge-stacks:/home/bitdoze-dockge/htdocs/dockge.bitdoze.com/dockge-stacks
    environment:
      - DOCKGE_STACKS_DIR=/home/bitdoze-dockge/htdocs/dockge.bitdoze.com/dockge-stacks
```

[Dockge Compose Generator](https://dockge.kuma.pet/) can help you create the exact compose file.

&lt;Notice type=&quot;warning&quot; title=&quot;Dockge v1.5.0 disabled the web console by default&quot;&gt;
The embedded terminal (Console) is now disabled by default for security reasons. To re-enable it, add `DOCKGE_ENABLE_CONSOLE=true` to the environment section. Only enable if you understand the risk. Compose editing works fine without it.
&lt;/Notice&gt;

Start Dockge:

```sh
cd /home/&lt;user&gt;/htdocs/&lt;website&gt;/dockge
docker compose up -d
```

**Verify:**

```sh
docker compose ps
curl -I http://localhost:5001
```

The container should show `running` and curl should return `HTTP/1.1 200 OK`.

### 8.4 Point domain to Dockge

In your DNS provider, create an A record for the Dockge subdomain (e.g., `dockge.example.com`) pointing to your server IP.

### 8.5 Create an SSL certificate

In CloudPanel, go to **Sites &gt; Manage Site &gt; SSL/TLS** and generate a Let&apos;s Encrypt certificate.

**Verify:** Visit `https://dockge.example.com`. You should see the Dockge UI with a padlock in your browser.

Note: CloudPanel v2.5.0 fixed a bug where the `.well-known` directory was deleted during SSL renewal. If you had issues with certificate renewal on older versions, updating CloudPanel should resolve it.

### 8.6 Access Dockge and create admin user

The first time you access Dockge you will be prompted to create a username and password. Do this immediately.

### 8.7 Open CloudPanel firewall ports

CloudPanel&apos;s firewall blocks most ports by default (only 22, 80, 443, 8443 are open). If you need direct port access for Docker apps that aren&apos;t routed through CloudPanel&apos;s reverse proxy, open the port ranges:

Go to **Admin Area &gt; Security &gt; Add Rule** and open the ranges you need:

&lt;Picture src={imag3} alt=&quot;CloudPanel Port Open&quot; /&gt;

This is optional if all your apps go through CloudPanel&apos;s reverse proxy (which is the recommended approach).

## 9. Secure the reverse proxy configuration

CloudPanel&apos;s default reverse proxy config uses `try_files $uri @reverse_proxy;`, which can expose stack files like `.env` and other secrets. The enhanced config below fixes that, plus adds WebSocket support and proper proxy headers.

### 9.1 Enhanced Nginx proxy configuration

In CloudPanel, go to **Sites &gt; Select your site &gt; Vhost Editor**. Replace the default proxy block with:

```nginx
location / {
  proxy_pass {{reverse_proxy_url}};
  proxy_http_version 1.1;
  proxy_set_header X-Forwarded-Host $host;
  proxy_set_header X-Forwarded-Server $host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header Host $http_host;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection &quot;Upgrade&quot;;
  proxy_pass_request_headers on;
  proxy_max_temp_file_size 0;
  proxy_connect_timeout 900;
  proxy_send_timeout 900;
  proxy_read_timeout 900;
  proxy_buffer_size 128k;
  proxy_buffers 4 256k;
  proxy_busy_buffers_size 256k;
  proxy_temp_file_write_size 256k;
}
```

Key headers explained:

- **`X-Forwarded-Proto $scheme`**: tells the backend app whether the original request came over HTTP or HTTPS. Without this, apps behind CloudPanel&apos;s HTTPS may generate insecure links.
- **`Upgrade` / `Connection &quot;Upgrade&quot;`**: enables WebSocket support for real-time apps (chat, live dashboards, Dockge&apos;s own UI updates).
- The buffer and timeout settings prevent issues with large uploads and long-running connections.

&lt;Notice type=&quot;info&quot; title=&quot;Why this matters&quot;&gt;
With the default config, files inside your Dockge stack directories (like `.env` files with database passwords) can be accessed directly through the browser. This enhanced config routes all requests through the reverse proxy, preventing direct file access.
&lt;/Notice&gt;

### 9.2 Protect sensitive files

For an extra layer of protection, add this block to your Vhost configuration:

```nginx
# Protect sensitive files
location ~* \.(php|sql|log|config|env)$ {
    deny all;
}
```

This blocks direct access to files with extensions like `.php`, `.sql`, `.log`, `.config`, and `.env` which often contain credentials or configuration data.

### 9.3 Add basic authentication

For an additional security layer on your Dockge or app sites:

1. Go to CloudPanel admin
2. Navigate to **Sites &gt; Select your site &gt; Security**
3. Add a username and password for basic authentication

Users will be prompted for credentials before accessing the application.

## 10. Deploy your first app

Now you are set. Access Dockge and start deploying apps.

&lt;Picture src={imag4} alt=&quot;Dockge Add Compose&quot; /&gt;

Below are some apps you can deploy on Dockge:

- [Install Umami Analytics](/umami-analytics-install/)
- [Install Outline Wiki](/outline-install/)
- [Slash Install](/slash-docker-deploy/)

For each app, the workflow is:

1. **Create a Reverse Proxy in CloudPanel**: same as you did for Dockge, create a site as a Reverse Proxy with the domain and port for the app
2. **Point the domain to the server**: add an A record in your DNS provider
3. **Create an SSL certificate**: go to CloudPanel under **Sites &gt; Manage Site &gt; SSL/TLS** and generate a Let&apos;s Encrypt certificate

## 11. Backups

Set up backups before deploying production apps, not after.

Activate CloudPanel&apos;s rClone external backups to S3, Dropbox, Google Drive, or any remote storage. Go to **Admin Area &gt; Backups** and configure the rClone integration.

If your stacks are stored under the `htdocs` directory (the approach in this guide), they are included automatically in CloudPanel backups. If you use `/opt/stacks`, you need a separate backup strategy.

See [CloudPanel remote backups](/cloudpanel-remote-backups/) for detailed setup.

If you are on Hetzner, you can also enable Hetzner snapshots for server-level backups.

&gt; I have also created a course that will help you get going with CloudPanel if you are a beginner, check **[CloudPanel Setup Course](https://webdoze.net/courses/cloudpanel-setup/)**

## 12. Troubleshooting

&lt;Accordion label=&quot;Checksum verification fails during CloudPanel install&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;
The installer script and checksum change with each CloudPanel release. If verification fails, re-fetch the latest checksum from the [CloudPanel install docs](https://www.cloudpanel.io/docs/v2/getting-started/other/) and replace it in the install command.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Docker daemon not starting after install&quot; group=&quot;troubleshooting&quot;&gt;
Check for conflicting packages from Ubuntu&apos;s default repository:

```bash
sudo systemctl status docker
```

If Docker won&apos;t start, remove the conflicting `docker.io` package:

```bash
sudo apt remove docker.io
sudo systemctl start docker
sudo systemctl enable docker
```

Verify: `sudo docker run hello-world`
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Dockge not accessible via domain&quot; group=&quot;troubleshooting&quot;&gt;
Check these in order:

1. DNS propagated: `dig +short yourdomain.com` should return your server IP
2. CloudPanel firewall has the port open (if using direct port access)
3. Dockge container is running: `docker compose ps`
4. Port mapping in compose.yaml matches the port in CloudPanel&apos;s reverse proxy config
5. SSL certificate is issued: check under **Sites &gt; Manage Site &gt; SSL/TLS**
&lt;/Accordion&gt;

&lt;Accordion label=&quot;SSL certificate fails to issue&quot; group=&quot;troubleshooting&quot;&gt;
- DNS must be fully propagated before requesting a certificate (check with `dig`)
- The `.well-known` directory must be accessible from the internet
- If using Cloudflare with the proxy enabled (orange cloud), disable it temporarily for certificate issuance, then re-enable
- CloudPanel v2.5.0 fixed a bug where `.well-known` was deleted during renewal. Make sure you are on the latest version.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Dockge stacks showing as &apos;not managed&apos; after upgrade&quot; group=&quot;troubleshooting&quot;&gt;
Verify the `DOCKGE_STACKS_DIR` environment variable in your compose.yaml matches the actual path where your stacks live. Then restart Dockge:

```bash
docker compose restart
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bots created the admin account before me&quot; group=&quot;troubleshooting&quot;&gt;
This happens when port 8443 is open to the world and you delay creating the admin account. There are two options:

1. If you haven&apos;t set anything up yet: reinstall CloudPanel and create the admin account within minutes
2. If you need to recover: restrict port 8443 to your IP immediately, then check CloudPanel&apos;s recovery options in their docs

See Section 5 for how to prevent this.
&lt;/Accordion&gt;

## 13. Alternatives to CloudPanel + Dockge

This setup works well when you need PHP/Node.js sites alongside Docker containers on the same VPS. But if your workload is purely Docker-based, there are simpler options:

| Stack | Best for | Tradeoff |
|-------|----------|----------|
| **CloudPanel + Dockge** (this guide) | PHP/Node sites + Docker on same VPS | Closed-source panel; security track record needs attention |
| **Dokploy** | Docker-native apps with built-in reverse proxy | No PHP/Node hosting outside Docker |
| **Coolify** | Full PaaS replacement for both | Heavier resource usage |
| **Nginx Proxy Manager** | Simple reverse proxy only | No site management, no file manager |

If you are evaluating Docker management tools specifically, check [Portainer alternatives like Dockge](/portainer-alternatives/) for a deeper comparison. For a broader look at panels, see the [self-hosted server panels](/best-self-hosted-panels/) comparison.

## FAQ

&lt;Accordion label=&quot;Can I use CloudPanel with Docker on ARM servers?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes, both CloudPanel and Dockge support ARM and x86_64. Use Ubuntu 24.04 ARM64 on providers like Hetzner (CAX instances) or Oracle Cloud free tier.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why not just use Nginx Proxy Manager or Traefik instead of CloudPanel?&quot; group=&quot;faq&quot;&gt;
CloudPanel gives you a file manager, PHP/Node.js hosting, database management, cron jobs, and firewall in addition to reverse proxy. If you only need reverse proxy for Docker apps, Nginx Proxy Manager or Traefik are simpler with fewer moving parts. If you also host PHP sites, CloudPanel is the better fit.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is it safe to run Docker alongside CloudPanel?&quot; group=&quot;faq&quot;&gt;
Yes, but keep CloudPanel updated with `clp-update`. CloudPanel manages Nginx on the host; Docker containers run in isolation. The main risk is unpatched CloudPanel instances, not the Docker co-existence itself.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I update Dockge?&quot; group=&quot;faq&quot;&gt;
Navigate to the Dockge compose directory and pull the latest image:

```bash
cd /home/&lt;user&gt;/htdocs/&lt;website&gt;/dockge
docker compose pull
docker compose up -d
```

Dockge will show an update notification in the UI when a new version is available.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does CloudPanel backup include my Docker stacks?&quot; group=&quot;faq&quot;&gt;
Only if you store stacks under CloudPanel&apos;s htdocs directory (the approach in this guide). If you use `/opt/stacks`, you need a separate backup strategy for your Docker data.
&lt;/Accordion&gt;</content:encoded><category>hosting</category><category>cloudpanel</category><category>dockge</category><category>docker</category></item><item><title>How to Block AI Crawlers &amp; Safeguard Your Website (2026)</title><link>https://www.bitdoze.com/block-ai-crawlers/</link><guid isPermaLink="true">https://www.bitdoze.com/block-ai-crawlers/</guid><description>Block AI crawlers from stealing your website content. Complete guide: Cloudflare one-click blocking, robots.txt setup, Nginx rules, and AI Labyrinth. Updated for 2026.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

AI crawlers now account for over 20% of all web traffic, and with 170+ AI bots actively scraping content, **blocking AI crawlers** matters for every website owner. Whether your content is training AI models without compensation, you&apos;re losing referral traffic to AI-powered search, or your VPS bandwidth is disappearing, this guide covers every method available in 2026, from Cloudflare&apos;s free one-click toggle to server-level Nginx rules and the AI Labyrinth defense.

The approach that works is layered defense. No single method stops all bots. I&apos;ll walk through seven methods, from the simplest (one click in Cloudflare) to the most granular (server-level configs and new web standards), and show you how to verify each one actually works.

## Why block AI crawlers in 2026?

Three reasons this matters more now than it did in 2024:



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/4s5I5Bz-IDE&quot;
  label=&quot;Stop AI Crawler Bots: How to Safeguard Your Website&quot;
/&gt;
**Bandwidth and cost.** AI bots are responsible for over 20% of all HTML requests across Cloudflare&apos;s network. Aggressive crawlers like ByteDance&apos;s Bytespider accessed over 40% of Cloudflare-protected sites. Anthropic&apos;s ClaudeBot saw an 800% volume increase in late 2025. If you&apos;re running a VPS with bandwidth caps, these bots are eating into your allocation for zero benefit.

**Content ownership.** Your content is being used to train commercial AI models without compensation or attribution. The visitors don&apos;t come to your site, don&apos;t subscribe to your newsletter, don&apos;t click your affiliate links. The AI company profits; you get the bandwidth bill.

**Legal context.** The EU AI Act (Article 53, in force August 2, 2025) now requires General-Purpose AI providers to implement copyright compliance policies and respect machine-readable opt-outs like `robots.txt`. If you&apos;re in the EU, your `robots.txt` file now carries legal weight. More on that in Method 6.

&lt;Notice type=&quot;info&quot; title=&quot;Not all AI bots are bad&quot;&gt;
Not all AI crawlers are harmful. Search-indexing bots like OAI-SearchBot and Claude-SearchBot may drive traffic to your site through AI-powered search results. Training crawlers like GPTBot and ClaudeBot won&apos;t send you a single visitor. This guide helps you decide what to block and what to allow.
&lt;/Notice&gt;

## The AI crawler landscape in 2026: who&apos;s crawling and why

The AI bot ecosystem has exploded since 2024. The community-maintained [ai-robots-txt repository](https://github.com/ai-robots-txt/ai.robots.txt) (4,000+ GitHub stars) now tracks 170+ AI crawlers. For practical purposes, you need to know about three categories, which map directly to Cloudflare&apos;s new classification system:

&lt;Tabs&gt;
&lt;Tab name=&quot;Training crawlers&quot;&gt;
These bots scrape your content to train AI models. They provide zero referral traffic. Block these.

| Crawler | Operator | robots.txt token |
|---------|----------|-----------------|
| GPTBot | OpenAI | `GPTBot` |
| ClaudeBot | Anthropic | `ClaudeBot` |
| CCBot | Common Crawl | `CCBot` |
| Google-Extended | Google (Gemini) | `Google-Extended` |
| Meta-ExternalAgent | Meta (Llama) | `meta-externalagent` |
| Meta-ExternalFetcher | Meta | `meta-externalfetcher` |
| Bytespider | ByteDance | `Bytespider` |
| Amazonbot | Amazon (Alexa) | `Amazonbot` |
| DeepSeekBot | DeepSeek | `DeepSeekBot` |
| cohere-ai | Cohere | `cohere-ai` |
| Diffbot | Diffbot | `Diffbot` |
| Applebot-Extended | Apple Intelligence | `Applebot-Extended` |
&lt;/Tab&gt;
&lt;Tab name=&quot;Search crawlers&quot;&gt;
These index your content for AI-powered search results. They may send referral traffic. Consider allowing.

| Crawler | Operator | robots.txt token |
|---------|----------|-----------------|
| OAI-SearchBot | OpenAI (ChatGPT search) | `OAI-SearchBot` |
| Claude-SearchBot | Anthropic (Claude search) | `Claude-SearchBot` |
| PerplexityBot | Perplexity | `PerplexityBot` |
| DuckAssistBot | DuckDuckGo | `DuckAssistBot` |
| Gemini-Deep-Research | Google | `Gemini-Deep-Research` |
&lt;/Tab&gt;
&lt;Tab name=&quot;Agent bots&quot;&gt;
These act on behalf of a specific user in real time. Not bulk crawling. Consider allowing.

| Crawler | Operator | robots.txt token |
|---------|----------|-----------------|
| ChatGPT-User | OpenAI | `ChatGPT-User` |
| Claude-User | Anthropic | `Claude-User` |
| Perplexity-User | Perplexity | `Perplexity-User` |
| MistralAI-User | Mistral (Le Chat) | `MistralAI-User` |
&lt;/Tab&gt;
&lt;/Tabs&gt;

### The Perplexity stealth crawler warning

This is the most important cautionary tale for anyone relying solely on `robots.txt`: In August 2025, Cloudflare caught [Perplexity using stealth, undeclared crawlers](https://blog.cloudflare.com/perplexity-is-using-stealth-undeclared-crawlers-to-evade-website-no-crawl-directives/) that masqueraded as Chrome on macOS. When blocked via their declared `PerplexityBot` user-agent, they switched to a generic browser fingerprint, used rotating IPs across multiple ASNs, and in some cases didn&apos;t even fetch `robots.txt`. Cloudflare observed 3-6 million daily stealth requests and de-listed Perplexity as a verified bot.

If you&apos;re interested in how Perplexity&apos;s AI search works and its approach, check out our article on [Perplexity&apos;s approach to AI search](/perplexity/).

The lesson: `robots.txt` is a polite request, not a security barrier. Technical enforcement at the WAF level is the only thing that actually stops bad actors.

## Method 1: Block AI crawlers with Cloudflare one-click toggle (recommended)

This is the simplest and most effective method. Cloudflare introduced a one-click toggle in July 2024 that blocks known AI scrapers and crawlers automatically, no WAF rule creation needed. It&apos;s available on the **Free plan** and Cloudflare updates the bot fingerprints as new crawlers emerge.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Cloudflare account (free tier works)&lt;/li&gt;
&lt;li&gt;Domain added to Cloudflare with DNS managed there&lt;/li&gt;
&lt;li&gt;Proxy enabled (orange cloud) on your DNS records&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Step 1: Enable the Cloudflare proxy**

Make sure your site&apos;s DNS records have the orange cloud (proxy) enabled, not just DNS-only. If you need help setting up Cloudflare for the first time, see how to [deploy your site on Cloudflare](/deploy-astrojs-cloudflare/) or [set up a blog on Cloudflare&apos;s free tier](/build-astro-blog-free/).

**Step 2: Navigate to Security &gt; Bots**

In the Cloudflare dashboard, go to **Security &gt; Bots**. You&apos;ll see the **&quot;AI Scrapers and Crawlers&quot;** section.

**Step 3: Toggle &quot;Block AI bots&quot; to ON**

Enable the toggle. That&apos;s it, Cloudflare handles the rest.

**Step 4: Verify it works**

Wait 5 minutes, then test:

```bash
curl -s -o /dev/null -w &quot;%{http_code}&quot; -A &quot;GPTBot/1.0&quot; https://yoursite.com
# Should return 403 or 406
```

You can also check the **Security &gt; Analytics** dashboard after a few hours to see how many AI requests have been blocked.

&lt;Notice type=&quot;success&quot;&gt;
Cloudflare has blocked over 416 billion AI bot requests since July 2025, and 2.5 million websites have enabled this protection. This is the fastest path: one click and you&apos;re covered. Cloudflare automatically updates bot signatures as new crawlers appear.
&lt;/Notice&gt;

## Method 2: Cloudflare granular AI bot policies (Search, Agent, Training)

If you want fine-grained control over which types of AI bots can access your site, Cloudflare&apos;s new three-category system (launched July 2026) gives you exactly that. This replaces the older &quot;Block AI bots&quot; managed preset.

Navigate to **Security Settings &gt; Configure AI bot policies** in your Cloudflare dashboard. You&apos;ll see three categories:

| Category | What it controls | Default (from Sept 15, 2026) |
|----------|-----------------|------------------------------|
| **Search** | Crawlers indexing content for AI search (OAI-SearchBot, etc.) | Allow |
| **Agent** | User-triggered agents acting in real time (ChatGPT-User, etc.) | Block on pages with ads |
| **Training** | Crawlers collecting data for model training (GPTBot, ClaudeBot, etc.) | Block on pages with ads |

For each category, you can choose:
- **Block**: block on all pages
- **Block on pages with ads**: middle ground (new default for Training and Agent)
- **Allow**: let them through

The &quot;Block on pages with ads&quot; option is clever. It lets AI bots access your content pages (which may drive traffic) while blocking them on monetized pages (where they&apos;d cost you ad revenue without contributing).

To check your results, go to **Security &gt; Analytics** after 24 hours and review blocked vs. allowed requests per category.

&lt;Notice type=&quot;warning&quot;&gt;
The old &quot;Block AI bots&quot; preset deprecates on September 15, 2026. If you&apos;re currently using it, migrate to the new three-category system before that date.
&lt;/Notice&gt;

&lt;Accordion label=&quot;What happened to the old WAF custom rule method?&quot; group=&quot;cf-methods&quot;&gt;
The old method used a WAF custom rule with the expression `(cf.verified_bot_category eq &quot;AI Crawler&quot;)`. This still works and doesn&apos;t consume your custom rule slots differently, but it&apos;s being superseded by the new three-category system which offers finer control (block training separately from search). If you already have this rule deployed, it&apos;s fine to keep it, but the new system at Security Settings &gt; Configure AI bot policies is the recommended path going forward.
&lt;/Accordion&gt;

## Method 3: AI Labyrinth: waste bot resources instead of blocking

Cloudflare&apos;s AI Labyrinth (launched March 2025) takes a different approach: instead of just blocking bots, it serves them **AI-generated decoy content** through hidden links that no human would ever click. This does three things:

1. **Wastes the crawler&apos;s compute resources** processing fake content
2. **Acts as a honeypot**: if a crawler follows the hidden links, Cloudflare knows it&apos;s a bot
3. **Feeds Cloudflare&apos;s ML models** to identify new bot patterns faster

It&apos;s available on the **Free plan** as an opt-in toggle at **Security &gt; Bots** (same page as the AI Scrapers toggle from Method 1).

&lt;Notice type=&quot;info&quot;&gt;
AI Labyrinth works alongside blocking, it&apos;s not a replacement. Enable it after turning on Method 1 for maximum defense-in-depth. The hidden links are invisible to human visitors, so there&apos;s zero impact on your site&apos;s user experience.
&lt;/Notice&gt;

This is the kind of defense I like: it doesn&apos;t just reject bad traffic, it actively makes the attacker&apos;s job harder and more expensive.

## Method 4: Block AI crawlers using robots.txt (complete list)

`robots.txt` is the web standard for declaring which crawlers can access your content. It&apos;s now legally significant in the EU under the AI Act (more on that in Method 6). But keep in mind: `robots.txt` is a request, not a barrier. Well-behaved bots respect it. Bad actors don&apos;t. Always combine this with Cloudflare or server-level blocking.

Here&apos;s a purpose-annotated `robots.txt` that covers the major AI crawlers:

&lt;Tabs&gt;
&lt;Tab name=&quot;Recommended robots.txt&quot;&gt;

```txt
# === AI TRAINING CRAWLERS (BLOCK) ===
# These bots collect data for model training, no referral benefit

User-agent: GPTBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: CCBot
Disallow: /

User-agent: Google-Extended
Disallow: /

User-agent: meta-externalagent
Disallow: /

User-agent: meta-externalfetcher
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: Amazonbot
Disallow: /

User-agent: cohere-ai
Disallow: /

User-agent: DeepSeekBot
Disallow: /

User-agent: Diffbot
Disallow: /

User-agent: Applebot-Extended
Disallow: /

# === AI SEARCH CRAWLERS (CONSIDER ALLOWING) ===
# These may drive referral traffic, uncomment to block

# User-agent: OAI-SearchBot
# Disallow: /

# User-agent: Claude-SearchBot
# Disallow: /

# User-agent: PerplexityBot
# Disallow: /

# User-agent: DuckAssistBot
# Disallow: /

# === AI USER AGENTS (CONSIDER ALLOWING) ===
# These are user-triggered, not bulk crawling

# User-agent: ChatGPT-User
# Disallow: /

# User-agent: Claude-User
# Disallow: /

# User-agent: Perplexity-User
# Disallow: /

# User-agent: MistralAI-User
# Disallow: /
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Minimal robots.txt&quot;&gt;

If you just want to block all known AI crawlers in one shot without deciding per-bot:

```txt
User-agent: GPTBot
Disallow: /

User-agent: ChatGPT-User
Disallow: /

User-agent: OAI-SearchBot
Disallow: /

User-agent: ClaudeBot
Disallow: /

User-agent: Claude-User
Disallow: /

User-agent: Claude-SearchBot
Disallow: /

User-agent: Google-Extended
Disallow: /

User-agent: Gemini-Deep-Research
Disallow: /

User-agent: PerplexityBot
Disallow: /

User-agent: Perplexity-User
Disallow: /

User-agent: meta-externalagent
Disallow: /

User-agent: meta-externalfetcher
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: Amazonbot
Disallow: /

User-agent: CCBot
Disallow: /

User-agent: Applebot-Extended
Disallow: /

User-agent: DeepSeekBot
Disallow: /

User-agent: MistralAI-User
Disallow: /

User-agent: cohere-ai
Disallow: /

User-agent: DuckAssistBot
Disallow: /

User-agent: Diffbot
Disallow: /
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

The [ai-robots-txt community repository](https://github.com/ai-robots-txt/ai.robots.txt) (4,000+ stars) maintains an even more comprehensive list with 170+ bots. It also provides ready-made configs for Nginx, Apache, Caddy, and HAProxy.

&lt;Notice type=&quot;warning&quot;&gt;
robots.txt is a polite request, not a security barrier. Perplexity was caught ignoring it entirely and spoofing browser fingerprints. Always combine with Cloudflare (Methods 1-3) or server-level blocking (Method 5) for actual protection.
&lt;/Notice&gt;

&lt;Accordion label=&quot;My robots.txt changes aren&apos;t taking effect&quot; group=&quot;robots-troubleshoot&quot;&gt;
Common causes:
- **Crawlers cache robots.txt** — it can take 24-48 hours for major crawlers to re-fetch and respect changes
- **File not at the root URL** — `robots.txt` must be at `https://yourdomain.com/robots.txt`, not in a subdirectory
- **Syntax errors** — a malformed file may be ignored entirely. Use Google&apos;s robots.txt tester in Search Console to validate
- **Your robots.txt is blocked** — if you have a WAF rule blocking all bots, they can&apos;t read the file either (the Cloudflare toggle handles this correctly — it allows robots.txt access)
&lt;/Accordion&gt;

## Method 5: Server-level AI crawler blocking (Nginx, Apache, Caddy)

For self-hosters running their own VPS, server-level user-agent blocking adds defense-in-depth below the WAF layer. If you&apos;re self-hosting on a VPS like [Hetzner Cloud](https://go.bitdoze.com/hetzner), this is your last line of defense when bots bypass or don&apos;t go through Cloudflare.

The ai-robots-txt community repo provides ready-made config files for all three major web servers. To [secure your VPS against malicious traffic](/crowdsec-secure-server/) more broadly, combine this with tools like CrowdSec.

&lt;Tabs&gt;
&lt;Tab name=&quot;Nginx&quot;&gt;

**Option A: Inline if block (simple)**

```nginx
# Add inside your server {} block
if ($http_user_agent ~* &quot;(GPTBot|ChatGPT-User|ClaudeBot|Claude-User|CCBot|PerplexityBot|Bytespider|Amazonbot|meta-externalagent|Google-Extended|OAI-SearchBot|DeepSeekBot|cohere-ai|Diffbot|Applebot-Extended)&quot;) {
    return 403;
}
```

**Option B: Community include file (maintained)**

Download the community-maintained config and include it:

```bash
# Download the latest list
curl -o /etc/nginx/conf.d/nginx-block-ai-bots.conf \
  https://raw.githubusercontent.com/ai-robots-txt/ai.robots.txt/main/nginx-block-ai-bots.conf
```

```nginx
# Add in your server {} block
include /etc/nginx/conf.d/nginx-block-ai-bots.conf;
```

After either option, test and reload:

```bash
nginx -t &amp;&amp; systemctl reload nginx
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Apache&quot;&gt;

Add to your `.htaccess` or virtual host config:

```apache
&lt;IfModule mod_rewrite.c&gt;
    RewriteEngine On
    RewriteCond %{HTTP_USER_AGENT} (GPTBot|ChatGPT-User|ClaudeBot|Claude-User|CCBot|PerplexityBot|Bytespider|Amazonbot|meta-externalagent|Google-Extended|DeepSeekBot|cohere-ai) [NC]
    RewriteRule .* - [F,L]
&lt;/IfModule&gt;
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Caddy&quot;&gt;

Add a route block in your `Caddyfile`:

```
@blocked_ai_bots {
    header_regexp User-Agent &quot;(GPTBot|ChatGPT-User|ClaudeBot|Claude-User|CCBot|PerplexityBot|Bytespider|Amazonbot|meta-externalagent|Google-Extended|DeepSeekBot|cohere-ai)&quot;
}

handle @blocked_ai_bots {
    abort
}
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot;&gt;
Server-level blocking is your last line of defense. Bots can spoof user-agents, so this shouldn&apos;t be your only method. Combine with Cloudflare (Methods 1-3) for complete protection.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Nginx blocking isn&apos;t working&quot; group=&quot;server-troubleshoot&quot;&gt;
Check these common issues:
- Run `nginx -t` to verify your config has no syntax errors
- Ensure the `if` block is inside the correct `server {}` context (not inside a `location {}` block)
- Check for conflicting `location` blocks that might override the server-level rule
- Verify nginx actually reloaded: `systemctl status nginx`
- Test locally first: `curl -A &quot;GPTBot/1.0&quot; http://localhost` before testing through Cloudflare
&lt;/Accordion&gt;

## Method 6: TDM Reservation Protocol and machine-readable opt-outs

Several new web standards have emerged to give publishers machine-readable ways to declare AI usage rights. These complement `robots.txt` and carry increasing legal weight, especially in the EU.

### TDM Reservation Protocol (W3C standard)

The TDM Reservation Protocol lets you declare rights reservations via HTTP response headers. Add this to your Nginx config:

```nginx
# Add to your server {} or location {} block
add_header tdm-reservation &quot;1&quot;;
add_header tdm-policy &quot;https://yourdomain.com/tdm-policy.json&quot;;
```

For Apache:

```apache
Header set tdm-reservation &quot;1&quot;
```

This is legally significant under the EU AI Act — GPAI providers must detect and respect these signals.

### Cloudflare Content-Signal

If you use Cloudflare&apos;s managed `robots.txt`, it now automatically prepends a Content-Signal directive:

```
User-agent: *
Content-Signal: search=yes,ai-train=no,use=reference
Allow: /
```

The `use=` parameter signals content usage levels: `immediate`, `reference`, or `full`. This is Cloudflare&apos;s way of making opt-outs machine-readable without requiring manual config.

### Other emerging standards

- **ai.txt** — Proposed standard for AI usage permissions (no-training, no-inference, Allow-RAG). Placed at site root or `/.well-known/ai.txt`
- **llms.txt** — A Markdown file at site root that gives LLMs a curated summary of key content. Not a blocking tool — it&apos;s an AI SEO tool for controlling how LLMs understand your site
- **RSL 1.0 (Really Simple Licensing)** — Open standard (December 2025) supplementing `robots.txt` with licensing categories and contribution payment models. Backed by Yahoo, Ziff Davis, and O&apos;Reilly Media

&lt;Notice type=&quot;info&quot; title=&quot;EU AI Act: robots.txt now has legal weight&quot;&gt;
If you&apos;re in the EU, a properly configured `robots.txt` carries legal weight under AI Act Article 53 (in force since August 2, 2025). The Hamburg Higher Regional Court confirmed in December 2025 that natural-language opt-outs in terms of use are insufficient — opt-outs must be machine-readable. In the US, `robots.txt` has no direct legal enforceability under current law. Either way, technical enforcement matters more than legal threats.
&lt;/Notice&gt;

## Method 7: IP blocking and supplementary methods

IP blocking is the oldest trick in the book but the least practical at scale — AI companies use cloud IPs that rotate frequently. Still, it has a place as a supplementary method.

&lt;Accordion label=&quot;Blocking AI crawler IPs with ufw&quot; group=&quot;ip-blocking&quot;&gt;
If you&apos;re seeing heavy traffic from specific IPs, you can block them at the firewall level. Check [OpenAI&apos;s bot documentation](https://platform.openai.com/docs/bots) for current IP ranges (they change over time).

```bash
# Example — verify current ranges before running
sudo ufw deny proto tcp from 23.98.142.176/28 to any port 80
sudo ufw deny proto tcp from 23.98.142.176/28 to any port 443
sudo ufw deny proto tcp from 40.84.180.224/28 to any port 80
sudo ufw deny proto tcp from 40.84.180.224/28 to any port 443
```

**Caveat:** IP ranges change. Check the docs for current values. Also note that if you&apos;re running Docker, containers may bypass `ufw` rules — see our guide on [firewall rules that actually work](/docker-bypasses-firewall/) with Docker.

**Identifying the IPs:** Check your web server logs for AI crawler traffic:

```bash
grep -E &quot;GPTBot|ClaudeBot|CCBot|Bytespider&quot; /var/log/nginx/access.log | awk &apos;{print $1}&apos; | sort | uniq -c | sort -rn
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloudflare WAF IP Access Rules&quot; group=&quot;ip-blocking&quot;&gt;
If you&apos;re on Cloudflare, go to **Security &gt; WAF &gt; Tools** to add IP Access Rules. You can block specific IPs or ranges without consuming WAF rule slots. Enter the IP, select &quot;Block,&quot; and add a note for why.

This is useful for blocking specific abusive IPs you&apos;ve identified in your logs, rather than trying to block entire AI company IP ranges.
&lt;/Accordion&gt;

### Other supplementary methods

**Meta tags.** The `noai` and `noimageai` meta tags are still valid. Add them to your HTML `&lt;head&gt;`:

```html
&lt;meta name=&quot;robots&quot; content=&quot;noai, noimageai&quot;&gt;
```

Compliant AI companies check for these, but they&apos;re easy to ignore technically.

**WordPress plugins.** If you&apos;re on WordPress:
- **Block AI Crawlers** (by bobmatyas, updated November 2025) — generates `robots.txt` blocking common AI crawlers and adds `noai`/`noimageai` meta tags
- **Known Agents** (formerly Dark Visitors) — tracks the AI crawler landscape and auto-generates your `robots.txt`

For more WordPress-specific options, see our guide on [WordPress anti-scraping plugins](/best-web-scraping-plugins-for-wordpress/).

**Netlify users.** Netlify offers a **User Agent Blocker** extension (Edge Function-based) that blocks AI crawlers from the project dashboard — worth checking if you host static sites there.

**DNS-level blocking.** You can also [block unwanted traffic at the DNS level with NextDNS](https://go.bitdoze.com/nextdns), which adds another layer of protection before requests even reach your server.

&lt;Accordion label=&quot;IP blocking isn&apos;t catching all bots&quot; group=&quot;ip-failure&quot;&gt;
This is expected. IP blocking has fundamental limitations:
- AI companies use cloud infrastructure (AWS, Azure, GCP) with IPs that rotate
- Bots can use residential proxies or VPNs to change IPs
- User-agent spoofing means the same IP can appear as any browser
- Blocking IP ranges may accidentally block legitimate cloud services

This is why IP blocking is supplementary — not primary. Use Cloudflare (Methods 1-3) for reliable blocking.
&lt;/Accordion&gt;

## How to verify your AI crawler blocks are working

Don&apos;t just enable protections and assume they work. Here&apos;s how to confirm:

**Test with curl using AI bot user-agents:**

```bash
# Test if GPTBot is blocked
curl -s -o /dev/null -w &quot;%{http_code}&quot; -A &quot;GPTBot/1.0&quot; https://yoursite.com
# Should return 403 or 406

# Test if robots.txt is still accessible (it should be)
curl -s -A &quot;GPTBot/1.0&quot; https://yoursite.com/robots.txt
# Should return your robots.txt content

# Test ClaudeBot
curl -s -o /dev/null -w &quot;%{http_code}&quot; -A &quot;ClaudeBot/1.0&quot; https://yoursite.com
# Should return 403
```

&lt;Notice type=&quot;success&quot; title=&quot;Quick verification&quot;&gt;
If the curl commands return `403`, your blocking is working. If they return `200`, something isn&apos;t configured correctly — check your Cloudflare toggle, Nginx reload status, or `robots.txt` syntax.
&lt;/Notice&gt;

**Check Cloudflare Security Analytics:**

Go to **Security &gt; Analytics** in the Cloudflare dashboard. After 24 hours you should see blocked requests in the &quot;Bot Traffic&quot; section. Filter by bot category to see training, search, and agent traffic separately.

**Search server logs:**

```bash
# Check for AI crawler hits in the last 24 hours
grep -E &quot;GPTBot|ClaudeBot|CCBot|Bytespider|PerplexityBot&quot; /var/log/nginx/access.log | tail -20

# Count blocks vs. gets
grep -E &quot;GPTBot|ClaudeBot&quot; /var/log/nginx/access.log | awk &apos;{print $9}&apos; | sort | uniq -c
```

**Note:** If you updated `robots.txt`, compliant crawlers cache it for 24-48 hours before re-fetching. Don&apos;t expect immediate results from `robots.txt` changes.

## The future: Pay Per Crawl and AI content monetization

Cloudflare introduced **Pay Per Crawl** (private beta, July 2025) as a third option beyond &quot;allow&quot; or &quot;block&quot; — **monetize**. Instead of blocking AI crawlers outright, publishers can charge per request. It uses HTTP 402 (Payment Required), Web Bot Auth with Ed25519 signatures, and Cloudflare acts as Merchant of Record.

The idea: AI companies get access to quality content, publishers get paid, and Cloudflare handles the payment infrastructure. For solo operators who&apos;ve spent years building content libraries, this could eventually become a revenue stream.

&lt;Notice type=&quot;info&quot;&gt;
Pay Per Crawl is in private beta as of July 2026. You can configure it from your Cloudflare dashboard, but widespread adoption depends on AI companies signing up. We&apos;ll update this guide when it becomes generally available.
&lt;/Notice&gt;

## Conclusion: build a layered defense against AI crawlers

No single method blocks all AI crawlers. The Perplexity stealth crawler scandal proved that even well-configured `robots.txt` can be ignored. Here&apos;s the defense stack I recommend:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cloudflare one-click toggle&lt;/strong&gt; (Method 1) — enable this first, it&apos;s one click and free&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Granular AI bot policies&lt;/strong&gt; (Method 2) — fine-tune Search vs. Agent vs. Training access&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AI Labyrinth&lt;/strong&gt; (Method 3) — opt-in, wastes bot resources for extra defense-in-depth&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;robots.txt&lt;/strong&gt; (Method 4) — legal compliance and legitimate bot guidance&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Server-level blocking&lt;/strong&gt; (Method 5) — Nginx/Apache/Caddy for self-hosters who want a safety net below Cloudflare&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TDM Reservation headers&lt;/strong&gt; (Method 6) — especially important if you&apos;re in the EU&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Verify your blocks&lt;/strong&gt; — test with curl, check analytics, grep your logs&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Combining Cloudflare (Methods 1-3) with `robots.txt` (Method 4) covers 99% of threats for most sites. Self-hosters on a VPS should add server-level blocking (Method 5) as a safety net.

If you&apos;re serious about taking control of who accesses your content, the layered approach works. For broader protection of your online presence, you can also [block unwanted traffic at the DNS level](/block-ads-malware-dns-protection/) and explore [self-hosted privacy solutions](/searxng-self-host-privacy-search/) that keep your data under your control.

&lt;Button text=&quot;Deploy Your Site on Cloudflare&quot; link=&quot;/deploy-astrojs-cloudflare/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;Will blocking AI crawlers hurt my SEO?&quot; group=&quot;faq&quot;&gt;
No. Search engine crawlers (Googlebot, Bingbot) are completely separate from AI training crawlers. The methods in this guide target AI-specific bots. Google&apos;s search crawler has its own user-agent and is not affected by blocking GPTBot, ClaudeBot, or any other AI training crawler. The only overlap is Google-Extended, which controls Gemini/Vertex AI training — blocking it does not affect Google Search indexing.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I block all AI bots or just training crawlers?&quot; group=&quot;faq&quot;&gt;
It depends on your goals. Training crawlers (GPTBot, ClaudeBot, CCBot) provide zero referral traffic — block these without hesitation. Search crawlers (OAI-SearchBot, Claude-SearchBot, PerplexityBot) may drive visitors to your site through AI-powered search results — consider allowing these. Agent bots (ChatGPT-User, Claude-User) are user-triggered and not bulk crawling — these are generally safe to allow. The recommended robots.txt in Method 4 is annotated to help you decide.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does robots.txt actually stop AI crawlers?&quot; group=&quot;faq&quot;&gt;
Only for compliant bots. Major companies (OpenAI, Google, Anthropic, Apple) generally comply with robots.txt — they have legal teams and public commitments. But Perplexity was caught in August 2025 ignoring it entirely, using stealth crawlers that spoofed Chrome&apos;s user-agent. Always combine robots.txt with Cloudflare or server-level blocking for real protection.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Cloudflare&apos;s AI bot blocking really free?&quot; group=&quot;faq&quot;&gt;
Yes. The one-click &quot;AI Scrapers and Crawlers&quot; toggle and all three AI category policies (Search, Agent, Training) are available on Cloudflare&apos;s Free plan. No WAF rule slots are consumed. AI Labyrinth is also free (opt-in). The only paid feature is Pay Per Crawl, which is in private beta.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about the EU AI Act? Do I need to do anything special?&quot; group=&quot;faq&quot;&gt;
If you&apos;re in the EU, ensure your robots.txt is properly configured — it now has legal weight under AI Act Article 53 (in force since August 2, 2025). Adding TDM Reservation headers (Method 6) provides additional legal protection. The Hamburg court ruling in December 2025 confirmed that natural-language opt-outs in terms of use are not sufficient — opt-outs must be machine-readable (robots.txt, TDM headers). In the US, robots.txt has no direct legal enforceability under current law, but technical enforcement still matters.
&lt;/Accordion&gt;</content:encoded><category>tools</category><category>ai</category><category>cloudflare</category><category>security</category></item><item><title>SEO Gets Tool Review: GSC &amp; GA4 Analytics in 2026</title><link>https://www.bitdoze.com/seo-gets-tool/</link><guid isPermaLink="true">https://www.bitdoze.com/seo-gets-tool/</guid><description>SEO Gets is a GSC &amp; GA4 analytics tool that makes Search Console data usable. Review covers pricing (free plan), content decay, indexing reports, and more.</description><pubDate>Wed, 22 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import imag1 from &quot;../../assets/images/24/02/seo-gets-overview.jpeg&quot;;
import imag2 from &quot;../../assets/images/24/02/seo-gets-website-overview.jpeg&quot;;

&lt;Notice type=&quot;info&quot; title=&quot;Updated July 2026&quot;&gt;
This article was originally published in February 2024 and has been substantially rewritten to reflect SEO Gets&apos; pricing changes, new GA4 integration, and major feature additions.
&lt;/Notice&gt;

Google Search Console is the default tool for anyone who owns a website. It&apos;s also clunky: limited to 1,000 data rows, no multi-site overview, and spotting trends across months of data requires regex gymnastics or exporting to a spreadsheet. If you&apos;re managing more than one site, it gets worse: you&apos;re tab-hopping between properties with no way to compare them side by side.

The SEO Gets tool solves those problems. It connects to your Google Search Console (read-only) and now GA4 data, pulling in 50,000 data rows instead of GSC&apos;s 1,000. It gives you a multi-site dashboard, content decay detection, keyword tracking, and a bunch of reports that would take hours to build manually. I&apos;ve been using it on bitdoze.com and several other projects since early 2024, and it&apos;s the first thing I open on Monday morning to check what&apos;s happening with my sites.

This review covers what SEO Gets does in 2026 (a lot has changed), its pricing tiers, key features, limitations, and whether it&apos;s worth your time. If you&apos;re still [choosing a good domain name](/choose-domain-name/) and building your first site, or you&apos;ve been running content for years, the free tier alone is worth a look. And if you&apos;re building on a modern stack, you might also want to [build a free blog with Astro](/build-astro-blog-free/) to pair with these analytics.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/bfrIxEjGNLc&quot;
  label=&quot;SEO Gets Tool Review 2026&quot;
/&gt;

## What is SEO Gets? A Google Search Console alternative

SEO Gets is a web app that acts as an overlay on top of your Google Search Console data. It pulls in your existing GSC metrics (clicks, impressions, CTR, average position) and presents them in a dashboard that&apos;s actually usable. Since January 2026, it also integrates with Google Analytics 4 on paid plans, making it a unified GSC + GA4 analytics tool.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Gff9Qfpx5v4&quot;
  label=&quot;SEO Gets Tool&quot;
/&gt;
The core value prop is simple: **50,000 data rows** instead of GSC&apos;s 1,000. That alone changes what you can analyze. Instead of seeing the top 25 queries per page, you see everything. Filtering works without regex. Multi-site overview shows all your properties on a single page with clicks and impressions.

&lt;Picture src={imag1} alt=&quot;SEO Gets dashboard showing multi-site overview with clicks and impressions data&quot; /&gt;

SEO Gets was founded by Guilherme Oenning (technical co-founder) and Matthew Mellinger (business co-founder). It&apos;s a SaaS product, no self-hosting, no open source dashboard. You authenticate with your Google account, grant read-only access, and the data loads.

**SEO Gets does NOT fully replace Google Search Console.** You still need GSC for:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Sitemap submission and monitoring&lt;/li&gt;
&lt;li&gt;URL Inspection tool (live test &amp; indexed status)&lt;/li&gt;
&lt;li&gt;Manual indexing requests&lt;/li&gt;
&lt;li&gt;Core Web Vitals reports&lt;/li&gt;
&lt;li&gt;Security issues and manual actions&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Think of SEO Gets as the analytics layer you use daily, while GSC stays as the admin panel you visit when you need to fix something.

## SEO Gets pricing: free, Core, and Pro plans (2026)

SEO Gets launched as a free tool in 2024. It now has three tiers. The free tier is genuinely generous: no click limits (that restriction was removed in April 2025), unlimited sites, and unlimited users.

&lt;Tabs&gt;
&lt;Tab name=&quot;Free ($0)&quot;&gt;
- Unlimited sites
- Unlimited users
- Master dashboard (all sites at a glance)
- 50,000 data rows
- Growth / Decaying tabs
- Query counting
- Branded vs non-branded filtering
- Google Core Update annotations
- CSV and PNG export
- 16 months of historical data
- No click limits
&lt;/Tab&gt;
&lt;Tab name=&quot;Core ($39/mo)&quot;&gt;
Everything in Free, plus:
- GA4 unified analytics
- Content groups
- Topic clusters
- Annotations
- SEO testing framework
- Client portals / Magic Links
- Striking distance report
- Cannibalization report
- Content decay heatmaps
- Saved filters
- 100 AI credits/month
&lt;/Tab&gt;
&lt;Tab name=&quot;Pro ($49/mo)&quot;&gt;
Everything in Core, plus:
- 1 Super Site included (index reporting for up to 5,000 pages, 5 years of history, 50K+ rows, email alerts)
- MCP server access for Claude Desktop
- Additional Super Sites: $10/month each
&lt;/Tab&gt;
&lt;/Tabs&gt;

Paid plans come with a **14-day free trial**, no credit card required.

### Free plan: what you get for $0

&lt;Notice type=&quot;success&quot;&gt;
For most solo operators managing 1-3 sites, the Free plan is all you need. The paid tiers only make sense when you need GA4 integration, content groups, or client reporting.
&lt;/Notice&gt;

The free tier gives you 50,000 data rows (that&apos;s 50x what raw GSC shows you). You get the multi-site master dashboard, growth/decay filtering, branded vs non-branded query separation, and Core Update annotations so you can see if a traffic drop correlates with a Google algorithm update. For a solo operator running a blog or small portfolio, this is more than enough.

What you miss: no GA4 integration, no content groups, no advanced reports (striking distance, cannibalization, content decay heatmaps), no SEO testing, and no MCP access.

### Core plan ($39/month): GA4 and advanced features

The headline feature here is **GA4 integration**. You get a unified dashboard showing GSC data (clicks, impressions, CTR, position) alongside GA4 data (sessions, key events, engagement rate, revenue). There&apos;s a dedicated GA4 dashboard with landing pages table and source/medium breakdown. GA4 data is included in CSV exports.

Beyond GA4, Core unlocks content groups, topic clusters, the SEO testing framework, content decay heatmaps, striking distance reports, and cannibalization detection. You also get 100 AI credits per month for AI-powered features like anomaly detection and content suggestions.

This tier makes sense if you&apos;re running multiple sites, doing client work, or you want GA4 and GSC data in a single view without building a Looker Studio dashboard from scratch.

### Pro plan ($49/month): Super Sites, index reporting, and MCP

The Pro plan adds **Super Sites**: index monitoring for up to 5,000 pages per site. You get indexing history, percentage indexed, filterable status views, and email alerts when indexing changes. Historical data extends to 5 years (vs 16 months on Free/Core).

You also get MCP server access, which lets you query your GSC and GA4 data directly inside Claude Desktop. If you&apos;re already using [MCP servers for AI-powered workflows](/brightdata-mcp-guide/), this is a natural extension.

Extra Super Sites cost $10/month each. This tier targets agencies, large sites that need index monitoring, and anyone doing AI-assisted SEO analysis.

## Key SEO Gets features for 2026

### GSC and GA4 unified analytics dashboard

This is what separates SEO Gets from raw Google Search Console. Since January 2026, you can see GSC metrics (clicks, impressions, CTR, position) and GA4 metrics (sessions, key events, engagement rate, revenue) in a single view.

&lt;Picture src={imag2} alt=&quot;SEO Gets website-level dashboard with keyword tracking, content groups, and traffic decay analysis&quot; /&gt;

The GA4 dashboard includes:

- **Landing pages table**: which pages drive sessions and conversions
- **Source/medium table**: where your traffic comes from
- **Key events and revenue trends**: track conversions alongside search performance
- **GA4 data in CSV exports**: no more merging spreadsheets manually

This is the feature that moved SEO Gets from &quot;nice GSC alternative&quot; to &quot;daily driver analytics tool.&quot; If you&apos;re currently toggling between GSC and GA4 tabs, unifying them saves real time. For a lighter-weight analytics option that doesn&apos;t require Google at all, check out [Plausible as a lightweight Google Analytics alternative](/plausible-tool/).

### Content groups and topic clusters

Content groups let you track URL patterns as a unit. You define patterns like:

```
/blog
/blog/*
/guides/*
/subject*
```

Once set up, you can see aggregate performance for each group: total clicks, impressions, CTR, and trend direction. This is useful for answering questions like &quot;is my blog content growing or decaying as a whole?&quot; or &quot;how are my guides performing compared to my tutorials?&quot;

Topic Clusters evolved from the original &quot;Tracked Keywords&quot; feature. You add specific keywords and SEO Gets groups the stats around them. Pick a keyword and drill down to see which pages rank, what queries drive traffic, and how the cluster trends over time.

### Index reporting for Super Sites

This is one of the most praised features in SEO Gets, and it launched in August 2025. Super Sites track up to 5,000 pages and show you:

- Indexing history over time
- Percentage of pages indexed
- Filterable views by indexing status
- Email alerts when indexing changes

If you&apos;ve ever dealt with [getting deindexed from search engines](/deindexed-in-bing-and-duckduckgo-now-what/), you know how important it is to monitor indexing health proactively. Super Sites give you that visibility without manually checking URLs in GSC&apos;s URL Inspection tool.

This feature requires the Pro plan or a Super Site add-on ($10/month per additional site).

### Content decay heatmaps and monitoring

Launched in March 2026, content decay detection shows you which pages are losing traffic compared to their 90-day baseline. The dashboard includes a &quot;Decaying Pages That May Need Refreshing&quot; table with:

- Year-over-year comparison
- Top query changes for each decaying page
- Critical / Warning status levels

This is the feature I use most for prioritizing content refreshes. Instead of guessing which articles need updating, I can see exactly which ones are losing traction and what queries they&apos;re losing. It turns content maintenance from a guessing game into a data-driven process.

### Striking distance and cannibalization reports

**Striking Distance** shows keywords ranking in positions 4-20 where small improvements could yield meaningful traffic gains. Each result now includes the top non-branded query per page, so you know exactly which term to optimize for.

**Cannibalization** shows where multiple pages on your site compete for the same query. This is a common problem on content-heavy sites: two blog posts targeting the same keyword end up splitting the traffic instead of one page ranking well. The report now includes the top non-branded query per page, making it easy to decide which page to consolidate or redirect.

### SEO testing framework

Launched in February 2025, this gives you structured before/after tests for SEO changes. Change a title tag, update content, modify internal links, then measure the impact with a proper test setup.

The framework integrates with annotations, so you can separate the effects of your changes from algorithm updates. If a Google Core Update rolls out during your test, the annotations make it clear what caused the traffic shift.

The feature has matured since its rough early days in early 2025. It&apos;s useful enough for routine optimization, though some power users still run hybrid workflows with dedicated SEO testing tools.

### MCP server for Claude Desktop

This is an AI-era feature that went into public beta in April 2026. The MCP server lets you query your GSC and GA4 data directly inside Claude Desktop using natural language.

Ask things like &quot;which pages lost the most traffic last month?&quot; or &quot;show me my top 10 growing queries&quot; and Claude pulls the data from your SEO Gets account.

Setup requires a Core or Pro plan. You add the MCP server to your Claude Desktop configuration:

```json
{
  &quot;mcpServers&quot;: {
    &quot;seo-gets&quot;: {
      &quot;type&quot;: &quot;url&quot;,
      &quot;url&quot;: &quot;https://app.seogets.com/mcp&quot;
    }
  }
}
```

&lt;Accordion label=&quot;MCP setup steps&quot; group=&quot;features&quot;&gt;

1. Sign up for a Core or Pro plan at [seogets.com](https://seogets.com/)
2. Go to `https://app.seogets.com/mcp` to get your API key
3. Open Claude Desktop settings and navigate to the MCP servers section
4. Add the server URL: `https://app.seogets.com/mcp`
5. Restart Claude Desktop
6. Start asking questions about your SEO data in natural language

&lt;/Accordion&gt;

If you&apos;re already exploring MCP-based workflows, this is a practical way to bring your SEO data into an AI assistant. See my [BrightData MCP guide](/brightdata-mcp-guide/) for more on what MCP servers can do.

### Team features, Magic Links, and client portals

SEO Gets added team functionality in November 2025 with four roles: Admin, Team Member, Contributor, and Viewer. You can share property access and manage team members from a central dashboard. Free tier users can also invite team members (added May 2025).

**Magic Links** generate shareable live dashboards for clients or stakeholders. They&apos;ve been white-labeled since April 2025, so your clients see your branding, not SEO Gets&apos;. This is useful if you&apos;re doing client work and want to give them a clean reporting view without granting full GSC access.

## Google Indexing Script: limitations you MUST know

The SEO Gets team&apos;s co-founder, Guilherme Oenning, also created the Google Indexing Script, a tool that uses the Google Indexing API to request faster indexing of URLs. The GitHub repo has 7.7k stars and 546 forks.

You can install and run it like this:

```bash
npm i -g google-indexing-script
mkdir ~/.gis &amp;&amp; mv service_account.json ~/.gis
gis yourdomain.com
```

&lt;Notice type=&quot;warning&quot; title=&quot;Critical limitation&quot;&gt;
The Google Indexing API **only works for pages with `JobPosting` or `BroadcastEvent` structured data**. For regular blog posts, product pages, and standard content, this script will NOT work. Do not expect it to speed up indexing of your normal website content.
&lt;/Notice&gt;

For regular pages, your best options are:

- Submit a sitemap in Google Search Console
- Use GSC&apos;s URL Inspection tool to request indexing manually (limited to a few URLs per day)
- Build strong internal linking so Googlebot discovers pages naturally

If you need to prepare a list of URLs for indexing audits, you can [export your WordPress post URLs](/export-wordpress-post-urls-titles/) to get a clean CSV.

&lt;Accordion label=&quot;Using the indexing script (job/event sites only)&quot; group=&quot;indexing&quot;&gt;

This script only works if your pages have `JobPosting` or `BroadcastEvent` structured data. If that&apos;s your use case:

1. Create a Google Cloud service account with Indexing API access
2. Download the service account JSON key
3. Move it to `~/.gis/service_account.json`
4. Run `gis yourdomain.com` and the script will scan your sitemap and submit eligible URLs
5. It checks indexing status daily and re-submits URLs that return errors

For everyone else: skip this script. Focus on sitemaps and internal linking instead.

&lt;/Accordion&gt;

## How SEO Gets compares to other SEO analytics tools

### SEO Gets vs raw Google Search Console

Google Search Console gives you 1,000 data rows, single-site view, and basic filtering. SEO Gets gives you 50,000 rows, multi-site dashboard, and advanced filtering without regex. Add content decay detection, striking distance reports, cannibalization analysis, and SEO testing, and the gap widens.

GSC is still the authoritative source. SEO Gets reads from it. You need both, but you&apos;ll spend most of your time in SEO Gets.

### SEO Gets vs Looker Studio

Looker Studio is free and flexible, but you have to build dashboards yourself. That means setting up connectors, designing layouts, maintaining them when data sources change, and troubleshooting when things break. For a solo operator, that&apos;s hours of work before you see a single chart.

SEO Gets is purpose-built for SEO analytics. It works out of the box with features Looker Studio doesn&apos;t offer: content decay heatmaps, striking distance analysis, SEO testing, index reporting. If you don&apos;t enjoy building dashboards, SEO Gets wins on setup time alone.

### SEO Gets vs Ahrefs, Semrush, and other SEO suites

SEO Gets is **not** a replacement for Ahrefs or Semrush. It doesn&apos;t do:

- Backlink analysis
- Competitor research
- Keyword research from scratch
- Rank tracking outside your own GSC data
- Site audit crawling

These tools are complementary. Use SEO Gets for your own site&apos;s GSC and GA4 data — the daily analytics. Use Ahrefs or Semrush for competitive intelligence, backlink monitoring, and keyword research. If you&apos;re also looking at data collection approaches for SEO research, there are [web scraping tools for WordPress](/best-web-scraping-plugins-for-wordpress/) that fill different gaps.

&lt;Tabs&gt;
&lt;Tab name=&quot;vs Google Search Console&quot;&gt;
**SEO Gets wins**: 50x more data rows, multi-site dashboard, content decay, striking distance, cannibalization, no regex needed.

**GSC wins**: Authoritative source, sitemap submission, URL inspection, Core Web Vitals, security issues, manual actions, free.

**Bottom line**: Use both. SEO Gets for daily analytics, GSC for admin tasks.
&lt;/Tab&gt;
&lt;Tab name=&quot;vs Looker Studio&quot;&gt;
**SEO Gets wins**: Purpose-built for SEO, works out of the box, content decay, SEO testing, index reporting, zero setup time.

**Looker Studio wins**: Free, fully customizable, can combine any data source, not limited to SEO data.

**Bottom line**: Unless you enjoy building dashboards, SEO Gets is the better use of your time.
&lt;/Tab&gt;
&lt;Tab name=&quot;vs Ahrefs / Semrush&quot;&gt;
**SEO Gets wins**: GSC + GA4 unified view, content decay detection, cheaper for pure analytics, free tier available.

**Ahrefs/Semrush win**: Backlink analysis, competitor research, keyword research, site audit, rank tracking, content gap analysis.

**Bottom line**: Different tools for different jobs. SEO Gets for your own site data, Ahrefs/Semrush for competitive intelligence. You likely need both.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## What SEO Gets doesn&apos;t do (limitations)

Honest callouts, because no tool does everything:

- **No backlink data** — you still need Ahrefs or Semrush for link analysis
- **No competitor analysis** — SEO Gets only sees your own GSC/GA4 data
- **GA4 integration is paid-only** — Free plan doesn&apos;t include it
- **MCP access is paid-only** — requires Core or Pro
- **Index Reporting requires Super Sites** — Pro plan or $10/month add-on per site
- **16-month data retention on Free/Core** — 5-year history only available through Super Sites on Pro
- **SEO Testing is still maturing** — improved since early 2025, but some users run hybrid workflows with dedicated testing tools

&lt;Notice type=&quot;info&quot; title=&quot;Google &amp;num=100 change&quot;&gt;
In September 2025, Google removed the `&amp;num=100` SERP parameter. This caused impression drops and position improvements across all GSC-based tools — not just SEO Gets. If your data shifted around that time, this is the explanation. SEO Gets added rank filtering for Super Sites in October 2025 to help normalize data across this change.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Privacy and security&quot; group=&quot;privacy&quot;&gt;

SEO Gets uses **read-only** access to your Google Search Console data. Here&apos;s what that means:

- It does NOT store your performance data permanently
- It temporarily stores a Google key for building dashboards
- Revoking access in your Google account removes all data
- No access to other Google products or services beyond what you explicitly grant
- No access to modify anything in GSC — read-only means read-only

If you&apos;re cautious about third-party access (and you should be), the read-only model is the minimum permission needed. You can revoke it anytime from your Google account&apos;s security settings.

&lt;/Accordion&gt;

If you&apos;re also concerned about data collection from AI bots crawling your site, here&apos;s a guide on how to [protect your website from AI crawlers](/block-ai-crawlers/).

## Final verdict: is SEO Gets worth it in 2026?

The free tier alone is worth it for any site owner. 50,000 data rows, multi-site dashboard, growth/decay filtering, branded vs non-branded queries, and Core Update annotations — all for $0. If you&apos;re managing 1–3 sites and just want better visibility into your GSC data, stop reading and go sign up.

The paid tiers make sense when you need GA4 unification, content decay detection, or client reporting. The Core plan at $39/month is the sweet spot for serious site operators who want GSC and GA4 in one place with actionable reports. The Pro plan at $49/month is for agencies and large sites that need index monitoring and MCP access.

It won&apos;t replace Ahrefs or Semrush for competitive research. It doesn&apos;t try to. SEO Gets does one thing — make your own site&apos;s search and analytics data usable — and it does it well.

If you&apos;re building or optimizing a site in 2026, whether it&apos;s on Astro, WordPress, or something else, and you need to pick the [best headless CMS for your Astro site](/best-headless-cms-for-astro/) or any other stack — pair it with SEO Gets from day one. The earlier you start tracking, the more useful the data becomes.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Free tier sufficient for 1–3 sites with 50K data rows&lt;/li&gt;
&lt;li&gt;GA4 + GSC unified dashboard (Core plan and above)&lt;/li&gt;
&lt;li&gt;Content decay detection for prioritizing content refreshes&lt;/li&gt;
&lt;li&gt;50x more data rows than raw Google Search Console&lt;/li&gt;
&lt;li&gt;MCP server for querying data in Claude Desktop (Pro plan)&lt;/li&gt;
&lt;li&gt;No backlink data — you still need Ahrefs or Semrush&lt;/li&gt;
&lt;li&gt;No competitor analysis — it&apos;s your own data only&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try SEO Gets Free&quot; link=&quot;https://seogets.com/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>tools</category><category>seo</category><category>analytics</category><category>google-search-console</category></item><item><title>Best Headless CMS for Astro in 2026: From No CMS to Enterprise</title><link>https://www.bitdoze.com/best-headless-cms-for-astro/</link><guid isPermaLink="true">https://www.bitdoze.com/best-headless-cms-for-astro/</guid><description>Looking for the best headless CMS for Astro? Compare 9 git-based and API-driven options with honest pricing, real tradeoffs, and a decision framework for your project.</description><pubDate>Tue, 21 Jul 2026 03:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I don&apos;t use a CMS for bitdoze.com. I write in VS Code, push to Git, and Cloudflare Pages builds the site. Content lives as MDX files validated by [Astro Content Collections](https://docs.astro.build/en/guides/content-collections/) with Zod schemas. Zero cost, zero database, zero maintenance.

I [migrated from WordPress to Astro](https://www.bitdoze.com/wordpress-to-astro-migration/) using `wordpress-export-to-markdown` and Codex CLI. The blog runs on Bun for build speed and deploys to Cloudflare Pages. If you&apos;re still picking a framework, I wrote up why [Astro beats Next.js and TanStack Start](https://www.bitdoze.com/astro-vs-nextjs-vs-tanstack-start-which-wins/) for static content sites.

So why write about headless CMS options for Astro? Because not everyone is a solo dev who&apos;s fine with Git and VS Code. Sometimes you need a browser UI. Sometimes your client needs to edit content. Sometimes you have five writers who don&apos;t know what a pull request is.

Astro&apos;s docs list 40+ CMS integrations. This article cuts that list down to the ones worth your time, starting with the honest baseline: maybe you don&apos;t need one at all.

## Do you even need a CMS for Astro?

Astro Content Collections already give you type-safe content management with no extra services. Here&apos;s the pattern used on this site (Astro 5+ content layer with a loader):

```ts
// src/content.config.ts
import { defineCollection } from &apos;astro:content&apos;;
import { z } from &apos;astro/zod&apos;;
import { glob } from &apos;astro/loaders&apos;;

const blog = defineCollection({
  loader: glob({ base: &apos;./src/content/posts&apos;, pattern: &apos;**/*.{md,mdx}&apos; }),
  schema: z.object({
    title: z.string(),
    date: z.coerce.date(),
    description: z.string(),
    draft: z.boolean().default(false),
  }),
});

export const collections = { blog };
```

That&apos;s a CMS schema. `getCollection(&apos;blog&apos;)` is your query layer. Frontmatter is your data layer. VS Code (or any editor) is your UI. Git is your version control.

For a solo dev running a blog or docs site, this covers most of what a Git-based CMS gives you. The missing piece is just a friendlier editing UI.

&lt;Notice type=&quot;info&quot; title=&quot;The no-CMS baseline&quot;&gt;
This is the angle most &quot;best CMS&quot; roundups skip. My setup costs $0/month and has zero dependencies beyond Git. Every CMS below has to justify why it&apos;s worth the extra moving parts.
&lt;/Notice&gt;

You can [build an Astro blog for free](https://www.bitdoze.com/build-astro-blog-free/) with this stack. If you want an AI-assisted editor, tools like Windsurf or Codex can manage your markdown too. I covered that in [building an Astro blog with Windsurf](https://www.bitdoze.com/windsurd-build-astro-blog/).

**Signs you don&apos;t need a CMS:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;You&apos;re the only person editing content&lt;/li&gt;
&lt;li&gt;You&apos;re comfortable with Git and a code editor&lt;/li&gt;
&lt;li&gt;Your content is mostly blog posts or documentation&lt;/li&gt;
&lt;li&gt;No client handoff required&lt;/li&gt;
&lt;li&gt;You prefer speed and zero cost over a browser UI&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Signs you do need a CMS:**

- Team editing (multiple authors, editors, reviewers)
- Non-technical content editors who need a browser UI
- Structured content types beyond blog posts (products, events, team members)
- Scheduling, approval workflows, or role-based access
- You just prefer a GUI over editing YAML frontmatter

If any of those apply, keep reading.

## Git-based CMS vs API-based CMS for Astro

Before picking a tool, pick an architecture. That choice sets your cost, complexity, and ops burden for years.

&lt;Tabs&gt;
&lt;Tab name=&quot;Git-based CMS&quot;&gt;
**How it works:** Content stored as Markdown/MDX/YAML/JSON files in your Git repo. The CMS is a UI that commits changes. Works cleanly with Astro&apos;s `getCollection()`.

**Hosting:** No separate backend. Your Git repo is the database.

**Cost:** Usually $0 to $10/month.

**Best for:** Blogs, documentation, marketing sites, small-to-medium content projects.

**Examples:** Pages CMS, Keystatic, TinaCMS, Decap CMS, Sveltia CMS, CloudCannon.
&lt;/Tab&gt;
&lt;Tab name=&quot;API-based CMS&quot;&gt;
**How it works:** Separate hosted or self-hosted backend with its own database. Content served via REST or GraphQL. You `fetch()` at build time or in SSR.

**Hosting:** SaaS (hosted for you) or self-hosted on a VPS with a database.

**Cost:** Free tier to $55+/month. Self-hosted is roughly $4–20/month for a VPS plus your time.

**Best for:** Complex content models, real-time collaboration, enterprise teams, multi-channel delivery.

**Examples:** Sanity, Strapi, Payload, Storyblok, Contentful, Directus.
&lt;/Tab&gt;
&lt;/Tabs&gt;

![Architecture comparison: Git-based CMS vs API-based CMS for Astro](../../assets/images/26/07/cms-architecture-comparison.svg)

### What are Git-based CMS tools?

A Git-based CMS is a UI layer on top of your Git repository. You define content types (blog posts, pages), and editors get a form or visual interface. When they save, the CMS commits the file to your repo — same as you would from VS Code.

Your content still works with Astro Content Collections. `getCollection(&apos;blog&apos;)` reads the same files whether a CMS or a human wrote them. No API calls, no database queries, no runtime dependency on the CMS being online.

The CMS only matters at edit time. At build time, it&apos;s just files.

### When an API-based CMS makes sense

Reach for an API CMS when you need things that don&apos;t map cleanly to files:

- Real-time collaboration (multiple people in the same document)
- Complex relationships (related products across thousands of entries)
- Approval chains and scheduled publishing
- Multi-channel delivery (same content to web, app, email)
- A full admin panel with dashboards, analytics, and user management

&lt;Notice type=&quot;warning&quot; title=&quot;Ops overhead&quot;&gt;
Self-hosted API CMS means a VPS ($4–20/month), database setup, backups, monitoring, and security updates. Budget that in. For a static blog, it&apos;s overkill next to Git-based options.
&lt;/Notice&gt;

If you&apos;re looking at self-hosted Strapi or Directus, you&apos;ll need a VPS. [Hetzner Cloud](https://go.bitdoze.com/hetzner) has affordable European servers from ~€4/month — that&apos;s what I use for my own fleet. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with KVM and NVMe.

## Best Git-based CMS for Astro compared

These fit Astro Content Collections best. Content stays as files, works with `getCollection()`, and doesn&apos;t need a separate database. Sorted by simplicity, not hype.

### Pages CMS: the simplest free option

&lt;Notice type=&quot;info&quot; title=&quot;Hidden gem&quot;&gt;
Pages CMS barely shows up in big CMS roundups, even though it was built for static sites. Closest thing I&apos;ve found to &quot;no CMS, but with a browser UI.&quot;
&lt;/Notice&gt;

[Pages CMS](https://pagescms.org/) is what I&apos;d reach for if I needed a CMS on an Astro site tomorrow. Add a `.pages.yml` config, sign in at app.pagescms.org, edit in the browser. Changes land as Git commits. No npm package, no database, no extra backend.

**Stats:** ~3.8k GitHub stars, MIT license, TypeScript. Created by Ronan Berder (@hunvreus) in late 2023.

**How it works with Astro:**

1. Add a `.pages.yml` at the repo root defining your content types
2. Connect the GitHub repo at [app.pagescms.org](https://app.pagescms.org)
3. Edit content in the browser
4. Changes commit to the repo; Cloudflare Pages (or your host) redeploys

**Pricing:** Fully free. Hosted app.pagescms.org is free. MIT licensed if you want to self-host.

**Key features:**
- Custom content types via YAML config
- Rich-text editor and Markdown support
- Media uploads (S3, Cloudflare R2)
- Full-text search across content
- Scheduling and granular access control
- Inline comments for review
- Email invites, so editors don&apos;t need a GitHub account

**Setup checklist:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Create &lt;code&gt;.pages.yml&lt;/code&gt; in your repo root with content type definitions&lt;/li&gt;
&lt;li&gt;Sign in at &lt;a href=&quot;https://app.pagescms.org&quot;&gt;app.pagescms.org&lt;/a&gt; with your GitHub account&lt;/li&gt;
&lt;li&gt;Connect your repository&lt;/li&gt;
&lt;li&gt;Edit content in the browser and confirm it commits to Git&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Limitations:**
- No embeddable `/admin` route — the CMS is a separate site (app.pagescms.org or your self-hosted instance)
- No repeater fields (still true as of mid-2026)
- Form-based editor only, not WYSIWYG-on-the-page
- GitHub only — no GitLab or Bitbucket yet
- Solo maintainer, so there&apos;s bus-factor risk. If the project stalls, your content is safe (it&apos;s just files in Git), but you lose the editing UI

**Self-hosting:** Needs PostgreSQL, Docker, and a GitHub App. The [self-hosting docs](https://pagescms.org/docs/guides/installing/) cover it. If you already run Dokploy or Docker Compose on a VPS, it&apos;s straightforward.

**What users say:** Reddit feedback is mostly positive: &quot;Really really easy to set up... exactly what I need.&quot; The usual complaint is that it&apos;s a separate website rather than an embedded admin, which some freelancers dislike for client handoffs.

**Verify it works:** After adding `.pages.yml`, open app.pagescms.org and confirm your repo appears. Create a test post and check that the commit shows up in Git history.

### Keystatic — TypeScript-native with first-class Astro support

[Keystatic](https://keystatic.com/) comes from Thinkmill (the KeystoneJS team). Everything is configured in TypeScript, and the admin UI lives at `/keystatic` inside your Astro project.

**Stats:** ~2.2k GitHub stars, MIT license, TypeScript. Started early 2023.

**How it works with Astro:**

1. Install `@keystatic/core` + `@keystatic/astro` + `@astrojs/react` + `@astrojs/markdoc`
2. Define collections and singletons in `keystatic.config.ts`
3. Content stored as Markdoc (`.mdoc`), YAML, or JSON in your repo
4. Admin UI at `/keystatic`

```ts
// keystatic.config.ts
import { config, collection, fields } from &apos;@keystatic/core&apos;;

export default config({
  storage: { kind: &apos;local&apos; },
  collections: {
    posts: collection({
      label: &apos;Posts&apos;,
      slugField: &apos;title&apos;,
      path: &apos;src/content/posts/*&apos;,
      format: { contentField: &apos;content&apos; },
      schema: {
        title: fields.slug({ name: { label: &apos;Title&apos; } }),
        content: fields.markdoc({ label: &apos;Content&apos; }),
      },
    }),
  },
});
```

**Pricing:** Free for local and GitHub workflows. Keystatic Cloud (optional): free up to 3 users/team; Pro at $10/month/team + $5/user beyond 3. Cloud adds GitHub auth, Cloud Images (CDN), and experimental multi-player editing.

&lt;Notice type=&quot;warning&quot; title=&quot;SSR required&quot;&gt;
Keystatic&apos;s `/keystatic` admin route needs a Node.js server. You can&apos;t use `output: &apos;static&apos;` alone — hybrid/SSR with an adapter like `@astrojs/node` is required. Public pages can stay static; only the admin route needs a running server.
&lt;/Notice&gt;

**Why I&apos;d consider Keystatic:** The TypeScript config is actually pleasant to work with. Collections, singletons, and the Reader API keep content type-safe without leaving your editor. If you&apos;re already using Bun for [faster Astro builds](https://www.bitdoze.com/migrate-astro-bun/), it fits that workflow.

**Limitations:**
- React dependency for the Admin UI — adds weight if your site is otherwise React-free
- Content auto-creates in sub-folders based on slug, which confuses a lot of people
- ~174 open GitHub issues (mid-2026)
- Some Reddit users found it &quot;surprisingly difficult to figure out&quot;

**What users say:** &quot;Surprisingly difficult... main problem was not knowing how to make posts without auto-generated sub folders... user error but real pain. Really wanted to love this one.&quot; Devs who like TypeScript-first tools tend to stick with it; people who want a five-minute visual setup often bounce.

**Verify it works:** After install, open `/keystatic` and confirm the admin loads. Create a test post and check the file lands in your content directory with the right schema.

### TinaCMS — visual editing for teams

[TinaCMS](https://tina.io/) has the most GitHub stars among Git-based options here. SSW (Australian consultancy) maintains it. The headline feature is visual editing: editors see changes on the actual page, not only in a form.

**Stats:** ~13.7k GitHub stars, Apache 2.0, TypeScript. Started mid-2019.

**How it works:** Git-based with a GraphQL layer. Content in Markdown/MDX/JSON/YAML. Astro is a first-class starter (they made it a default option around 2024). Install with `npx create-tina-app@latest` and pick the Astro starter.

**Pricing (TinaCloud), per project:**
- Free: $0 forever, 2 users, 2 roles, community support
- Team: $24/month ($290/year), 3 users included, up to 10
- Team Plus: $41/month ($490/year), 5 users included, up to 20, Editorial Workflow
- Business: $249/month ($2,990/year), 20 users included, unlimited seats

Tina has said the vast majority of users stay on free.

&lt;Notice type=&quot;warning&quot; title=&quot;Dependency heavy&quot;&gt;
Several people report version conflicts and &quot;dependency hell&quot; with TinaCMS. Test before you bet a production site on it. Full visual editing leans on TinaCloud — self-hosting that experience is messy.
&lt;/Notice&gt;

**Limitations:**
- ~430+ open GitHub issues
- Free tier stops at 2 users — third editor means Team at $24/month
- Visual editing features need TinaCloud
- Heavier dependency tree than Pages CMS or Keystatic

**Verify it works:** Run `npx create-tina-app@latest` with the Astro starter. Confirm the visual editor loads and you can create/edit a post with live preview.

### Decap CMS and Sveltia CMS — the open-source workhorses

**Decap CMS** (formerly Netlify CMS) is the old default for Git-based CMS. ~19.3k GitHub stars, MIT. Around since 2015. Netlify dropped official support; the community fork (Decap) keeps it alive at a slower pace. The UI feels dated next to newer tools.

&lt;Notice type=&quot;error&quot; title=&quot;Auth is the #1 pain point&quot;&gt;
Decap authentication is where most projects stall. Netlify Identity is deprecated. Auth0 is a project of its own. Best bets: GitHub OAuth with a proxy (DecapBridge), or skip Decap and use Pages CMS / Keystatic.
&lt;/Notice&gt;

**Sveltia CMS** (~2.6k GitHub stars, MIT) is a drop-in replacement for Decap. Same config format, same Git model, but faster, modern UI, mobile support, and built-in i18n. Already on Decap and hate the UI? Swap the script include. Done.

Both work via CDN include — no npm package. Config file + script tag. Content still works with `getCollection()` because it&apos;s just files.

**Limitation:** Neither does visual editing. Form-based only, similar to Pages CMS. Auth still inherits Decap&apos;s Netlify-era design, which is the main source of frustration.

**Verify it works:** After config, open `/admin` and complete the GitHub OAuth flow. Create a test post and check the Git commit.

### CloudCannon — visual editing for client handoffs

[CloudCannon](https://cloudcannon.com/) is the premium Git-based CMS. Featured partner on Astro&apos;s docs, and one of the polished visual editing experiences in the Git-based space. Content stays in your repo. Branch-based editing and publishing.

**Pricing:**
- Standard: $55/month
- Partner Program Lite: $10/month per client (for freelancers/agencies)

Built-in image optimization, DAM connectors, content scheduling, and solid Astro integration with component starters.

**Limitations:** Proprietary. Content is in Git, but the visual editor is CloudCannon&apos;s product. Leave and you keep the files, lose the workflow. At $55/month standard, it&apos;s steep for solo projects. Partner pricing ($10/client) is the interesting number if you ship client sites regularly.

**Vendor lock-in:** Content is yours. The editing workflow is not. Price hike or shutdown means finding a new UI, not a content migration.

### Front Matter CMS — the VS Code extension

[Front Matter CMS](https://frontmatter.codes/) is a free VS Code extension: content dashboard, media management, SEO checks, content types, and a frontmatter panel — all inside the editor.

**Pricing:** Free. MIT licensed.

Closest thing to &quot;no CMS, slightly nicer.&quot; Great for solo devs who want a sidebar without jumping to a browser. Useless for teams or non-technical editors — everyone needs VS Code.

Works in [Windsurf](https://go.bitdoze.com/windsurf) and other VS Code forks. Pair it with AI-assisted editing if you like that workflow.

**Verify it works:** Install from the VS Code marketplace. Confirm the sidebar dashboard lists your content files.

### StudioCMS — Astro-native

[StudioCMS](https://studiocms.dev/) is built by the Astro community for Astro. It left beta in January 2026 (v0.1.0). Dashboard, storage API, taxonomy, and an SSR-focused design aimed at the Astro ecosystem.

**Stats:** ~800 GitHub stars. Still young, but no longer &quot;don&apos;t touch production.&quot;

Worth a look if you want Astro-only tooling and are fine with a project that just left beta. For client work or high-stakes sites, I&apos;d still pick something more battle-tested (Pages CMS, Keystatic, Tina, CloudCannon). For a personal project or early adopter site, give it a try.

## Best API-based headless CMS for Astro

These need a separate backend. More power for complex models, teams, and enterprise. If you&apos;re running a simple blog, this section is overkill — use a Git-based option above.

Self-hosting? You&apos;ll need a VPS. [Hetzner Cloud](https://go.bitdoze.com/hetzner) from ~€4/month. [Vultr](https://go.bitdoze.com/vultr) if you want more regions.

### Sanity — most customizable studio

[Sanity](https://www.sanity.io/) has an official Astro plugin and a highly customizable admin Studio. Portable Text for structured rich content, real-time collaboration, image CDN with auto WebP/AVIF, GROQ, React-based Studio you can shape to your content model.

**Pricing:** Free plan is usable (up to 20 seats with limited roles). Growth is **$15 per seat / month**, not a flat $15.

**The catch:** Almost everything is DIY. You get a lot of control, and you wire most of it yourself. Great if you enjoy building custom editing UIs. Painful if you just want to ship a blog.

**Verify it works:** Set up Studio + the Astro plugin, create a test entry, and confirm the page renders it through the plugin&apos;s data fetching.

### Strapi and Payload — self-hosted open source

**[Strapi](https://strapi.io/)** is the big open-source headless CMS (~73k GitHub stars). Customizable admin, REST + GraphQL, role-based permissions. Self-hosted free. Needs Node.js and a database on a VPS.

**[Payload](https://payloadcms.com/)** is newer and well-liked for content modeling and extensibility (~44k stars). Also self-hosted and free at the core.

&lt;Notice type=&quot;info&quot; title=&quot;Self-hosting cost&quot;&gt;
Budget $6–10/month for a VPS with at least 2GB RAM. A [Hetzner](https://go.bitdoze.com/hetzner) CX22 (2 vCPU, 4GB RAM, ~€5/month) runs Strapi fine for low-traffic sites. [DigitalOcean](https://go.bitdoze.com/do) is another solid option.
&lt;/Notice&gt;

Both make sense when you want a full admin panel and API. For a static Astro blog, the ops work (VPS, database, backups, updates, security) rarely pays for itself.

**Verify it works:** After Docker deploy, open the admin URL, create a test entry, hit the API endpoint.

**Failure mode:** If the VPS dies, builds that fetch content fail. Plan monitoring and backups.

### Storyblok — best visual editor

[Storyblok](https://www.storyblok.com/) has an official Astro SDK and one of the best visual editors in headless CMS. Closest feeling to WordPress for non-technical editors: click a component on the page, edit inline. Component-based &quot;Bloks&quot; inside rich text.

**Pricing:**
- Starter (free): 1 seat, limited traffic/API
- Growth: $99/month (5 seats)
- Growth Plus: $349/month (15 seats)
- Extra seats: $15/month

Astro integration is maintained. If you&apos;re handing a site to a client who knows WordPress, Storyblok will feel familiar.

**Limitation:** SaaS only. No self-hosting. You&apos;re on their pricing for the long haul.

### Contentful — enterprise-ready but watch pricing

Contentful is strong for enterprise: content modeling, workflows, roles, multilingual, DAM, reliable APIs. I&apos;ve covered [Contentful with Astro](https://www.bitdoze.com/categories/cms/) before.

&lt;Notice type=&quot;warning&quot; title=&quot;Sticker shock&quot;&gt;
Free tier works for small sites. Pricing climbs fast with usage. At higher MAU, enterprise quotes can hit thousands per month. Reddit regularly calls it expensive. Test on free before you commit.
&lt;/Notice&gt;

**Limitations:** Awkward mid-article component insertion and rigid text-block handling. Solid as an API-first backend; authoring UX trails Storyblok for everyday content work.

### Directus — wrap any database

[Directus](https://directus.io/) is open-source and self-hosted. It wraps SQL databases (PostgreSQL, MySQL, SQLite, etc.) with REST + GraphQL and an admin panel. Real-time subscriptions, granular permissions, Flows (automation).

Good fit when you already have data in SQL and want a CMS on top. Core self-hosted is free. Cloud is a paid add-on (plans start higher than most solo budgets — check current pricing before you plan around it).

**Failure mode:** Same VPS maintenance as Strapi/Payload. CMS down at build time = failed deploy.

### BCMS — avoid for new projects

&lt;Notice type=&quot;error&quot; title=&quot;Abandoned open source&quot;&gt;
BCMS open-source is frozen since October 2024. The [GitHub repo](https://github.com/bcms/cms) README says it&apos;s no longer maintained. BCMS Pro is closed-source with unclear pricing. Don&apos;t start new projects on the OSS version.
&lt;/Notice&gt;

BCMS (~460 stars, MIT) looked fine on paper: modern UI, flexible modeling, Next/Astro/Svelte. The core team moved development private in September 2024. No updates, fixes, or security patches on the open repo.

Skip it for new Astro work. The [BCMS site](https://thebcms.com/) now markets a &quot;headless CMS for AI agents&quot; direction, which is a different product.

**Already on BCMS?** Content is safe if you self-host, but you&apos;re on unsupported code. Plan a migration.

## Pricing comparison: what each CMS actually costs

| CMS | Type | Free Tier | Entry Paid | Self-Hosted? |
|-----|------|-----------|------------|-------------|
| **No CMS (MDX + Git)** | Files | ✅ $0/month | $0 | N/A |
| **Pages CMS** | Git-based | ✅ Everything free | $0 | ✅ (needs PostgreSQL) |
| **Keystatic** | Git-based | ✅ Up to 3 users | $10/month + $5/user | ✅ (core is free) |
| **TinaCMS** | Git-based | ✅ 2 users | $24/month (3 users) | ⚠️ Complex |
| **Decap CMS** | Git-based | ✅ Everything free | $0 | ✅ (decoupled auth) |
| **Sveltia CMS** | Git-based | ✅ Everything free | $0 | N/A (CDN-hosted SPA) |
| **CloudCannon** | Git-based | ❌ No free tier | $55/month ($10 partner) | ❌ SaaS only |
| **Front Matter CMS** | Git-based (VS Code) | ✅ Everything free | $0 | N/A (extension) |
| **StudioCMS** | Astro-native | ✅ Free | $0 | ✅ (own stack / Astro) |
| **Sanity** | API-based | ✅ Free plan (seats limited) | $15/**seat**/month | Studio self-hostable; Content Lake is SaaS |
| **Storyblok** | API-based | ✅ Starter (1 user) | Growth: $99/mo (5 seats) | ❌ SaaS only |
| **Contentful** | API-based | ✅ Free tier | Escalates fast | ❌ SaaS only |
| **Strapi** | API-based | ✅ Self-hosted free | Cloud plans vary | ✅ (VPS needed) |
| **Payload** | API-based | ✅ Self-hosted free | Cloud plans vary | ✅ (VPS needed) |
| **Directus** | API-based | ✅ Self-hosted free | Cloud add-on (check current) | ✅ (VPS needed) |
| **BCMS** | API-based | ⚠️ Frozen OSS | Pro: unclear | ✅ (MongoDB + Docker) |

&lt;Notice type=&quot;success&quot; title=&quot;Cheapest paths&quot;&gt;
Three options stay at $0/month: no CMS (plain MDX + Git), Pages CMS (free hosted), and Front Matter CMS (VS Code extension). Need a browser UI? Start with Pages CMS.
&lt;/Notice&gt;

**Hidden costs:**
- Self-hosted API CMS: $4–20/month VPS + setup, backups, updates
- Git-based CMS with image uploads: S3/R2 storage, or repo bloat if you commit binaries
- Keystatic Cloud Images: Pro plan feature, external dependency
- CloudCannon: $55/month standard, or $10/client on Partner Program

## How to pick the right CMS for your Astro project

Two questions matter: **who edits content** and **where content lives**.

&lt;Tabs&gt;
&lt;Tab name=&quot;Solo dev&quot;&gt;
**Recommendation:** No CMS, Pages CMS (free browser UI), or Front Matter CMS (VS Code sidebar).

Personal blog or docs: start with no CMS. Add Pages CMS if you want a browser without changing infrastructure. Add Front Matter if you just want a nicer VS Code panel.

**Why not Keystatic or Tina?** You can. They add React, SSR adapters, or Cloud accounts that a solo blog rarely needs unless you want a specific feature.
&lt;/Tab&gt;
&lt;Tab name=&quot;Small team (2–5)&quot;&gt;
**Recommendation:** Pages CMS (free, simple) or Keystatic (free for ≤3 users, $10+ after).

Pages CMS wins on simplicity — email invites for non-GitHub users, browser editing, commits to Git. Keystatic wins if the team likes TypeScript config and an embedded `/keystatic` admin.

TinaCMS works if you need visual editing, but $24/month for three users adds up for small teams.
&lt;/Tab&gt;
&lt;Tab name=&quot;Client handoff&quot;&gt;
**Recommendation:** CloudCannon ($10/partner), TinaCMS (free for 2 users), or Storyblok (visual editing).

Freelancers handing sites to non-technical clients: CloudCannon Partner at $10/month per client is hard to beat. Tina works for simple two-user handoffs. Storyblok if they want something closer to WordPress.

Sanity if you&apos;re willing to build a custom Studio around their workflow.
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Decision matrix

| Scenario | Best pick | Why |
|----------|-----------|-----|
| Solo dev, personal blog | No CMS (MDX + Git) | $0, zero complexity |
| Solo dev, wants browser UI | Pages CMS | Free, simple, no npm |
| Solo dev, wants TypeScript config | Keystatic | First-class Astro, type-safe |
| Team of 3–5, budget | Pages CMS | Free, email invites |
| Team of 3–5, visual editing | TinaCMS | Visual editor, $24/month |
| Client handoff, budget | CloudCannon Partner | $10/month per client |
| Client handoff, premium | Storyblok | Strong visual editor |
| Complex content models | Sanity or Strapi | Full API, custom types |
| Enterprise, multi-channel | Contentful or DatoCMS | Proven at scale |

## Common pitfalls when adding a CMS to Astro

Failure modes I keep seeing:

**1. Decap CMS auth is a time sink.** Netlify Identity is deprecated. Auth0 is heavy. Starting fresh? Pages CMS or Keystatic. Stuck on Decap? GitHub OAuth + DecapBridge.

**2. Keystatic sub-folder confusion.** Content auto-creates folders from slugs. Flat structure under `src/content/posts/` needs careful path config. The TypeScript config is powerful and easy to misconfigure.

**3. TinaCMS dependency conflicts.** Version clashes with Astro and other packages show up often. Smoke-test with `npx create-tina-app@latest` before committing. Pin versions if things break.

**4. BCMS open-source is dead.** Don&apos;t start new projects on it. Frozen since October 2024.

**5. Contentful pricing scales harshly.** Free is fine for small sites. Higher traffic/usage can jump to enterprise pricing. Check usage early.

&lt;Notice type=&quot;warning&quot; title=&quot;Image bloat in Git-based CMS&quot;&gt;
Git-based tools often store images in the repo. Fifty posts with a few images each is fine. Hundreds of posts with high-res assets will bloat clones and builds. Offload media to a CDN. [Bunny.net](https://go.bitdoze.com/bunny) storage is cheap (~$0.01/GB/month). Keystatic Cloud Images and CloudCannon have built-in options.
&lt;/Notice&gt;

**6. API CMS = single point of failure.** Backend down at build time = failed deploy. For static sites your pipeline depends on that service. Monitor it, and keep a plan for stale builds. If you&apos;re [deploying to Cloudflare Pages](https://www.bitdoze.com/astro-plausible-cloudflare-workers/), recent cached builds can buy time — only if you&apos;ve deployed recently.

## Frequently asked questions

&lt;Accordion label=&quot;Do I really need a CMS for Astro?&quot; group=&quot;faq&quot;&gt;
No. Content Collections with Zod and MDX give you type-safe, validated content with no extra services. Solo blog or docs site? That&apos;s enough. The CMS is a UI layer. If you&apos;re fine with VS Code and Git, skip it. bitdoze.com runs that way.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the cheapest headless CMS for Astro?&quot; group=&quot;faq&quot;&gt;
Pages CMS is free end-to-end (hosted + MIT). Front Matter CMS is free as a VS Code extension. No CMS at all is also $0. For API options, self-hosted Strapi and Payload are free but need a VPS (~$4–20/month).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I self-host a headless CMS on my VPS?&quot; group=&quot;faq&quot;&gt;
Yes. Pages CMS, Strapi, Directus, and Payload all support it. Plan for at least 2GB RAM for Strapi/Directus. A [Hetzner](https://go.bitdoze.com/hetzner) CX22 (~€5/month) covers low-traffic self-hosted CMSes. You still own Docker, backups, and security updates. If you already run Dokploy or similar [self-hosted panels](https://www.bitdoze.com/best-self-hosted-panels/), adding a CMS container is straightforward.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Keystatic vs TinaCMS — which is better for Astro?&quot; group=&quot;faq&quot;&gt;
Keystatic if you want TypeScript config and a clean `/keystatic` admin. TinaCMS if the team needs visual editing on the live page. Keystatic is lighter (no TinaCloud required) but needs an SSR adapter. Tina has more features, more dependencies, and costs more once you leave the free tier.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is BCMS still a good choice?&quot; group=&quot;faq&quot;&gt;
No. Open-source has been frozen since October 2024. The README says no further updates. BCMS Pro is closed-source with unclear pricing. For new Astro projects use Pages CMS, Keystatic, or another active project.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I handle images without bloating my Git repo?&quot; group=&quot;faq&quot;&gt;
Use an external CDN. [Bunny.net](https://go.bitdoze.com/bunny) storage is about $0.01/GB/month with a global CDN. Pages CMS supports S3 and Cloudflare R2. Keystatic Cloud Images is on the Pro plan. Cloudinary has a useful free tier. For [faster Astro builds](https://www.bitdoze.com/astro-7-faster-builds/), a lean repo still matters — large image directories slow clone and build times.
&lt;/Accordion&gt;

## Wrapping up

The best headless CMS for Astro might be no CMS at all. That&apos;s how I run this blog. Content Collections + MDX + Git give you type-safe content for $0 with nothing to babysit.

When you do need a CMS — team editing, non-technical editors, client handoffs — Git-based tools line up better with Astro&apos;s architecture. Pages CMS is the simplest free option. Keystatic if you want TypeScript-native config and first-class Astro support. TinaCMS if you need visual editing. CloudCannon for client handoffs.

Only go API-based when you need real-time collaboration, complex models, or enterprise features — and budget for VPS, database, backups, and monitoring if you self-host.

Pick based on who edits your content, not which product has the longest feature list.

&lt;Button text=&quot;Migrated from WordPress? Start here&quot; link=&quot;https://www.bitdoze.com/wordpress-to-astro-migration/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category><category>headless-cms</category><category>jamstack</category></item><item><title>Add an AI Image Agent to Mastra with Kie.ai</title><link>https://www.bitdoze.com/mastra-image-agent-kie-ai/</link><guid isPermaLink="true">https://www.bitdoze.com/mastra-image-agent-kie-ai/</guid><description>Step-by-step guide to adding a Mastra image generation agent powered by Kie.ai. Cover Nano Banana, Flux-2, GPT Image, Seedream, uploads, async jobs, and local downloads for blog covers and YouTube thumbnails.</description><pubDate>Tue, 21 Jul 2026 01:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I already have a [Mastra assistant](/build-ai-agent-mastra/) that can search the web, read files, run shell commands, and remember context. That covers research and coding. It does not cover the other half of content work: covers, thumbnails, social graphics, and logo-aware edits.

So I added a dedicated image agent. You chat with it in Mastra Studio, it picks a model, writes a prompt, calls an image API, polls until the job finishes, and saves the PNG into the workspace. No separate design tab, no copy-pasting temporary URLs that disappear two weeks later.

The generation backend is [Kie.ai](https://go.bitdoze.com/kie-ai). One API key, one async job format, and access to the models I actually use for blog covers and YouTube thumbnails: Google Nano Banana / Nano Banana 2, Seedream, Flux-2, GPT Image 2, Ideogram, Topaz upscale, Recraft background removal, and more.

This article walks through how I wired that agent into Mastra, from the Kie client up to the agent instructions. You can drop the same pattern into the [mastra-assistant](https://github.com/bitdoze/mastra-assistant) project or any existing Mastra app.

&lt;Button text=&quot;Try Kie.ai (API Key)&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Build a Mastra Agent First&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;




&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/_VrXrxJxSQI&quot;
  label=&quot;How to Give Your AI Agent Image Capabilities (Mastra + Kie.ai Tutorial)
&quot;
/&gt;

## Why Kie.ai for image generation

If you only need OpenAI&apos;s image model, call OpenAI. The pain starts when you want several models behind one integration:

- Google Nano Banana 2 for fast, high-quality 16:9 covers
- Seedream Pro for photoreal product-style shots
- Flux-2 Pro for sharp commercial graphics
- GPT Image 2 when instruction following and text-in-image matter
- Ideogram when typography has to be readable
- Topaz / Recraft for upscale and background removal

Each official provider has its own auth, payload shape, polling story, and pricing page. Kie sits in the middle as a multimodel API layer: image, video, music, and LLM models behind one wallet and one task API.

Here is the practical picture from their platform and docs:

| Feature | What you get |
|---|---|
| Models | 100+ across image, video, audio, and chat (Nano Banana, GPT Image, Seedream, Flux, Veo, Kling, Seedance, Suno, Claude, Gemini, GPT, and more) |
| Pricing | Often ~30–50% below official APIs; some high-demand models much lower (platform claims up to ~80% on selected models) |
| Billing | Credit wallet; platform states failed generations are not charged |
| Credits | Do not expire; new accounts get free trial credits to test in the Playground |
| Auth | `Authorization: Bearer YOUR_API_KEY` against `https://api.kie.ai` |
| Jobs | Async: `POST /api/v1/jobs/createTask` → poll `GET /api/v1/jobs/recordInfo?taskId=...` or use a webhook |
| Rate limits | About 20 new requests / 10 seconds by default; concurrent running tasks are generous |
| Retention | Generated media ~14 days; logs ~2 months (download what you care about) |
| Ops | Logs UI, API key rate caps, optional IP allowlist, Discord/Telegram support channels |

For agent work, the async job model is the important part. Image generation is not a 200ms chat completion. Your tool must create a task, wait (or poll), then download the result before Kie&apos;s temporary URL ages out. That is exactly what the Mastra tools below do.

Image pricing examples from their public pricing page (check [kie.ai/pricing](https://kie.ai/pricing) for live numbers):

- Nano Banana 2: roughly $0.04–$0.09 per image depending on resolution
- GPT Image 2: roughly $0.03–$0.08 per image at common sizes
- Official APIs: often 2–5x higher on the same model tier

I use Kie because I can switch model ids without rewriting the agent every time a new Google or OpenAI image model lands.

&lt;Button text=&quot;Open Kie.ai Market &amp; Pricing&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Read Kie API Docs&quot; link=&quot;https://docs.kie.ai/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Affiliate disclosure&quot;&gt;
Some links to Kie.ai in this article are affiliate links (`go.bitdoze.com/kie-ai`). I use the product for image generation in my own Mastra setup. Pricing and model availability change; always verify on the official site.
&lt;/Notice&gt;

## What we will build

By the end you will have:

1. A small Kie client (create task, poll, credits, file upload, download)
2. Five Mastra tools: list models, generate, task status, credits, list assets
3. An image agent with instructions for thumbnails, blog covers, and reference-image workflows
4. Registration in `src/mastra/index.ts` so Studio shows `image-agent`

The agent can:

- Generate text-to-image assets at 16:9 for blog/YouTube
- Edit with logos or face references (image-to-image / edit models)
- Upscale or remove backgrounds
- Save files under `workspace/images/generated/`
- Research visual references with TinyFish if you already have those tools from the [Mastra assistant guide](/build-ai-agent-mastra/)

Reference uploads are handled inside `generate_kie_image` via `localImagePaths` (upload local file → Kie URL → attach to the task). You do not need a separate prepare tool for the basic flow.

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A working Mastra project (see &lt;a href=&quot;/build-ai-agent-mastra/&quot;&gt;Build Your Own AI Agent with Mastra&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Node.js 22+ (or Bun, if that is how you run the app)&lt;/li&gt;
&lt;li&gt;An LLM API key for the agent brain (OpenRouter, OpenCode Go, etc.)&lt;/li&gt;
&lt;li&gt;A &lt;a href=&quot;https://go.bitdoze.com/kie-ai&quot;&gt;Kie.ai API key&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Optional but useful: TinyFish for &quot;research competitor thumbnails before generating&quot; workflows.

## Architecture

```text
User (Studio chat)
    │
    ▼
image-agent  (LLM + instructions)
    │
    ├─ list_kie_image_models
    ├─ generate_kie_image  ──► Kie createTask + poll + download
    │                          (uploads localImagePaths first if needed)
    ├─ get_kie_image_task
    ├─ get_kie_credits
    └─ list_image_assets
            │
            ▼
    workspace/images/uploads/     (your logos &amp; refs)
    workspace/images/generated/   (outputs)
```

The LLM never talks to Kie directly. It only chooses tools. That keeps auth, retries, path safety, and download logic in TypeScript where you can test it.

## Step 1: Get a Kie API key

1. Sign up at [Kie.ai](https://go.bitdoze.com/kie-ai)
2. Open [API Key management](https://kie.ai/api-key)
3. Create a key and store it only in server-side env (never in frontend code)

Add to `.env`:

```bash
KIE_API_KEY=your-kie-api-key
```

Optional overrides:

```bash
# Use a stronger/cheaper model just for the image agent
AGENT_IMAGE_MODEL=google/gemini-2.5-flash
AGENT_IMAGE_MAX_STEPS=25
```

## Step 2: Kie client basics

Create `src/mastra/tools/kie-client.ts`. This is the low-level layer. Keep model catalog and HTTP here so tools stay thin.

### Auth and bases

```ts
export const KIE_API_BASE = &quot;https://api.kie.ai&quot;;
// File uploads use a separate host
export const KIE_UPLOAD_BASE = &quot;https://kieai.redpandaai.co&quot;;

export function getApiKey(): string {
  const apiKey = process.env.KIE_API_KEY;
  if (!apiKey) {
    throw new Error(
      &quot;KIE_API_KEY is not set. Add it to .env (from https://kie.ai/api-key).&quot;,
    );
  }
  return apiKey;
}

function authHeaders(json = true): HeadersInit {
  const headers: Record&lt;string, string&gt; = {
    Authorization: `Bearer ${getApiKey()}`,
  };
  if (json) headers[&quot;Content-Type&quot;] = &quot;application/json&quot;;
  return headers;
}
```

### Create task + poll

Kie&apos;s Market API is async. A `200` on create means &quot;accepted,&quot; not &quot;image ready.&quot;

```ts
export async function kieCreateTask(body: {
  model: string;
  input: Record&lt;string, unknown&gt;;
  callBackUrl?: string;
}): Promise&lt;{ ok: true; data: { taskId: string } } | { ok: false; error: string }&gt; {
  const res = await fetch(`${KIE_API_BASE}/api/v1/jobs/createTask`, {
    method: &quot;POST&quot;,
    headers: authHeaders(),
    body: JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok || json?.code !== 200) {
    return { ok: false, error: json?.msg || `createTask failed (HTTP ${res.status})` };
  }
  const taskId = json?.data?.taskId as string | undefined;
  if (!taskId) return { ok: false, error: &quot;createTask succeeded but no taskId returned&quot; };
  return { ok: true, data: { taskId } };
}

export async function kieGetTask(taskId: string) {
  const res = await fetch(
    `${KIE_API_BASE}/api/v1/jobs/recordInfo?taskId=${encodeURIComponent(taskId)}`,
    { method: &quot;GET&quot;, headers: authHeaders(false) },
  );
  const json = await res.json();
  const data = json?.data;
  if (!data) return { ok: false as const, error: json?.msg || &quot;No task data&quot; };

  let resultUrls: string[] | undefined;
  if (data.resultJson) {
    const parsed =
      typeof data.resultJson === &quot;string&quot; ? JSON.parse(data.resultJson) : data.resultJson;
    if (parsed?.resultUrls) resultUrls = parsed.resultUrls;
  }

  return {
    ok: true as const,
    data: {
      taskId: data.taskId as string,
      model: data.model as string | undefined,
      // waiting | queuing | generating | success | fail
      state: data.state as string | undefined,
      resultUrls,
      failMsg: data.failMsg as string | undefined,
      creditsConsumed: data.creditsConsumed as number | undefined,
    },
  };
}

export async function pollTaskUntilDone(
  taskId: string,
  options?: { timeoutMs?: number; intervalMs?: number },
) {
  const timeoutMs = options?.timeoutMs ?? 10 * 60 * 1000;
  const intervalMs = options?.intervalMs ?? 3000;
  const start = Date.now();
  let delay = intervalMs;

  while (Date.now() - start &lt; timeoutMs) {
    const result = await kieGetTask(taskId);
    if (!result.ok) return result;
    // Terminal states only. waiting / queuing / generating keep looping.
    if (result.data.state === &quot;success&quot; || result.data.state === &quot;fail&quot;) return result;
    await new Promise((r) =&gt; setTimeout(r, delay));
    delay = Math.min(delay * 1.25, 15000);
  }
  return { ok: false as const, error: `Timed out waiting for task ${taskId}` };
}
```

### Credits

```ts
export async function kieGetCredits() {
  const res = await fetch(`${KIE_API_BASE}/api/v1/chat/credit`, {
    method: &quot;GET&quot;,
    headers: authHeaders(false),
  });
  const json = await res.json();
  if (!res.ok || json?.code !== 200) {
    return { ok: false as const, error: json?.msg || &quot;credit check failed&quot; };
  }
  return { ok: true as const, data: Number(json.data) };
}
```

### Upload local files (for logos / face refs)

I2I and edit models need public or Kie-hosted image URLs. Upload local workspace files first:

```ts
export async function kieUploadLocalFile(absPath: string) {
  const buf = await Bun.file(absPath).arrayBuffer(); // or fs.readFileSync
  const bytes = Buffer.from(buf);
  const ext = absPath.split(&quot;.&quot;).pop()?.toLowerCase() ?? &quot;png&quot;;
  const mime =
    ext === &quot;png&quot; ? &quot;image/png&quot; : ext === &quot;webp&quot; ? &quot;image/webp&quot; : &quot;image/jpeg&quot;;
  const base64Data = `data:${mime};base64,${bytes.toString(&quot;base64&quot;)}`;

  const res = await fetch(`${KIE_UPLOAD_BASE}/api/file-base64-upload`, {
    method: &quot;POST&quot;,
    headers: authHeaders(),
    body: JSON.stringify({
      base64Data,
      uploadPath: &quot;images&quot;,
      fileName: absPath.split(&quot;/&quot;).pop(),
    }),
  });
  const json = await res.json();
  // Docs return downloadUrl and fileUrl
  const fileUrl =
    json?.data?.downloadUrl || json?.data?.fileUrl || json?.downloadUrl;
  if (!fileUrl) {
    return { ok: false as const, error: json?.msg || &quot;Upload returned no URL&quot; };
  }
  return { ok: true as const, data: { fileUrl: String(fileUrl) } };
}
```

Uploaded files on Kie&apos;s host are temporary (docs: deleted after a few days; treat URLs as short-lived). Prefer `downloadUrl` or `fileUrl` from the response and pass them into the generation task right away.

### Download results into the workspace

Kie media is temporary (~14 days; some docs also note result URLs can age out faster). Always pull to disk.

```ts
import { join } from &quot;node:path&quot;;
import { mkdirSync, writeFileSync } from &quot;node:fs&quot;;

export const IMAGES_GENERATED = join(process.cwd(), &quot;workspace/images/generated&quot;);
export const IMAGES_UPLOADS = join(process.cwd(), &quot;workspace/images/uploads&quot;);

export function resolveWorkspacePath(relativeOrAbs: string): string {
  if (relativeOrAbs.startsWith(&quot;/&quot;) || /^[A-Za-z]:\\/.test(relativeOrAbs)) {
    return relativeOrAbs;
  }
  // Accept &quot;images/uploads/logo.png&quot; or &quot;workspace/images/uploads/logo.png&quot;
  const cleaned = relativeOrAbs.replace(/^workspace\//, &quot;&quot;);
  return join(process.cwd(), &quot;workspace&quot;, cleaned);
}

export function findModel(modelId: string) {
  return KIE_IMAGE_MODELS.find((m) =&gt; m.id === modelId);
}

export async function downloadToGenerated(url: string, fileName?: string) {
  mkdirSync(IMAGES_GENERATED, { recursive: true });
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Download failed HTTP ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  const stamp = new Date().toISOString().replace(/[:.]/g, &quot;-&quot;);
  const base = (fileName ?? `kie-${stamp}`).replace(/[^a-zA-Z0-9._-]/g, &quot;_&quot;);
  const withExt = base.includes(&quot;.&quot;) ? base : `${base}.png`;
  const outPath = join(IMAGES_GENERATED, withExt);
  writeFileSync(outPath, buf);
  return { path: outPath, bytesWritten: buf.length };
}
```

### Curated model catalog

Do not make the LLM invent model ids. Ship a curated list the agent can list and filter:

```ts
export type ImageMode =
  | &quot;text-to-image&quot;
  | &quot;image-to-image&quot;
  | &quot;edit&quot;
  | &quot;upscale&quot;
  | &quot;remove-background&quot;;

export type KieImageModel = {
  id: string;
  name: string;
  family: string;
  mode: ImageMode;
  bestFor: string;
  supportsImageUrls: boolean;
  /** Kie input field for reference images (varies by model) */
  imageField?: &quot;image_input&quot; | &quot;image_urls&quot; | &quot;input_urls&quot; | &quot;image&quot;;
  defaultAspectRatio?: string;
  notes?: string;
};

export const KIE_IMAGE_MODELS: KieImageModel[] = [
  {
    id: &quot;nano-banana-2&quot;,
    name: &quot;Google Nano Banana 2&quot;,
    family: &quot;Google&quot;,
    mode: &quot;text-to-image&quot;,
    bestFor: &quot;Blog covers, social graphics, optional image refs&quot;,
    supportsImageUrls: true,
    imageField: &quot;image_input&quot;,
    defaultAspectRatio: &quot;16:9&quot;,
    notes: &quot;Bare id nano-banana-2 (not google/nano-banana-2). resolution: 1K|2K|4K&quot;,
  },
  {
    id: &quot;seedream/5-pro-text-to-image&quot;,
    name: &quot;Seedream 5.0 Pro T2I&quot;,
    family: &quot;Seedream&quot;,
    mode: &quot;text-to-image&quot;,
    bestFor: &quot;Photoreal thumbnails and product shots&quot;,
    supportsImageUrls: false,
    defaultAspectRatio: &quot;16:9&quot;,
    notes: &quot;Requires quality: basic|high in addition to aspect_ratio&quot;,
  },
  {
    id: &quot;flux-2/pro-text-to-image&quot;,
    name: &quot;Flux-2 Pro T2I&quot;,
    family: &quot;Flux-2&quot;,
    mode: &quot;text-to-image&quot;,
    bestFor: &quot;Sharp commercial graphics&quot;,
    supportsImageUrls: false,
    defaultAspectRatio: &quot;16:9&quot;,
  },
  {
    id: &quot;gpt-image-2-text-to-image&quot;,
    name: &quot;GPT Image 2 T2I&quot;,
    family: &quot;GPT Image&quot;,
    mode: &quot;text-to-image&quot;,
    bestFor: &quot;Instruction following, text-in-image&quot;,
    supportsImageUrls: false,
    defaultAspectRatio: &quot;16:9&quot;,
  },
  {
    id: &quot;google/nano-banana-edit&quot;,
    name: &quot;Google Nano Banana Edit&quot;,
    family: &quot;Google&quot;,
    mode: &quot;edit&quot;,
    bestFor: &quot;Logo-aware edits and photo instructions&quot;,
    supportsImageUrls: true,
    imageField: &quot;image_urls&quot;,
    defaultAspectRatio: &quot;16:9&quot;,
  },
  {
    id: &quot;gpt-image-2-image-to-image&quot;,
    name: &quot;GPT Image 2 I2I&quot;,
    family: &quot;GPT Image&quot;,
    mode: &quot;image-to-image&quot;,
    bestFor: &quot;Face lock / identity-preserving edits&quot;,
    supportsImageUrls: true,
    imageField: &quot;input_urls&quot;,
    defaultAspectRatio: &quot;16:9&quot;,
  },
  {
    id: &quot;topaz/image-upscale&quot;,
    name: &quot;Topaz Image Upscale&quot;,
    family: &quot;Topaz&quot;,
    mode: &quot;upscale&quot;,
    bestFor: &quot;Final 2x/4x upscale&quot;,
    supportsImageUrls: true,
    imageField: &quot;image_urls&quot;,
  },
  {
    id: &quot;recraft/remove-background&quot;,
    name: &quot;Recraft Remove Background&quot;,
    family: &quot;Recraft&quot;,
    mode: &quot;remove-background&quot;,
    bestFor: &quot;Cut out product/logo backgrounds&quot;,
    supportsImageUrls: true,
    imageField: &quot;image&quot;,
  },
];
```

Add more ids from [docs.kie.ai](https://docs.kie.ai/) as you need them. The agent only sees what you list.

## Step 3: Mastra image tools

Create `src/mastra/tools/kie-image.ts`. Tools are `createTool` + Zod. The `description` field is the model-facing API docs, so write it carefully.

### List models

```ts
import { createTool } from &quot;@mastra/core/tools&quot;;
import { z } from &quot;zod&quot;;
import { KIE_IMAGE_MODELS } from &quot;./kie-client&quot;;

export const listKieImageModels = createTool({
  id: &quot;list_kie_image_models&quot;,
  description:
    &quot;List available Kie.ai image models for thumbnails, blog covers, and marketing graphics. Call this before generating if you are unsure which model id to use.&quot;,
  inputSchema: z.object({
    mode: z
      .enum([
        &quot;text-to-image&quot;,
        &quot;image-to-image&quot;,
        &quot;edit&quot;,
        &quot;upscale&quot;,
        &quot;remove-background&quot;,
        &quot;all&quot;,
      ])
      .optional(),
    family: z.string().optional(),
  }),
  outputSchema: z.object({
    count: z.number(),
    models: z.array(
      z.object({
        id: z.string(),
        name: z.string(),
        family: z.string(),
        mode: z.string(),
        bestFor: z.string(),
        supportsImageUrls: z.boolean(),
        notes: z.string().optional(),
      }),
    ),
  }),
  execute: async (input) =&gt; {
    let models = KIE_IMAGE_MODELS;
    if (input.mode &amp;&amp; input.mode !== &quot;all&quot;) {
      models = models.filter((m) =&gt; m.mode === input.mode);
    }
    if (input.family) {
      const f = input.family.toLowerCase();
      models = models.filter((m) =&gt; m.family.toLowerCase().includes(f));
    }
    return {
      count: models.length,
      models: models.map((m) =&gt; ({
        id: m.id,
        name: m.name,
        family: m.family,
        mode: m.mode,
        bestFor: m.bestFor,
        supportsImageUrls: m.supportsImageUrls,
        notes: m.notes,
      })),
    };
  },
});
```

### Generate (create + poll + download)

```ts
import { existsSync } from &quot;node:fs&quot;;
import {
  findModel,
  kieCreateTask,
  pollTaskUntilDone,
  kieUploadLocalFile,
  downloadToGenerated,
  getApiKey,
  resolveWorkspacePath,
} from &quot;./kie-client&quot;;

export const generateKieImage = createTool({
  id: &quot;generate_kie_image&quot;,
  description: `Generate or edit an image via Kie.ai. Creates an async task, polls until done, downloads into workspace/images/generated/, returns local paths.
Defaults:
- Blog/YouTube 16:9: nano-banana-2, seedream/5-pro-text-to-image, flux-2/pro-text-to-image
- Text-heavy graphics: gpt-image-2-text-to-image or ideogram/v3-text-to-image
- Logo/reference edits: google/nano-banana-edit or nano-banana-2 with localImagePaths
Important: Nano Banana 2 ids are bare: nano-banana-2 (NOT google/nano-banana-2).`,
  inputSchema: z.object({
    model: z.string().describe(&quot;Exact Market model id&quot;),
    prompt: z.string().min(1).max(20000).optional(),
    aspectRatio: z.string().optional().describe(&quot;e.g. 16:9, 1:1, 9:16&quot;),
    quality: z.string().optional().describe(&quot;Seedream: basic|high&quot;),
    resolution: z.string().optional().describe(&quot;1K | 2K | 4K when supported&quot;),
    imageUrls: z.array(z.string().url()).optional(),
    localImagePaths: z
      .array(z.string())
      .optional()
      .describe(&quot;Workspace paths under images/uploads/, uploaded to Kie first&quot;),
    fileName: z.string().optional(),
    wait: z.boolean().optional().describe(&quot;Default true: poll + download&quot;),
  }),
  outputSchema: z.object({
    success: z.boolean(),
    taskId: z.string().optional(),
    model: z.string().optional(),
    state: z.string().optional(),
    remoteUrls: z.array(z.string()).optional(),
    localPaths: z.array(z.string()).optional(),
    creditsConsumed: z.number().optional(),
    error: z.string().optional(),
  }),
  execute: async (input) =&gt; {
    try {
      getApiKey();
    } catch (e) {
      return { success: false, error: e instanceof Error ? e.message : String(e) };
    }

    const imageUrls: string[] = [...(input.imageUrls ?? [])];
    if (input.localImagePaths?.length) {
      for (const p of input.localImagePaths) {
        const abs = resolveWorkspacePath(p);
        if (!existsSync(abs)) {
          return {
            success: false,
            error: `localImagePath not found: ${p}. Do not generate without the reference.`,
          };
        }
        const uploaded = await kieUploadLocalFile(abs);
        if (!uploaded.ok) {
          return { success: false, error: `Upload failed for ${p}: ${uploaded.error}` };
        }
        imageUrls.push(uploaded.data.fileUrl);
      }
    }

    const meta = findModel(input.model);
    const taskInput: Record&lt;string, unknown&gt; = {
      prompt: input.prompt,
      aspect_ratio: input.aspectRatio ?? meta?.defaultAspectRatio ?? &quot;16:9&quot;,
    };
    if (input.quality) taskInput.quality = input.quality;
    if (input.resolution) taskInput.resolution = input.resolution;
    // Seedream Pro requires quality even if the agent forgets it
    if (input.model.startsWith(&quot;seedream/&quot;) &amp;&amp; !taskInput.quality) {
      taskInput.quality = &quot;basic&quot;;
    }
    if (imageUrls.length) {
      // Field name is model-specific: image_input | image_urls | input_urls | image
      const field =
        meta?.imageField ??
        (meta?.id === &quot;nano-banana-2&quot; || meta?.id === &quot;nano-banana-pro&quot;
          ? &quot;image_input&quot;
          : meta?.id?.startsWith(&quot;gpt-image&quot;)
            ? &quot;input_urls&quot;
            : &quot;image_urls&quot;);
      if (field === &quot;image&quot;) {
        taskInput.image = imageUrls[0];
      } else {
        taskInput[field] = imageUrls;
      }
    }

    const created = await kieCreateTask({ model: input.model, input: taskInput });
    if (!created.ok) return { success: false, error: created.error, model: input.model };

    const taskId = created.data.taskId;
    if (input.wait === false) {
      return { success: true, taskId, model: input.model, state: &quot;submitted&quot; };
    }

    const done = await pollTaskUntilDone(taskId);
    if (!done.ok) return { success: false, taskId, error: done.error, model: input.model };
    if (done.data.state === &quot;fail&quot;) {
      return {
        success: false,
        taskId,
        state: &quot;fail&quot;,
        error: done.data.failMsg || &quot;Generation failed&quot;,
        model: input.model,
      };
    }

    const remoteUrls = done.data.resultUrls ?? [];
    const localPaths: string[] = [];
    for (let i = 0; i &lt; remoteUrls.length; i++) {
      const base =
        input.fileName &amp;&amp; remoteUrls.length === 1
          ? input.fileName
          : input.fileName
            ? `${input.fileName}-${i + 1}`
            : undefined;
      const saved = await downloadToGenerated(remoteUrls[i], base);
      localPaths.push(saved.path);
    }

    return {
      success: true,
      taskId,
      model: input.model,
      state: done.data.state,
      remoteUrls,
      localPaths,
      creditsConsumed: done.data.creditsConsumed,
    };
  },
});
```

### Credits, task status, and local assets

Keep these small. The agent uses them for &quot;do I have budget?&quot; and &quot;where is my logo?&quot;

```ts
export const getKieCredits = createTool({
  id: &quot;get_kie_credits&quot;,
  description: &quot;Check remaining Kie.ai account credits before bulk generation.&quot;,
  inputSchema: z.object({}),
  outputSchema: z.object({
    success: z.boolean(),
    credits: z.number().optional(),
    error: z.string().optional(),
  }),
  execute: async () =&gt; {
    try {
      getApiKey();
    } catch (e) {
      return { success: false, error: e instanceof Error ? e.message : String(e) };
    }
    const result = await kieGetCredits();
    if (!result.ok) return { success: false, error: result.error };
    return { success: true, credits: result.data };
  },
});

export const getKieImageTask = createTool({
  id: &quot;get_kie_image_task&quot;,
  description:
    &quot;Check status of a Kie image task by taskId. When success and download=true, save files under images/generated/.&quot;,
  inputSchema: z.object({
    taskId: z.string(),
    download: z.boolean().optional(),
    fileName: z.string().optional(),
  }),
  outputSchema: z.object({
    success: z.boolean(),
    taskId: z.string().optional(),
    state: z.string().optional(),
    remoteUrls: z.array(z.string()).optional(),
    localPaths: z.array(z.string()).optional(),
    error: z.string().optional(),
  }),
  execute: async (input) =&gt; {
    const result = await kieGetTask(input.taskId);
    if (!result.ok) return { success: false, taskId: input.taskId, error: result.error };

    const remoteUrls = result.data.resultUrls ?? [];
    const localPaths: string[] = [];
    if (result.data.state === &quot;success&quot; &amp;&amp; input.download !== false) {
      for (let i = 0; i &lt; remoteUrls.length; i++) {
        const base =
          input.fileName &amp;&amp; remoteUrls.length === 1
            ? input.fileName
            : input.fileName
              ? `${input.fileName}-${i + 1}`
              : undefined;
        const saved = await downloadToGenerated(remoteUrls[i], base);
        localPaths.push(saved.path);
      }
    }
    if (result.data.state === &quot;fail&quot;) {
      return {
        success: false,
        taskId: input.taskId,
        state: &quot;fail&quot;,
        error: result.data.failMsg || &quot;Task failed&quot;,
      };
    }
    return {
      success: true,
      taskId: input.taskId,
      state: result.data.state,
      remoteUrls,
      localPaths: localPaths.length ? localPaths : undefined,
    };
  },
});

export const listImageAssets = createTool({
  id: &quot;list_image_assets&quot;,
  description:
    &quot;List local images in workspace/images/uploads and workspace/images/generated. Use relative paths as localImagePaths.&quot;,
  inputSchema: z.object({
    subdir: z.enum([&quot;uploads&quot;, &quot;generated&quot;, &quot;all&quot;]).optional(),
  }),
  outputSchema: z.object({
    count: z.number(),
    assets: z.array(
      z.object({
        relativePath: z.string(),
        size: z.number(),
        modifiedAt: z.string(),
      }),
    ),
  }),
  execute: async (input) =&gt; {
    const { readdirSync, statSync, existsSync } = await import(&quot;node:fs&quot;);
    const { join } = await import(&quot;node:path&quot;);
    const dirs =
      input.subdir === &quot;uploads&quot;
        ? [IMAGES_UPLOADS]
        : input.subdir === &quot;generated&quot;
          ? [IMAGES_GENERATED]
          : [IMAGES_UPLOADS, IMAGES_GENERATED];
    const assets: { relativePath: string; size: number; modifiedAt: string }[] = [];
    for (const dir of dirs) {
      if (!existsSync(dir)) continue;
      for (const name of readdirSync(dir)) {
        if (name.startsWith(&quot;.&quot;)) continue;
        const full = join(dir, name);
        const st = statSync(full);
        if (!st.isFile()) continue;
        assets.push({
          relativePath: full.includes(&quot;/workspace/&quot;)
            ? full.slice(full.indexOf(&quot;images/&quot;))
            : full,
          size: st.size,
          modifiedAt: st.mtime.toISOString(),
        });
      }
    }
    return { count: assets.length, assets };
  },
});
```

Wire exports:

```ts
export const kieImageTools = {
  listKieImageModels,
  generateKieImage,
  getKieImageTask,
  getKieCredits,
  listImageAssets,
};
```

&lt;Notice type=&quot;warning&quot; title=&quot;Do not skip reference uploads&quot;&gt;
If the user provides a face or logo path, upload it and pass it into an I2I/edit model. Never fall back to pure text-to-image and invent the face. Fail the tool call instead.
&lt;/Notice&gt;

## Step 4: Define the image agent

Create `src/mastra/agents/image-agent.ts`. One agent, focused instructions, tools only for images (plus optional web research).

```ts
import { Agent } from &quot;@mastra/core/agent&quot;;
import {
  UnicodeNormalizer,
  PromptInjectionDetector,
} from &quot;@mastra/core/processors&quot;;
import { memory } from &quot;../memory&quot;;
import { workspace } from &quot;../workspaces&quot;;
import {
  listKieImageModels,
  generateKieImage,
  getKieImageTask,
  getKieCredits,
  listImageAssets,
} from &quot;../tools/kie-image&quot;;

const IMAGE_AGENT_MODEL =
  process.env.AGENT_IMAGE_MODEL ??
  process.env.AGENT_MODEL ??
  &quot;google/gemini-2.5-flash&quot;;

export const imageAgent = new Agent({
  id: &quot;image-agent&quot;,
  name: &quot;Image Agent&quot;,
  instructions: () =&gt; {
    const iso = new Date().toISOString().split(&quot;T&quot;)[0];
    const year = String(new Date().getUTCFullYear());
    return `TODAY IS ${iso}. THE CURRENT YEAR IS ${year}.

You are the Image Agent for YouTube thumbnails, blog covers, social graphics, product shots, and brand-aware edits.

## Capabilities
- List models with list_kie_image_models
- Generate/edit with generate_kie_image (async create + poll + download)
- Manage assets under images/uploads/ and images/generated/
- Check credits with get_kie_credits before bulk runs

## Workspace layout
- images/uploads/: logos, references, user sources
- images/generated/: Kie outputs

## Model defaults
| Use case | Preferred model |
|---|---|
| YouTube / blog 16:9 | nano-banana-2, seedream/5-pro-text-to-image, flux-2/pro-text-to-image |
| Fast draft | nano-banana-2-lite or seedream/5-lite-text-to-image |
| Readable text | gpt-image-2-text-to-image or ideogram/v3-text-to-image |
| Edit with logo/ref | google/nano-banana-edit, nano-banana-2 (+ localImagePaths) |
| Face lock | gpt-image-2-image-to-image (+ localImagePaths) |
| Upscale | topaz/image-upscale |
| Remove background | recraft/remove-background |

### Critical model IDs
- nano-banana-2, nano-banana-2-lite, nano-banana-pro: bare IDs (NOT google/nano-banana-2*)
- google/nano-banana, google/nano-banana-edit, google/imagen4: keep the google/ prefix
- Call list_kie_image_models when unsure; never invent prefixes

## Workflow
1. Clarify goal briefly (platform, aspect ratio, style). Prefer defaults: blog/YouTube → 16:9.
2. If logos/refs are needed, list_image_assets or ask the user to drop files in images/uploads/.
3. Write a strong prompt: subject, composition, lighting, style, palette, exact text, negatives.
4. Call generate_kie_image with a clear fileName.
5. Report local paths under images/generated/ and offer iteration.

## Prompt craft
- Be specific: camera angle, lighting, materials, mood, brand hex colors.
- Thumbnails: high contrast, large subject, 3–6 words of text, safe margins.
- Blog covers: readable at small size, match site aesthetic, avoid clutter.
- When using a logo: use an edit/I2I model and describe placement.

## Rules
- Prefer real tool work over guessing model ids.
- Do not invent file paths. List assets first.
- Kie remote URLs expire; always rely on local downloads.
- If KIE_API_KEY is missing, say so clearly.
- Keep responses concise: model, prompt used, output paths.`;
  },
  model: IMAGE_AGENT_MODEL,
  memory,
  workspace,
  inputProcessors: [
    new UnicodeNormalizer({
      stripControlChars: true,
      collapseWhitespace: true,
    }),
    // optional: PromptInjectionDetector with a cheap guard model
  ],
  tools: {
    listKieImageModels,
    generateKieImage,
    getKieImageTask,
    getKieCredits,
    listImageAssets,
  },
  defaultOptions: { maxSteps: Number(process.env.AGENT_IMAGE_MAX_STEPS ?? 25) },
});
```

A few design choices that matter:

- `instructions` is a function so the date stays current (same pattern as the [assistant agent](/build-ai-agent-mastra/)).
- Model defaults live in the prompt, not only in your head. The LLM follows a table more reliably than a vague &quot;pick a good model.&quot;
- `workspace` is optional but useful so the agent can also list/read files if you enable workspace tools. The dedicated list/generate tools are enough for a minimal setup.
- `maxSteps: 25` is enough for list → generate → optional retry without runaway loops.

## Step 5: Register the agent

In `src/mastra/index.ts`:

```ts
import { Mastra } from &quot;@mastra/core/mastra&quot;;
import { assistant } from &quot;./agents/assistant&quot;;
import { imageAgent } from &quot;./agents/image-agent&quot;;

export const mastra = new Mastra({
  agents: {
    assistant,
    imageAgent,
  },
  // storage, logger, server (same as your existing app)
});
```

If you use domain flags (lighter deploys), gate it:

```ts
// ENABLE_IMAGE=false to skip registration
if (process.env.ENABLE_IMAGE !== &quot;false&quot;) {
  agents.imageAgent = imageAgent;
}
```

## Step 6: Run and try it

```bash
# .env must include:
# KIE_API_KEY=...
# OPENROUTER_API_KEY=...   (or your LLM provider)
# AGENT_MODEL=google/gemini-2.5-flash

npm run dev
# or: bun run dev
```

Open Studio at `http://localhost:4111`, select Image Agent, and try:

```text
Create a 16:9 blog cover for an article about adding an image agent to Mastra.
Style: clean purple gradient tech illustration, subtle node graph, title text
&quot;Mastra Image Agent&quot;, high contrast, no clutter. Save as mastra-image-cover.
```

What you should see in traces:

1. Optional `list_kie_image_models` or direct model choice (`nano-banana-2` / Seedream / Flux)
2. `generate_kie_image` with `aspectRatio: &quot;16:9&quot;` and a long prompt
3. Poll until `state: success`
4. Local path like `workspace/images/generated/mastra-image-cover.png`

### Logo / face reference workflow

```bash
mkdir -p workspace/images/uploads
cp ~/Downloads/logo.png workspace/images/uploads/logo.png
```

Then in chat:

```text
Use images/uploads/logo.png. Generate a YouTube thumbnail 16:9 for
&quot;Self-Host Mastra on a VPS&quot;. Place the logo bottom-left, keep it crisp,
dark background, large bold title, high contrast.
```

The agent should pass `localImagePaths: [&quot;images/uploads/logo.png&quot;]` into an edit/I2I model such as `google/nano-banana-edit` or `nano-banana-2`, not invent the logo from text.

## Model cheat sheet

| Goal | Model id | Notes |
|---|---|---|
| Default blog cover | `nano-banana-2` | Bare id; optional `image_input` refs; 1K/2K/4K |
| Photoreal | `seedream/5-pro-text-to-image` | Needs `quality: basic\|high` |
| Commercial sharp | `flux-2/pro-text-to-image` | Marketing graphics |
| Text in image | `gpt-image-2-text-to-image` or `ideogram/v3-text-to-image` | Typography |
| Edit / logo | `google/nano-banana-edit` | Field: `image_urls` |
| Face lock | `gpt-image-2-image-to-image` | Field: `input_urls` |
| Upscale | `topaz/image-upscale` | Check docs for factor fields |
| Cutout | `recraft/remove-background` | Often a singular `image` field |

Model ids change as Kie onboards new releases. Treat your catalog in `kie-client.ts` as the source of truth, and keep `list_kie_image_models` in the tool belt.

## Prompt patterns that work

**Blog cover (16:9)**

```text
Wide 16:9 blog hero image. Subject: abstract TypeScript agent nodes connected
by thin purple lines on a soft lavender gradient. Clean modern tech illustration,
flat with subtle depth, generous negative space on the right for title overlay.
No watermarks, no tiny unreadable text, no crowded UI mockups. High contrast.
```

**YouTube thumbnail (16:9)**

```text
YouTube thumbnail 16:9, high contrast, large central subject (developer at laptop
with glowing purple AI node graph). Bold 4-word title &quot;Build Image Agents&quot; in
thick white sans-serif with dark outline. Leave safe margins. Punchy colors,
shallow depth of field, no busy background.
```

**Logo composite**

```text
Place the provided logo in the bottom-left corner, keep proportions, do not
distort or recolor the logo mark. Dark navy background, soft bokeh lights,
product photography lighting, 16:9.
```

## Production tips

1. Download everything. Kie keeps media ~14 days. Your workspace is the durable store.
2. Fail hard on missing refs. If upload fails, return an error. Silent T2I fallback ruins brand work.
3. Watch credits. Call `get_kie_credits` before batch runs.
4. Separate agent from assistant. Image work burns steps and tokens on long prompts. A dedicated agent keeps the coding assistant focused.
5. Rate limits. Default is about 20 new jobs per 10 seconds. For bulk covers, queue a short delay between creates.
6. Keep secrets server-side. `KIE_API_KEY` only in `.env` / host secrets. Never ship it to the browser.
7. Optional webhook. For long video models later, prefer `callBackUrl` over long polls. Images are usually fine with 3–15s polling.

## Troubleshooting

&lt;Accordion&gt;
&lt;details&gt;
&lt;summary&gt;401 / &quot;You do not have access permissions&quot;&lt;/summary&gt;

Missing or wrong `Authorization: Bearer ...` header, or empty `KIE_API_KEY`. Confirm the key at [kie.ai/api-key](https://kie.ai/api-key) and that the process actually loaded `.env`.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Model not supported / invalid model&lt;/summary&gt;

Wrong id. Nano Banana 2 is `nano-banana-2`, not `google/nano-banana-2`. Call `list_kie_image_models` and copy the id exactly from your catalog / [docs.kie.ai](https://docs.kie.ai/).
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Task stuck in waiting / queuing / generating&lt;/summary&gt;

Still running. Keep polling. Raise `timeoutMs` for heavy models. Check [kie.ai/logs](https://kie.ai/logs) for the real state and fail message.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Upload succeeded but no fileUrl&lt;/summary&gt;

Parse `downloadUrl` as well as `fileUrl`. Kie&apos;s upload host may return either field depending on endpoint.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Image looks wrong / face not matching&lt;/summary&gt;

You used a pure T2I model without refs, or the upload never attached. Confirm `localImagePaths` resolved, upload returned a URL, and the model supports image inputs (`google/nano-banana-edit`, `nano-banana-2`, `gpt-image-2-image-to-image`, Seedream I2I). Also check the image field name: `image_input` vs `image_urls` vs `input_urls`.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;HTTP 429&lt;/summary&gt;

Hit the create-rate limit. Back off and retry. Contact Kie support if you need higher limits for production volume.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Seedream task rejected on create&lt;/summary&gt;

Seedream 5 Pro requires `quality` (`basic` or `high`) and `aspect_ratio`. The sample tool defaults `quality` to `basic` when the model id starts with `seedream/`.
&lt;/details&gt;
&lt;/Accordion&gt;

## Where this fits with the rest of Mastra

- Base assistant: [Build Your Own AI Agent with Mastra](/build-ai-agent-mastra/)
- Native tools vs MCP: [Mastra tools vs MCP](/mastra-tools-vs-mcp/)
- Voice cloning TTS: [Add Voice Cloning TTS with Fish Audio](/mastra-fish-audio-tts/)
- Framework choice: [Mastra vs Eve](/mastra-vs-eve-typescript-ai-agents/)
- Web research tools: [TinyFish for AI agents](/tinyfish-ai-agents-web-search/)
- Kie product review (pricing, competitors, pros/cons): [Kie.ai Review 2026](/kie-ai-review/)
- Video generation with Veo 3.1, Kling 3.0, Seedance (same async pattern): [Kie.ai Video Generation Guide](/kie-ai-video-generation/)
- Kie platform: [go.bitdoze.com/kie-ai](https://go.bitdoze.com/kie-ai) · docs at [docs.kie.ai](https://docs.kie.ai/)

Once image generation works, the same pattern extends to voice (Fish Audio) and video (Veo, Kling, Seedance on Kie). I keep those as separate agents so step budgets and instructions stay clean.

&lt;Button text=&quot;Get a Kie.ai API Key&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Mastra Assistant Guide&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Next step&quot;&gt;
Register the image agent next to your assistant, open Studio, and generate a cover into `workspace/images/generated/`. If something fails, check the Kie logs page and the tool error string first. Most issues are wrong model ids or missing `KIE_API_KEY`.
&lt;/Notice&gt;</content:encoded><category>ai</category><category>mastra</category><category>ai-tools</category><category>image-generation</category></item><item><title>Bun Package Manager: Complete Guide vs NPM, Yarn &amp; PNPM</title><link>https://www.bitdoze.com/bun-package-manager/</link><guid isPermaLink="true">https://www.bitdoze.com/bun-package-manager/</guid><description>Bun package manager is up to 30x faster than npm. This 2026 guide covers installing Bun, migrating from npm/yarn/pnpm, and using bun.lock, bun outdated &amp; more.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import img1 from &quot;../../assets/images/24/02/bun-speed.webp&quot;;

Bun is a JavaScript runtime and package manager built from scratch with Zig and JavaScriptCore. It aims to be a drop-in replacement for NPM, Yarn, and PNPM with significantly faster install times and a simpler toolchain. Since this article was first published in February 2024, Bun has gained full Windows support, a new text-based lockfile, and was acquired by Anthropic in December 2025.

&lt;Notice type=&quot;info&quot; title=&quot;Updated July 2026&quot;&gt;
- **Native Windows support**: Bun 1.1 (April 2024) added full Windows support passing 98% of the test suite. No WSL needed.
- **Text-based lockfile**: Bun 1.2 (January 2025) made `bun.lock` (human-readable JSONC) the default, replacing the binary `bun.lockb`.
- **Anthropic acquired Bun** (December 2025): Bun now powers Claude Code and the Claude Agent SDK. Long-term viability concerns from the original article are largely resolved.
- **New commands**: `bun outdated`, `bun why`, `bun audit`, `bun update --interactive`, `bun publish`, and `bun patch` are all production-ready.
&lt;/Notice&gt;

## Why switch to Bun: bun vs npm, yarn &amp; pnpm compared

The core argument for switching is speed. Bun installs packages in parallel using multiple threads, caches results globally, and can be **up to 30x faster** than npm on cold installs. On warm installs (cache populated, lockfile present), the difference is even wider.

But speed is not the only reason. Here&apos;s what you get:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Up to 30x faster installs (cold) and near-instant warm installs&lt;/li&gt;
&lt;li&gt;Native Windows, macOS, and Linux support, no WSL needed&lt;/li&gt;
&lt;li&gt;Text-based `bun.lock` that diffs cleanly in git PRs&lt;/li&gt;
&lt;li&gt;Auto-migration from `package-lock.json`, `yarn.lock`, and `pnpm-lock.yaml`&lt;/li&gt;
&lt;li&gt;Built-in test runner, bundler, and package publisher&lt;/li&gt;
&lt;li&gt;Backed by Anthropic, powers Claude Code and the Claude Agent SDK&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Speed comparison: npm vs pnpm vs Bun

Bun&apos;s own benchmarks claim &quot;up to 30x faster&quot; installs than npm. That number is from their marketing, not an independent source. In practice, the gap depends on your project size, network, and cache state.

The [pnpm.io/benchmarks](https://pnpm.io/benchmarks) page (daily-updated) compares npm, pnpm, Yarn Classic, and Yarn PnP, but does not include Bun. For npm vs pnpm, the benchmark shows pnpm is roughly 2-3x faster than npm on clean installs. Bun&apos;s real-world advantage over npm is typically 5-15x, depending on the scenario.

Your results will vary. Test on your own project. See the real-world benchmark below for one data point.

When this article was first published, some developers questioned Bun&apos;s long-term viability, drawing parallels with Yarn&apos;s rise and fall. The Anthropic acquisition in December 2025 has largely settled that debate. Bun is no longer a side project. It&apos;s the runtime behind Claude Code.

You can check [how to migrate Astro to Bun on CloudFlare](https://www.bitdoze.com/migrate-astro-bun/) for a real migration walkthrough.

## How to install Bun on macOS, Linux &amp; Windows

Bun installs on all major platforms. Below are the current recommended methods.

&lt;Tabs&gt;
&lt;Tab name=&quot;macOS&quot;&gt;
```sh
brew install oven-sh/bun/bun
```
The old two-step `brew tap oven-sh/bun` + `brew install bun` still works, but the single command above is preferred.
&lt;/Tab&gt;
&lt;Tab name=&quot;Linux&quot;&gt;
```sh
curl -fsSL https://bun.sh/install | bash
```

&lt;Notice type=&quot;info&quot; title=&quot;Linux prerequisites&quot;&gt;
The `unzip` package is required. Install it with `sudo apt install unzip` (Debian/Ubuntu) or the equivalent for your distro. Kernel 5.6+ is recommended; minimum is 5.1. Bun gracefully degrades on kernels as old as 3.10 (RHEL 7) but with reduced performance. Check your kernel with `uname -r`.
&lt;/Notice&gt;
&lt;/Tab&gt;
&lt;Tab name=&quot;Windows&quot;&gt;
```powershell
powershell -c &quot;irm bun.sh/install.ps1 | iex&quot;
```
Requires Windows 10 version 1809 or later. Full native support, no WSL needed. Bun on Windows passes 98% of its own test suite.
&lt;/Tab&gt;
&lt;Tab name=&quot;npm (cross-platform)&quot;&gt;
```sh
npm install -g bun
```
Quick option if you already have Node.js and npm installed. Works on all platforms.
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Verify installation

```sh
bun --version
# Should print something like: 1.3.14

bun --revision
# Prints the build revision (useful for bug reports)
```

To self-update Bun:

```sh
bun upgrade
```

&lt;Notice type=&quot;warning&quot; title=&quot;CPU compatibility&quot;&gt;
Standard Bun builds require AVX2 instructions (Haswell-era Intel, Excavator-era AMD or newer). On older CPUs you&apos;ll get an &quot;Illegal Instruction&quot; error. If that happens, install the baseline build: `curl -fsSL https://bun.sh/install | bash -s -- --baseline`.
&lt;/Notice&gt;

If `bun` is not found after install, make sure `~/.bun/bin` is in your PATH. Add this to your shell config:

```sh
# bash/zsh, add to ~/.bashrc or ~/.zshrc
export BUN_INSTALL=&quot;$HOME/.bun&quot;
export PATH=&quot;$BUN_INSTALL/bin:$PATH&quot;
```

For Fish shell, see [best Fish shell plugins and tools](https://www.bitdoze.com/best-fish-shell-plugins/) for PATH setup guidance.

If you need Node.js installed alongside Bun, see [how to install Node.js using NVM](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/).

## Bun lockfile: bun.lock vs bun.lockb

Before Bun 1.2 (January 2025), the default lockfile was the binary `bun.lockb`. It worked, but you couldn&apos;t diff it in git, couldn&apos;t review lockfile changes in PRs, and merge conflicts were impossible to resolve by hand.

**Bun 1.2 made `bun.lock` the default.** It&apos;s a text-based JSONC file that diffs and merges like any other source file.

&lt;Notice type=&quot;info&quot; title=&quot;Migrating from bun.lockb&quot;&gt;
```sh
bun install --save-text-lockfile --frozen-lockfile --lockfile-only
# Then remove the old binary lockfile
rm bun.lockb
```
After this, `bun install` will maintain `bun.lock` going forward. The binary `bun.lockb` is still supported for backward compatibility, but there&apos;s no reason to keep it.
&lt;/Notice&gt;

**Verify:** `ls bun.lock` should exist. Run `git diff bun.lock`. You should see readable JSONC output.

If `bun install` still generates `bun.lockb` instead of `bun.lock`, check `bunfig.toml` for `saveTextLockfile = false` or upgrade your Bun version.

## How to migrate from NPM, Yarn, or PNPM to Bun

Since Bun 1.1, `bun install` can auto-migrate from existing lockfiles. But the cleanest approach is still to remove the old lockfile first, then let Bun generate a fresh `bun.lock`.

&lt;Tabs&gt;
&lt;Tab name=&quot;From npm&quot;&gt;
```sh
rm package-lock.json
bun install
```
&lt;/Tab&gt;
&lt;Tab name=&quot;From yarn&quot;&gt;
```sh
rm yarn.lock
bun install
```
&lt;/Tab&gt;
&lt;Tab name=&quot;From pnpm&quot;&gt;
```sh
rm pnpm-lock.yaml
bun install
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

**Verify:** `ls bun.lock` should exist, `ls node_modules` should show installed packages, and `bun run build` (or your project&apos;s build command) should succeed.

If you run into issues with native addons, check `bun pm untrusted`. Bun blocks lifecycle scripts (postinstall, etc.) by default since Bun 1.1. See the troubleshooting section below for how to trust packages.

For a full Astro migration walkthrough, see [migrate Astro to Bun on CloudFlare](https://www.bitdoze.com/migrate-astro-bun/).

## Bun command reference: essential commands &amp; npm equivalents

### Core commands

| Bun Command | npm Equivalent | Purpose |
|---|---|---|
| `bun install` | `npm install` | Install all dependencies from package.json |
| `bun add &lt;pkg&gt;` | `npm install &lt;pkg&gt;` | Add a new package |
| `bun add &lt;pkg&gt; --dev` | `npm install &lt;pkg&gt; --save-dev` | Add a dev dependency |
| `bun remove &lt;pkg&gt;` | `npm uninstall &lt;pkg&gt;` | Remove a package |
| `bun run &lt;script&gt;` | `npm run &lt;script&gt;` | Execute a script from package.json |
| `bun outdated` | `npm outdated` | Show outdated packages |
| `bun update &lt;pkg&gt;` | `npm update &lt;pkg&gt;` | Update within semver range |
| `bun update &lt;pkg&gt; --latest` | `npm install &lt;pkg&gt;@latest` | Update to latest version |
| `bun update --interactive` | *(no built-in equivalent)* | Interactive picker (Bun 1.3+) |
| `bun why &lt;pkg&gt;` | `npm explain &lt;pkg&gt;` | Explain why a package is installed |
| `bun audit` | `npm audit` | Scan for security vulnerabilities |
| `bun publish` | `npm publish` | Publish a package to npm |
| `bun patch &lt;pkg&gt;` | `npx patch-package` | Patch a dependency |
| `bun pm pack` | `npm pack` | Create a tarball for publishing |

### Checking for outdated packages

```sh
bun outdated
# Shows all outdated packages with current and latest versions

bun outdated --filter &apos;@myorg/*&apos;
# Filter to specific scope
```

### Updating packages

```sh
bun update &lt;package&gt;            # Update within semver range
bun update &lt;package&gt; --latest   # Update to latest, ignoring semver
bun update                      # Update all deps within semver range
bun update --interactive        # Pick which deps to update interactively
```

For a deeper dive into updating packages with Bun, see [how to update Node packages with Bun](https://www.bitdoze.com/bun-update-packages/) and [how to update all Node.js dependencies](https://www.bitdoze.com/nodejs-update-dependencies/).

### Security and trust

&lt;Notice type=&quot;warning&quot; title=&quot;Lifecycle scripts are blocked by default&quot;&gt;
Since Bun 1.1, postinstall and other lifecycle scripts from packages are blocked by default for security. This is a good thing (it prevents supply chain attacks), but it means some packages (like native addons) won&apos;t build until you trust them.
&lt;/Notice&gt;

```sh
bun pm untrusted          # List packages with blocked lifecycle scripts
bun pm trust &lt;package&gt;    # Trust a specific package
bun pm trust --all        # Trust all packages (use with caution)
```

## Real-world benchmark: Bun install speed test

I ran this test on the bitdoze.com Astro blog. The project has a typical mix of dependencies. Here are the raw numbers:

**NPM:**

```sh
➜  bitdoze-astro-bkw git:(main) time npm install

npm install  13.22s user 3.82s system 23% cpu 1:13.89 total

npm run build  55.96s user 3.41s system 126% cpu 46.776 total
```

**Bun:**

```sh
➜  bitdoze-astro-bkw git:(main) time bun install

bun install  10.83s user 2.14s system 121% cpu 10.694 total

bun run build  55.30s user 3.34s system 126% cpu 46.181 total
```

&lt;Picture src={img1} alt=&quot;Bun install speed benchmark: 11 seconds vs npm&apos;s 73 seconds, 7x faster package installation&quot; /&gt;

**Results:** Bun install took ~11 seconds. npm install took 73 seconds. That&apos;s **7x faster** for this project. The build process was identical. The same Astro build runs at the same speed regardless of which package manager installed the dependencies.

This was tested in February 2024 and re-verified in July 2026. Bun 1.2+ is roughly 30% faster than the version originally tested, so the gap has likely widened for new installs. For a look at how Astro 7 improved build speeds, see [Astro 7 benchmark: build times cut in half](https://www.bitdoze.com/astro-7-faster-builds/). You can also [optimize Astro build speeds](https://www.bitdoze.com/astro-ssg-build-optimization/) further with SSG-specific tweaks.

Your results will vary based on project size, dependency count, hardware, and network conditions.

## Bun in Docker &amp; CI/CD: production best practices

### Dockerfile example

Bun provides official Docker images. Use `oven/bun:alpine` for the build stage (smaller, musl-based) and `oven/bun:distroless` for the runtime (minimal attack surface):

```dockerfile
FROM oven/bun:alpine AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

FROM oven/bun:distroless AS runtime
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
CMD [&quot;bun&quot;, &quot;run&quot;, &quot;start&quot;]
```

To skip devDependencies in production:

```sh
bun install --frozen-lockfile --production
```

### CI/CD with GitHub Actions

&lt;Notice type=&quot;info&quot; title=&quot;Always use --frozen-lockfile in CI&quot;&gt;
`bun install --frozen-lockfile` fails if `bun.lock` is out of date. This catches lockfile drift before it reaches production. Run `bun install` locally and commit the updated lockfile if CI fails on this step.
&lt;/Notice&gt;

```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - run: bun install --frozen-lockfile
      - run: bun run build
      - run: bun test
```

Common failure: if `--frozen-lockfile` fails, it means your `bun.lock` doesn&apos;t match `package.json`. Run `bun install` locally, commit the lockfile, and push.

For more Docker commands and patterns, see [top Docker commands you must know](https://www.bitdoze.com/docker-commands/).

## Bun usage examples

### Add and remove packages

```sh
# Add packages
bun add tailwindcss autoprefixer postcss

# Add a dev dependency
bun add prettier --dev

# Remove a package
bun remove tailwindcss
```

### Run scripts

```sh
bun run dev      # Run the &quot;dev&quot; script from package.json
bun run build    # Run the &quot;build&quot; script
bun run test     # Run the &quot;test&quot; script
```

For monorepos with workspaces, run scripts in specific packages:

```sh
bun run --filter @myorg/web build
```

### Check and update dependencies

```sh
# See what&apos;s outdated
bun outdated

# Update interactively (Bun 1.3+)
bun update --interactive

# Update a specific package to latest
bun update tailwindcss --latest
```

### Audit for vulnerabilities

```sh
bun audit
# Scans dependencies for known security vulnerabilities
```

## Troubleshooting common Bun issues

&lt;Accordion label=&quot;&amp;quot;Illegal Instruction&amp;quot; error on startup&quot; group=&quot;troubleshooting&quot;&gt;
Your CPU lacks AVX2 support (pre-Haswell Intel, pre-Excavator AMD). Bun&apos;s standard builds require AVX2.

**Fix:** Install the baseline build that works on older CPUs:
```sh
curl -fsSL https://bun.sh/install | bash -s -- --baseline
```
Or on Windows:
```powershell
powershell -c &quot;irm bun.sh/install.ps1 -- --baseline | iex&quot;
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;bun: command not found&quot; group=&quot;troubleshooting&quot;&gt;
`~/.bun/bin` is not in your PATH.

**Fix:** Add to your shell config:
```sh
# bash (~/.bashrc) or zsh (~/.zshrc)
export BUN_INSTALL=&quot;$HOME/.bun&quot;
export PATH=&quot;$BUN_INSTALL/bin:$PATH&quot;
```
Then reload: `source ~/.bashrc` (or `source ~/.zshrc`).

For Fish shell, add to `~/.config/fish/config.fish`:
```fish
set --export BUN_INSTALL &quot;$HOME/.bun&quot;
set --export PATH &quot;$BUN_INSTALL/bin&quot; $PATH
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Lifecycle scripts blocked (postinstall failing)&quot; group=&quot;troubleshooting&quot;&gt;
Bun blocks lifecycle scripts (postinstall, preinstall, etc.) by default since Bun 1.1. This is a security measure against supply chain attacks.

**Fix:**
```sh
bun pm untrusted          # See which packages are blocked
bun pm trust &lt;package&gt;    # Trust a specific package
bun pm trust --all        # Trust all (use with caution)
```
Alternatively, add to `package.json`:
```json
{
  &quot;trustedDependencies&quot;: [&quot;package-name&quot;]
}
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Kernel too old on Linux&quot; group=&quot;troubleshooting&quot;&gt;
Bun requires kernel 5.1 minimum, with 5.6+ recommended. It will attempt to run on kernels as old as 3.10 (RHEL 7) with reduced functionality.

**Check your kernel:**
```sh
uname -r
```
If you&apos;re on an older kernel, consider upgrading or using a newer distro. On shared hosting or managed VPS, you may not control the kernel, check with your provider.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Private registry not working&quot; group=&quot;troubleshooting&quot;&gt;
Since Bun 1.2, Bun reads `.npmrc` files (`$HOME/.npmrc` and project-level `.npmrc`). You can also configure registries in `bunfig.toml`:

```toml
[install.scopes]
&quot;@myorg&quot; = { url = &quot;https://registry.myorg.com&quot;, token = &quot;npm_...&quot; }
```

Or use `.npmrc`:
```
//registry.myorg.com/:_authToken=npm_...
@myorg:registry=https://registry.myorg.com
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How to roll back to npm&quot; group=&quot;troubleshooting&quot;&gt;
If Bun is causing issues and you need to revert:

```sh
rm -rf node_modules bun.lock
npm install
```

This restores your npm setup. If you kept `package-lock.json` as a backup, you&apos;ll get your exact previous dependency tree back. If not, npm will generate a new one from `package.json`.
&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is Bun ready for production?&quot; group=&quot;faq&quot;&gt;
For package management, yes. `bun install` is stable, fast, and used in production CI/CD pipelines. For the Bun runtime (replacing Node.js), maturity varies by use case. Most teams start by using Bun as a package manager only, running their app on Node.js.

The Anthropic acquisition (December 2025) is a strong signal. Bun powers Claude Code and the Claude Agent SDK, which means Anthropic has a direct business interest in Bun&apos;s reliability.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bun work with private registries?&quot; group=&quot;faq&quot;&gt;
Yes. Since Bun 1.2, Bun reads `.npmrc` files for auth tokens and scoped registry configuration. It also supports `bunfig.toml` for Bun-specific configuration. Both `$HOME/.npmrc` (global) and project-level `.npmrc` are supported.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Bun in monorepos?&quot; group=&quot;faq&quot;&gt;
Yes. Bun supports npm-compatible workspaces. Bun 1.3 added `--linker=isolated` for pnpm-style strict dependency resolution and catalogs for centralized version management across packages.

For simple monorepos (a few packages), Bun works well. For complex monorepos with deep dependency trees and strict hoisting requirements, pnpm&apos;s mature tooling may still be safer.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Bun handle disk usage?&quot; group=&quot;faq&quot;&gt;
Bun uses a global cache at `~/.bun/install/cache` but creates full `node_modules` copies in each project. This means pnpm (which uses hard links) saves more disk space when you have many projects on the same machine.

If disk space is tight and you run dozens of projects, pnpm is the better choice for disk efficiency. For a handful of projects, the difference is negligible.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I use bun.lockb or bun.lock?&quot; group=&quot;faq&quot;&gt;
Use `bun.lock`. It&apos;s been the default since Bun 1.2, it&apos;s human-readable, and it diffs properly in git. `bun.lockb` is only relevant if you&apos;re on Bun 1.1 or earlier, or if you explicitly set `saveTextLockfile = false` in `bunfig.toml`. There&apos;s no reason to choose the binary format on a modern Bun version.
&lt;/Accordion&gt;

## Conclusion

Bun as a package manager is production-ready and significantly faster than npm, yarn, or pnpm for install times. The text-based `bun.lock`, full Windows support, and Anthropic backing have addressed the main concerns from when this article was first published.

My recommendation: try Bun as a package manager on your next project. Remove the old lockfile, run `bun install`, and see how it feels. You can always [roll back](#troubleshooting-common-bun-issues) if something breaks.

The runtime side (replacing Node.js) is a separate decision with more nuance. But for `bun install` vs `npm install`? There&apos;s no contest.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/BsnCpESUEqM&quot;
  label=&quot;Bun Video Presentation&quot;
/&gt;</content:encoded><category>tools</category><category>bun</category><category>npm</category><category>yarn</category></item><item><title>How to Install Umami Analytics on Docker (2026 Guide)</title><link>https://www.bitdoze.com/umami-analytics-install/</link><guid isPermaLink="true">https://www.bitdoze.com/umami-analytics-install/</guid><description>Learn how to install Umami Analytics with Docker Compose on your VPS. Step-by-step guide to self-host this privacy-focused Google Analytics alternative.</description><pubDate>Tue, 21 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/02/umami-add-website.png&quot;;
import imag3 from &quot;../../assets/images/24/02/umami-dashboard.jpeg&quot;;

[Umami](https://umami.is/) is a simple, fast, privacy-focused web analytics tool and a solid Google Analytics alternative. It&apos;s MIT-licensed, has over 37,000 GitHub stars, and collects only the metrics you need without tracking your visitors. If you want to compare options, [Plausible](https://www.bitdoze.com/plausible-tool/) and [Matomo](https://matomo.org/) are similar self-hosted analytics tools. You can also [install Plausible Analytics](https://www.bitdoze.com/install-plausible-analytics/) with a similar Docker setup.

With Google Analytics 4 and GDPR enforcement in the EU, self-hosted web analytics has become the default for operators who want full data ownership. Google Analytics scripts are heavy and slow down your site. Umami adds less than 2KB of overhead.

This guide covers how to install Umami Analytics on Docker with Docker Compose, including tracker setup, ad-blocker bypass, upgrades, and backups. Umami v3 shipped in November 2025 with a new UI, heatmaps, session replay, and it&apos;s now PostgreSQL-only (MySQL support was dropped).

&lt;Notice type=&quot;info&quot; title=&quot;Umami v3 (November 2025)&quot;&gt;
Umami v3 is a major release with a redesigned UI, Segments, Cohorts, Session Replay, Heatmaps, and Web Vitals tracking. MySQL is no longer supported. PostgreSQL is the only database option. The compose files in this guide are updated for v3. See the &lt;a href=&quot;https://umami.is/blog/umami-v3&quot; rel=&quot;nofollow&quot;&gt;Umami v3 blog post&lt;/a&gt; for full details.
&lt;/Notice&gt;

&gt; You can find more free open source self-hosted apps at [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Steps to install Umami Analytics with Docker Compose

We&apos;ll deploy Umami on a VPS with Docker Compose, then configure Cloudflare Tunnels for public access with SSL. The video walkthrough below covers the full process.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/dWrgbxwIo8M&quot;
  label=&quot;Umami Analytics install on Docker&quot;
/&gt;

### 1. Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;VPS with Docker and Docker Compose v2 installed. A &lt;a href=&quot;https://go.bitdoze.com/hetzner&quot; rel=&quot;nofollow&quot;&gt;Hetzner&lt;/a&gt; CX22 at ~€4/mo is sufficient (Umami + Postgres use ~300MB RAM total). &lt;a href=&quot;https://go.bitdoze.com/hostinger-vps&quot; rel=&quot;nofollow&quot;&gt;Hostinger&lt;/a&gt; is another affordable option.&lt;/li&gt;
&lt;li&gt;Dockge or any Docker management tool, or just plain &lt;code&gt;docker compose&lt;/code&gt;. See the &lt;a href=&quot;https://www.bitdoze.com/dockge-install/&quot;&gt;Dockge install guide&lt;/a&gt; for a full walkthrough. You can also check other &lt;a href=&quot;https://www.bitdoze.com/best-self-hosted-panels/&quot;&gt;self-hosted server panels&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Cloudflare Tunnel configured for your VPS (or any reverse proxy with SSL termination)&lt;/li&gt;
&lt;li&gt;A domain or subdomain ready for Umami&lt;/li&gt;
&lt;li&gt;PostgreSQL v12.14+ (we use v15-alpine, which satisfies this)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t use &apos;analytics&apos; as your subdomain&quot;&gt;
Ad-blockers commonly block requests to &lt;code&gt;analytics.yourdomain.com&lt;/code&gt;. Use something like &lt;code&gt;stats&lt;/code&gt;, &lt;code&gt;metrics&lt;/code&gt;, or &lt;code&gt;track&lt;/code&gt; instead. This applies to the subdomain AND the script path.
&lt;/Notice&gt;

### 2. Docker Compose file for Umami

Below are two compose files. The first is a clean setup with no backup, use this if you plan to back up with a cron-based `pg_dump` (recommended). The second adds a `tiredofit/db-backup` sidecar for automatic scheduled dumps.

&lt;Tabs&gt;
&lt;Tab name=&quot;Simple (No Backup)&quot;&gt;

```yaml
services:
  umami:
    image: docker.umami.is/umami-software/umami:postgresql-latest
    ports:
      - &quot;3000:3000&quot;
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
    depends_on:
      umami-db:
        condition: service_healthy
    init: true
    restart: always
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;curl http://localhost:3000/api/heartbeat&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: UTC
    volumes:
      - ./umami-db-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
```

The Umami service runs on port 3000, connects to PostgreSQL over the internal Docker network, and includes a health check hitting `/api/heartbeat`. The Postgres data lives in a local volume at `./umami-db-data`. You can change the port mapping to whatever you want.

&lt;/Tab&gt;
&lt;Tab name=&quot;With Backup&quot;&gt;

```yaml
services:
  umami:
    image: docker.umami.is/umami-software/umami:postgresql-latest
    ports:
      - &quot;3000:3000&quot;
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
      APP_SECRET: ${APP_SECRET}
      DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
    depends_on:
      umami-db:
        condition: service_healthy
    init: true
    restart: always
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;curl http://localhost:3000/api/heartbeat&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      TZ: UTC
    volumes:
      - ./umami-db-data:/var/lib/postgresql/data
    restart: always
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  umami-db-backup:
    container_name: umami-db-backup
    image: tiredofit/db-backup
    volumes:
      - ./backups:/backup
    environment:
      DB_TYPE: postgres
      DB_HOST: umami-db
      DB_NAME: ${POSTGRES_DB}
      DB_USER: ${POSTGRES_USER}
      DB_PASS: ${POSTGRES_PASSWORD}
      DB_BACKUP_INTERVAL: 720
      DB_CLEANUP_TIME: 72000
      CHECKSUM: SHA1
      COMPRESSION: GZ
      CONTAINER_ENABLE_MONITORING: false
    depends_on:
      umami-db:
        condition: service_healthy
    restart: always
```

The backup sidecar dumps the database every 12 hours and cleans up backups older than 50 days. Dumps land in `./backups`. The `tiredofit/db-backup` image is heavier than a simple cron. If you want something lighter, see the [backup and restore section](#database-backup-and-restore) for a one-liner `pg_dump` alternative.

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Pin your image version&quot;&gt;
&lt;code&gt;postgresql-latest&lt;/code&gt; now points to Umami v3 (currently v3.2.0 as of June 2026). For reproducible deploys, pin to a specific tag like &lt;code&gt;3.2.0&lt;/code&gt;. Note that v3 tags dropped the &lt;code&gt;v&lt;/code&gt; prefix. It&apos;s &lt;code&gt;3.2.0&lt;/code&gt;, not &lt;code&gt;v3.2.0&lt;/code&gt;. See &lt;a href=&quot;https://hub.docker.com/r/umamisoftware/umami/tags&quot; rel=&quot;nofollow&quot;&gt;Docker Hub tags&lt;/a&gt; for all available versions. The image is also available on GHCR (&lt;code&gt;ghcr.io/umami-software/umami:latest&lt;/code&gt;) and Docker Hub (&lt;code&gt;umamisoftware/umami:postgresql-latest&lt;/code&gt;).
&lt;/Notice&gt;

If you need to [run multiple PostgreSQL databases](https://www.bitdoze.com/multiple-postgres-databases-docker/) on the same host, you can consolidate them into a single Postgres container to save memory.

### 3. Configure the .env file

Create a `.env` file in the same directory as your compose file (or add the variables in Dockge&apos;s environment section).

```sh
POSTGRES_USER=umami
POSTGRES_PASSWORD=your-secure-password
POSTGRES_DB=umami
APP_SECRET=run-openssl-rand-hex-32-to-generate
DISABLE_TELEMETRY=1
```

&lt;Notice type=&quot;warning&quot; title=&quot;Generate a unique APP_SECRET&quot;&gt;
Never use the example value. Generate a secure secret with:&lt;br /&gt;&lt;code&gt;openssl rand -hex 32&lt;/code&gt;&lt;br /&gt;Paste the output as your &lt;code&gt;APP_SECRET&lt;/code&gt;. This is used for session encryption.
&lt;/Notice&gt;

### 4. Deploy Umami

If using Dockge, add a name for your stack and hit deploy. With plain Docker Compose:

```sh
docker compose up -d
```

Verify both containers are healthy:

```sh
docker compose ps
```

You should see both `umami` and `umami-db` with status `healthy`. Check the Umami logs to confirm migrations ran:

```sh
docker compose logs -f umami
```

Healthy output looks like: `All migrations have been successfully applied` followed by the server listening on port 3000.

Access Umami at `http://your-vps-ip:3000`. Log in with the default credentials:

- **Username:** `admin`
- **Password:** `umami`

&lt;Notice type=&quot;error&quot; title=&quot;Change the default password now&quot;&gt;
The default credentials &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;umami&lt;/code&gt; are public knowledge. Go to &lt;strong&gt;Settings → Profile → Change password&lt;/strong&gt; immediately, before exposing Umami to the internet.
&lt;/Notice&gt;

Quick verification with curl:

```sh
curl http://localhost:3000/api/heartbeat
# Should return: OK
```

### 5. Configure Cloudflare Tunnels for Umami

Go to **Access → Tunnels** in the Cloudflare dashboard, choose your tunnel, and add a hostname mapping a domain or subdomain to the Umami service on port 3000.

&lt;Picture
  src={imag1}
  alt=&quot;Cloudflare Tunnel setup for Umami Analytics&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Getting &apos;Unknown&apos; visitor IPs?&quot;&gt;
If Umami shows &quot;Unknown&quot; for visitor locations behind Cloudflare Tunnels, add &lt;code&gt;CLIENT_IP_HEADER=cf-connecting-ip&lt;/code&gt; to the &lt;code&gt;umami&lt;/code&gt; service environment block in your compose file. This tells Umami to read the real client IP from Cloudflare&apos;s header. Restart the container after the change.
&lt;/Notice&gt;

&gt; You can also check [Setup CloudPanel as Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy to your Docker containers, or [self-host with Docker and Cloudflare Tunnels](https://www.bitdoze.com/cloudreve-docker-setup/) for another deployment example.

### 6. Add your first website to Umami

After the tunnel is configured, access Umami at your domain. Log in and go to **Settings** in the header. You&apos;ll see a button to add a website:

&lt;Picture
  src={imag2}
  alt=&quot;Adding a website in Umami Analytics&quot;
/&gt;

After adding a website, click **Edit Website** (or go to **Settings**) to find your tracking code. You can paste this into your site&apos;s `&lt;head&gt;`.

&lt;Picture
  src={imag3}
  alt=&quot;Umami Analytics dashboard overview&quot;
/&gt;

## Umami tracking code and tracker configuration

Once your website is added, Umami gives you a tracking script. Here&apos;s how to use it and what options are available.

### Basic tracking script

The standard embed looks like this:

```html
&lt;script
  defer
  src=&quot;https://your-umami-domain.com/script.js&quot;
  data-website-id=&quot;your-website-id&quot;
&gt;&lt;/script&gt;
```

- `data-website-id` (the UUID of the website you added in Umami, visible in the tracking code snippet)
- `data-host-url` (set this if your tracker domain differs from the dashboard domain, e.g., you serve the script from a CDN)

### Custom events and advanced options

Umami supports both programmatic and declarative event tracking.

**Programmatic** (call from JavaScript):

```js
// Track a custom event with properties
umami.track(&quot;signup&quot;, { plan: &quot;pro&quot;, source: &quot;header&quot; });
```

**Declarative** (add HTML attributes to any element):

```html
&lt;button data-umami-event=&quot;click-download&quot; data-umami-file=&quot;whitepaper.pdf&quot;&gt;
  Download
&lt;/button&gt;
```

Other useful data attributes:

| Attribute | Purpose |
|-----------|---------|
| `data-domains=&quot;example.com,api.example.com&quot;` | Restrict tracking to specific domains |
| `data-do-not-track=&quot;true&quot;` | Respect the browser&apos;s DNT header |
| `data-auto-track=&quot;false&quot;` | Disable automatic pageview tracking (useful for SPAs that handle their own routing) |
| `data-performance=&quot;true&quot;` | Track Core Web Vitals (new in v3.1.0) |
| `data-auto-pageview=&quot;false&quot;` | Suppress automatic pageview on script load (new in v3.2.0) |

&gt; If you&apos;re using an Astro site, see [Plausible Analytics for Astro with Cloudflare Workers](https://www.bitdoze.com/astro-plausible-cloudflare-workers/) for a related analytics integration approach.

### Bypass ad blockers

Ad-blockers target two default paths: `/script.js` and `/api/send`. You can rename both with environment variables:

```env
TRACKER_SCRIPT_NAME=custom-tracker
COLLECT_API_ENDPOINT=/api/collect
```

Add these to the `umami` service environment in your compose file, then restart. Update your tracking script `src` to match:

```html
&lt;script
  defer
  src=&quot;https://your-domain.com/custom-tracker.js&quot;
  data-website-id=&quot;your-website-id&quot;
&gt;&lt;/script&gt;
```

&lt;Notice type=&quot;info&quot; title=&quot;Ad-blocker bypass requires compose changes&quot;&gt;
After adding &lt;code&gt;TRACKER_SCRIPT_NAME&lt;/code&gt; and &lt;code&gt;COLLECT_API_ENDPOINT&lt;/code&gt; to your compose file, restart the Umami container: &lt;code&gt;docker compose up -d --force-recreate umami&lt;/code&gt;. Make sure any reverse proxy rules pass through the new endpoint path.
&lt;/Notice&gt;

&lt;Accordion label=&quot;Full ad-blocker bypass example&quot; group=&quot;tracker&quot;&gt;

Here&apos;s a complete snippet to add to the `umami` service environment in your compose file:

```yaml
environment:
  DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@umami-db:5432/${POSTGRES_DB}
  APP_SECRET: ${APP_SECRET}
  DISABLE_TELEMETRY: ${DISABLE_TELEMETRY:-1}
  TRACKER_SCRIPT_NAME: my-stats
  COLLECT_API_ENDPOINT: /api/c
  CLIENT_IP_HEADER: cf-connecting-ip
```

Then in your site&apos;s HTML:

```html
&lt;script
  defer
  src=&quot;https://stats.yourdomain.com/my-stats.js&quot;
  data-website-id=&quot;your-website-id&quot;
&gt;&lt;/script&gt;
```

If you use a reverse proxy (Nginx, Caddy, Traefik), ensure the custom paths are forwarded to the Umami container on port 3000. No extra routing config is needed if the proxy sends all traffic to Umami.

&lt;/Accordion&gt;

## How to update Umami Analytics on Docker

### Standard updates (same major version)

For minor updates within v3.x:

```bash
# 1. Back up the database first
docker compose exec -T umami-db pg_dump -U umami umami | gzip &gt; backups/umami-$(date +%F-%H%M).sql.gz

# 2. Pull the latest image
docker compose pull

# 3. Recreate containers
docker compose up -d --force-recreate

# 4. Verify: look for &quot;All migrations have been successfully applied&quot;
docker compose logs -f umami
```

### Upgrading from Umami v2 to v3

&lt;Notice type=&quot;warning&quot; title=&quot;Breaking changes in Umami v3&quot;&gt;
v3 drops MySQL support, has a completely new UI, and runs schema migrations that can take time on large databases. &lt;strong&gt;Always back up before upgrading.&lt;/strong&gt; If you were on MySQL, follow the &lt;a href=&quot;https://docs.umami.is/docs/guides/migrate-mysql-postgresql&quot; rel=&quot;nofollow&quot;&gt;MySQL to PostgreSQL migration guide&lt;/a&gt; first.
&lt;/Notice&gt;

Steps:

```bash
# 1. Back up
docker compose exec -T umami-db pg_dump -U umami umami &gt; umami-backup-$(date +%F-%H%M).sql

# 2. Pull and recreate
docker compose pull
docker compose up -d --force-recreate umami

# 3. Watch logs for migration output
docker compose logs -f umami

# 4. Run ANALYZE on PostgreSQL (critical after major upgrades)
docker compose exec -T umami-db psql -U umami umami -c &quot;ANALYZE;&quot;
```

The `ANALYZE` command updates PostgreSQL&apos;s query planner statistics after schema migrations. The [official Umami docs](https://docs.umami.is/docs/updates) recommend this. Without it, dashboard queries can become slow after a major upgrade.

After upgrading, old Docker images accumulate on disk. You can [clean up Docker disk space](https://www.bitdoze.com/clean-docker-overlay2-dir/) to reclaim storage.

### Database backup and restore

&lt;Notice type=&quot;error&quot; title=&quot;Always back up before upgrading&quot;&gt;
A failed migration can corrupt data. Always run &lt;code&gt;pg_dump&lt;/code&gt; before pulling a new major version.
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Manual pg_dump&quot;&gt;

**Backup** (one-liner, can be cron&apos;d):

```bash
docker compose exec -T umami-db pg_dump -U umami umami | gzip &gt; backups/umami-$(date +%F).sql.gz
```

Add to crontab for daily backups at 3 AM:

```bash
0 3 * * * cd /path/to/umami &amp;&amp; docker compose exec -T umami-db pg_dump -U umami umami | gzip &gt; backups/umami-$(date +\%F).sql.gz
```

**Restore:**

```bash
gunzip &lt; backups/umami-YYYY-MM-DD.sql.gz | docker compose exec -T umami-db psql -U umami umami
```

For offsite backups, push the compressed dump to any S3-compatible storage (MinIO, Cloudflare R2, Backblaze B2).

&lt;/Tab&gt;
&lt;Tab name=&quot;tiredofit/db-backup&quot;&gt;

If you used the &quot;With Backup&quot; compose file, dumps are in the `./backups` directory, created every 12 hours. To restore from a tiredofit backup:

```bash
# Find the latest backup
ls -lt backups/

# Restore it
zcat backups/umami-db-*.sql.gz | docker compose exec -T umami-db psql -U umami umami
```

The `tiredofit/db-backup` sidecar is heavier than a cron-based approach (it runs its own scheduler, compression, and cleanup). For a minimal footprint, the manual `pg_dump` cron is lighter.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Verify and troubleshoot your Umami installation

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;docker compose ps&lt;/code&gt;: both &lt;code&gt;umami&lt;/code&gt; and &lt;code&gt;umami-db&lt;/code&gt; show status &lt;code&gt;healthy&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;docker compose logs umami&lt;/code&gt;: shows &quot;All migrations have been successfully applied&quot;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;curl http://localhost:3000/api/heartbeat&lt;/code&gt;: returns OK&lt;/li&gt;
&lt;li&gt;Login works with &lt;code&gt;admin&lt;/code&gt; / &lt;code&gt;umami&lt;/code&gt; (change password immediately)&lt;/li&gt;
&lt;li&gt;Embed the tracking script on a test page, open browser DevTools → Network tab, confirm &lt;code&gt;script.js&lt;/code&gt; loads (200 OK)&lt;/li&gt;
&lt;li&gt;Visit the test page, check Umami dashboard for a real-time visit&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you want to [monitor your server resources](https://www.bitdoze.com/sever-monitoring/) alongside Umami, set up a lightweight monitoring stack.

&lt;Accordion label=&quot;Container keeps restarting&quot; group=&quot;troubleshoot&quot;&gt;

Check `docker compose logs umami`. The most common cause is a wrong `DATABASE_URL` or the database container isn&apos;t healthy yet. Verify your `.env` variables match what the compose file references. The `depends_on` condition should wait for the DB health check, but if the DB is slow to start on a low-memory VPS, give it 30 seconds.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;&apos;service umami-db not found&apos; error&quot; group=&quot;troubleshoot&quot;&gt;

A typo in `depends_on`. Make sure it says `umami-db` (with &quot;m&quot;), not `unami-db`. This was a bug in earlier versions of this guide&apos;s compose file.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Blank or slow dashboard after upgrade&quot; group=&quot;troubleshoot&quot;&gt;

After a major version upgrade (especially v2 → v3), PostgreSQL&apos;s query planner may have stale statistics. Run:

```bash
docker compose exec -T umami-db psql -U umami umami -c &quot;ANALYZE;&quot;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Visitor IPs show as &apos;Unknown&apos;&quot; group=&quot;troubleshoot&quot;&gt;

When Umami runs behind Cloudflare Tunnels or a reverse proxy, the client IP doesn&apos;t reach it directly. Add to the `umami` service environment:

```env
CLIENT_IP_HEADER=cf-connecting-ip
```

Use `x-forwarded-for` if you&apos;re behind Nginx or Traefik instead of Cloudflare. Restart the container after the change.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Tracking script blocked by ad-blockers&quot; group=&quot;troubleshoot&quot;&gt;

Default paths `/script.js` and `/api/send` are on blocklists. Use `TRACKER_SCRIPT_NAME` and `COLLECT_API_ENDPOINT` environment variables to rename them. See the [bypass ad blockers section](#bypass-ad-blockers) above.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Out of memory / OOM kills&quot; group=&quot;troubleshoot&quot;&gt;

Umami uses ~200MB RAM, Postgres ~50-100MB. A 1GB VPS is the practical minimum. Check resource usage with `docker stats`. If containers are getting OOM-killed, consider adding memory limits in compose:

```yaml
deploy:
  resources:
    limits:
      memory: 256M
```

&lt;/Accordion&gt;

## Umami v3: what&apos;s new

| Version | Date | Highlights |
|---------|------|-----------|
| v3.0.0 | Nov 2025 | New UI, Segments, Cohorts, Links, Pixels, Admin page. PostgreSQL-only (MySQL dropped). |
| v3.1.0 | Apr 2026 | **Boards**, **Session Replay**, **Web Vitals**, redesigned share page, OR filters, regex, funnels. Requires Node 22. |
| v3.2.0 | Jun 2026 | **Heatmaps**, improved Session Replay, event/session property reporting, `data-auto-pageview` attribute. |

&lt;Notice type=&quot;info&quot; title=&quot;Self-hosting vs Umami Cloud&quot;&gt;
Self-hosted Umami = unlimited events, full data ownership, free (MIT license). Umami Cloud starts at free (100K events/mo, 1 website) but the Pro plan is $20/mo for 1M events and 20 websites. For a solo operator running a few sites, self-hosting on a ~€4/mo VPS is the clear winner.
&lt;/Notice&gt;

PostgreSQL v12.14+ is required for v3. The `postgres:15-alpine` image used in this guide satisfies this.

## Conclusion

You now have Umami Analytics installed on Docker, with a working tracker configuration, ad-blocker bypass options, and a solid upgrade and backup workflow. Umami is free, uses about 300MB of RAM total, and gives you full ownership of your analytics data. No third-party access, no GDPR headaches.

Key things to remember: back up before every major upgrade, pin your image version for reproducibility, and change the default password immediately after first login. If you want to explore other options, you can also [install Plausible Analytics](https://www.bitdoze.com/install-plausible-analytics/) with a similar Docker approach, or [monitor your server resources](https://www.bitdoze.com/sever-monitoring/) to keep an eye on your VPS.

&lt;Button text=&quot;Explore More Self-Hosted Tools&quot; link=&quot;https://toolhunt.net/sh/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>analytics</category></item><item><title>Openship Review: Self-Hosted PaaS vs Coolify and Dokploy</title><link>https://www.bitdoze.com/openship-self-hosted-paas/</link><guid isPermaLink="true">https://www.bitdoze.com/openship-self-hosted-paas/</guid><description>Hands-on look at Openship, the new open-source deploy platform. Install with CLI or Docker Compose, compare it to Coolify and Dokploy, and see when each tool fits.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import openshipUi from &quot;../../assets/images/26/07/openshipui.webp&quot;;

[Openship](https://openship.io/) showed up in the self-hosted PaaS space with a clear pitch: push code, get containers, keep full ownership. The project is Apache 2.0, lives at [github.com/oblien/openship](https://github.com/oblien/openship), and already sits around 3.6k GitHub stars with a managed cloud option if you do not want to run the control plane yourself.

I already run and write about [Coolify](/coolify-install-heroku-alternative/) and [Dokploy](/dokploy-install/). Both solve the &quot;I want Heroku on my VPS&quot; problem. Openship is the new one in that same drawer, with a few ideas that are different enough to notice: builds can stay off the production box, there is a real CLI plus desktop app, hybrid cloud/self-host is part of the product story, and it ships a built-in mail server.

This guide covers what Openship is, how to install it (CLI, Docker Compose, desktop, cloud), how it stacks up against Coolify and Dokploy, and when I would pick each one.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;What Openship actually does and how the deploy flow works&lt;/li&gt;
&lt;li&gt;Install options: CLI service, Docker Compose, desktop app, managed cloud&lt;/li&gt;
&lt;li&gt;Feature comparison with Coolify and Dokploy&lt;/li&gt;
&lt;li&gt;Who should try Openship now vs stick with a mature panel&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

You will need a Linux VPS (or a laptop for local/desktop use). [Hetzner](https://go.bitdoze.com/hetzner) and similar providers work fine. For more panel options, see [best self-hosted server panels](/best-self-hosted-panels/) and [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/).

## What is Openship?

Openship is an open-source deployment platform with built-in CI/CD. You connect a Git repo (or a local project), it detects the stack, builds an image, ships a container, and wires domains plus SSL.

You can run it three ways:

1. **Openship Cloud** — managed control plane and compute, pay for usage
2. **Self-hosted** — full stack on your Linux box or VPS
3. **Hybrid** — mix managed services and your own servers under one workflow

Interfaces are broader than most self-hosted panels:

- CLI (`openship`)
- Web dashboard
- Desktop app (Mac, Windows, Linux AppImage)
- REST API and MCP for AI agents

Official docs still call parts of the product early, but the core path (install, link project, deploy) is documented at [openship.io/docs](https://openship.io/docs). Current release at the time of writing is in the `0.1.x` range.

&lt;Picture src={openshipUi} alt=&quot;Openship web dashboard UI&quot; /&gt;

## How Openship deploys apps

The marketing site describes a flow that is worth understanding before you install anything:

1. **Connect** — link a Git repo and pick a target (Openship Cloud or your server over SSH). Their pitch is that nothing heavy has to live on the target box as a permanent agent/dashboard for every node.
2. **Build** — image build can run on your machine or in the cloud, then get tagged as an immutable artifact. Production stays focused on serving traffic.
3. **Ship** — the image goes to the target over SSH and starts as a container on a private network.
4. **Route** — OpenResty + Let&apos;s Encrypt for domains and SSL, with zero-downtime swaps and one-click rollbacks.
5. **Operate** — logs, metrics, rollbacks from CLI, web UI, desktop, or MCP.

That &quot;build elsewhere, run plain containers on the server&quot; angle is the main contrast with Coolify and Dokploy, where the control plane and often the build job sit on the same VPS that runs your apps.

## Key features

| Area | What you get |
| :--- | :--- |
| CI/CD | Push-to-deploy, preview envs, rollbacks, immutable deploys |
| Stacks | Node, Python, Go, Rust, PHP, Ruby, Java, .NET, Docker, monorepos |
| Data | Postgres, MySQL, MongoDB, Redis, object storage, workers |
| Networking | Custom domains, wildcards, auto SSL, private networking |
| Ops | Live logs, metrics, scheduled jobs, backups |
| Mail | Built-in transactional mail with SPF/DKIM/DMARC setup |
| Interfaces | CLI, web, desktop, REST, MCP |
| License | Apache 2.0 |

The mail piece is unusual. Coolify and Dokploy do not try to replace SES/Mailgun for you. Openship does, which is nice for password resets and receipts if you want fewer third-party accounts. I would still treat deliverability as something you verify yourself (reputation, reverse DNS, warm-up), not a free pass.

## System requirements

From the [installation docs](https://openship.io/docs/installation):

| Component | Minimum | Recommended |
| :--- | :--- | :--- |
| CPU | 2 cores | 4+ cores |
| RAM | 2 GB | 4+ GB |
| Disk | 20 GB | 50+ GB SSD |
| OS | Ubuntu 22.04+ | Ubuntu 24.04 |

For a real multi-app box I would start at 4 GB RAM the same way I do with Dokploy. Docker Compose mode also runs Postgres + Redis + API + dashboard, so do not starve it.

## Install Openship

Pick one path. CLI is the fastest for a single server. Docker Compose is better if you want the stack explicit and portable. Desktop is for local work. Cloud skips ops entirely.

### Option 1: CLI (quickest self-host)

Works on Linux and macOS. Installs the CLI, then starts Openship as a background service (systemd user unit with linger on Linux, launchd on macOS).

```bash
# Install CLI
curl -fsSL https://get.openship.io | sh

# Or via a JS package manager
npm i -g openship
# pnpm add -g openship
# bun add -g openship
```

Start the platform:

```bash
openship up
```

That brings up the API on `:4000` and the dashboard on `:3001`. First run downloads what it needs. Local access needs no login.

Useful commands:

```bash
openship open              # open dashboard
openship status            # health check
openship stop              # stop service (no auto-restart on reboot)
openship up --foreground   # run attached in this terminal
openship up --no-ui        # API only
```

### Option 2: Docker Compose

Use this when you want Postgres, Redis, API, dashboard, and the web app as normal containers you can inspect and back up like anything else.

```bash
git clone https://github.com/oblien/openship.git
cd openship
cp .env.example .env
```

Edit `.env` before you start. At minimum change secrets:

```bash
# Generate secrets
node -e &quot;console.log(require(&apos;crypto&apos;).randomBytes(32).toString(&apos;hex&apos;))&quot;
```

Set at least:

```env
CLOUD_MODE=false
DEPLOY_MODE=docker
POSTGRES_USER=openship
POSTGRES_PASSWORD=replace-with-strong-password
POSTGRES_DB=openship
BETTER_AUTH_SECRET=replace-with-32-byte-hex
INTERNAL_TOKEN=replace-with-another-32-byte-hex
BETTER_AUTH_URL=http://YOUR_SERVER_IP:4000
OPENSHIP_REQUIRE_REDIS=true
```

Then build and start:

```bash
docker compose up -d --build
```

Services from the upstream compose file:

| Service | Port | Role |
| :--- | :--- | :--- |
| `postgres` | internal 5432 | Platform DB |
| `redis` | internal 6379 | Queue, cache, rate limits |
| `api` | 4000 | Control plane |
| `dashboard` | 3001 | App UI |
| `web` | 3000 | Marketing/docs site |

Check health:

```bash
curl -s http://127.0.0.1:4000/api/health
docker compose ps
docker compose logs -f api
```

Open `http://YOUR_SERVER_IP:3001` for the dashboard. Put Caddy/Nginx/Traefik in front with TLS before you expose this on the public internet.

&lt;Notice type=&quot;warning&quot; title=&quot;Do not ship default secrets&quot;&gt;
The sample `.env` values are for local bring-up. Change `POSTGRES_PASSWORD`, `BETTER_AUTH_SECRET`, and `INTERNAL_TOKEN` on any VPS. The API refuses to boot without a real `INTERNAL_TOKEN` on non-desktop deploys.
&lt;/Notice&gt;

### Option 3: Desktop app

Handy for local deploys from a folder without standing up a server panel.

**Linux AppImage:**

```bash
curl -fsSL -o Openship.AppImage \
  https://github.com/oblien/openship/releases/latest/download/Openship.AppImage
chmod +x Openship.AppImage
./Openship.AppImage
```

If FUSE is missing on Debian/Ubuntu:

```bash
sudo apt-get install -y libfuse2
# or:
./Openship.AppImage --appimage-extract-and-run
```

**macOS / Windows:** grab the latest DMG or ZIP from [GitHub releases](https://github.com/oblien/openship/releases/latest), or run `openship install` after the CLI is present.

### Option 4: Openship Cloud

If you want zero control-plane maintenance:

1. Sign up at [app.openship.io](https://app.openship.io/)
2. Connect a repo
3. Deploy

From a machine with the CLI, you can point at a remote API instead of running local:

```bash
openship login --api-url https://api.example.com --token YOUR_PAT
```

## Deploy your first app

After the platform is up:

```bash
cd your-project
openship init      # link directory to a project
openship deploy
```

`openship init` writes `.openship/project.json` so later commands know which project to use. Preview deploys:

```bash
openship deploy --env preview
```

From there you add custom domains, env vars, and Git auto-deploys in the dashboard or via the [docs for first deployment](https://openship.io/docs/first-deployment).

Supported stacks on paper include Next.js, Node, Python, Go, Rust, Docker, Postgres, Redis, Rails, Laravel, Django, Bun, and more. For existing compose apps, Openship claims you can deploy compose files as-is, same general idea as [Dokploy Compose deploys](/dokploy-docker-compose-app/).

## Openship vs Coolify vs Dokploy

I care less about feature checklists than about day-to-day friction: RAM tax, how builds behave under load, how painful SSL and rollbacks are, and whether the project will still be around next year.

### Quick comparison

| | Openship | Coolify | Dokploy |
| :--- | :--- | :--- | :--- |
| **Maturity** | New (`0.1.x`, growing fast) | Mature (v4 stable, v5 multi-server in progress) | Mature, very active |
| **GitHub stars (approx.)** | ~3.6k | ~50k+ | ~30k+ |
| **License** | Apache 2.0 | Apache 2.0 | Mixed (core open, some source-available) |
| **UI** | Web + desktop + CLI | Web dashboard | Web dashboard |
| **Build location** | Can build on your machine / off prod | Usually on the Coolify server | Usually on the Dokploy server |
| **Reverse proxy** | OpenResty | Caddy | Traefik |
| **Idle footprint** | Compose stack needs Postgres+Redis+API+UI | ~1.5–2 GB | ~400–500 MB |
| **One-click templates** | Smaller / early | 280+ | Dozens |
| **Databases** | Postgres, MySQL, Mongo, Redis + tooling | Strong UI + backups | Good enough + S3/R2 backups |
| **Mail server** | Built-in | No (bring your own) | No (bring your own) |
| **MCP / AI agents** | Yes | Yes (v4.1+) | Limited / evolving |
| **Cloud option** | First-party Openship Cloud + hybrid | Coolify Cloud exists | Mostly self-host focused |
| **Best fit today** | Experimenters who want CLI/desktop/hybrid | All-in-one hub, lots of services | Lightweight VPS PaaS |

### Where Openship looks strong

**Multiple clients for the same backend.** Coolify and Dokploy are dashboard-first. Openship treats CLI and desktop as first-class, which is closer to how a lot of devops people actually work. MCP on top is useful if you already drive infra from agents.

**Build off the production box.** On a 2–4 GB VPS, a Nixpacks/Railpack build can OOM the same machine that serves traffic. Openship&apos;s &quot;build locally or in the cloud, stream the image&quot; model is the right shape for small boxes. Kamal does something similar with a registry; Openship packages more of the platform around it.

**Hybrid and exit story.** Cloud ⇄ self-host without rewriting the app is a real selling point if you are tired of Vercel-style lock-in. Everything ships as normal containers.

**Built-in mail.** Niche, but if you hate bolting on yet another transactional email vendor for a side project, it is a differentiator.

### Where Openship is weaker today

**Age and ecosystem.** Coolify has years of edge cases filed and fixed. Dokploy has a pile of community compose templates and guides (including several on this site). Openship docs still say they are filling things out. Expect rough edges.

**Template library.** If you want one-click Plausible, Outline, n8n, and friends, Coolify still wins. Dokploy is lighter but you can paste compose files easily. Openship is more &quot;bring your app&quot; than &quot;app store for self-hosting.&quot;

**Operational track record.** Production-ready core is the claim. For a business app with uptime SLAs, I would still put Coolify or Dokploy first until Openship has more public battle scars and longer release history.

**Resource story is not &quot;zero overhead.&quot;** CLI mode with an embedded DB is lighter than full Compose, but Compose mode is a real multi-service stack. It is not Kamal&apos;s &quot;almost nothing on the server&quot; model. See the Kamal section in [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/) if that is what you want.

### Feature-oriented breakdown

&lt;Tabs&gt;
&lt;Tab name=&quot;Day-to-day UX&quot;&gt;

- **Coolify** — richest UI, biggest service catalog, heaviest RAM. Best when the panel *is* your ops console for many apps. Full [Coolify v5 review](/coolify-v5-self-hosted-paas-review/).
- **Dokploy** — clean UI, low idle RAM, Swarm when you outgrow one node. My default on cheap VPS boxes. [Install guide](/dokploy-install/).
- **Openship** — CLI + desktop + web. Feels closer to a productized deploy toolkit than a pure hosting panel. Better if you live in the terminal or want local GUI without SSHing into a panel.

&lt;/Tab&gt;
&lt;Tab name=&quot;Deploy model&quot;&gt;

- **Coolify / Dokploy** — git webhook → build on server → restart container behind Caddy/Traefik.
- **Openship** — connect repo or local dir → build (often off-box) → ship image over SSH → route via OpenResty → rollback from immutable versions.
- **Implication** — Openship can spare small VPS boxes from build spikes; Coolify/Dokploy keep the whole loop on one machine you already manage.

&lt;/Tab&gt;
&lt;Tab name=&quot;Ops extras&quot;&gt;

| Need | Prefer |
| :--- | :--- |
| 280+ one-click apps | Coolify |
| Small VPS, simple SaaS deploys | Dokploy |
| CLI + desktop + MCP | Openship |
| Scheduled DB backups to R2 | Dokploy ([guide](/dokploy-backups-cloudflare-r2/)) or Coolify |
| Built-in transactional mail | Openship |
| Multi-server today | Dokploy Swarm; Coolify multi-server maturing in v5 |
| Managed cloud fallback | Openship Cloud or Coolify Cloud |

&lt;/Tab&gt;
&lt;/Tabs&gt;

## When to pick which tool

&lt;Accordion label=&quot;Pick Openship when...&quot; group=&quot;pick&quot; expanded=&quot;true&quot;&gt;
You want to try a modern Apache-licensed PaaS with CLI and desktop, you like the idea of building off the prod server, or you want a path between managed cloud and your own VPS without two different products. Good for side projects, labs, and teams already comfortable living with a young `0.1.x` tool.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Coolify when...&quot; group=&quot;pick&quot;&gt;
You want one dashboard for many services, huge template coverage, and a large community. You have at least 4 GB RAM (honestly 8 GB if you stack apps). See [Coolify install](/coolify-install-heroku-alternative/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Dokploy when...&quot; group=&quot;pick&quot;&gt;
You want a lightweight Heroku-like panel on a 2 GB VPS, native Compose support, and optional Swarm. That is still my default recommendation for most readers. Start with [Dokploy install](/dokploy-install/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Kamal when...&quot; group=&quot;pick&quot;&gt;
You want almost zero control-plane overhead and are fine with YAML + CLI only. Covered in the [three-way comparison](/coolify-vs-dokploy-vs-kamal-2/).
&lt;/Accordion&gt;

## Practical tips if you self-host Openship

1. **Put TLS in front early.** Dashboard on `:3001` and API on `:4000` should not sit naked on `0.0.0.0` for long.
2. **Back up Postgres volumes** if you use Compose. Treat `postgres_data` like any other state volume.
3. **Pin a release** when you clone for Compose (`git checkout v0.1.x`) so a random `git pull` does not surprise you mid-week.
4. **Keep firewall rules tight.** Only 80/443 public if you reverse-proxy; lock SSH to keys.
5. **Test rollback** on a throwaway app before you trust it with something you care about.
6. **Read the security notes** in the repo (`SECURITY.md`) and rotate `INTERNAL_TOKEN` / auth secrets if they ever leak into logs or chat.

## FAQ

&lt;Accordion label=&quot;Is Openship production-ready?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
The maintainers describe a production-ready core that is still moving quickly. For non-critical apps and internal tools, sure. For revenue-critical production with a small ops team, I would pilot it first and keep Coolify or Dokploy as the safe default until the release line and docs settle further.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Openship replace Coolify or Dokploy?&quot; group=&quot;faq&quot;&gt;
Not yet for most people. It overlaps, but the product shape is different (CLI/desktop/hybrid/mail). Think of it as another option in the self-hosted PaaS shelf, not an automatic upgrade path.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I move off Openship later?&quot; group=&quot;faq&quot;&gt;
That is part of the design pitch: plain containers and standard images. You should still export env vars, volumes, and DNS yourself. There is no magic &quot;export my whole company&quot; button that removes ops work.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;CLI or Docker Compose for a VPS?&quot; group=&quot;faq&quot;&gt;
CLI if you want the vendor-supported service install and embedded DB path. Compose if you want visible containers, standard Postgres/Redis, and the same mental model as the rest of your Docker hosts.
&lt;/Accordion&gt;

## Useful links

- Site: [openship.io](https://openship.io/)
- Docs: [openship.io/docs](https://openship.io/docs)
- GitHub: [github.com/oblien/openship](https://github.com/oblien/openship)
- npm: [npmjs.com/package/openship](https://www.npmjs.com/package/openship)
- Related on bitdoze: [Dokploy install](/dokploy-install/), [Coolify install](/coolify-install-heroku-alternative/), [Coolify v5 review](/coolify-v5-self-hosted-paas-review/), [Coolify vs Dokploy vs Kamal](/coolify-vs-dokploy-vs-kamal-2/), [best self-hosted panels](/best-self-hosted-panels/)

## Bottom line

Openship is a credible new entry in the self-hosted PaaS space. The interesting bits are the multi-surface UX (CLI, desktop, web, MCP), off-box builds, hybrid cloud story, and Apache 2.0 license. The weak bits are the obvious ones for a young project: smaller community, thinner template ecosystem, and less proven long-term ops history than Coolify or Dokploy.

If you already like Dokploy on a small VPS, you do not need to migrate. If you enjoy trying new deploy tools and want something that feels closer to a modern product than a pure panel, install it with the CLI or Compose stack above and ship a throwaway app first.

&lt;Button text=&quot;Get Openship on GitHub&quot; link=&quot;https://github.com/oblien/openship&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>openship</category><category>self-hosted</category><category>docker</category></item><item><title>How to Install Outline Wiki on Docker: Complete 2025 Guide</title><link>https://www.bitdoze.com/outline-install/</link><guid isPermaLink="true">https://www.bitdoze.com/outline-install/</guid><description>Learn how to install Outline Wiki with Docker Compose in 2025. Self-hosted Notion alternative with Slack/OIDC auth, SMTP, file storage &amp; troubleshooting tips.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/02/slack-oauth.jpeg&quot;;

If you want to install Outline Wiki on Docker, this guide covers it. Outline is a self-hosted Notion alternative: a knowledge base and wiki for docs, specs, meeting notes, and support answers on infrastructure you control. It runs on PostgreSQL and Redis, supports local file storage, and has ~39.8k GitHub stars with the latest release at v1.9.0.

This guide walks through a complete Docker Compose setup: Slack or OIDC authentication, SMTP for email notifications, local file storage, Cloudflare Tunnels, troubleshooting, and backups. If you&apos;re looking at other self-hosted note-taking tools, check [Docmost, another self-hosted wiki](https://www.bitdoze.com/docmost-docker-install/) or [Memos for lighter note-taking](https://www.bitdoze.com/memos-install/).

&lt;Notice type=&quot;info&quot; title=&quot;Updated July 2025&quot;&gt;
This guide has been updated for Outline v1.9.0, Postgres 18, Redis 7, and Docker Compose V2. Previous versions had several breaking issues (Postgres volume path, deprecated Compose syntax) that are now fixed.
&lt;/Notice&gt;

## What is Outline Wiki? A self-hosted Notion alternative

[Outline](https://www.getoutline.com/) is a knowledge base and documentation platform built for teams. It focuses on one thing: organizing and sharing internal docs. It doesn&apos;t try to be an all-in-one workspace like Notion.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Fast collaborative editor with markdown support, slash commands, and interactive embeds&lt;/li&gt;
&lt;li&gt;Real-time collaboration (multiple people editing the same document simultaneously)&lt;/li&gt;
&lt;li&gt;17+ languages with RTL (right-to-left) text support&lt;/li&gt;
&lt;li&gt;Detailed user permissions and collections for organizing docs by team or topic&lt;/li&gt;
&lt;li&gt;Desktop apps for macOS and Windows, plus a PWA you can install on iOS and Android home screens&lt;/li&gt;
&lt;li&gt;Dark mode&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

A few things to know before committing:

- **License:** Outline uses the [BSL 1.1 license](https://github.com/outline/outline), source-available but not fully open-source. For most self-hosted teams this doesn&apos;t matter, but it&apos;s worth knowing if compliance is a concern.
- **No email+password auth by design.** Outline requires an SSO provider (Slack, OIDC, Google, Microsoft, GitHub, Discord, GitLab, SAML, Passkeys, or email magic links). This is deliberate, not a missing feature.
- **No native mobile apps.** There&apos;s a PWA for iOS/Android and desktop apps for macOS/Windows, but no App Store or Play Store listing.

**How does it compare to Notion?** Outline is narrower in scope (docs and knowledge base only), simpler to use, and you can self-host it. Notion has databases, project management, and a much wider feature set; you&apos;re locked into their cloud.

## Prerequisites for installing Outline Wiki on Docker

Before you start, have these in place:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS or mini PC (minimum 1 CPU / 512MB RAM for light testing; realistic minimum 2 vCPU / 2GB RAM for a team of 5+)&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose V2 installed (&lt;code&gt;docker compose version&lt;/code&gt; should work)&lt;/li&gt;
&lt;li&gt;A domain or subdomain pointed at your server (e.g. &lt;code&gt;docs.yourdomain.com&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;A reverse proxy (Cloudflare Tunnels, Traefik, Nginx, or Caddy)&lt;/li&gt;
&lt;li&gt;An authentication provider account (Slack workspace is easiest, or an OIDC provider like Authentik, Keycloak, Authelia)&lt;/li&gt;
&lt;li&gt;A way to manage your stacks ([Dockge for Docker management](https://www.bitdoze.com/dockge-install/), [Dokploy as an alternative deployment panel](https://www.bitdoze.com/dokploy-install/), or [Coolify self-hosted PaaS](https://www.bitdoze.com/coolify-install-heroku-alternative/))&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;VPS cost&quot;&gt;
A [Hetzner](https://go.bitdoze.com/hetzner) CX22 (2 vCPU, 4GB RAM, 40GB SSD) costs ~€5/month and is plenty for a small team running Outline. [Hostinger](https://go.bitdoze.com/hostinger-vps) VPS is another budget option. If you&apos;d rather run it at home, an [ASUS Mini PC](https://go.bitdoze.com/asus-dc510) or any [mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/) works fine too.
&lt;/Notice&gt;

What gets installed: Outline (latest or pinned), PostgreSQL 18, and Redis 7.

For a comparison of Docker management panels, see the [best self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/). If you prefer Traefik over Cloudflare Tunnels, I have a full guide on [Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

## Step 1: Set up Slack authentication for Outline Wiki

Slack is free and the simplest auth option for teams that already use it. Here&apos;s how to set it up.

1. Go to [Slack API Apps](https://api.slack.com/apps/) and click **Create New App** → **From scratch**.
2. Name it (e.g. &quot;Outline Docs&quot;) and select your workspace.
3. Under **OAuth &amp; Permissions**, add the redirect URL:
   ```
   https://docs.yourdomain.com/auth/slack.callback
   ```
4. Under **Basic Information**, copy the **Client ID** and **Client Secret**.

&lt;Picture src={img1} alt=&quot;Slack OAuth app configuration page showing redirect URL setup for Outline Wiki authentication&quot; /&gt;

The required scopes are `identity.avatar`, `identity.basic`, `identity.email`, and `identity.team`. Slack requests these by default for Sign in with Slack.

&lt;Notice type=&quot;info&quot; title=&quot;Not using Slack?&quot;&gt;
Skip to the [OIDC authentication section](#optional-set-up-oidc-authentication-for-outline-wiki) below. Outline has native built-in OIDC support that works with Authentik, Keycloak, Authelia, Gitea, and any standards-compliant provider. No separate oidc-server image needed.
&lt;/Notice&gt;

## Step 2: Configure SMTP for Outline Wiki (email notifications and invites)

&lt;Notice type=&quot;warning&quot; title=&quot;SMTP is required&quot;&gt;
Without SMTP configured, user invitations, email notifications, email magic link sign-in, and password reset flows will silently fail. Set this up before inviting your team.
&lt;/Notice&gt;

SMTP is a quick addition. You need these environment variables:

| Variable | Example | Notes |
|----------|---------|-------|
| `SMTP_HOST` | `smtp.mailgun.org` | Your SMTP server hostname |
| `SMTP_PORT` | `465` | Usually 465 (SSL) or 587 (TLS) |
| `SMTP_USERNAME` | `postmaster@mg.yourdomain.com` | SMTP credentials |
| `SMTP_PASSWORD` | `your_smtp_password` | SMTP credentials |
| `SMTP_FROM_EMAIL` | `Outline &lt;noreply@yourdomain.com&gt;` | From address shown in emails |
| `SMTP_SECURE` | `true` | Default true; set to `false` for local/testing |

If you use a known provider (Mailgun, SendGrid, SES, Gmail), you can replace `SMTP_HOST`/`SMTP_PORT` with `SMTP_SERVICE=mailgun` (or `sendgrid`, `ses`, `gmail`).

The actual SMTP variables go into the `docker.env` file shown in Step 3. I&apos;ve included them as commented-out lines you can uncomment.

## Step 3: Outline Wiki Docker Compose configuration

Here&apos;s the complete, updated `docker-compose.yml`. Key changes from older versions: no `version` field (deprecated in Compose V2), pinned Postgres 18 and Redis 7 Alpine images, fixed Postgres volume path, healthchecks with `condition: service_healthy`, and `expose` for internal services.

```yaml
services:
  outline:
    image: docker.getoutline.com/outlinewiki/outline:latest
    env_file: ./docker.env
    ports:
      - &quot;3000:3000&quot;
    volumes:
      - ./storage-data:/var/lib/outline/data
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    env_file: ./docker.env
    expose:
      - &quot;6379&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;redis-cli&quot;, &quot;ping&quot;]
      interval: 10s
      timeout: 30s
      retries: 3
    restart: unless-stopped

  postgres:
    image: postgres:18
    env_file: ./docker.env
    expose:
      - &quot;5432&quot;
    volumes:
      - ./database-data:/var/lib/postgresql
    healthcheck:
      test: [&quot;CMD&quot;, &quot;pg_isready&quot;, &quot;-d&quot;, &quot;outline&quot;, &quot;-U&quot;, &quot;user&quot;]
      interval: 30s
      timeout: 20s
      retries: 3
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: outline
      PGSSLMODE: disable
    restart: unless-stopped
```

**What changed from the old version:**

1. Removed `version: &quot;3.2&quot;` (deprecated in Docker Compose V2)
2. Changed Postgres volume from `/var/lib/postgresql/data` to `/var/lib/postgresql` (Postgres 18 breaking change)
3. Pinned `postgres:18` and `redis:7-alpine` instead of unpinned `:latest`
4. Added `condition: service_healthy` to `depends_on`, so Outline waits for Postgres and Redis to be ready
5. Changed `ports` to `expose` for internal services (Redis and Postgres don&apos;t need host port mapping)
6. Added `PGSSLMODE: disable` to Postgres environment
7. Added `env_file: ./docker.env` to all services (shared env file pattern from official docs)
8. Removed `container_name` and `hostname` (Docker&apos;s built-in DNS handles service discovery)
9. Removed the `redis.conf` volume mount (not needed for basic Outline usage)
10. Added `restart: unless-stopped` to Postgres and Redis

### The docker.env file explained

Create a `docker.env` file in the same directory as your `docker-compose.yml`:

```
# PostgreSQL
POSTGRES_USER=user
POSTGRES_PASSWORD=pass
POSTGRES_DB=outline
PGSSLMODE=disable

# Outline
URL=https://docs.yourdomain.com
SECRET_KEY=generate_with_openssl_rand_hex_32
UTILS_SECRET=generate_with_openssl_rand_hex_32
PORT=3000

# Database connection
DATABASE_URL=postgres://user:pass@postgres:5432/outline
REDIS_URL=redis://redis:6379

# File storage
FILE_STORAGE=local
FILE_STORAGE_LOCAL_ROOT_DIR=/var/lib/outline/data
FILE_STORAGE_UPLOAD_MAX_SIZE=26214400

# Auth — Slack
SLACK_CLIENT_ID=your_slack_client_id
SLACK_CLIENT_SECRET=your_slack_client_secret

# Auth — OIDC (alternative to Slack, pick one)
# OIDC_CLIENT_ID=your_oidc_client_id
# OIDC_CLIENT_SECRET=your_oidc_client_secret
# OIDC_AUTH_URI=https://auth.yourdomain.com/application/o/authorize/
# OIDC_TOKEN_URI=https://auth.yourdomain.com/application/o/token/
# OIDC_USERINFO_URI=https://auth.yourdomain.com/application/o/userinfo/
# OIDC_USERNAME_CLAIM=preferred_username
# OIDC_DISPLAY_NAME=SSO Login
# OIDC_SCOPES=openid profile email

# SMTP (needed for invites and email notifications)
# SMTP_HOST=smtp.example.com
# SMTP_PORT=465
# SMTP_USERNAME=your_username
# SMTP_PASSWORD=your_password
# SMTP_FROM_EMAIL=Outline &lt;noreply@yourdomain.com&gt;
# SMTP_SECURE=true

# Optional but recommended
FORCE_HTTPS=true
# RATE_LIMITER_MULTIPLIER=1.0
# LOG_LEVEL=debug  # only for troubleshooting
```

Generate both secrets with:

```sh
openssl rand -hex 32
```

&lt;Notice type=&quot;error&quot; title=&quot;SECRET_KEY is critical&quot;&gt;
If you lose `SECRET_KEY`, all encrypted data (tokens, sessions, integrations) is destroyed. There&apos;s a recovery script (`node ./build/server/scripts/reset-encrypted-data.js`) but it wipes encrypted data. Back up your `docker.env` file alongside your database.
&lt;/Notice&gt;

**Key variables explained:**

- **`URL`**: must match your public domain exactly (including `https://`). Outline uses this for callback URLs and asset links.
- **`FORCE_HTTPS`**: set to `true` when behind a reverse proxy that handles TLS. Set to `false` for local testing without TLS. Getting this wrong causes redirect loops.
- **`DATABASE_URL`**: the hostname (`postgres`) must match the service name in `docker-compose.yml`, not `localhost`.
- **`RATE_LIMITER_MULTIPLIER`**: float value, default 1.0. Increase for larger teams hitting rate limits.
- **`LOG_LEVEL=debug`**: enable verbose logging temporarily when troubleshooting startup issues.

For more on Docker Compose environment management, see [Docker environment variables in Compose](https://www.bitdoze.com/docker-env-vars/) and [Docker Compose secrets management](https://www.bitdoze.com/docker-compose-secrets/).

### PostgreSQL and Redis service configuration

**Postgres 18** is pinned because the Docker image changed its internal data directory. Using the old `/var/lib/postgresql/data` path with Postgres 18+ causes: `Error: Postgres detected data in /var/lib/postgresql/data (unused mount/volume)`. The fix is using `/var/lib/postgresql` without the `/data` suffix.

**Redis 7 Alpine** is a small image (~10MB) that works with zero configuration for Outline. No custom `redis.conf` needed.

**`expose` vs `ports`:** Internal services (Postgres, Redis) use `expose`. They&apos;re accessible to other containers on the Docker network but not mapped to host ports. Only Outline&apos;s port 3000 is exposed to the host. This is more secure than mapping all three services to host ports.

## Step 4: Deploy and verify your Outline Wiki installation

Deploy with:

```sh
docker compose up -d
```

### Check all containers are running

```sh
docker compose ps
```

All three services should show &quot;Up&quot; and healthy:

```
NAME              STATUS                    PORTS
outline-app       Up (healthy)              0.0.0.0:3000-&gt;3000/tcp
outline-postgres  Up (healthy)              5432/tcp
outline-redis     Up (healthy)              6379/tcp
```

Check Outline logs for startup and migration messages:

```sh
docker compose logs outline
```

Look for `Server started on port 3000` and migration messages. If you see connection errors to Postgres or Redis, check that the service names in `DATABASE_URL` and `REDIS_URL` match your `docker-compose.yml`.

### Fix file upload permissions

Outline runs as UID 1001 inside the container. Without the right permissions, image uploads fail silently:

```sh
chown 1001 ./storage-data
```

Verify: open Outline in your browser, create a document, and upload an image. If it works, you&apos;re set.

### Test WebSocket connectivity

Open the same document in two browser tabs. Type in one tab. Changes should appear in the other within a second. This confirms WebSocket connections work through your reverse proxy.

If real-time sync doesn&apos;t work, your reverse proxy likely isn&apos;t forwarding WebSocket upgrade headers. See the troubleshooting section below.

## Optional: Set up OIDC authentication for Outline Wiki

&lt;Notice type=&quot;info&quot; title=&quot;OIDC is recommended for self-hosted setups&quot;&gt;
OIDC is the most flexible authentication option for self-hosted Outline. Works with Authentik, Keycloak, Authelia, Gitea, and any standards-compliant OIDC provider. No separate oidc-server image needed. It&apos;s built in.
&lt;/Notice&gt;

You have two ways to configure OIDC: manual endpoints or automatic discovery via issuer URL.

&lt;Tabs&gt;
&lt;Tab name=&quot;Manual config&quot;&gt;
Set each endpoint explicitly in your `docker.env`:

```
OIDC_CLIENT_ID=your_oidc_client_id
OIDC_CLIENT_SECRET=your_oidc_client_secret
OIDC_AUTH_URI=https://auth.yourdomain.com/application/o/authorize/
OIDC_TOKEN_URI=https://auth.yourdomain.com/application/o/token/
OIDC_USERINFO_URI=https://auth.yourdomain.com/application/o/userinfo/
OIDC_USERNAME_CLAIM=preferred_username
OIDC_DISPLAY_NAME=SSO Login
OIDC_SCOPES=openid profile email
```

The callback URL for your OIDC provider is:
```
https://docs.yourdomain.com/auth/oidc.callback
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Issuer URL (auto-discovery)&quot;&gt;
If your provider supports auto-discovery (Authentik, Keycloak, Authelia do), you only need the issuer URL:

```
OIDC_CLIENT_ID=your_oidc_client_id
OIDC_CLIENT_SECRET=your_oidc_client_secret
OIDC_ISSUER_URL=https://auth.yourdomain.com/application/o/your-app/
OIDC_DISPLAY_NAME=SSO Login
OIDC_SCOPES=openid profile email
```

Outline fetches the endpoints automatically from `{OIDC_ISSUER_URL}/.well-known/openid-configuration`.
&lt;/Tab&gt;
&lt;/Tabs&gt;

Other auth providers Outline supports: Discord, GitLab, GitHub, Google Workspace, Microsoft, SAML, Passkeys (biometric/security keys), and email magic links. Outline will never have email+password auth. This is a deliberate design decision.

After enabling OIDC, restart Outline (`docker compose restart outline`) and test the login flow. The OIDC button should appear on the login page.

## Optional: Configure Cloudflare Tunnels for Outline Wiki

To expose Outline via Cloudflare Tunnels:

1. Go to Cloudflare dashboard → **Access** → **Tunnels**
2. Select your tunnel → **Configure** → **Public Hostname** → **Add a public hostname**
3. Set the domain to your subdomain (e.g. `docs.yourdomain.com`)
4. Set the service to `http://localhost:3000` (or your mapped port)

&lt;Notice type=&quot;warning&quot; title=&quot;Disable Cloudflare Rocket Loader&quot;&gt;
Cloudflare Rocket Loader **must be disabled** for your Outline subdomain. It injects scripts that break Outline&apos;s client-side JavaScript rendering. Go to **Speed** → **Optimization** → **Rocket Loader** → set to **Off** for your Outline domain. The official Outline docs explicitly warn about this.
&lt;/Notice&gt;

Set `FORCE_HTTPS=true` in your `docker.env` when using Cloudflare Tunnels (it handles TLS at the edge).

If you prefer self-hosted tunnel management, check out [Pangolin as a self-hosted Cloudflare Tunnels alternative](https://www.bitdoze.com/pangolin-cloudflare-tunnels-alternative/).

## Troubleshooting common Outline Wiki Docker issues

&lt;Accordion label=&quot;HTTPS redirect loops&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Browser shows `ERR_TOO_MANY_REDIRECTS` or the page keeps reloading.

**Fix:** If your reverse proxy terminates TLS and forwards HTTP to Outline, `FORCE_HTTPS=true` can cause redirect loops once the browser caches HSTS headers. Set `FORCE_HTTPS=false` in `docker.env` and clear your browser&apos;s HSTS cache: in Chrome, go to `chrome://net-internals/#hsts`, query your domain, and delete it.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloudflare Rocket Loader conflicts&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Outline loads but buttons don&apos;t work, the editor is broken, or JavaScript errors appear in the browser console.

**Fix:** Disable Rocket Loader in Cloudflare dashboard: **Speed** → **Optimization** → **Rocket Loader** → set to **Off** for your Outline subdomain.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;File upload permission denied&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Uploading images or files in a document fails — either silently or with a permission error in logs.

**Fix:** Run `chown 1001 ./storage-data` in your Outline stack directory. The Node.js process runs as UID 1001 inside the container and needs write access to the mounted volume.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;WebSocket / collaborative editing not working&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Real-time collaboration doesn&apos;t sync between browser tabs or users.

**Fix:** Your reverse proxy needs to support WebSocket upgrade. For Cloudflare Tunnels, WebSockets work by default. For Nginx, ensure `proxy_set_header Upgrade $http_upgrade` and `proxy_set_header Connection &quot;upgrade&quot;` are set. For Traefik, WebSocket support is built in — verify your router config.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Database connection errors&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Outline fails to start with connection refused or authentication errors against Postgres.

**Fix:** Check three things:
1. `DATABASE_URL` hostname must match the Postgres service name in `docker-compose.yml` (`postgres`, not `localhost` or `outline-postgres`)
2. `PGSSLMODE=disable` must be set in both the Outline and Postgres environments
3. Verify Postgres healthcheck is passing: `docker compose ps` should show the Postgres container as &quot;healthy&quot;
&lt;/Accordion&gt;

&lt;Accordion label=&quot;SECRET_KEY format errors&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Outline fails to start with a secret key validation error.

**Fix:** `SECRET_KEY` must be exactly 64 hex characters generated with `openssl rand -hex 32`. Do not use a passphrase, a URL, or any arbitrary string. Same for `UTILS_SECRET`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;DNS lookup not allowed&quot; group=&quot;troubleshooting&quot;&gt;
**Symptom:** Outline can&apos;t reach your OIDC provider, SMTP server, or other services on a local/private network. Logs show DNS lookup or connection errors to private IPs.

**Fix:** Set `ALLOWED_PRIVATE_IP_ADDRESSES` in `docker.env` with comma-separated IPs of the services Outline needs to reach. For example: `ALLOWED_PRIVATE_IP_ADDRESSES=10.0.0.5,192.168.1.100`. This is a security measure that blocks SSRF attacks by default.
&lt;/Accordion&gt;

For more verbose logging during troubleshooting, temporarily add `LOG_LEVEL=debug` to your `docker.env` and restart the container.

## How to back up and update Outline Wiki on Docker

### Backing up your database and files

Three things need regular backups:

**1. Postgres database:**

```sh
docker exec outline-postgres pg_dump -U user outline &gt; backup_$(date +%F).sql
```

**2. File storage directory:**

```sh
cp -r ./storage-data ./storage-data-backup-$(date +%F)
```

**3. docker.env file:**

This contains `SECRET_KEY` and `UTILS_SECRET`. Without `SECRET_KEY`, all encrypted data in the database is unrecoverable. Back it up alongside your database dump.

For a more robust backup strategy, you can push database dumps and file storage to S3-compatible storage. See how to [clean up Docker disk space](https://www.bitdoze.com/clean-docker-overlay2-dir/) if your backup volumes are eating disk.

### Updating Outline Wiki to the latest version

The update process:

```sh
# 1. Back up first
docker exec outline-postgres pg_dump -U user outline &gt; backup_$(date +%F).sql

# 2. Pull new images
docker compose pull

# 3. Restart with new images
docker compose up -d

# 4. Check logs for migration output
docker compose logs -f outline
```

Migrations run automatically on container startup. There&apos;s no manual migration step — but this also means **migrations are irreversible**. If an update breaks something, you need a database backup to roll back.

**Version pinning for production:** Instead of `:latest`, use a specific tag like `outline:1.9.0`. This lets you control when updates happen and prevents surprise breakage on redeploy.

```yaml
image: docker.getoutline.com/outlinewiki/outline:1.9.0
```

For a detailed guide on updating Docker Compose containers, see [how to update Docker Compose containers](https://www.bitdoze.com/updating-container-docker-compose/).

&lt;Notice type=&quot;warning&quot; title=&quot;Migrations are irreversible&quot;&gt;
Migrations run automatically on startup and cannot be reversed. Always back up your database before updating Outline.
&lt;/Notice&gt;

## Final thoughts on self-hosting Outline Wiki

Outline has matured significantly — v1.9.0 with ~39.8k GitHub stars, native OIDC support, desktop apps, a PWA for mobile, and a polished collaborative editor. It&apos;s one of the better self-hosted documentation platforms if you want something focused and clean instead of Notion&apos;s everything-kitchen-sink approach.

Key takeaways from this setup:

- **Pin your image versions** in production to avoid surprise breakage on redeploy
- **Back up `docker.env`** alongside your database — losing `SECRET_KEY` means losing all encrypted data
- **Set up SMTP early** — without it, invites and notifications silently don&apos;t work
- **Pin Postgres to 18** and use the `/var/lib/postgresql` volume path (not `/var/lib/postgresql/data`)
- Use `docker compose` (V2 plugin), not `docker-compose` (deprecated)

Outline uses the BSL 1.1 license — source-available but not fully open-source. For most self-hosted teams this is fine, but it&apos;s worth knowing.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/IY1jONuTEic&quot;
  label=&quot;Outline Install&quot;
/&gt;

&gt; **Note:** The video above was recorded with an older version. The steps are similar but some commands and configurations have been updated in this guide.

For more self-hosted Docker apps, check out the [best self-hosted Docker apps for business](https://www.bitdoze.com/docker-containers-business/) and [self-hosted Docker containers for your home server](https://www.bitdoze.com/docker-containers-home-server/).</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>docker-compose</category></item><item><title>Best PHP Cloud Hosting Providers for 2025</title><link>https://www.bitdoze.com/php-cloud-hosting/</link><guid isPermaLink="true">https://www.bitdoze.com/php-cloud-hosting/</guid><description>Compare the best PHP cloud hosting providers for 2025 — Hetzner, Cloudways, Hostinger, Vultr &amp; more. Honest pricing, performance, and recommendations for every budget.</description><pubDate>Mon, 20 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

Choosing the best PHP cloud hosting provider in 2025 comes down to how much control you want, how much you&apos;re willing to spend, and which PHP versions you need. A lot has changed since last year: Hetzner restructured its plans, Cloudways dropped its entry price, 000webhost shut down, and PHP 8.4 is now the latest stable release.

This article covers every viable option: unmanaged VPS providers where you pair a server with a panel like CloudPanel, managed platforms like Cloudways that handle the ops for you, and budget shared hosting for beginners. I&apos;ll also cover free hosting (what&apos;s left of it), cost gotchas that trip people up, and how to verify your setup actually works.

&lt;Notice type=&quot;info&quot; title=&quot;My Recommendation&quot;&gt;
I run all my PHP applications on [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) with [CloudPanel](https://www.bitdoze.com/install-cloudpanel-host-nodejs/) as the server manager. If you&apos;re comfortable with SSH and want the best price-to-performance, this is the setup I&apos;d recommend. If you&apos;d rather not touch a terminal and don&apos;t mind paying more, go with [Cloudways](https://go.bitdoze.com/cloudways) — it handles everything for you.
&lt;/Notice&gt;

## What&apos;s new in PHP hosting for 2025

### PHP 8.4 and the version landscape

PHP 8.4 was released November 21, 2024 with property hooks, asymmetric visibility, HTML5 support, and performance improvements. PHP 8.3 is the current production standard most apps target. PHP 8.2 is the minimum actively supported version (security fixes through Dec 2025). PHP 7.4 reached end-of-life in November 2022 — if your host is still running it, move immediately.

| Version | Released | Security support until | Status |
|---------|----------|----------------------|--------|
| PHP 8.4 | Nov 2024 | Dec 2026 | Current latest |
| PHP 8.3 | Nov 2023 | Dec 2025 | Production standard |
| PHP 8.2 | Dec 2022 | Dec 2025 | Minimum supported |
| PHP 8.1 | Nov 2021 | Dec 2024 | EOL |
| PHP 7.4 | Nov 2019 | Nov 2022 | EOL — security risk |

Many managed hosts still top out at PHP 8.2 (Hostinger hPanel, Cloudways Flexible). Cloudways Autonomous supports 8.3. If you need 8.4 today, unmanaged VPS with CloudPanel or manual setup is the fastest path.

### 000webhost shutdown and what it means

&lt;Notice type=&quot;warning&quot; title=&quot;000webhost is gone&quot;&gt;
000webhost (owned by Hostinger) stopped accepting new users July 8, 2024 and fully shut down October 14, 2024. If you see it recommended in other articles, that information is outdated. InfinityFree is the remaining free PHP hosting option — with significant caveats covered below.
&lt;/Notice&gt;

## Best PHP cloud hosting providers compared

### Pricing at a glance

The &quot;practical minimum&quot; is what you actually need for a working PHP site with IPv4 — not the cheapest plan listed on a marketing page.

| Provider | Practical minimum | Managed? | IPv4 included? | Notes |
|----------|------------------|----------|---------------|-------|
| [Hostinger](https://go.bitdoze.com/hostinger-vps) | $2.99/mo (48mo intro) | Yes | Yes | Renewal jumps to $7.99/mo |
| [Vultr](https://go.bitdoze.com/vultr) | $3.50/mo | No | Yes ($3.50 plan) | $2.50 plan is IPv6-only |
| [Hetzner](https://go.bitdoze.com/hetzner) | ~€3.99/mo + €0.50 IPv4 | No | No (+€0.50/mo) | Best perf-per-euro |
| [DigitalOcean](https://go.bitdoze.com/do) | $6/mo | No | Yes | $4 plan has only 512MB RAM |
| [Cloudways](https://go.bitdoze.com/cloudways) | $11/mo | Yes | Yes | No root access |
| Linode/Akamai | $5/mo | No | Yes | Akamai-backed infrastructure |

### Quick decision matrix

Not sure which provider fits? Here&apos;s the short version:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Not technical, small site&lt;/strong&gt; → Hostinger shared hosting ($2.99/mo intro)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Want managed, can pay more&lt;/strong&gt; → Cloudways ($11/mo and up)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Technical + want cheapest&lt;/strong&gt; → Hetzner CX23 + CloudPanel (~€4.49/mo total)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Need global data centers&lt;/strong&gt; → Vultr (32 locations)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Developer-friendly UI&lt;/strong&gt; → DigitalOcean Droplets&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Already in Akamai ecosystem&lt;/strong&gt; → Linode&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

For a deeper look at how these unmanaged VPS providers stack up on actual performance, see [our detailed VPS performance comparison](/digitalocean-vs-vultr-vs-hetzner/).

## #1. Hetzner — best price-to-performance PHP cloud hosting

Hetzner is a German cloud provider that consistently delivers the best hardware for the money. I&apos;ve been running production servers on Hetzner for over 4 years and the performance has been reliable. A server with comparable specs to Vultr or DigitalOcean often costs half the price.

Hetzner completely reorganized its plan structure in 2024-2025:

| Plan tier | Example | vCPU | RAM | Storage | Monthly (excl. IPv4) |
|-----------|---------|------|-----|---------|---------------------|
| Cost-Optimized (x86) | CX23 | 2 | 4GB | 40GB | ~€3.99 |
| Cost-Optimized (x86) | CX33 | 4 | 8GB | 80GB | ~€6.49 |
| Regular Performance | CPX22 | 2 | 4GB | 80GB | ~€5.49 |
| Regular Performance | CPX32 | 4 | 8GB | 160GB | ~€10.99 |
| ARM (Cost-Optimized) | CAX11 | 2 | 4GB | 40GB | ~€4.49 |

Cost-Optimized plans use older hardware and work well for dev, staging, and moderate PHP sites. Regular Performance plans run the latest AMD EPYC-Genoa CPUs for production workloads. ARM (CAX) plans offer better performance-per-euro for cloud-native stacks, worth testing if your PHP app has no x86-specific dependencies. See [ARM vs x86 performance for web workloads](/arm-vs-x86-vps-server-benchmarks/) for benchmarks.

&lt;Notice type=&quot;info&quot; title=&quot;IPv4 surcharge&quot;&gt;
Hetzner now charges €0.50/mo extra for IPv4 on all shared plans. Factor this into your budget — a CX23 with IPv4 runs about €4.49/mo total. Backups cost an additional 20% of the instance price.
&lt;/Notice&gt;

Hetzner added Singapore as a datacenter location in 2024, joining Germany, Finland, and the US. All plans include 20TB bandwidth, DDoS protection, block storage, load balancers, and floating IPs.

For the full plan breakdown and post-price-increase details, see [Hetzner&apos;s updated pricing and cost-optimized plans](/hetzner-cloud-cost-optimized-plans/). For a hands-on review, check the [full Hetzner Cloud review](/hetzner-cloud-review/).

**[Try Hetzner — $20 FREE Credits](https://go.bitdoze.com/hetzner)**

## #2. Cloudways — best managed PHP hosting

Cloudways is a managed cloud hosting platform that handles server setup, configuration, optimization, and security so you can focus on your PHP application. You deploy on top of DigitalOcean, AWS, or Google Cloud infrastructure through Cloudways&apos; dashboard, no terminal required.

Cloudways was acquired by DigitalOcean in 2022, which means tighter infrastructure integration. The platform has evolved since then:

- **Lightning Stack** (launched 2025), a new NGINX-based stack replacing Apache, with 22-27% faster uncached performance.
- **Cloudways Copilot**, an AI-powered diagnostics and troubleshooting assistant.
- **Autonomous plans**, Kubernetes-based autoscaling for high-traffic sites, starting at $35/mo.

| Plan | RAM | vCPU | Storage | Bandwidth | Monthly |
|------|-----|------|---------|-----------|---------|
| DO Micro | 2GB | 1 | 50GB | 2TB | $11 |
| DO Small | 2GB | 1 | 50GB | 2TB | $28 |
| DO Medium | 8GB | 4 | 160GB | 5TB | $88 |
| Autonomous Growth | autoscale | autoscale | 20GB | 150GB | $99 |

&lt;Notice type=&quot;info&quot; title=&quot;DigitalOcean ownership&quot;&gt;
Cloudways is now owned by DigitalOcean. This means better infrastructure integration but also worth knowing if you prefer provider independence. Cloudways adds Cloudflare Enterprise integration for $5/domain on top of the base plan.
&lt;/Notice&gt;

The main tradeoff: you don&apos;t have root access to the underlying VPS. For most PHP applications this doesn&apos;t matter. Cloudways handles PHP version switching, SSL, caching, and backups through the UI. But if you need custom server-level configurations, you&apos;ll find limits quickly.

I use Cloudways for clients who want to manage things themselves but don&apos;t have deep technical knowledge. The dashboard makes server administration accessible without a Linux background.

**[Try Cloudways](https://go.bitdoze.com/cloudways)**

## #3. Vultr — best PHP cloud hosting for global reach

Vultr offers high-performance SSD VPS servers across 32 global data centers — the widest footprint among the providers in this list. If your audience is spread across Asia, South America, or other regions where Hetzner doesn&apos;t have a presence, Vultr is the best option.

&lt;Notice type=&quot;warning&quot; title=&quot;IPv6-only plan caveat&quot;&gt;
Vultr&apos;s $2.50/mo plan is IPv6-only and limited to 2 instances per account. Most PHP applications need IPv4 for DNS compatibility, API integrations, and email deliverability. Plan for the $3.50/mo minimum with IPv4 for real PHP hosting.
&lt;/Notice&gt;

| Plan | vCPU | RAM | Storage | Bandwidth | Monthly |
|------|------|-----|---------|-----------|---------|
| Regular (IPv6-only) | 1 | 0.5GB | 10GB | 0.5TB | $2.50 |
| Regular (IPv4) | 1 | 0.5GB | 10GB | 0.5TB | $3.50 |
| Regular | 1 | 1GB | 25GB | 1TB | $5.00 |
| High Performance (AMD) | 1 | 1GB | 25GB | 2TB | $6.00 |

Vultr also launched VX1 compute plans (dedicated CPU, up to 82% better performance-per-dollar vs hyperscaler efficiency instances) for readers scaling up. These start at $43+/mo and target production workloads that need consistent CPU performance.

Like Hetzner, Vultr is unmanaged, so you&apos;ll need a server panel. Pair it with CloudPanel or Ploi for a manageable experience. If you need a datacenter close to your visitors and Hetzner doesn&apos;t cover that region, Vultr is the best alternative.

**[Try Vultr — Get $100 Free to Test Them](https://go.bitdoze.com/vultr)**

## #4. DigitalOcean — developer-friendly PHP hosting

DigitalOcean was one of the first cloud providers I used for PHP hosting. Their Droplets ecosystem is mature, the documentation is good, and the community tutorials cover most stack combinations.

Pricing has improved recently:

| Plan | vCPU | RAM | Storage | Bandwidth | Monthly |
|------|------|-----|---------|-----------|---------|
| Basic | 1 | 0.5GB | 10GB | 500GB | $4 |
| Basic | 1 | 1GB | 25GB | 1TB | $6 |
| Basic | 1 | 2GB | 50GB | 2TB | $12 |
| Basic | 2 | 4GB | 80GB | 4TB | $24 |

The $4/mo plan exists, but with 512MB RAM it&apos;s too tight for most PHP applications running a web server and database. The $6/mo plan (1GB RAM, 25GB storage) is the realistic entry point. Per-second billing starts January 2026, so you only pay for what you use.

DigitalOcean offers managed databases (MySQL, PostgreSQL, Redis), App Platform, and Spaces (S3-compatible storage). The ecosystem is broader than Vultr if you need managed add-ons.

I moved away from DigitalOcean years ago after noticing inconsistent performance on some servers. It depends on the datacenter and load, and many people run production on DigitalOcean without issues. Test it yourself with the free credits. For a side-by-side comparison, see [our VPS comparison article](/digitalocean-vs-vultr-vs-hetzner/).

**[Try DigitalOcean — $100 Free Credits](https://go.bitdoze.com/do)**

## #5. Hostinger — cheapest PHP cloud hosting for beginners

Hostinger is the right pick when you&apos;re not technical, have a small-to-medium PHP site, and want the lowest barrier to entry. Their shared hosting uses LiteSpeed web server with NVMe storage, and the custom hPanel control panel simplifies PHP version switching, SSL installation, and other common tasks.

| Plan | Intro price (48mo) | Renewal | Storage | Websites |
|------|-------------------|---------|---------|----------|
| Premium Shared | $2.99/mo | $7.99/mo | 100GB | 100 |
| Business Shared | $3.99/mo | $8.99/mo | 200GB | 100 |
| Cloud Startup | $7.99/mo | $25.99/mo | 200GB | 300 |

&lt;Notice type=&quot;warning&quot; title=&quot;Renewal pricing&quot;&gt;
Hostinger&apos;s intro price ($2.99/mo) requires a 48-month commitment. Renewal jumps to $7.99/mo. Budget for the renewal cost — this catches people off guard every year.
&lt;/Notice&gt;

PHP version support tops out at 8.2 in hPanel as of mid-2025. If you need PHP 8.3 or 8.4, Hostinger shared hosting isn&apos;t there yet. They also added an AI website builder and offer cloud hosting plans starting at $7.99/mo for more resources.

For users who outgrow shared hosting, Hostinger also offers KVM-based VPS plans that pair well with CloudPanel.

**[Try Hostinger](https://go.bitdoze.com/hostinger-vps)**

## Honorable mention: Linode/Akamai

Linode (acquired by Akamai in 2022) is a direct competitor to DigitalOcean and Vultr. Plans start at $5/mo for 1 vCPU, 1GB RAM, 25GB storage with IPv4 included. The infrastructure is now backed by Akamai&apos;s global network, which brings strong DDoS protection and edge capabilities.

Linode is a good choice if you&apos;re already in the Akamai ecosystem or want an alternative to the bigger names. The offering is similar to DigitalOcean: shared CPU plans, block storage, managed databases, Kubernetes. Nothing dramatically differentiates it from Vultr or DigitalOcean on price, but the Akamai backing adds credibility for production workloads.

## Free PHP hosting alternatives in 2025

&lt;Notice type=&quot;error&quot; title=&quot;Honest take&quot;&gt;
Free hosting is fine for experiments and learning, never for production. Downtime, data loss, and hidden limits are the norm. If your application matters at all, spend $3-6/mo on a real VPS.
&lt;/Notice&gt;

### Free PHP hosting: what&apos;s left

**InfinityFree** is the main remaining free PHP hosting option. It offers 5GB disk space, unlimited bandwidth, PHP 8.3 support (upgraded April 2025), and MySQL/MariaDB databases. The catch: no cron jobs, limited PHP extensions, and a 50,000 hits/day limit. For testing a PHP script or building a school project, it works. For anything users depend on, it doesn&apos;t.

### Free hosting alternatives (static sites)

If you don&apos;t actually need PHP — if your site can be static HTML, CSS, and JavaScript — these free hosting options are faster and more reliable than any free PHP host:

**Cloudflare Pages** — 500 deployments/month, unlimited bandwidth, SSL, CDN, DDoS protection. Integrates with Git for automatic deployments. 100 custom domains per project. Best free static hosting available.

**Netlify** — 100GB bandwidth/month, 300 build minutes, serverless functions (125K requests/month on free tier). Good for JAMstack sites with light backend needs.

**Vercel** — Hobby plan is free for personal, non-commercial use. Automatic HTTPS, preview deployments on every git push. Usage limits reset monthly but exceeding them requires waiting or upgrading.

**GitHub Pages** — Direct deployment from a GitHub repository. Supports custom domains. Free tier limited to public repositories. No build minute limits but no server-side processing.

## Server management panels for PHP

If you go the unmanaged VPS route (Hetzner, Vultr, DigitalOcean, Linode), you need a server management panel unless you want to configure NGINX, PHP-FPM, databases, SSL, and firewalls from the command line every time.

### CloudPanel (top pick)

CloudPanel is free, supports PHP 7.1 through 8.5, Node.js 18/20/22, and runs on NGINX with HTTP/3 and QUIC. It handles MySQL 8.0/8.4, MariaDB 10.6-11.4, Redis, and Varnish Cache. Works on Debian 11/12 and Ubuntu 22.04/24.04, with ARM64 support for Hetzner&apos;s CAX plans.

It&apos;s 100% free, no paid tiers, no feature gating. See [how to install CloudPanel on your VPS](/install-cloudpanel-host-nodejs/) for a step-by-step guide. For performance tuning, you can [set up Varnish Cache with CloudPanel](/cloudpanel-varnish-cache/). For backups, [configure CloudPanel remote backups](/cloudpanel-remote-backups/) to OneDrive or Google Drive.

### Ploi.io

Ploi&apos;s pricing changed — Ploi Core is now free, Basic at €8/mo, Pro at €30/mo. Ploi provides a more polished UI than CloudPanel and handles server provisioning, deployments, SSL, and queue management. Good for users who want managed convenience without paying Cloudways prices.

### Other options

The panel landscape has grown beyond CloudPanel and Ploi:

- **Coolify**, Heroku/Netlify alternative with 280+ one-click services and push-to-deploy. Free self-hosted. Over 58K GitHub stars.
- **Dokploy**, Vercel alternative with 350+ templates and preview deployments. Free. Over 35K GitHub stars.
- **CyberPanel**, LiteSpeed-powered with built-in email. Had a serious CVE (CVE-2024-51567, patched in 2.3.8+). Update immediately if you&apos;re running it.
- **1Panel**, modern Go-based panel, popular in APAC, covers both PHP and Docker workloads.

For a full comparison of these and others, see [our complete guide to self-hosted server panels](/best-self-hosted-panels/).

## How to verify your PHP hosting setup

After deploying your server and installing PHP, verify everything actually works before pointing DNS at it.

**Check the PHP version:**

```bash
php -v
```

Expected output should show your target version (8.2, 8.3, or 8.4). If it shows 7.x, something is wrong.

**Check critical extensions:**

```bash
php -m | grep -E &quot;mbstring|xml|curl|gd|mysql|zip&quot;
```

Most PHP applications need all of these. Missing `mbstring` or `curl` will cause silent failures in frameworks like Laravel and Symfony.

**Create a phpinfo test page:**

```bash
echo &quot;&lt;?php phpinfo(); ?&gt;&quot; &gt; /var/www/html/info.php
```

Visit `http://your-server-ip/info.php` in a browser. You should see the full PHP configuration page. This confirms PHP-FPM is processing requests correctly.

&lt;Notice type=&quot;warning&quot; title=&quot;Delete phpinfo immediately&quot;&gt;
Remove the test file right after verifying. Exposing phpinfo() publicly leaks your server configuration, installed modules, and file paths, which is useful information for attackers.
&lt;/Notice&gt;

```bash
rm /var/www/html/info.php
```

**Check PHP-FPM status:**

```bash
systemctl status php8.2-fpm
```

Replace `8.2` with your installed version. The status should show `active (running)`. If it&apos;s failed or inactive, check `/var/log/php8.x-fpm.log` for errors.

**Quick CPU benchmark (optional):**

```bash
sysbench cpu --cpu-max-prime=20000 run
```

This gives you a baseline for comparing your VPS against others. See [how to benchmark your cloud server](/benchmark-cloud-servers/) for more thorough testing.

After your setup is verified and before going live, [secure your VPS with CrowdSec](/crowdsec-secure-server/) — it&apos;s free and blocks brute-force attacks automatically.

## Hidden costs to watch out for

&lt;Notice type=&quot;warning&quot; title=&quot;Budget for the real price&quot;&gt;
The advertised monthly price is rarely what you&apos;ll pay. These are the cost gotchas that catch people off guard.
&lt;/Notice&gt;

- **Hetzner IPv4 surcharge.** €0.50/mo on all shared plans. Not included in the headline price.
- **Hostinger renewal pricing.** Intro $2.99/mo jumps to $7.99/mo at renewal. The 48-month commitment locks you in, but renewal hits hard.
- **Vultr $2.50 plan.** IPv6-only, limited to 2 instances. Unusable for standard PHP hosting with DNS and email. Real minimum is $3.50/mo.
- **DigitalOcean backups.** Automatic backups cost 20% of the droplet price. A $6/mo droplet becomes $7.20/mo with backups.
- **Hetzner backups.** Also 20% of instance price.
- **Cloudways bandwidth overages.** $0.02-$0.12/GB depending on the underlying provider. High-traffic sites can rack up charges.
- **DigitalOcean managed databases.** Start at $15/mo for the smallest PostgreSQL instance. Self-hosting your database on the same droplet is cheaper if you can manage it.

## Final verdict: which PHP cloud hosting should you choose?

&lt;Notice type=&quot;success&quot; title=&quot;Start here&quot;&gt;
My top pick remains Hetzner + CloudPanel for the best price-to-performance ratio. Start with [Hetzner&apos;s $20 free credits](https://go.bitdoze.com/hetzner), set up CloudPanel, and test it yourself. If you need managed hosting, [Cloudways at $11/mo](https://go.bitdoze.com/cloudways) is the easiest path. For beginners on a budget, [Hostinger](https://go.bitdoze.com/hostinger-vps) gets you running for under $3/mo.
&lt;/Notice&gt;

Here&apos;s the recap:

- **Best overall value:** [Hetzner](https://go.bitdoze.com/hetzner) + CloudPanel. Cheapest for the performance you get, with 4+ years of proven reliability.
- **Best managed hosting:** [Cloudways](https://go.bitdoze.com/cloudways). No terminal needed, Lightning Stack performance, starts at $11/mo.
- **Best global coverage:** [Vultr](https://go.bitdoze.com/vultr). 32 data centers, good for audiences in regions Hetzner doesn&apos;t cover.
- **Best developer experience:** [DigitalOcean](https://go.bitdoze.com/do). Mature ecosystem, good docs, per-second billing.
- **Best for beginners:** [Hostinger](https://go.bitdoze.com/hostinger-vps). Lowest barrier to entry, managed shared hosting from $2.99/mo.

Don&apos;t overthink it. Most of these providers offer free credits or cheap trials. Spin up a server, deploy your PHP app, and see how it performs. For a side-by-side breakdown of the unmanaged options, see [our detailed VPS comparison](/digitalocean-vs-vultr-vs-hetzner/).</content:encoded><category>hosting</category><category>php</category><category>cloud-hosting</category><category>hetzner</category></item><item><title>Extract Text From PDF on Linux Command Line (pdftotext)</title><link>https://www.bitdoze.com/pdf-extract-text-linux-cmd/</link><guid isPermaLink="true">https://www.bitdoze.com/pdf-extract-text-linux-cmd/</guid><description>Extract text from PDF files on Linux command line using pdftotext. Covers install, layout modes, password-protected PDFs, OCR for scanned docs &amp; batch scripts.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Extracting text from PDF files on the Linux command line is straightforward with `pdftotext`, part of the `poppler-utils` package. It converts PDF documents to plain text. No GUI, no cloud services, no accounts. Install the package, point it at a file, and get text out the other side.

This guide covers install, basic usage, every useful option, handling password-protected and scanned PDFs, alternatives when pdftotext is not enough, batch scripts, and troubleshooting.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Install poppler-utils on any major Linux distro&lt;/li&gt;
&lt;li&gt;Extract text from PDFs with pdftotext (basic and advanced)&lt;/li&gt;
&lt;li&gt;Handle password-protected and scanned/image-based PDFs&lt;/li&gt;
&lt;li&gt;Use batch scripts to process multiple PDFs at once&lt;/li&gt;
&lt;li&gt;Troubleshoot common extraction failures&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## What is poppler-utils?

Poppler-utils is a collection of command-line utilities for manipulating PDF files. It is based on the [poppler library](https://poppler.freedesktop.org/), a fork of the xpdf library. The star of this package is `pdftotext`, which converts PDF files to plain text with configurable layout, encoding, and page selection.

Other tools in the package handle different PDF tasks:

### Key poppler-utils tools at a glance

| Tool | Purpose |
|------|---------|
| **pdftotext** | Convert PDF to plain text (the focus of this article) |
| **pdfinfo** | Print PDF metadata: title, author, page count, etc. |
| **pdftohtml** | Convert PDF to HTML |
| **pdfimages** | Extract images from a PDF |
| **pdfseparate** | Split a PDF into single-page files |
| **pdfunite** | [Merge PDF files with pdfunite](https://www.bitdoze.com/pdf-merge-linux-cmd/) into one |

If you work with PDFs from the terminal regularly, poppler-utils is one of those packages you install once on every server. It pairs well with other [essential Linux commands](https://www.bitdoze.com/linux-commands/) you use daily.

## How to install poppler-utils on Linux

Poppler-utils is in the official repositories of every major Linux distribution. Pick your distro below.

&lt;Tabs&gt;
&lt;Tab name=&quot;Ubuntu / Debian / Mint&quot;&gt;
```sh
sudo apt install poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Fedora / RHEL 8+ / CentOS Stream&quot;&gt;
```sh
sudo dnf install poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Arch Linux / Manjaro&quot;&gt;
```sh
sudo pacman -S poppler
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Alpine Linux&quot;&gt;
```sh
sudo apk add poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;openSUSE&quot;&gt;
```sh
sudo zypper install poppler-tools
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Legacy RHEL/CentOS 7&quot;&gt;
If you are still on RHEL/CentOS 7, use `sudo yum install poppler-utils`. On anything newer (RHEL 8+, Fedora, AlmaLinux, Rocky Linux), use `dnf`.
&lt;/Notice&gt;

### Verify installation

Run:

```sh
pdftotext --version
```

Expected output (version will vary by distro):

```
pdftotext version 24.02.0
Copyright 2005-2024 The Poppler Developers - http://poppler.freedesktop.org
Copyright 1996-2011 Glyph &amp; Cog, LLC
```

If you get `command not found`, the package is not installed or not in your PATH. Re-run the install command for your distro.

&lt;Notice type=&quot;info&quot; title=&quot;Check your version for newer features&quot;&gt;
Some options require minimum poppler versions:
- **`-tsv`** needs poppler ≥ 22.05.0 (Ubuntu 22.10+, Fedora 37+, Arch rolling)
- **`-remove-hyphens`** needs poppler &gt;= 26.05.0 (May 2026). Only on rolling distros or manual builds, NOT in Ubuntu 24.04 (ships 24.02.0).

Check with `pdftotext --version` before relying on these features.
&lt;/Notice&gt;

## How to use pdftotext: basic text extraction

### Basic syntax and first extraction

The simplest invocation takes an input PDF and produces a text file:

```sh
pdftotext input.pdf output.txt
```

If you omit the output filename, pdftotext uses the same name with a `.txt` extension:

```sh
pdftotext input.pdf
# Creates input.txt in the current directory
```

Verify it worked:

```sh
wc -l output.txt
```

If the line count is zero or near-zero, the PDF is likely scanned/image-based. See the OCR section below.

### Reading from stdin and writing to stdout

You can pipe PDF data in and text out using `-` as the filename:

```sh
cat input.pdf | pdftotext - -
```

This is useful in scripts and pipelines where you do not want intermediate files:

```sh
pdftotext report.pdf - | grep -i &quot;quarterly revenue&quot;
```

## Customize output format with pdftotext options

pdftotext has many options for controlling how text comes out. They fall into a few groups.

&lt;Accordion label=&quot;Layout control: -layout, -raw, -fixed&quot; group=&quot;options&quot; expanded=&quot;true&quot;&gt;

- **`-layout`**: Preserves the original layout of the PDF, including columns, tables, and spacing. This is my default for multi-column documents.

  ```sh
  pdftotext -layout input.pdf output.txt
  ```

- **`-raw`**: Keeps the original text order but ignores layout positioning. Useful when `-layout` garbles text due to unusual fonts.

  ```sh
  pdftotext -raw input.pdf output.txt
  ```

- **`-fixed number`**: Assumes fixed-pitch (monospace) text with the given character width in points. Useful for PDFs that were generated from fixed-width text.

  ```sh
  pdftotext -fixed 8 input.pdf output.txt
  ```

- **`-colspacing number`**: Controls column detection threshold (default: 0.7). Lower values (e.g., 0.3) detect narrower column gaps; higher values (e.g., 1.5) are more lenient. Adjust when columns are getting merged or split incorrectly.

  ```sh
  pdftotext -layout -colspacing 0.3 input.pdf output.txt
  ```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Page selection: -f and -l&quot; group=&quot;options&quot;&gt;

Extract a specific page range:

```sh
# Extract only pages 5 through 12
pdftotext -f 5 -l 12 input.pdf output.txt
```

- **`-f number`**: First page to extract (1-based).
- **`-l number`**: Last page to extract.

Combine with other options:

```sh
pdftotext -f 1 -l 5 -upw &quot;mypass&quot; -layout report.pdf pages1-5.txt
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Structured output: -bbox, -tsv, -htmlmeta&quot; group=&quot;options&quot;&gt;

These options generate structured output for programmatic processing:

- **`-bbox`**: Generates HTML with bounding box coordinates for each word. Useful for positional text analysis.

  ```sh
  pdftotext -bbox input.pdf output.html
  ```

- **`-tsv`**: Outputs tab-separated values with bounding box data per block, line, and word. Columns: `level`, `page_num`, `par_num`, `block_num`, `line_num`, `word_num`, `left`, `top`, `width`, `height`, `conf`, `text`.

  ```sh
  pdftotext -tsv input.pdf output.tsv
  ```

  &lt;Notice type=&quot;info&quot; title=&quot;TSV mode requires poppler ≥ 22.05.0&quot;&gt;
  Available in Ubuntu 22.10+, Fedora 37+, Arch rolling. Check your version with `pdftotext --version`.
  &lt;/Notice&gt;

- **`-htmlmeta`**: Generates HTML with PDF metadata (title, author) embedded.

  ```sh
  pdftotext -htmlmeta input.pdf output.html
  ```

- **`-bbox-layout`**: Like `-bbox` but preserves layout structure in the HTML output.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Text cleanup: -nodiag, -remove-hyphens, -eol&quot; group=&quot;options&quot;&gt;

- **`-nodiag`**: Discards diagonal text. This is useful for removing watermark text that appears at an angle in the PDF.

  ```sh
  pdftotext -nodiag input.pdf output.txt
  ```

- **`-remove-hyphens all|soft|none`**: Controls how end-of-line hyphens are handled:
  - `all`: Remove all end-of-line hyphens and merge words (default)
  - `soft`: Only remove soft hyphens (U+00AD), keep ASCII hyphens
  - `none`: Keep all hyphens and line breaks

  ```sh
  pdftotext -remove-hyphens all input.pdf output.txt
  pdftotext -remove-hyphens soft input.pdf output.txt
  ```

  &lt;Notice type=&quot;warning&quot; title=&quot;-remove-hyphens requires poppler ≥ 26.05.0&quot;&gt;
  This option was added in May 2026. It is NOT in Ubuntu 24.04 (ships 24.02.0). Only available on rolling distros (Arch) or via manual compile. Has no effect in `-raw` or `-layout` mode.
  &lt;/Notice&gt;

- **`-eol unix|dos|mac`**: Sets the end-of-line convention in the output text. Default is the host OS convention.

  ```sh
  pdftotext -eol dos input.pdf output.txt
  ```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Output encoding and other options: -enc, -nopgbrk, -q, -r&quot; group=&quot;options&quot;&gt;

- **`-enc encoding`**: Sets the output text encoding. Common values: `UTF-8`, `ISO-8859-1`, `ASCII`, `UCS-2`.

  ```sh
  pdftotext -enc UTF-8 input.pdf output.txt
  ```

- **`-nopgbrk`**: Removes form feed characters (`^L`) between pages. Default behavior inserts them.

- **`-q`**: Quiet mode. Suppresses error messages.

- **`-r number`**: Sets the resolution in DPI (default: 72). Affects output of some rendering-dependent options.

- **`-x number -y number -W number -H number`**: Crop area coordinates. Defines a rectangular region to extract text from.

  ```sh
  pdftotext -x 100 -y 100 -W 400 -H 600 input.pdf output.txt
  ```

- **`-cropbox`**: Uses the crop box instead of the media box (with `-bbox`).

&lt;/Accordion&gt;

You can see the full list of options with:

```sh
pdftotext -h
```

## Extract text from password-protected PDFs

Many PDFs have restrictions that prevent copying or printing text. pdftotext can handle two types of passwords.

### Using -opw and -upw options

- **`-opw &quot;password&quot;`** — Owner password. Bypasses all restrictions (printing, copying, modifying). Use this when the PDF lets you open it but blocks text extraction.

  ```sh
  pdftotext -opw &quot;ownerpass&quot; restricted.pdf output.txt
  ```

- **`-upw &quot;password&quot;`** — User password. Required to open the file at all. Use this when the PDF prompts for a password on open.

  ```sh
  pdftotext -upw &quot;userpass&quot; locked.pdf output.txt
  ```

If you get exit code 3 or &quot;Permission denied&quot; errors, the PDF has restrictions. Try `-opw` first.

### Decrypting with qpdf before extraction

For complex encryption or when pdftotext&apos;s password options do not work, decrypt the PDF first with `qpdf`:

```sh
# Install qpdf
sudo apt install qpdf

# Decrypt
qpdf --password=secret --decrypt locked.pdf unlocked.pdf

# Then extract
pdftotext unlocked.pdf output.txt
```

This removes all password protection permanently from `unlocked.pdf`, so handle it accordingly.

## When pdftotext doesn&apos;t work: OCR for scanned PDFs

&lt;Notice type=&quot;warning&quot; title=&quot;pdftotext cannot read scanned PDFs&quot;&gt;
If your PDF was created by scanning paper documents, it contains images, not text. `pdftotext` will produce empty or near-empty output. You need OCR (optical character recognition) to add a text layer.
&lt;/Notice&gt;

This is the number one reason pdftotext &quot;doesn&apos;t work&quot; for people. A scanned PDF looks like it has text on screen, but the text is actually a picture of text.

### How to detect a scanned/image-based PDF

Quick check:

```sh
pdftotext suspect.pdf - | wc -c
```

If the byte count is very low (under 100 bytes for a multi-page document), the PDF is almost certainly scanned. You can also use `pdfinfo`:

```sh
pdfinfo suspect.pdf | grep Pages
```

If it shows many pages but pdftotext extracts almost nothing, it is scanned.

### Solution 1: OCRmyPDF + Tesseract

OCRmyPDF adds an invisible OCR text layer to scanned PDFs, making them searchable and extractable:

```sh
# Install
sudo apt install ocrmypdf tesseract-ocr

# Add OCR text layer
ocrmypdf scanned.pdf searchable.pdf

# Now extract text
pdftotext searchable.pdf output.txt
```

Verify it worked:

```sh
pdftotext searchable.pdf - | wc -c
# Should show substantial byte count
```

For non-English PDFs, install the appropriate Tesseract language pack:

```sh
# French
sudo apt install tesseract-ocr-fra

# German
sudo apt install tesseract-ocr-deu

# Then specify language
ocrmypdf -l fra scanned.pdf searchable.pdf
```

### Solution 2: pdftoppm + tesseract directly

If OCRmyPDF is not available or you need more control over the OCR process, convert PDF pages to images first, then run Tesseract:

```sh
# Convert PDF pages to PPM images at 300 DPI
pdftoppm -r 300 scanned.pdf page

# OCR the first page
tesseract page-1.ppm output

# Output will be in output.txt
```

This approach gives you control over resolution and per-page processing, but you need to loop over multiple pages yourself.

## Alternative command-line tools for PDF text extraction

pdftotext handles most cases, but sometimes you need a different tool. Here is when to reach for something else.

| Tool | When to use it | Install |
|------|---------------|---------|
| **mutool draw** | Complex layouts where pdftotext garbles columns | `sudo apt install mupdf-tools` |
| **pdfgrep** | Search PDFs without extracting everything | `sudo apt install pdfgrep` |
| **xpdf pdftotext** | Tabular data (has `-table` mode poppler lacks) | `sudo apt install xpdf` |
| **calibre ebook-convert** | Ebook workflows, different extraction engine | `sudo apt install calibre` |

If you prefer a web-based GUI for PDF operations, take a look at [self-hosted PDF manipulation with Stirling PDF](https://www.bitdoze.com/stirling-pdf-self-host-manipulation/) — it runs in Docker and handles extraction, conversion, merging, and more through a browser.

### mutool draw (MuPDF)

Sometimes handles complex layouts better than pdftotext:

```sh
mutool draw -F txt input.pdf -o output.txt
```

### pdfgrep — search PDFs without full extraction

When you just need to find a string in a PDF, not extract the whole thing:

```sh
pdfgrep -n &quot;search term&quot; document.pdf

# Search across multiple PDFs
pdfgrep -rn &quot;search term&quot; *.pdf
```

### xpdf pdftotext (with -table mode)

xpdf&apos;s version of pdftotext has options that poppler&apos;s does not, notably `-table` for tabular data and `-simple` for single-column layouts:

```sh
sudo apt install xpdf
pdftotext -table input.pdf output.txt
```

&lt;Notice type=&quot;info&quot; title=&quot;xpdf vs poppler pdftotext&quot;&gt;
These are two different binaries with the same name. On Ubuntu, installing xpdf may change which `pdftotext` is in your PATH. Check with `which pdftotext` and `pdftotext --version` to confirm which one you are running. xpdf&apos;s version shows &quot;xpdf version&quot; in its output; poppler&apos;s shows &quot;poppler&quot; copyright.
&lt;/Notice&gt;

### calibre ebook-convert

A heavier tool, but useful for ebook-oriented workflows:

```sh
ebook-convert input.pdf output.txt
```

## Batch processing and practical scripts

### Extract all PDFs in a directory

```sh
for pdf in *.pdf; do
    pdftotext -layout &quot;$pdf&quot; &quot;${pdf%.pdf}.txt&quot;
done
```

This creates a `.txt` file for every `.pdf` in the current directory.

### Pipe extracted text to grep for searching

Search within a PDF without creating intermediate files:

```sh
pdftotext document.pdf - | grep -i &quot;keyword&quot;
```

After extracting text, you can [transform text case with sed](https://www.bitdoze.com/sed-change-case/) or do further text processing in your pipeline.

### One-liner to verify extraction succeeded

Use this in scripts to check if extraction produced real output:

```sh
test $(wc -c &lt; output.txt) -gt 10 &amp;&amp; echo &quot;OK&quot; || echo &quot;LIKELY SCANNED PDF&quot;
```

Combine with password and page range:

```sh
pdftotext -f 1 -l 5 -upw &quot;mypass&quot; -layout report.pdf pages1-5.txt
test $(wc -c &lt; pages1-5.txt) -gt 10 &amp;&amp; echo &quot;Extraction OK&quot; || echo &quot;Failed or empty&quot;
```

When batch processing, you may also find it useful to [compare folder contents](https://www.bitdoze.com/compare-folders-content-differences/) to verify all expected output files were created.

## Troubleshooting common pdftotext issues

&lt;Accordion label=&quot;Empty output (scanned PDF)&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;

**Symptom:** `pdftotext` produces an empty or near-empty output file.

**Cause:** The PDF is scanned/image-based with no text layer.

**Fix:** Use OCRmyPDF to add an OCR text layer (see the [OCR section](#when-pdftotext-doesnt-work-ocr-for-scanned-pdfs) above).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Garbled or missing text&quot; group=&quot;troubleshooting&quot;&gt;

**Symptom:** Extracted text has wrong characters, missing letters, or garbled output.

**Cause:** The PDF uses custom font encodings that pdftotext cannot map to Unicode.

**Fixes:**
1. Try `-raw` mode to bypass layout processing:
   ```sh
   pdftotext -raw input.pdf output.txt
   ```
2. Force UTF-8 encoding:
   ```sh
   pdftotext -enc UTF-8 input.pdf output.txt
   ```
3. If poppler cannot handle it, try `mutool draw -F txt` as an alternative.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bad column detection&quot; group=&quot;troubleshooting&quot;&gt;

**Symptom:** Columns are merged together or text from different columns is interleaved.

**Cause:** The column detection heuristic does not match the PDF&apos;s layout.

**Fix:** Adjust `-colspacing` (default: 0.7). Lower values detect narrower column gaps:

```sh
pdftotext -layout -colspacing 0.3 input.pdf output.txt
```

Try values between 0.3 and 1.5 until the layout looks right.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Watermark text in output&quot; group=&quot;troubleshooting&quot;&gt;

**Symptom:** Diagonal watermark text (e.g., &quot;CONFIDENTIAL&quot;, &quot;DRAFT&quot;) appears in the extracted text.

**Cause:** pdftotext extracts all text, including diagonal overlays.

**Fix:** Use `-nodiag` to discard diagonal text (poppler ≥ 0.80.0):

```sh
pdftotext -nodiag input.pdf output.txt
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Understanding pdftotext exit codes&quot; group=&quot;troubleshooting&quot;&gt;

For scripting and automation, pdftotext returns meaningful exit codes:

| Exit code | Meaning |
|-----------|---------|
| 0 | Success |
| 1 | Error opening PDF file |
| 2 | Error opening output file |
| 3 | Permission error (PDF has restrictions) |
| 99 | Other error |

Use in scripts:

```sh
pdftotext input.pdf output.txt
case $? in
    0) echo &quot;Success&quot; ;;
    1) echo &quot;Cannot open PDF&quot; ;;
    3) echo &quot;PDF has restrictions — try -opw option&quot; ;;
    *) echo &quot;Unknown error&quot; ;;
esac
```

&lt;/Accordion&gt;

## Conclusion

`pdftotext` from `poppler-utils` is the default tool for extracting text from PDFs on Linux. It handles most cases out of the box: basic conversion, layout preservation, page selection, encoding control, and password-protected files. For scanned PDFs, pair it with OCRmyPDF and Tesseract. For tabular data that pdftotext mangles, try xpdf&apos;s `-table` mode or `mutool draw`.

For more PDF work on the command line, you can [merge PDF files with pdfunite](https://www.bitdoze.com/pdf-merge-linux-cmd/) or [self-host Stirling PDF](https://www.bitdoze.com/stirling-pdf-self-host-manipulation/) for a browser-based toolkit. And if you are building out your Linux admin skills, check out guides on [securing your SSH server](https://www.bitdoze.com/secure-ssh-server-linux/) and [checking remote ports with nc](https://www.bitdoze.com/check-remote-port-in-linux-nc/).</content:encoded><category>linux</category><category>pdf</category><category>command-line</category></item><item><title>How to Merge PDF Files on Linux Command Line (pdfunite)</title><link>https://www.bitdoze.com/pdf-merge-linux-cmd/</link><guid isPermaLink="true">https://www.bitdoze.com/pdf-merge-linux-cmd/</guid><description>Merge PDF files on Linux command line with pdfunite from poppler-utils. Covers installation, usage, alternatives (qpdf, Ghostscript), and troubleshooting.</description><pubDate>Sun, 19 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

You can merge PDF files on Linux command line in seconds using `pdfunite` from the `poppler-utils` package. It is the fastest way to combine PDFs when you are working in a terminal, running batch scripts on a headless server, or automating document pipelines. This guide covers `pdfunite` in depth, then shows when to reach for alternatives like `qpdf` (preserves bookmarks and hyperlinks) and `Ghostscript` (compresses output). If you work with PDFs regularly from the terminal, these are [essential Linux commands](/linux-commands) to have in your toolkit.

## What is poppler-utils?

Poppler-utils is a set of command-line utilities built on the poppler PDF library (a fork of xpdf). It ships with most Linux distros and includes six tools:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;pdfinfo:&lt;/strong&gt; print PDF metadata (title, author, page count, encryption status)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdftotext:&lt;/strong&gt; &lt;a href=&quot;/pdf-extract-text-linux-cmd/&quot;&gt;converts a PDF file to plain text&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdftohtml:&lt;/strong&gt; convert PDF to HTML&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdfimages:&lt;/strong&gt; extract embedded images from a PDF&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdfseparate:&lt;/strong&gt; split a PDF into single-page files&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;pdfunite:&lt;/strong&gt; merge multiple PDF files into one&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

This article focuses on `pdfunite` for merging, with coverage of the other tools where they help in a merge workflow (like `pdfinfo` for verification).

## How to install poppler-utils on Linux

Poppler-utils is in the official repos of every major distro. Install it with your package manager.

&lt;Tabs&gt;
&lt;Tab name=&quot;Debian / Ubuntu&quot;&gt;
```sh
sudo apt install poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;RHEL / Fedora&quot;&gt;
```sh
sudo dnf install poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Arch Linux&quot;&gt;
```sh
sudo pacman -S poppler
```
&lt;/Tab&gt;
&lt;Tab name=&quot;openSUSE&quot;&gt;
```sh
sudo zypper install poppler-tools
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Alpine&quot;&gt;
```sh
sudo apk add poppler-utils
```
&lt;/Tab&gt;
&lt;Tab name=&quot;macOS (Homebrew)&quot;&gt;
```sh
brew install poppler
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Verify installation&quot;&gt;
Run `pdfunite --version` to confirm it is installed. On Ubuntu 24.04 LTS you will see something like:

```
pdfunite version 24.02.0
Copyright 2005-2024 The Poppler Developers - http://poppler.freedesktop.org
Copyright 1996-2011 Glyph &amp; Cog, LLC
```

Version numbers vary by distro. Ubuntu 24.04 ships `24.02.0`, Debian sid has `26.01.0`, and the latest upstream release is `26.07.0` (July 2026). Any of these will work fine for merging PDFs.
&lt;/Notice&gt;

## How to merge PDF files with pdfunite

### Basic syntax

The syntax is simple: list the input PDFs first, then the output filename last.

```sh
pdfunite file1.pdf file2.pdf file3.pdf output.pdf
```

This concatenates `file1.pdf`, `file2.pdf`, and `file3.pdf` in that order into `output.pdf`.

&lt;Notice type=&quot;warning&quot; title=&quot;Output file overwritten without warning&quot;&gt;
pdfunite silently overwrites the output file if it already exists. There is no confirmation prompt. Double-check your output filename before running the command.
&lt;/Notice&gt;

### Merging with wildcards

You can use shell globbing to merge all PDFs in a directory:

```sh
pdfunite *.pdf output.pdf
```

&lt;Notice type=&quot;warning&quot; title=&quot;Wildcard ordering trap&quot;&gt;
The shell expands `*` alphabetically, not numerically. If your files are named `page_1.pdf`, `page_2.pdf`, ..., `page_10.pdf`, the order will be `page_1.pdf, page_10.pdf, page_2.pdf`. That is wrong.

Fix: use zero-padded filenames (`page_01.pdf`, `page_02.pdf`, ..., `page_10.pdf`) or list files explicitly.
&lt;/Notice&gt;

### Merging from a file list

If you have a text file listing the PDFs you want to merge (one filename per line), note that `pdfunite` does **not** support reading from stdin via a `-` flag. Use command substitution instead.

**Simple approach** (works if filenames have no spaces):

```sh
pdfunite $(cat files.txt) output.pdf
```

**Safer approach** (handles filenames with spaces):

```sh
mapfile -t files &lt; files.txt
pdfunite &quot;${files[@]}&quot; output.pdf
```

The `files.txt` file should contain one PDF path per line:

```
report-intro.pdf
report-chapter1.pdf
report-chapter2.pdf
report-appendix.pdf
```

### Verify the merge

After merging, check the page count to confirm all pages are present:

```sh
pdfinfo output.pdf | grep &quot;^Pages:&quot;
```

Compare against the sum of individual page counts:

```sh
pdfinfo file1.pdf | grep &quot;^Pages:&quot;
pdfinfo file2.pdf | grep &quot;^Pages:&quot;
pdfinfo output.pdf | grep &quot;^Pages:&quot;   # should equal the sum
```

Also check the file is not empty:

```sh
ls -lh output.pdf
```

## Limitations of pdfunite (what you should know)

pdfunite is fast and simple, but it concatenates PDFs at the page content level. This means:

- **Hyperlinks are lost.** Internal TOC links and cross-references break after merging.
- **Bookmarks/outlines are stripped.** PDF outlines (the sidebar navigation in viewers like Evince or Acrobat) disappear.
- **No encryption support.** You cannot merge password-protected PDFs directly.
- **No page manipulation.** You cannot rotate, reorder, or extract specific pages.
- **No compression.** The output file can be larger than expected.

&lt;Notice type=&quot;error&quot; title=&quot;pdfunite strips hyperlinks and bookmarks&quot;&gt;
If your PDFs have a table of contents with clickable links or PDF outlines/bookmarks, do not use pdfunite. Use [qpdf](#qpdf--preserve-hyperlinks-and-bookmarks) instead. It preserves both.
&lt;/Notice&gt;

For simple merges (combining invoices, scanned pages, or reports without internal links), pdfunite is the right tool. If you need bookmarks or hyperlinks to survive, switch to qpdf.

## Alternative tools for merging PDFs on Linux

When pdfunite is not enough, these three tools cover the gaps.

&lt;Tabs&gt;
&lt;Tab name=&quot;qpdf&quot;&gt;

### qpdf: preserve hyperlinks and bookmarks

qpdf is the best alternative when you need to preserve PDF structure. It handles encryption, page ranges, and keeps bookmarks and hyperlinks intact.

**Install:**

```sh
# Debian / Ubuntu
sudo apt install qpdf

# RHEL / Fedora
sudo dnf install qpdf

# Arch Linux
sudo pacman -S qpdf
```

**Merge PDFs:**

```sh
qpdf --empty --pages file1.pdf file2.pdf file3.pdf -- output.pdf
```

**Merge specific page ranges:**

```sh
# Pages 1-5 from file1, all pages from file2
qpdf --empty --pages file1.pdf 1-5 file2.pdf 1-z -- output.pdf
```

**Decrypt then merge:**

```sh
qpdf --decrypt --password=SECRET encrypted.pdf decrypted.pdf
pdfunite decrypted.pdf other.pdf merged.pdf
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Ghostscript&quot;&gt;

### Ghostscript: merge and compress

Ghostscript is heavier (~30 MB install) but does something no other lightweight tool does: compress PDFs during or after merging. Good for reducing file size before uploading or emailing.

**Install:**

```sh
# Debian / Ubuntu
sudo apt install ghostscript

# RHEL / Fedora
sudo dnf install ghostscript

# Arch Linux
sudo pacman -S ghostscript
```

**Merge and compress in one step:**

```sh
gs -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite \
   -dPDFSETTINGS=/ebook \
   -sOutputFile=merged.pdf file1.pdf file2.pdf
```

**Compress an existing PDF:**

```sh
gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 \
   -dPDFSETTINGS=/ebook -dNOPAUSE -dQUIET -dBATCH \
   -sOutputFile=compressed.pdf merged.pdf
```

**PDFSETTINGS quality levels:**

| Setting | DPI | Use case |
|---|---|---|
| `/screen` | 72 | Smallest file, screen-only reading |
| `/ebook` | 150 | Good balance of size and quality |
| `/printer` | 300 | High quality for printing |
| `/prepress` | 300 | Color-preserving, largest file |

&lt;/Tab&gt;
&lt;Tab name=&quot;stapler&quot;&gt;

### stapler: lightweight pdftk replacement

pdftk was removed from Fedora repos in 2019 and is hard to install on modern Ubuntu due to its gcj dependency. `stapler` is a Python-based replacement that does page-level operations.

**Install:**

```sh
# Via pip (any distro)
pip install stapler

# Fedora
sudo dnf install pdf-stapler
```

**Merge PDFs:**

```sh
stapler sel file1.pdf file2.pdf output.pdf
```

stapler also supports page selection, splitting, and other pdftk-like operations. If you need a pdftk-compatible interface without the install headaches, stapler is the tool to use.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Tool comparison table

| Feature | pdfunite | qpdf | Ghostscript | stapler |
|---|---|---|---|---|
| Simple merge | ✅ Best | ✅ Good | ✅ Works | ✅ Good |
| Preserve hyperlinks | ❌ | ✅ | ❌ | ✅ |
| Preserve bookmarks | ❌ | ✅ | ❌ | ✅ |
| Handle encrypted PDFs | ❌ | ✅ Best | Limited | Limited |
| Compress output | ❌ | Limited | ✅ Best | ❌ |
| Page-level ops | ❌ | ✅ | Limited | ✅ Best |
| Speed | Very fast | Fast | Slower | Fast |
| Package size | under 1 MB | ~3 MB | ~30 MB | ~14 MB |
| In modern distro repos | ✅ | ✅ | ✅ | ❌ (pdftk removed) |

&lt;Notice type=&quot;info&quot; title=&quot;Which tool should I use?&quot;&gt;
Start with **pdfunite** for simple merges. It is the fastest and lightest. Switch to **qpdf** when bookmarks, hyperlinks, or encrypted PDFs matter. Use **Ghostscript** when you need to compress the output. Use **stapler** when you need pdftk-style page manipulation.
&lt;/Notice&gt;

## Real-world workflows and automation

### Batch merge script with validation

This script validates all input files exist, merges them, and reports the page count:

```sh
#!/bin/bash
set -euo pipefail

OUTPUT=&quot;${1:-merged.pdf}&quot;
shift

# Validate all inputs exist
for f in &quot;$@&quot;; do
    [[ -f &quot;$f&quot; ]] || { echo &quot;ERROR: File not found: $f&quot; &gt;&amp;2; exit 1; }
done

pdfunite &quot;$@&quot; &quot;$OUTPUT&quot;
echo &quot;Created: $OUTPUT ($(pdfinfo &quot;$OUTPUT&quot; | grep &quot;^Pages:&quot; | awk &apos;{print $2}&apos;) pages)&quot;
```

Usage:

```sh
chmod +x merge-pdfs.sh
./merge-pdfs.sh combined-report.pdf chapter1.pdf chapter2.pdf chapter3.pdf
```

For more advanced automation workflows, you can integrate PDF processing into tools like [n8n for document processing pipelines](/n8n-self-host-workflow-automation).

### Handling encrypted PDFs

pdfunite cannot process password-protected PDFs. Decrypt them first with qpdf:

```sh
# Decrypt
qpdf --decrypt --password=THEPASS protected.pdf unprotected.pdf

# Then merge as usual
pdfunite unprotected.pdf other.pdf merged.pdf

# Clean up the decrypted file
rm unprotected.pdf
```

### Verifying the merge

Always verify after merging, especially in scripts:

```sh
# Check total page count matches sum of inputs
EXPECTED=$(pdfinfo file1.pdf | grep &quot;^Pages:&quot; | awk &apos;{sum += $2} END {print sum}&apos;)
EXPECTED=$((EXPECTED + $(pdfinfo file2.pdf | grep &quot;^Pages:&quot; | awk &apos;{print $2}&apos;)))
ACTUAL=$(pdfinfo output.pdf | grep &quot;^Pages:&quot; | awk &apos;{print $2}&apos;)

if [[ &quot;$EXPECTED&quot; -eq &quot;$ACTUAL&quot; ]]; then
    echo &quot;OK: $ACTUAL pages&quot;
else
    echo &quot;MISMATCH: expected $EXPECTED, got $ACTUAL&quot; &gt;&amp;2
    exit 1
fi
```

### Keeping poppler-utils updated (security)

&lt;Notice type=&quot;warning&quot; title=&quot;Keep poppler-utils updated&quot;&gt;
Poppler processes arbitrary PDF files, and it has had multiple CVEs (CVE-2026-10118, CVE-2025-52885, CVE-2025-43718, and others). If you are merging PDFs from untrusted sources, keep the package updated and consider [running CLI tools in isolated environments](/docker-podman-ai-cli-tools-safe-environment).

```sh
# Debian / Ubuntu
sudo apt update &amp;&amp; sudo apt upgrade poppler-utils

# RHEL / Fedora
sudo dnf upgrade poppler-utils
```
&lt;/Notice&gt;

&lt;Button text=&quot;Automate document workflows with n8n&quot; link=&quot;/n8n-self-host-workflow-automation&quot; variant=&quot;outline&quot; color=&quot;blue&quot; /&gt;

## Troubleshooting common errors

&lt;Accordion label=&quot;Permission denied&quot; group=&quot;troubleshooting&quot;&gt;

You do not have read or write permission on the PDF files.

```sh
# Check permissions
ls -l file.pdf

# Fix: give owner read+write
chmod u+rw file.pdf
```

If the files are owned by another user, you may need `sudo` or to change ownership with `chown`.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;File not found&quot; group=&quot;troubleshooting&quot;&gt;

The file does not exist or the filename is wrong.

```sh
# Check your working directory
pwd

# List PDFs in the current directory
ls *.pdf

# Use tab completion to avoid typos
pdfunite file1&lt;TAB&gt;
```

If you are running the command in a script, use absolute paths or verify the working directory first.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Invalid or damaged PDF file&quot; group=&quot;troubleshooting&quot;&gt;

The PDF is corrupted. Unlike what some guides claim, `pdfinfo` does **not** have a `-repair` flag. Use one of these tools to attempt repair:

**With qpdf** (rebuilds the PDF structure):

```sh
qpdf --check damaged.pdf
qpdf --qdf damaged.pdf repaired.pdf
```

**With Ghostscript** (rewrites the entire PDF):

```sh
gs -o repaired.pdf -sDEVICE=pdfwrite -dPDFSETTINGS=/prepress damaged.pdf
```

**With pdftk** (if installed):

```sh
pdftk damaged.pdf output repaired.pdf
```

If none of these work, the file is likely beyond repair. Compare file sizes with [comparing folders on the command line](/compare-folders-content-differences) if you have a backup to check against.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Command not found: pdfunite&quot; group=&quot;troubleshooting&quot;&gt;

poppler-utils is not installed. Verify:

```sh
which pdfunite
# No output = not installed
```

Go back to the [installation section](#how-to-install-poppler-utils-on-linux) and install it for your distro.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Memory issues with large PDFs&quot; group=&quot;troubleshooting&quot;&gt;

pdfunite loads full PDFs into memory. If you are merging files that are hundreds of MB each, you may run into memory pressure. Check your system resources:

```sh
# Check available memory
free -h

# Monitor swap usage
swapon --show
```

For very large files, consider Ghostscript which uses a streaming approach:

```sh
gs -dBATCH -dNOPAUSE -q -sDEVICE=pdfwrite \
   -dPDFSETTINGS=/prepress \
   -sOutputFile=merged.pdf large1.pdf large2.pdf
```

If you need to [monitor Linux system resources](/swap-usage-linux) during batch operations, set up alerts so you catch OOM conditions early.

&lt;/Accordion&gt;

## Conclusion

For merging PDF files on the Linux command line, the tool you pick depends on what you need:

- **pdfunite:** default choice for simple merges. Fast, tiny, already installed on most systems.
- **qpdf:** use when bookmarks, hyperlinks, or encrypted PDFs are involved.
- **Ghostscript:** use when you need to compress the output or merge very large files.
- **stapler:** use when you need pdftk-style page manipulation.

Companion poppler tools are useful in a merge workflow: `pdfinfo` to check page counts and encryption status before merging, `pdfseparate` to split a PDF (the reverse operation), and `pdftotext` to extract text for verification.

If you run a VPS or headless server and process PDFs regularly, keep poppler-utils updated and consider [securing your Linux server](/secure-ssh-server-linux) if it handles files from external sources.

&lt;Button text=&quot;Explore more Linux commands&quot; link=&quot;/linux-commands&quot; variant=&quot;outline&quot; color=&quot;blue&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;Can pdfunite merge password-protected PDFs?&quot; group=&quot;faq&quot;&gt;
No. pdfunite cannot process encrypted PDFs. Decrypt them first with qpdf:

```sh
qpdf --decrypt --password=THEPASS protected.pdf unprotected.pdf
pdfunite unprotected.pdf other.pdf merged.pdf
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does pdfunite preserve bookmarks and hyperlinks?&quot; group=&quot;faq&quot;&gt;
No. pdfunite concatenates page content at a low level, which strips bookmarks (outlines) and breaks internal hyperlinks. If you need these preserved, use qpdf instead:

```sh
qpdf --empty --pages file1.pdf file2.pdf -- output.pdf
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the difference between pdfunite and qpdf?&quot; group=&quot;faq&quot;&gt;
pdfunite is simpler and lighter (under 1 MB, fastest). It is great for basic merges where you do not care about bookmarks or hyperlinks. qpdf is heavier (~3 MB) but preserves PDF structure, handles encrypted files, supports page ranges, and can decrypt PDFs. For most people, start with pdfunite and switch to qpdf when you hit a limitation.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I merge PDFs in a specific order?&quot; group=&quot;faq&quot;&gt;
List the files explicitly in the order you want:

```sh
pdfunite intro.pdf chapter1.pdf chapter2.pdf appendix.pdf output.pdf
```

If using wildcards, shell glob expansion is alphabetical: `page_1.pdf` comes before `page_10.pdf`, not `page_2.pdf`. Use zero-padded filenames (`page_01.pdf`, `page_02.pdf`, ..., `page_10.pdf`) to get correct numerical ordering.
&lt;/Accordion&gt;</content:encoded><category>linux</category><category>pdf</category><category>command-line</category></item><item><title>How to Choose Between Fork and Cluster Mode in PM2</title><link>https://www.bitdoze.com/pm2-fork-cluster/</link><guid isPermaLink="true">https://www.bitdoze.com/pm2-fork-cluster/</guid><description>PM2 fork vs cluster mode: key differences, when to use each, plus graceful reload, zero-downtime restart, and production best practices for Node.js applications.</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

PM2 fork vs cluster mode is one of the first decisions you face when running Node.js applications in production. [PM2](https://pm2.keymetrics.io/) is the most widely used Node.js process manager (43,000+ GitHub stars, 600M+ npm downloads), and it gives you two fundamentally different ways to run your apps. Pick the wrong mode and you either waste resources or break features like graceful reload.

PM2 v7.0.3 (June 2026) is the current release. It requires Node.js &gt;= 18, adds Bun runtime support, ships OpenTelemetry tracing, and includes native source maps. If you&apos;re running an older PM2, the upgrade path matters. More on that below.

This guide covers what each mode does, the real differences (including some corrections to what you&apos;ll find elsewhere), when to pick each one, and the production best practices that keep your apps alive.

For a broader PM2 tutorial, see [managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/).

&lt;Notice type=&quot;info&quot; title=&quot;Quick Decision: Fork or Cluster?&quot;&gt;

| Use Fork Mode When | Use Cluster Mode When |
|---|---|
| Background jobs, cron, workers, scripts | HTTP, TCP, or WebSocket servers |
| Non-Node.js apps (Python, Ruby, binaries) | Node.js-only apps |
| Different Node versions per app | All apps on same Node version |
| Memory-constrained VPS (1-2 GB) | Multi-core VPS (2+ cores, 2+ GB) |
| Development or local testing | Production with uptime requirements |

&lt;/Notice&gt;

## What is fork mode in PM2?

Fork mode is PM2&apos;s default. It spawns a single process per application using Node&apos;s `child_process.fork()`. One process, one app.

Fork mode is the right choice for:

- **Scripts, workers, and cron jobs:** anything that doesn&apos;t listen on a port.
- **Non-Node.js runtimes:** fork mode can run Python, Ruby, or arbitrary binaries.
- **Apps needing different Node versions:** use `node_args` to point at a specific binary.
- **Development and testing:** simpler, lower memory footprint.

### Basic fork mode usage

```sh
# Run app.js in fork mode (default)
pm2 start app.js
```

Or with an ecosystem file:

```js
// ecosystem.config.js
module.exports = {
  apps: [{
    script: &quot;app.js&quot;,
    exec_mode: &quot;fork&quot;
  }]
};
```

```sh
pm2 start ecosystem.config.js
```

A common misconception: features like cron restarts, source map support, and custom log formats are **not** fork-mode exclusives. These are general PM2 attributes that work in both fork and cluster mode. PM2 v7 replaced the `source-map-support` npm dependency with native `process.setSourceMapsEnabled()`, so source maps work everywhere without extra packages.

For managing [PM2 environment variables](https://www.bitdoze.com/pm2-env-vars/) across modes, see the linked guide.

## What is cluster mode in PM2?

Cluster mode spawns multiple worker processes using the Node.js `cluster` module. Each worker is a separate OS process running your app, and they share the same server port via IPC handle sharing. That&apos;s a Node.js feature, not something PM2 invented. The cluster module handles round-robin load balancing across workers.

### Starting cluster mode

```sh
# Run with as many workers as CPU cores
pm2 start app.js -i max

# Run with exactly 4 workers
pm2 start app.js -i 4
```

With an ecosystem file:

```js
// ecosystem.config.js
module.exports = {
  apps: [{
    script: &quot;app.js&quot;,
    instances: &quot;max&quot;,
    exec_mode: &quot;cluster&quot;
  }]
};
```

Since PM2 v7, setting `instances` on a Node.js app automatically enables cluster mode. Explicit `exec_mode: &quot;cluster&quot;` is optional but recommended for clarity. Your future self will thank you.

The `instances` values:

- `&quot;max&quot;` or `0`: one worker per CPU core
- `-1`: all cores minus one (leaves headroom for the OS)
- Any number: that many workers

### Cluster mode advantages

- **Multi-core utilization:** your app handles parallel requests across all cores.
- **Fault tolerance:** if one worker crashes, PM2 restarts it while the others keep serving.
- **Graceful reload / zero-downtime restart:** `pm2 reload` replaces workers one-by-one (covered in detail below).

### Cluster mode limitations

- **Higher memory usage:** each worker is a separate process. 4 workers at 200 MB each = 800 MB minimum.
- **Requires the same Node.js version** for all workers (cluster module limitation). Bun is supported in PM2 v7+ but has edge cases. Test thoroughly.
- **Requires stateless applications.** This is the gotcha most tutorials skip.

&lt;Notice type=&quot;warning&quot; title=&quot;Stateless Apps Required&quot;&gt;

If your app stores session data in memory, uses in-process caches, or relies on shared mutable state, cluster mode will break it. Worker A gets a request, stores the session. Worker B gets the next request and has no idea about that session.

Use Redis, Memcached, or your database for shared state. For WebSocket apps, see the sticky session section below.

&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Memory Math&quot;&gt;

4 instances x 200 MB = 800 MB RAM. On a [2 GB Hetzner Cloud VPS](https://go.bitdoze.com/hetzner), you can safely run about 8 instances total across all clustered apps (leaving room for the OS and PM2 itself). Plan your instance count before you hit OOM.

&lt;/Notice&gt;

## PM2 fork vs cluster mode: key differences

| Feature | Fork Mode | Cluster Mode |
|---|---|---|
| Process model | Single process per app | Multiple workers per app |
| Load balancing | None (or external) | Automatic (Node.js cluster module) |
| Graceful reload (`pm2 reload`) | Falls back to restart (brief downtime) | True rolling restart (zero downtime) |
| Port sharing | Separate ports required | Same port via IPC handle sharing |
| Non-Node.js runtimes | Supported (Python, Ruby, binaries) | Node.js only (Bun supported in v7+) |
| Multiple Node versions | Supported via `node_args` | Single version (cluster module limitation) |
| Cron restarts | Yes | Yes |
| Source map support | Yes (native in v7+) | Yes (native in v7+) |
| Custom log formats | Yes | Yes |
| Memory per instance | Lower (single process) | Higher (N x single instance) |
| Best for | Workers, scripts, cron, mixed runtimes | HTTP/TCP servers, production web apps |

If you&apos;re sizing a VPS for cluster mode, [benchmark your VPS](https://www.bitdoze.com/benchmark-cloud-servers/) first to understand the actual headroom.

Cluster mode is usually the better default for production web apps that serve HTTP traffic. Fork mode wins for non-networked workloads, background jobs, and environments where you need multiple runtimes or Node versions.

## Graceful reload and zero-downtime restart in PM2

Graceful reload and zero-downtime restart are what separate a dev setup from a production deployment. Most PM2 guides skip this part.

### pm2 reload vs pm2 restart

These are different commands with different behavior:

```bash
pm2 reload my-app     # rolling restart (cluster) / restart (fork)
pm2 restart my-app    # immediate kill + start
```

- **`pm2 restart`** kills all processes, then starts them. Brief downtime in fork mode. Full downtime in cluster mode (all workers killed at once).
- **`pm2 reload`** in cluster mode is a true rolling restart. Workers are replaced one-by-one; at least one worker is always serving traffic. In fork mode, `pm2 reload` falls back to `pm2 restart` (downtime), because there&apos;s only one process. There&apos;s nothing to roll.

For zero-downtime deploys in cluster mode, always use `pm2 reload`.

### Application-side graceful shutdown (SIGINT handling)

Setting `wait_ready: true` in your ecosystem config tells PM2 to wait for a &quot;ready&quot; signal from your app before considering it started. But that signal has to come from your code. When PM2 sends a SIGINT to reload a worker, your app needs to handle it: close the server, flush connections, exit cleanly.

Without this code, PM2 will wait for `listen_timeout`, then force-kill the process. That&apos;s not graceful. It&apos;s a timeout with data loss risk.

```js
const express = require(&apos;express&apos;);
const app = express();

// ... your routes and middleware ...

const PORT = process.env.PORT || 3000;

const server = app.listen(PORT, () =&gt; {
  console.log(`Worker ${process.pid} listening on ${PORT}`);
  // Tell PM2 the worker is ready (required with wait_ready: true)
  if (process.send) {
    process.send(&apos;ready&apos;);
  }
});

// Handle PM2&apos;s graceful reload signal
process.on(&apos;SIGINT&apos;, () =&gt; {
  console.log(&apos;SIGINT received, closing server...&apos;);
  server.close(() =&gt; {
    // Close DB connections, flush logs, release resources
    console.log(&apos;Server closed, exiting&apos;);
    process.exit(0);
  });
  // Safety: force exit if not closed within kill_timeout
  setTimeout(() =&gt; {
    console.error(&apos;Forced shutdown after timeout&apos;);
    process.exit(1);
  }, 5000);
});
```

The ecosystem config to pair with this:

```js
module.exports = {
  apps: [{
    name: &quot;my-api&quot;,
    script: &quot;./app.js&quot;,
    instances: &quot;max&quot;,
    exec_mode: &quot;cluster&quot;,
    wait_ready: true,        // wait for process.send(&apos;ready&apos;)
    listen_timeout: 5000,    // ms to wait for &apos;ready&apos; signal
    kill_timeout: 5000,      // ms to wait for clean exit after SIGINT
  }]
};
```

&lt;Notice type=&quot;warning&quot; title=&quot;Without This Code, Graceful Reload Won&apos;t Work&quot;&gt;

If your app doesn&apos;t handle SIGINT and call `process.send(&apos;ready&apos;)`, PM2 will wait for `listen_timeout` then force-kill. Your users get dropped connections. Add the shutdown handler above before enabling `wait_ready`.

&lt;/Notice&gt;

## PM2 cluster mode best practices for production

### Production ecosystem configuration

Generate a starter file with `pm2 init simple` (or `pm2 ecosystem`, both still work). Then build it out:

```js
// ecosystem.config.js
module.exports = {
  apps: [{
    name: &quot;my-api&quot;,
    script: &quot;./app.js&quot;,
    instances: &quot;max&quot;,
    exec_mode: &quot;cluster&quot;,              // explicit, recommended for clarity
    max_memory_restart: &quot;300M&quot;,         // auto-restart if worker exceeds 300 MB
    wait_ready: true,                   // wait for process.send(&apos;ready&apos;)
    listen_timeout: 5000,               // ms to wait for ready signal
    kill_timeout: 5000,                 // ms to wait for clean shutdown
    max_restarts: 10,                   // restart limit within min_uptime window
    min_uptime: &quot;10s&quot;,                  // if process runs less than 10s, it&apos;s a crash
    log_date_format: &quot;YYYY-MM-DD HH:mm:ss Z&quot;,
    merge_logs: true,                   // merge worker logs into one file
    env: {
      NODE_ENV: &quot;development&quot;,
    },
    env_production: {
      NODE_ENV: &quot;production&quot;,
    },
  }]
};
```

Start in production:

```bash
pm2 start ecosystem.config.js --env production
```

### Instance scaling strategy

Not every app needs `instances: &quot;max&quot;`. Scale based on your workload:

- **CPU-bound apps:** `instances: &quot;max&quot;` (one per core).
- **I/O-bound apps:** can try 1.5x-2x core count; watch memory.
- **Memory-constrained VPS:** calculate `available_RAM / per_instance_RAM` = safe max.

Dynamic scaling without restart:

```bash
pm2 scale my-app +2     # add 2 more workers
pm2 scale my-app 4      # scale to exactly 4 workers
```

### Environment variable management

A common gotcha: when you restart via CLI with new env vars, they won&apos;t update unless you pass `--update-env`:

```bash
NODE_ENV=production pm2 restart my-app --update-env
```

Ecosystem files always update env vars on restart or reload. Use them when possible. See [PM2 environment variables](https://www.bitdoze.com/pm2-env-vars/) for the full details.

### Security: filter_env

If your VPS has other services or users, prevent sensitive env vars from leaking into PM2 child processes with `filter_env`:

```js
// In ecosystem.config.js
module.exports = {
  apps: [{
    name: &quot;my-api&quot;,
    script: &quot;./app.js&quot;,
    // Only pass these env var prefixes to the worker
    filter_env: [&quot;NODE_&quot;, &quot;APP_&quot;, &quot;DB_&quot;],
    // Or strip ALL global env vars:
    // filter_env: true,
    // ...
  }]
};
```

For broader VPS hardening, see [securing your VPS server](https://www.bitdoze.com/crowdsec-secure-server/).

### Log rotation

By default, PM2 logs grow until your disk is full. Install the log rotation module:

```bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 30
pm2 set pm2-logrotate:compress true
```

This keeps logs to 10 MB per file, retains 30 rotated files, and compresses old ones. Essential for any production VPS.

For broader monitoring, see [monitoring CPU usage on Linux](https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/).

&lt;ListCheck&gt;

**Production checklist**

- Set `max_memory_restart` to prevent memory leaks from crashing the box
- Set `wait_ready: true` and implement SIGINT handling in your app
- Set `max_restarts` and `min_uptime` to catch crash loops
- Use `merge_logs: true` to keep log files manageable
- Install `pm2-logrotate` to prevent disk fill
- Use ecosystem files (not CLI flags) for reproducible deploys
- Run `pm2 save` and `pm2 startup` to survive reboots
- Keep Node.js and dependencies updated. See [keeping your Node.js dependencies updated](https://www.bitdoze.com/nodejs-update-dependencies/)

&lt;/ListCheck&gt;

## PM2 in containers: pm2-runtime for Docker

If you&apos;re running PM2 inside a Docker container, use `pm2-runtime` instead of `pm2 start`.

Regular `pm2 start` spawns a daemon process, wrong for containers. The container starts, PM2 forks, the main process exits, and Docker thinks the container is done. `pm2-runtime` stays in the foreground and streams logs to stdout/stderr, which is what Docker expects.

```dockerfile
FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY . .

CMD [&quot;pm2-runtime&quot;, &quot;start&quot;, &quot;ecosystem.config.js&quot;]
```

Or without an ecosystem file:

```dockerfile
CMD [&quot;pm2-runtime&quot;, &quot;npm&quot;, &quot;--&quot;, &quot;start&quot;]
```

One decision: in Docker, cluster mode scales via `instances` in the ecosystem file **or** via Docker/Kubernetes replicas. Pick one, don&apos;t do both, or you&apos;ll get N x M workers and wonder why your container is OOM-killed.

For more on container commands, see [Docker commands for production](https://www.bitdoze.com/docker-commands/). Fork mode in PM2 can also run [Python apps in Docker](https://www.bitdoze.com/docker-run-python/) or other non-Node.js runtimes.

## WebSocket and sticky session considerations

Cluster mode uses round-robin load balancing by default. For HTTP requests, that&apos;s fine. Each request is independent. For WebSocket connections, it&apos;s a problem. The initial handshake lands on Worker A, but subsequent frames might get routed to Worker B, which has no idea about that connection.

Three solutions:

1. **Sticky sessions at the reverse proxy:** Nginx: `ip_hash`. HAProxy: `cookie`-based stickiness. This pins a client to one worker.
2. **Redis adapter for socket.io:** `@socket.io/redis-adapter` lets workers share connection state. Works well but adds Redis as a dependency.
3. **Separate WebSocket server:** run your WebSocket handler in fork mode on a different port, alongside your clustered HTTP server. Simple, no shared state needed.

Pick the option that matches your architecture. If you&apos;re running a single VPS with Nginx in front, sticky sessions are the easiest path.

## PM2 v7: what&apos;s new (2025-2026)

PM2 v7.0.0 (May 2026) was a major release. Key changes relevant to the fork vs cluster decision:

- **Node.js &gt;= 18 required.** PM2 dropped Node 16 support. If you&apos;re still on 16, you need to upgrade first.
- **Bun runtime support.** Fork mode since v6.0.5, cluster mode since v7.0.0. Caveat: Bun + cluster has edge cases (see GitHub issues). Test thoroughly before deploying.
- **Native source maps.** Replaced the `source-map-support` npm dependency with `process.setSourceMapsEnabled()`. Works in both modes, no extra packages needed.
- **OpenTelemetry built-in.** `@opentelemetry/api`, `sdk-node`, and `auto-instrumentations-node` are direct dependencies. Caveat: in cluster mode, workers may report duplicate spans (known OpenTelemetry issue).
- **`pm2 ls` shows host metrics by default** (v7.0.2). CPU, memory, disk at a glance.
- **`max_memory_restart` shown in `pm2 describe`** (v7.0.1). Easier to audit memory limits.
- **Security fixes.** CVE-2025-5891 (ReDoS), CVE-2026-27699 (proxy-agent), three command injection fixes, prototype pollution fix. If you&apos;re on v5 or v6, these are good reasons to upgrade.

&lt;Notice type=&quot;info&quot; title=&quot;Upgrading from PM2 v5/v6?&quot;&gt;

PM2 v7 requires Node.js &gt;= 18. Check your Node version first (`node -v`). The security fixes alone justify the upgrade. Run `npm install -g pm2@latest` then `pm2 update` to reload your processes with the new PM2 binary. If you&apos;re using Bun, see [updating packages with Bun](https://www.bitdoze.com/bun-update-packages/) for the upgrade workflow.

&lt;/Notice&gt;

## Troubleshooting PM2 fork and cluster issues

&lt;Accordion label=&quot;EADDRINUSE: port already in use&quot; group=&quot;faq&quot;&gt;

In fork mode, two apps can&apos;t share a port. If you see `EADDRINUSE`, another process (or another PM2 app) is already listening on that port.

```bash
# Check what&apos;s using the port
lsof -i :3000

# Check PM2 apps for conflicts
pm2 list
```

In cluster mode, workers share the port automatically. You won&apos;t hit this within one app. But two different cluster-mode apps still can&apos;t share a port.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Workers crashing in a loop&quot; group=&quot;faq&quot;&gt;

If you see `pm2 restart count exceeded` in the logs, your workers are crashing faster than `min_uptime` allows. PM2 stops restarting after `max_restarts` attempts.

```bash
# Check logs for the root cause
pm2 logs my-app --lines 100

# Describe the app for restart counts
pm2 describe my-app
```

Fix the underlying bug first. If it&apos;s a transient issue (e.g., database not ready), increase `max_restarts` or lower `min_uptime` temporarily.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Graceful reload not working&quot; group=&quot;faq&quot;&gt;

If `pm2 reload` kills connections instead of draining them, your app probably isn&apos;t handling SIGINT or isn&apos;t sending `process.send(&apos;ready&apos;)`. See the graceful shutdown code in the best practices section above.

Verify:

```bash
# Check if wait_ready is enabled
pm2 describe my-app | grep wait_ready

# Check logs for &quot;SIGINT received&quot; message during reload
pm2 logs my-app
```

If you don&apos;t see &quot;SIGINT received&quot; in the logs during reload, your handler isn&apos;t registered.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Memory leaks / OOM&quot; group=&quot;faq&quot;&gt;

Use `max_memory_restart` to auto-restart workers that exceed a memory threshold. This catches leaks before they crash the whole VPS.

```bash
# Watch memory in real time
pm2 monit
```

If a specific worker keeps hitting the limit, it&apos;s leaking. Fix the code. `max_memory_restart` is a band-aid, not a cure.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;WebSocket connections dropping in cluster mode&quot; group=&quot;faq&quot;&gt;

PM2&apos;s cluster mode uses round-robin by default. WebSocket connections that start on one worker may get routed to another on reconnect. See the sticky session section above for solutions: Nginx `ip_hash`, Redis adapter, or a separate fork-mode WebSocket server.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Environment variables not updating&quot; group=&quot;faq&quot;&gt;

When restarting via CLI with new env vars, you must pass `--update-env`:

```bash
NODE_ENV=production pm2 restart my-app --update-env
```

Ecosystem files always update env vars on restart/reload. This is the most common PM2 gotcha in CI/CD pipelines. Your deploy script sets a new env var, restarts, and nothing changes.

&lt;/Accordion&gt;

### Quick verification commands

After deploying, confirm everything is running as expected:

```bash
pm2 list          # check mode column (fork/cluster), instance count, status
pm2 describe 0    # detailed info: exec_mode, restart count, memory limits
pm2 monit         # live CPU/memory per instance
pm2 logs          # check for startup errors
```

## Conclusion

The old &quot;fork for features, cluster for scaling&quot; split is outdated. Cron restarts, source maps, and custom log formats work in both modes. Choose based on your app&apos;s architecture:

- **Cluster mode** for production HTTP/TCP servers that need uptime and multi-core utilization. The zero-downtime reload alone makes it worth the extra memory.
- **Fork mode** for background workers, scripts, cron jobs, non-Node.js runtimes, and environments where you need different Node versions per app.

If you&apos;re running on a budget VPS like [Hetzner Cloud](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps), cluster mode with 2 to 4 instances works fine on a 2 to 4 GB plan. Just do the memory math first.

For the full PM2 tutorial covering process management, monitoring, and deployment, see [managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/). If you&apos;re looking at broader server management options, check out [self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) for alternatives to manual PM2 management.

&lt;Button text=&quot;PM2 Management Tutorial&quot; link=&quot;https://www.bitdoze.com/pm2-manage-apps/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>tools</category><category>pm2</category><category>node</category><category>process-management</category></item><item><title>Streamlit vs Taipy 2026: Best Python Web App Framework?</title><link>https://www.bitdoze.com/streamlit-vs-taipy/</link><guid isPermaLink="true">https://www.bitdoze.com/streamlit-vs-taipy/</guid><description>Streamlit vs Taipy comparison for 2026. Covers native auth, production features, pricing, performance, and when to choose each Python framework for data apps.</description><pubDate>Sat, 18 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

The Streamlit vs Taipy debate has shifted a lot since these tools first gained traction. Both are Python web app frameworks that turn scripts into interactive apps without writing HTML or JavaScript. Both are free, open-source (Apache 2.0), and actively maintained. The gap between them, and the gaps each one has closed, makes the 2026 comparison very different from even two years ago.

This article covers current versions (Streamlit v1.59.0, Taipy v4.1.1), production features, pricing, self-hosting, and a decision framework to help you pick the right tool for your Python data apps. If you want a broader view of the ecosystem, see our [comprehensive guide to Python web frameworks](/best-python-web-frameworks/).

&lt;Notice type=&quot;info&quot; title=&quot;Updated for 2026&quot;&gt;
This article was originally published in January 2024 and has been substantially rewritten to reflect Streamlit v1.59 and Taipy v4.1. Version numbers, code examples, and feature claims have been verified against current documentation.
&lt;/Notice&gt;

## What&apos;s changed since 2024: Streamlit vs Taipy at a glance

&lt;Notice type=&quot;error&quot; title=&quot;Correction&quot;&gt;
A previous version of this article stated &quot;Taipy is written in Rust.&quot; This is incorrect. Taipy is a Python library. Both Streamlit and Taipy are written in Python. The claim likely originated from a confusion with Polars (which is written in Rust) in an early comparison article.
&lt;/Notice&gt;

Both tools have shipped major releases since January 2024. Streamlit went from ~v1.29 to v1.59.0; Taipy went from ~v2.x to v4.1.1. Here are the headline changes for each.

&lt;Tabs&gt;
&lt;Tab name=&quot;Streamlit (v1.59.0)&quot;&gt;

- **Native OIDC authentication** (v1.42, Feb 2025): `st.login()`, `st.logout()`, `st.user` with Google, Microsoft Entra, Okta, Auth0, and Keycloak support. Free, no enterprise license needed.
- **Starlette/Uvicorn server** (v1.57, Apr 2026): replaced Tornado with proper ASGI. Better reverse-proxy compatibility, mountable inside FastAPI.
- **Parallel fragments** (v1.58, May 2026): `@st.fragment(parallel=True)` enables concurrent fragment execution without blocking the UI.
- **Advanced theming** (v1.44+): custom fonts, colors, roundness, chart colors, reusable theme files. No CSS hacks needed.
- **AI/chat features**: `st.chat_input` with file uploads, audio input, paste support; `st.write_stream` for LLM streaming; `st.mermaid_chart`; `st.pdf`.

&lt;/Tab&gt;
&lt;Tab name=&quot;Taipy (v4.1.1)&quot;&gt;

- **Unified package** (v4.0, Oct 2024): merged 6 separate packages (taipy-config, taipy-common, taipy-gui, taipy-core, taipy-templates, taipy-rest) into a single `taipy` package. Simpler installs.
- **New UI controls** (v4.0): Metric, Progress, Chat controls; table editing; scenario selector with filtering.
- **EventProcessor &amp; MockState** (v4.1, Feb 2026): better testing support and event-driven processing.
- **Async callbacks** (v4.1): asynchronous callback support for non-blocking operations.
- **Chart performance** (v4.1): rendering improvements for large datasets.

&lt;/Tab&gt;
&lt;/Tabs&gt;

| Metric | Streamlit | Taipy |
|--------|-----------|-------|
| GitHub stars | ~45,300 | ~19,300 |
| Latest version | 1.59.2 (July 2026) | 4.1.1 (Feb 2026) |
| License | Apache 2.0 | Apache 2.0 |
| Language | Python | Python |
| Founded | 2019 | 2022 |
| Parent company | Snowflake ($800M acquisition) | Avaiga |

## Key differences between Streamlit and Taipy in 2026

The old &quot;10 differences&quot; list from the original article had several items that were wrong or outdated. Here&apos;s a structured comparison across the dimensions that actually matter.

### Execution model

Taipy uses callbacks to re-run only the code path affected by a user action. If you change one input, only that callback executes. The rest of the app doesn&apos;t re-run. This is fundamentally different from Streamlit&apos;s default top-to-bottom re-run model. Streamlit mitigated this with `@st.fragment` (v1.42+), which lets you isolate sections for partial re-runs, and fragments can now write to outside containers (v1.59). But Taipy&apos;s callback-first architecture is still more efficient for complex data pipelines.

### Async and concurrency

Taipy has always supported both synchronous and asynchronous calls with separate GUI and Core threads. The UI never freezes during backend computation. Streamlit closed the gap significantly: `@st.fragment(parallel=True)` (v1.58) enables concurrent fragment execution, and the Starlette/Uvicorn server (v1.57) provides proper async request handling. For most internal tools, the difference is now negligible. For compute-heavy ML pipelines, Taipy&apos;s thread separation still matters.

### Layout and design

Streamlit added horizontal flex containers (v1.48), width parameters (v1.46+), advanced theming without CSS (v1.44+), custom light and dark themes with reusable theme files (v1.51), `st.space` for spacing (v1.51), and `st.bottom` pinned container (v1.57). The &quot;can&apos;t customize Streamlit&quot; criticism is outdated. Taipy still offers more explicit layout control through its markdown-based page syntax, which some developers prefer for complex multi-page apps.

### Data handling

Taipy&apos;s data decimation and pipeline management remain strengths for big data and ML workloads. Its scenario management system lets you version, track, and compare data pipelines, something Streamlit doesn&apos;t attempt. Streamlit improved with `st.data_editor` enhancements (column sorting, pinning, search/filter), session-scoped caching (v1.53), and `st.cache_resource` with cleanup hooks (v1.53).

### Chat and AI features

Both tools now have chat UI components. Streamlit has `st.chat_input` with file uploads (v1.43), audio input (v1.52), paste support (v1.59), and `st.write_stream` for OpenAI/LLM streaming. Taipy added its Chat control in v4.0. Streamlit has become the go-to for AI and chat app demos. If you&apos;re building an LLM front-end, Streamlit&apos;s chat primitives are more mature.

&lt;Accordion label=&quot;Full comparison table&quot; group=&quot;differences&quot;&gt;

| Dimension | Streamlit | Taipy |
|-----------|-----------|-------|
| Execution model | Top-to-bottom re-run (fragments for partial) | Callback-based (only affected code re-runs) |
| Async/concurrency | `@st.fragment(parallel=True)` + Starlette | Native sync+async, separate GUI/Core threads |
| Layout control | Flex containers, advanced theming, `st.bottom` | Markdown-based pages, more explicit control |
| Data handling | `st.data_editor`, session caching | Data decimation, scenario pipelines, versioning |
| Chat/AI | `st.chat_input`, `st.write_stream`, audio input | Chat control (v4.0), less mature |
| Jupyter support | No native support (third-party `streamlit-jupyter`) | Native Jupyter Notebook integration |
| VSCode extension | No | Yes (auto-completion, live preview) |
| Authentication | Free OIDC via `st.login()` | Enterprise-only (paid) |
| Community | ~45,300 GitHub stars, active forum | ~19,300 GitHub stars, smaller community |
| Production readiness | Significantly improved (auth, Starlette, fragments) | Built for production from the start |

&lt;/Accordion&gt;

For another angle on how Streamlit compares to other tools, see our [Streamlit vs NiceGUI](/streamlit-vs-nicegui/) comparison.

## Streamlit&apos;s new production features (v1.42 to v1.59)

The biggest criticism of Streamlit in 2024 was &quot;great for prototypes, not for production.&quot; That&apos;s no longer accurate. Here&apos;s what changed.

### Native authentication with st.login()

Streamlit v1.42 (February 2025) added `st.login()`, `st.logout()`, and `st.user` with OIDC support. You can gate any Streamlit app behind Google, Microsoft Entra, Okta, Auth0, or Keycloak. Free, no enterprise license.

Configuration goes in `.streamlit/secrets.toml`:

```toml
# .streamlit/secrets.toml
[auth]
redirect_uri = &quot;http://localhost:8501/oauth2callback&quot;
cookie_secret = &quot;your-random-secret-here&quot;
client_id = &quot;your-client-id&quot;
client_secret = &quot;your-client-secret&quot;
server_metadata_url = &quot;https://accounts.google.com/.well-known/openid-configuration&quot;
```

Then in your app:

```python
import streamlit as st

if not st.user.is_logged_in:
    st.button(&quot;Log in with Google&quot;, on_click=st.login)
    st.stop()

st.title(f&quot;Welcome, {st.user.name}&quot;)
st.button(&quot;Log out&quot;, on_click=st.logout)
```

&lt;Notice type=&quot;success&quot; title=&quot;Production milestone&quot;&gt;
Native authentication was the #1 requested Streamlit feature for production use. With OIDC support, you can now gate Streamlit apps behind corporate SSO without third-party proxies or paid enterprise tiers.
&lt;/Notice&gt;

If you&apos;re building AI-powered apps with authentication, check out our guide on [integrating Groq API with Streamlit](/groq-api-mistral-streamlit/) and [building AI agents with Agno and Streamlit](/agno-squad/).

### Async and parallel execution with Starlette

Streamlit v1.57 (April 2026) replaced Tornado with Starlette/Uvicorn as the web server. This brings:

- **Proper ASGI compatibility**: better reverse-proxy behavior with Caddy, Nginx, Traefik
- **Mountable inside FastAPI**: `st.App` ASGI entry point for custom routes
- **`App.run()` entry point** (v1.59): run with `python app.py` instead of `streamlit run`

Then v1.58 added `@st.fragment(parallel=True)`:

```python
@st.fragment(parallel=True)
def background_chart():
    # This runs in parallel without blocking the UI
    data = expensive_query()
    st.line_chart(data)
```

**What to watch out for**: The Tornado to Starlette migration may break custom Streamlit components that relied on Tornado APIs. If you have custom components, test them against v1.57+ before upgrading in production. Auth cookie persistence had issues in v1.57, fixed in v1.58.

### Layout, theming, and design upgrades

Streamlit now offers meaningful design customization:

- **Horizontal flex containers** (v1.48): alignment, direction, gap parameters
- **Width parameters** for most elements (v1.46+)
- **Advanced theming** (v1.44+): custom fonts, colors, roundness, chart colors, heading sizes via TOML config
- **Custom light and dark themes** with reusable theme files (v1.51)
- **`st.space`** for vertical/horizontal spacing (v1.51)
- **`st.bottom`** pinned container (v1.57): great for chat input bars

### AI and chat features

Streamlit added several AI and chat features:

- `st.chat_input` with file uploads (v1.43), audio input (v1.52), paste support (v1.59)
- `st.write_stream` for OpenAI streaming (v1.43+), now supporting Responses API (v1.59)
- `st.skeleton` loading placeholders (v1.59)
- `st.mermaid_chart` (v1.59)
- `st.pdf` rendering (v1.49)
- Bundled AI coding skills (`streamlit skills`, v1.58)

## Taipy&apos;s architecture and enterprise advantages (v3.0 to v4.1)

Taipy&apos;s core architectural strengths haven&apos;t changed, but the packaging and feature set have.

### Taipy&apos;s callback model vs Streamlit&apos;s fragment model

Taipy&apos;s callbacks only re-execute the code path affected by a user action. This is a fundamentally different execution model from Streamlit&apos;s top-to-bottom re-run. Combined with separate GUI and Core threads, the UI never freezes during backend computation.

Why this matters: if you&apos;re building a data pipeline app where a user changes one parameter and needs to re-run a specific calculation, Taipy only runs that calculation. Streamlit&apos;s `@st.fragment` isolates sections for partial re-runs, which helps. But it&apos;s a patch on top of the re-run model, not a different architecture.

For simple dashboards with a few widgets, the difference is negligible. For complex multi-step data workflows with large datasets, Taipy&apos;s approach uses less compute and feels snappier.

### Taipy 4.0 package restructure

Taipy 4.0 (October 2024) merged 6 separate packages into a single `taipy` package:

```bash
# Old (Taipy 3.x) - 6 packages
pip install taipy taipy-config taipy-common taipy-gui taipy-core taipy-templates taipy-rest

# New (Taipy 4.x) - 1 package
pip install taipy
```

Taipy 4.1 (February 2026) added EventProcessor for event-driven processing, MockState for testing, async callbacks, and chart rendering performance improvements.

&lt;Notice type=&quot;warning&quot; title=&quot;Upgrading from Taipy 3.x&quot;&gt;
If you&apos;re upgrading from Taipy 3.x, you must manually uninstall the old sub-packages first: `pip uninstall taipy taipy-config taipy-common taipy-gui taipy-core taipy-templates taipy-rest` before installing the new unified package. Skipping this step causes import conflicts.
&lt;/Notice&gt;

### Taipy Enterprise: auth, SSO, and Designer

Taipy&apos;s Community edition (Apache 2.0) is free and self-hostable. Authentication, SSO, ACL management, Taipy Designer (a drag-drop GUI builder), dedicated support, and platform integrations (Databricks, Snowflake, Dataiku, AWS SageMaker) are Enterprise-only features. Pricing requires contacting sales. No public pricing is available.

This is a cost consideration: Streamlit&apos;s OIDC auth is free. Taipy&apos;s auth requires an Enterprise license. If you need to gate your app behind SSO and you&apos;re cost-conscious, Streamlit has a clear advantage.

### Updated Taipy code example

Here&apos;s a working Taipy 4.x example with the unified package. Note: verify against the latest Taipy version, as the 4.x API may have further changes.

```python
import taipy as tp
from taipy.gui import Gui, State

# Define callback
def on_submit(state: State):
    state.message = f&quot;Hello {state.name}!&quot;

# Define page
name = &quot;&quot;
message = &quot;&quot;

page = &quot;&quot;&quot;
# Taipy Hello App

Enter your name: &lt;|{name}|input|&gt;

&lt;|Submit|button|on_action=on_submit|&gt;

Message: &lt;|{message}|text|&gt;
&quot;&quot;&quot;

if __name__ == &quot;__main__&quot;:
    gui = Gui(page)
    gui.run(title=&quot;Taipy Hello App&quot;, port=8080)
```

## Pricing and self-hosting: running Streamlit and Taipy on a VPS

Both tools are free and open-source for self-hosting. Both run comfortably on a $5-10/month VPS. A [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) at the CX22 tier (2 vCPU, 4 GB RAM) handles moderate traffic for either framework. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with NVMe storage if you prefer a different provider.

### Streamlit Community Cloud vs self-hosting

Streamlit Community Cloud: free, up to 3 apps, public GitHub repos only. Good for demos and portfolios. For anything private or production-grade, you need to self-host. Streamlit in Snowflake uses Snowflake credits (enterprise pricing).

For self-hosting, the Starlette server (v1.57) makes reverse-proxy setups cleaner. Proper ASGI means no more Tornado quirks behind Caddy or Nginx. Python version note: Streamlit dropped Python 3.9 support. Taipy supports Python 3.9-3.12.

### Docker deployment for both tools

Both tools Dockerize cleanly. Streamlit&apos;s Starlette server gives you proper ASGI, which plays well with reverse proxies. Taipy Community requires self-hosting (no managed cloud option). Both need a process manager (Docker restart policy or systemd) for production.

For a full guide on running Python apps in containers, see [how to run any Python app in Docker](/docker-run-python/). If you&apos;re setting up a Python project from scratch, [setting up Python projects with uv](/uv-get-start/) is the modern approach. For Dokploy-based deployments, check [deploying Python projects with Dokploy](/dokploy-python-railpack-uv/).

&lt;Tabs&gt;
&lt;Tab name=&quot;Streamlit Docker&quot;&gt;

```yaml
# docker-compose.yml
services:
  streamlit:
    image: python:3.12-slim
    working_dir: /app
    volumes:
      - .:/app
    ports:
      - &quot;8501:8501&quot;
    command: &gt;
      sh -c &quot;pip install streamlit &amp;&amp;
             streamlit run app.py
             --server.port=8501
             --server.address=0.0.0.0
             --server.headless=true&quot;
    restart: unless-stopped
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Taipy Docker&quot;&gt;

```yaml
# docker-compose.yml
services:
  taipy:
    image: python:3.12-slim
    working_dir: /app
    volumes:
      - .:/app
    ports:
      - &quot;8080:8080&quot;
    command: &gt;
      sh -c &quot;pip install taipy &amp;&amp;
             python app.py&quot;
    restart: unless-stopped
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

For a production-grade Streamlit deployment with TLS and tunneling, see our guide to [deploy Streamlit on a VPS with Cloudflare Tunnels](/streamlit-deploy-vps-cloudflare/).

## When to choose Streamlit vs Taipy: decision guide

No hand-waving. Here are the clear scenarios.

### Choose Streamlit when

- Building AI/chat interfaces or LLM demos (native chat components, `st.write_stream`)
- Need free OIDC authentication without enterprise licensing
- Want the largest community, most tutorials, easiest hiring
- Building internal tools where rapid iteration matters more than fine-grained reactivity
- Already in the Snowflake ecosystem

### Choose Taipy when

- Building data pipelines or ML workflows with complex scenario management
- Need granular reactivity: callbacks re-run only what changed
- Processing large datasets where data decimation matters
- Building multi-page production apps where separate GUI/Core threads prevent UI freezes
- Need Jupyter Notebook integration for data science workflows
- Willing to pay for Enterprise features (auth, SSO, Designer)

### Use both when

- Prototype in Streamlit (faster iteration), production in Taipy (better backend management)
- Use Streamlit for quick internal dashboards, Taipy for customer-facing data products

&lt;ListCheck&gt;

**Streamlit is the right pick if you need:**

- Free authentication with OIDC providers
- The largest Python web app community and ecosystem
- Native AI/chat UI components out of the box
- Fast prototyping with minimal boilerplate
- Easy deployment on Community Cloud or any VPS

**Taipy is the right pick if you need:**

- Callback-based execution that only re-runs what changed
- Separate GUI and Core threads for non-blocking UI
- Built-in data pipeline and scenario management
- Jupyter Notebook integration for data science workflows
- Production-grade architecture from day one

&lt;/ListCheck&gt;

## Alternatives to Streamlit and Taipy for Python data apps

The Python web app ecosystem has grown. Here are the other tools worth knowing about:

- **Gradio** (Hugging Face): best for ML model demos and Hugging Face Spaces integration. Strong chat interface support and MCP integration. If you&apos;re building a quick model demo, Gradio is often the fastest path.
- **Reflex** (formerly Pynecone): full-stack web apps in pure Python with more control over routing and state. Closer to a traditional web framework than a dashboard tool.
- **Shiny for Python** (Posit): reactive execution model from the R ecosystem. Scales better than Streamlit for complex reactive apps. Worth a look if you come from R.
- **Dash** (Plotly): production-oriented with explicit callback architecture. Strong for data visualization. More verbose than Streamlit but more predictable at scale.
- **Marimo**: reactive notebooks with WASM support. Interesting hybrid between notebook and web app.
- **NiceGUI**: based on FastAPI and Vue/Quasar, more web-dev-oriented. See our [Streamlit vs NiceGUI](/streamlit-vs-nicegui/) comparison and [NiceGUI Python UI framework](/nicegui-get-started/) guide.

For a broader view, [FastHTML for building Python web UIs](/fasthtml-start/) is another option if you want something closer to raw HTML with Python convenience.

## Quick start: Streamlit vs Taipy code examples

Side-by-side examples you can copy, paste, and run.

&lt;Tabs&gt;
&lt;Tab name=&quot;Streamlit&quot;&gt;

```python
import streamlit as st
import pandas as pd

st.set_page_config(page_title=&quot;Data Explorer&quot;, layout=&quot;wide&quot;)

# Optional: uncomment for OIDC auth
# if not st.user.is_logged_in:
#     st.button(&quot;Log in&quot;, on_click=st.login)
#     st.stop()

st.title(&quot;Data Explorer&quot;)

with st.sidebar:
    st.page_link(&quot;app.py&quot;, label=&quot;Home&quot;, icon=&quot;🏠&quot;)
    dataset = st.selectbox(&quot;Dataset&quot;, [&quot;Iris&quot;, &quot;Tips&quot;, &quot;Penguins&quot;])

data = getattr(pd, f&quot;read_csv&quot;, lambda x: pd.DataFrame())(
    f&quot;https://raw.githubusercontent.com/mwaskom/seaborn-data/master/{dataset}.csv&quot;
)

# Fragment for partial re-runs
@st.fragment
def data_section():
    col1, col2 = st.columns(2)
    with col1:
        st.subheader(&quot;Raw Data&quot;)
        st.dataframe(data, use_container_width=True, height=400)
    with col2:
        st.subheader(&quot;Chart&quot;)
        numeric_cols = data.select_dtypes(&quot;number&quot;).columns
        if len(numeric_cols) &gt;= 1:
            st.line_chart(data[numeric_cols[:3]], use_container_width=True)

data_section()

with st.expander(&quot;Column Statistics&quot;):
    st.dataframe(data.describe())
```

Run with: `streamlit run app.py`

&lt;/Tab&gt;
&lt;Tab name=&quot;Taipy&quot;&gt;

```python
from taipy.gui import Gui, State
import pandas as pd

# Callback: only re-runs when triggered
def on_dataset_change(state: State):
    url = f&quot;https://raw.githubusercontent.com/mwaskom/seaborn-data/master/{state.dataset}.csv&quot;
    state.data = pd.read_csv(url)

# Initial state
dataset = &quot;iris&quot;
data = pd.read_csv(
    f&quot;https://raw.githubusercontent.com/mwaskom/seaborn-data/master/{dataset}.csv&quot;
)

# Page definition
page = &quot;&quot;&quot;
# Data Explorer

Dataset: &lt;|{dataset}|selector|lov=iris;tips;penguins|on_change=on_dataset_change|&gt;

## Raw Data
&lt;|{data}|table|height=400px|&gt;

## Statistics
&lt;|{data.describe()}|table|&gt;
&quot;&quot;&quot;

if __name__ == &quot;__main__&quot;:
    gui = Gui(page)
    gui.run(title=&quot;Data Explorer&quot;, port=8080)
```

Run with: `python app.py`

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Taipy 4.x verification&quot;&gt;
The Taipy example uses patterns verified against the Taipy 4.x unified API. If you&apos;re running Taipy 3.x or earlier, the imports and patterns differ. Always test against your installed version.
&lt;/Notice&gt;

## Conclusion

Both Streamlit and Taipy have matured significantly since 2024. Streamlit closed the production gap with native authentication, a Starlette server, parallel fragments, and advanced theming. It remains the easier on-ramp with the larger community and the richer AI/chat feature set. Taipy retains architectural advantages for data-heavy, reactive applications. Its callback model, separate threads, and scenario management are hard to replicate with Streamlit&apos;s re-run approach.

The &quot;right&quot; choice depends on your use case. The decision guide in Section 7 gives you the actionable framework. For most internal tools and AI demos, Streamlit is the default pick. For complex data pipelines and ML workflows, Taipy is worth the steeper learning curve.

If you&apos;re ready to deploy, here&apos;s how to get Streamlit running on your VPS:

&lt;Button text=&quot;Deploy Streamlit on your VPS&quot; link=&quot;/streamlit-deploy-vps-cloudflare/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is Streamlit production-ready in 2026?&quot; group=&quot;faq&quot;&gt;
Yes, significantly more so than in 2024. Native OIDC authentication (`st.login()`), the Starlette/Uvicorn server for proper ASGI, `@st.fragment` for partial and parallel re-runs, session-scoped caching, and the `st.App` API for mounting inside FastAPI all address the major production blockers that existed in 2024. It&apos;s not enterprise-grade by default (no built-in RBAC, limited multi-tenancy), but it&apos;s solid for internal tools, dashboards, and customer-facing apps behind an OIDC provider.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Taipy free for commercial use?&quot; group=&quot;faq&quot;&gt;
The Community edition is Apache 2.0 licensed, free for commercial use including self-hosting. However, authentication, SSO, ACL management, Taipy Designer (drag-drop GUI builder), dedicated support, and platform integrations (Databricks, Snowflake, AWS SageMaker) are Enterprise-only features. Enterprise pricing is not public. You need to contact Taipy&apos;s sales team.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which is faster, Streamlit or Taipy?&quot; group=&quot;faq&quot;&gt;
Depends on the workload. Taipy&apos;s callback model means less redundant computation for complex pipelines. If you change one input, only that callback re-runs. Streamlit&apos;s fragments mitigate the re-run problem for isolated sections, but the underlying model is still top-to-bottom by default. For simple dashboards with a few widgets, performance is comparable. For data-heavy apps with expensive computations triggered by individual inputs, Taipy&apos;s approach uses less CPU and feels more responsive.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Streamlit or Taipy with Jupyter Notebooks?&quot; group=&quot;faq&quot;&gt;
Taipy runs natively in Jupyter Notebooks, which is useful for data science workflows where you want to iterate in a notebook and deploy as an app. Streamlit doesn&apos;t have first-class Jupyter support, but the third-party `streamlit-jupyter` package (last release July 2025) provides some integration. If Jupyter integration is a hard requirement, Taipy is the better choice.
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>streamlit</category><category>taipy</category><category>python</category></item><item><title>Kie.ai Review 2026: Cheap AI Image, Video &amp; LLM API (Pros, Cons, Alternatives)</title><link>https://www.bitdoze.com/kie-ai-review/</link><guid isPermaLink="true">https://www.bitdoze.com/kie-ai-review/</guid><description>Honest Kie.ai review for developers: unified AI API for Nano Banana, GPT Image, Veo, Kling, Suno, Claude, and more. Pricing, how I use it with Mastra, vs fal.ai and Replicate, and when to skip it.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I got tired of juggling five image APIs just to ship blog covers and YouTube thumbnails. Google has one portal. OpenAI has another. Flux wants something else. Video is worse: Veo, Kling, Seedance each come with their own keys, billing, and async quirks.

[Kie.ai](https://go.bitdoze.com/kie-ai) sells a simple pitch: one API, one wallet, many of the models people actually want, usually cheaper than the official endpoints. Image, video, music, and chat under the same `createTask` flow.

I use Kie as the generation backend for a [Mastra image agent](/mastra-image-agent-kie-ai/) that writes prompts, creates jobs, polls until done, and saves PNGs into a workspace. This review is what Kie is, what it is good for, where it is weaker than fal.ai or Replicate, and whether the price discount is worth the trade-offs.

&lt;Button text=&quot;Try Kie.ai (Get API Key)&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Wire Kie into a Mastra Image Agent&quot; link=&quot;/mastra-image-agent-kie-ai/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this review covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;What Kie.ai is and which services it offers&lt;/li&gt;
&lt;li&gt;Image, video, music, and LLM model families (with example prices)&lt;/li&gt;
&lt;li&gt;How the async API works (create task, poll, webhook, file upload)&lt;/li&gt;
&lt;li&gt;How I use Kie for blog covers and thumbnails with Mastra&lt;/li&gt;
&lt;li&gt;Kie vs fal.ai, Replicate, OpenRouter, and official APIs&lt;/li&gt;
&lt;li&gt;Pros, cons, pricing notes, and who should skip it&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Affiliate disclosure&quot;&gt;
Some Kie.ai links in this article are affiliate links (`https://go.bitdoze.com/kie-ai`). I use the product in production for image generation. Pricing, model lists, and discounts change; always check the official [pricing page](https://kie.ai/pricing) and [docs](https://docs.kie.ai/).
&lt;/Notice&gt;

## What is Kie.ai?

**Kie.ai** (often written KIE or Kie AI) is a developer platform that resells and unifies access to third-party generative models. You create an API key, top up credits, and call a shared Market API instead of integrating every provider separately.

Think of it as a multimodel gateway for generation, not a chat UI product. The product is the API:

- One auth header: `Authorization: Bearer YOUR_API_KEY`
- One job API for most media models: create task → poll or webhook
- One credit balance for image, video, audio, and many chat models
- A web Playground to test prompts before you write code
- Logs for every task (inputs, status, credits, results)

Public claims from their site (verify live numbers yourself):

| Claim | Detail |
|---|---|
| Developers | 120,000+ |
| Countries | 80+ |
| Model coverage | 100+ across image, video, music, LLM |
| Typical savings | ~30–50% vs official APIs; selected models higher |
| Failed jobs | Platform states they are not charged |
| Credit expiry | Credits do not expire |
| Free trial | New users get trial credits + Playground |

If you want a single integration surface for &quot;generate a cover, then maybe a short video, then maybe music,&quot; Kie is built for that. If you only need one official model and SLA from the vendor, buy the official API.

## What services does Kie offer?

Kie groups models into a Market. Categories matter more than brand names when you design an agent.

### 1. Image generation and editing

This is what I use most. Typical families:

| Family | Example models | Good for |
|---|---|---|
| Google Nano Banana | `google/nano-banana`, `nano-banana-2`, `nano-banana-pro`, `google/nano-banana-edit` | Blog covers, social graphics, fast drafts, edits with refs |
| Seedream | `seedream/5-pro-text-to-image`, I2I variants | Photoreal product-style shots, thumbnails |
| Flux-2 | `flux-2/pro-text-to-image`, flex variants | Sharp commercial graphics |
| GPT Image | `gpt-image-2-text-to-image`, `gpt-image-2-image-to-image` | Instruction following, text in image, identity lock |
| Ideogram | `ideogram/v3-text-to-image` | Readable typography, logo-ish text |
| Imagen | `google/imagen4`, fast/ultra | High-fidelity photoreal |
| Qwen / Z-Image | Qwen T2I/edit, Z-Image | Multilingual prompts, stylized art |
| Utility | Topaz upscale, Recraft remove-bg / crisp upscale | Finish pipeline |

Example public image prices from Kie&apos;s pricing page (USD, subject to change):

| Model (approx.) | Kie price | Official (listed) | Discount (listed) |
|---|---|---|---|
| Nano Banana 2 1K | ~$0.04 / image | ~$0.08 | ~50% |
| Nano Banana 2 2K | ~$0.06 / image | ~$0.12 | ~50% |
| Nano Banana 2 4K | ~$0.09 / image | ~$0.16 | ~44% |
| GPT Image 2 1K T2I | ~$0.03 / image | ~$0.22 | ~86% |
| Seedream 5 Pro 1K | ~$0.035 / image | ~$0.045 | ~22% |

Those deltas are why Kie is attractive for high-volume cover generation. Always re-check [kie.ai/pricing](https://kie.ai/pricing).

### 2. Video generation

Heavy models for text-to-video and image-to-video:

- Google Veo 3.1 (quality 1080p / 4K tiers)
- Kling 3.0 and earlier Kling lines (multi-shot, native audio on newer versions)
- ByteDance Seedance 2.0 (including Fast / Mini variants)
- Grok Imagine video
- Wan, Hailuo, Runway Aleph, and others on the Market

Video is where the unified async API helps. Jobs take longer; you either poll `recordInfo` or pass a webhook. Example listed pricing: Veo 3.1 quality 1080p around **$1.28** on Kie vs higher official rates; Grok Imagine video billed per second at a large discount vs official. Again: live page wins over this article. For model ids, curl examples, image-to-video, and a working download script, see my [Kie.ai Video Generation Guide](/kie-ai-video-generation/).

### 3. Music and audio

- Suno (music generation, covers, stems-related tools depending on endpoint)
- ElevenLabs (TTS / dialogue models via Market)
- Other speech / isolation models as they onboard them

I still use [Fish Audio](/fish-audio-review/) for my own voice cloning workflow (including a [Mastra `fish_tts` tool](/mastra-fish-audio-tts/)). Kie is useful if you want Suno-style music or ElevenLabs access on the same credit wallet as images.

### 4. Chat / LLM APIs

Kie also lists chat models (Claude, GPT, Gemini, Grok families) with token pricing often far below official list prices. Example style from their tables: large discounts on Claude Opus / Sonnet and GPT chat tiers.

For day-to-day agent coding I still prefer **OpenRouter** or **OpenCode Go** because model routing, tooling ecosystem, and reliability for agent loops are more mature there. I treat Kie chat as an option, not my default brain for [Mastra agents](/build-ai-agent-mastra/).

### 5. Platform services around the models

| Service | Why it matters |
|---|---|
| API keys | Rate caps, optional IP allowlist |
| Playground | Test a model before coding |
| Logs | Task history, credits, errors |
| Billing / wallet | Single top-up; invoices for business |
| File upload API | Host references for I2I/edit (temporary URLs) |
| Webhooks | Avoid long polling on slow video jobs |
| VIP support | Discord / Telegram channels for API users |

## How the Kie API works (simple version)

You do not get a synchronous &quot;here is a PNG&quot; for most Market models. Flow:

```text
1. POST https://api.kie.ai/api/v1/jobs/createTask
   Authorization: Bearer YOUR_API_KEY
   body: { model, input, callBackUrl? }

2. Response 200 + taskId  →  task accepted, NOT finished

3a. Poll GET /api/v1/jobs/recordInfo?taskId=...
    until state = success | fail
    (in-between: waiting | queuing | generating)
 OR
3b. Wait for webhook callback

4. Read result URLs from resultJson
5. Download files yourself (media retained ~14 days)
```

Credits check:

```http
GET https://api.kie.ai/api/v1/chat/credit
Authorization: Bearer YOUR_API_KEY
```

Default rate limit (from docs): about **20 new requests per 10 seconds**. Concurrent running tasks are relatively high. HTTP **429** means back off; rejected creates do not queue.

**Retention:** generated media ~**14 days**, logs ~**2 months**. Your app should download and store assets if they matter. File uploads for references use a separate host (`https://kieai.redpandaai.co`) and are also temporary.

**Security:** never put `KIE_API_KEY` in frontend code or public repos. Use server-side env only.

Docs: [docs.kie.ai](https://docs.kie.ai/) · Market quickstart: [Market API](https://docs.kie.ai/market/quickstart)

&lt;Button text=&quot;Create a free Kie API key&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## How I use Kie.ai (real workflow)

I run a [Mastra](https://mastra.ai/) stack for research, content, and media. The coding assistant uses web search ([TinyFish](/tinyfish-ai-agents-web-search/)). The image agent uses Kie.

### What I generate with Kie

1. Blog cover images (16:9) for bitdoze.com posts
2. YouTube thumbnails with high contrast and short titles
3. Logo-aware edits (drop a logo in `workspace/images/uploads/`, I2I/edit model)
4. Upscale / background removal when a final asset needs polish

### What the agent does for me

- Picks a model id from a curated catalog (`nano-banana-2`, Seedream Pro, Flux-2 Pro, GPT Image 2, etc.)
- Writes a detailed prompt (subject, lighting, palette, text, negatives)
- Calls tools that wrap Kie: create task → poll → download to `workspace/images/generated/`
- Uploads local reference images to Kie&apos;s file host when doing face/logo lock
- Checks remaining credits before bulk runs

I wrote a full integration guide here: [Add an AI Image Agent to Mastra with Kie.ai](/mastra-image-agent-kie-ai/).

### Why not call Google/OpenAI directly?

I could. For a single model, official APIs are fine. My problem was model shopping:

- Nano Banana 2 for speed + quality on covers
- Seedream when I want a different photoreal look
- GPT Image when text-in-image must be correct
- Topaz when I need a clean upscale

Without Kie, that is four accounts, four SDKs, four billing dashboards. With Kie, it is one `KIE_API_KEY` and a model string.

### What I do not use Kie for (today)

- Primary LLM for coding agents (OpenRouter / OpenCode Go)
- Voice cloning of my own voice (Fish Audio)
- Mission-critical chat with vendor SLA requirements

Kie is the media generation layer, not my entire AI stack.

## Pricing: how Kie makes money

Kie is prepaid credits. You top up a wallet and spend per generation (or per token for chat).

### Rules that matter

- Failed tasks: platform states **$0** (you are not charged for failed generations)
- Credits do not expire
- Transparent public pricing with &quot;Kie vs official / fal&quot; columns on many rows
- Larger top-ups may include bonus credits (see billing page)
- Business invoices available after top-up

### Mental model for costs

| Workload | Rough unit | Notes |
|---|---|---|
| Blog covers | per image | Cents, not dollars, on common models |
| Thumbnails | per image | Same; iterations add up if you regenerate a lot |
| Upscale / remove-bg | per image | Utility models, usually cheap relative to video |
| Short AI video | per video or per second | Dollars, not cents; test in Playground first |
| Chat | per 1M tokens | Often deep discounts vs list price |

If you generate 50 covers a month at ~$0.04–$0.09 each, you are still in coffee-money territory. If you run continuous video pipelines, model the budget carefully and monitor [logs](https://kie.ai/logs).

## Pros and cons

### Pros

- One integration for many image/video/music/chat models
- Aggressive pricing vs official APIs on popular models
- No charge on failed jobs (friendly for agent loops that retry)
- Credits never expire
- Playground + logs make debugging less painful
- Async pattern is consistent across Market models
- File upload API for reference images
- Good fit for agent tools (Mastra, custom backends, automation)

### Cons

- Not the official vendor: stability can lag first-party APIs (they say this themselves)
- 14-day media retention forces you to download results
- Model ids and input field names differ (`image_urls` vs `image_input` vs `input_urls`); your client must map them
- Support is community/private channels, not enterprise TAM for most users
- Chat/LLM routing is less of a reason to switch if you already use OpenRouter
- Docs quality varies by model page; expect some trial and error
- Rate limits may need a support ticket for high QPS products

## Kie.ai vs alternatives

Here is the competitive picture for developers in 2026.

| Platform | Best at | Weak at | When I pick it |
|---|---|---|---|
| [Kie.ai](https://go.bitdoze.com/kie-ai) | Cheap multi-model media (image/video/music) + simple job API | Official SLA, deepest enterprise support | Covers, thumbnails, multi-model experiments |
| fal.ai | Fast inference, huge model catalog, strong media DX | Can get expensive; another stack to learn | When a specific fal model/pipeline is best-in-class |
| Replicate | Easy model hosting, huge open ecosystem | Cost at scale; less &quot;all flagship closed models cheaper&quot; | Open weights, custom model deploys |
| OpenRouter | Chat/LLM routing, agent-friendly | Not a media generation first stop | Agent brains, multi-LLM apps |
| Official APIs (Google, OpenAI, etc.) | First-party features, support, newest flags | Many keys, often higher list price | Compliance, SLA, single-vendor products |
| Together / Fireworks / Groq | Fast open LLM inference | Not a full image/video supermarket | High-throughput text models |

### Kie vs fal.ai

Both sell developer access to generative models. fal is often stronger on inference platform branding and certain model pipelines. Kie leans hard on price comparison tables and a credit wallet that spans image + video + music + chat.

If your main need is &quot;cheapest reliable access to Nano Banana / GPT Image / Veo / Kling with one key,&quot; try Kie first. If you are already deep in fal&apos;s ecosystem and latency tooling, stay there unless price forces a move.

### Kie vs Replicate

Replicate shines for open-source model runs and custom deployments. Kie shines for closed / popular commercial models at a discount. Different jobs. Many teams use both.

### Kie vs OpenRouter

Do not treat them as the same product.

- OpenRouter → LLMs for agents and apps
- Kie → generative media (+ optional chat discounts)

My stack: OpenRouter/OpenCode for the agent brain, Kie for image tools.

### Kie vs official Google / OpenAI image APIs

Official wins on:

- Newest feature day-one
- Vendor support contracts
- Clearer long-term product roadmap

Kie wins on:

- Multi-model without multi-billing
- Lower unit cost on many listed models
- One async integration for agents

For a personal content engine and small SaaS experiments, Kie is enough. For a bank or a strict procurement process, start with official.

## Who should use Kie.ai?

**Good fit**

- Indie hackers and content sites generating lots of covers/thumbnails
- AI agent builders who need image/video tools behind one key
- Startups testing multiple media models before locking a vendor
- Anyone who hates maintaining five provider SDKs for similar jobs

**Bad fit**

- Teams that need a signed enterprise SLA with Google/OpenAI only
- Apps that cannot tolerate occasional provider-side instability
- Products that must keep every byte inside a specific cloud region contract
- Users who only need one free chat model (use free tiers elsewhere)

## Getting started in 10 minutes

1. Sign up at [Kie.ai](https://go.bitdoze.com/kie-ai)
2. Create an API key at [kie.ai/api-key](https://kie.ai/api-key)
3. Try a model in the Playground
4. Call createTask from your backend
5. Poll `recordInfo` (or set a webhook)
6. Download the result URL to your own storage

Minimal create example:

```bash
curl -X POST &quot;https://api.kie.ai/api/v1/jobs/createTask&quot; \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;model&quot;: &quot;nano-banana-2&quot;,
    &quot;input&quot;: {
      &quot;prompt&quot;: &quot;16:9 tech blog cover, purple gradient, clean node graph, high contrast&quot;,
      &quot;aspect_ratio&quot;: &quot;16:9&quot;,
      &quot;resolution&quot;: &quot;1K&quot;
    }
  }&apos;
```

Then poll:

```bash
curl &quot;https://api.kie.ai/api/v1/jobs/recordInfo?taskId=YOUR_TASK_ID&quot; \
  -H &quot;Authorization: Bearer $KIE_API_KEY&quot;
```

For a full agent integration (tools, uploads, local downloads), use the [Mastra image agent guide](/mastra-image-agent-kie-ai/).

## FAQ

&lt;Accordion&gt;
&lt;details&gt;
&lt;summary&gt;Is Kie.ai free?&lt;/summary&gt;

New accounts typically get trial credits and can use the Playground. Ongoing usage is paid from a credit wallet. Check current offers on signup.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Do Kie credits expire?&lt;/summary&gt;

No. Official FAQ: credits do not expire.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Am I charged if generation fails?&lt;/summary&gt;

Kie states failed tasks are not charged. That is one of the better policies for agent retries. Confirm on their billing/FAQ if you need it for accounting.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;How long are images and videos stored?&lt;/summary&gt;

About 14 days for media files. Download anything you need permanently. Logs stay longer (~2 months). Uploaded reference files are also temporary.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Is Kie safe for production?&lt;/summary&gt;

Many teams run production workloads on it. Kie themselves note stability can be slightly below official providers as a trade-off for price. For my image pipeline that is acceptable; for life-critical systems, evaluate carefully and keep fallbacks.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Can I use Kie only for images?&lt;/summary&gt;

Yes. That is how I started. You do not have to touch video or music.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Kie.ai vs fal.ai, which is cheaper?&lt;/summary&gt;

It depends on the model and week. Kie&apos;s pricing page often shows Kie vs official/fal columns. Compare the exact model you need rather than trusting a global winner.
&lt;/details&gt;

&lt;details&gt;
&lt;summary&gt;Where do I get support?&lt;/summary&gt;

Dashboard links to Discord/Telegram VIP-style channels; email is `support@kie.ai`. Prefer the private channels for API issues.
&lt;/details&gt;
&lt;/Accordion&gt;

## Verdict

Kie.ai is a strong pick if you want cheaper multi-model media generation without building a provider matrix. One key, one wallet, async jobs that agents can wrap cleanly.

I use it for real content work: blog covers, thumbnails, and brand edits through a Mastra image agent. I do not use it as my only AI platform. LLMs stay on OpenRouter/OpenCode; voice cloning stays on Fish Audio; Kie handles the &quot;make the picture&quot; layer.

If that matches your stack, start with trial credits, generate ten covers, watch the logs, and decide with real unit costs, not marketing tables alone.

&lt;Button text=&quot;Try Kie.ai&quot; link=&quot;https://go.bitdoze.com/kie-ai&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Build a Mastra Image Agent with Kie&quot; link=&quot;/mastra-image-agent-kie-ai/&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Build a Mastra Coding Agent&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Bottom line&quot;&gt;
Kie.ai = multimodel generation API at a discount. Good for image/video tooling in agents and content sites. Compare prices per model, download your outputs, and keep an official fallback if you need vendor SLA.
&lt;/Notice&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>api</category><category>image-generation</category></item><item><title>LM Studio Bionic Review: Local AI Agent for Coding and Work (2026)</title><link>https://www.bitdoze.com/lm-studio-bionic/</link><guid isPermaLink="true">https://www.bitdoze.com/lm-studio-bionic/</guid><description>Hands-on look at LM Studio Bionic on an M4 Pro Mac Mini. Code and Work workspaces, Voxtral voice, MLX models, MCP apps, and which local models actually work.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

[LM Studio](https://lmstudio.ai/) just shipped **Bionic**, a separate desktop app built as an AI agent for open models. Think Codex-style coding agent and cloud co-work tools, but aimed at local LLMs first, with optional Secure Cloud models when you need more power.

I tested it on an **M4 Pro Mac Mini with 24 GB RAM**, using small MLX models for speed, plus vision, file analysis, and Voxtral voice. This is an early product. The idea is strong. Real coding quality still depends almost entirely on which model you load.

![LM Studio Bionic UI on macOS](../../assets/images/26/07/bionic-ui.webp)

&lt;YouTubeEmbed url=&quot;https://youtu.be/9Pm_DnTRJZM&quot; label=&quot;LM Studio Bionic hands-on on M4 Pro Mac Mini&quot; /&gt;

Official announcement: [Introducing LM Studio Bionic](https://lmstudio.ai/blog/introducing-lm-studio-bionic).

If you already use terminal agents, compare this with the [OpenCode setup guide](/opencode-setup-guide/), [Codex app with any model](/codex-app-any-model/), and the [GitHub Copilot alternatives](/github-copilot-alternatives-2026/) roundup.

## What is LM Studio Bionic?

Bionic is not a redesign of the classic LM Studio chat UI. It is a **new app** for agent work:

- **Code projects** for repo-aware coding, file edits, shell commands, and review
- **Work projects** for docs, PDFs, notes, image understanding, and general tasks
- **Local models** through the LM Studio runtime (GGUF, MLX on Apple Silicon)
- **Secure Cloud** frontier open models with zero data retention by default
- **Voice input** with local transcription (Voxtral by Mistral at launch)
- **MCP / connected apps** (Notion and other servers) for tools beyond the filesystem

LM Studio still exists for low-level model management. Bionic is the agent layer on top.

&lt;Notice type=&quot;info&quot; title=&quot;Privacy pitch&quot;&gt;
LM Studio says Bionic has zero data retention and does not train on your data. Local inference never leaves your machine. Cloud calls are processed transiently. Web search and some cloud features require their plan.
&lt;/Notice&gt;

## Platforms and install

Bionic is available for **macOS, Windows, and Linux**. Download from [lmstudio.ai](https://lmstudio.ai/).

On Mac, install is a normal app download. After launch you get:

1. Workspaces / projects list
2. Model library and loaded instances
3. Settings (general, updates, web search, appearance, voice, linked devices)
4. Code vs Work project creation flow

Linked devices were waitlisted at the time of testing.

## Code vs Work workspaces

This split matters.

### Code workspace

Create a Code project, name it, pick a model, then attach a local folder (for example an Astro site).

In the Code UI you typically get:

- Chat / agent timeline with reasoning
- File tree on the side
- In-app browser / previews
- Context usage meter
- Activity / tool call view while the agent writes and runs commands

Officially, Bionic is meant to inspect repos, edit code, show diffs, and search the codebase. In practice, polish still lags mature tools like the [Codex app](/codex-app-any-model/) or [OpenCode](/opencode-setup-guide/).

### Work workspace

Work projects skip the &quot;open a coding folder&quot; requirement. You can attach external files and folders later.

Good fits:

- Image Q&amp;A
- Transcripts and notes
- PDFs and docs
- Lightweight research with local files
- Voice-first chat

This is closer to a private co-worker than a full IDE agent.

## Hands-on: M4 Pro Mac Mini, 24 GB RAM

### Hardware and model choice

I used **MLX models** on Apple Silicon and started with a small, fast model:

| Setting | Value |
| --- | --- |
| Machine | Mac Mini M4 Pro, 24 GB unified memory |
| Format | MLX |
| Test model | Gemma 4 E2B (~4 GB, tools + vision + reasoning) |
| Context | Maxed for the small model |
| Voice model | Voxtral (~3 GB) |
| Peak memory observed | ~20 GB while agent + recording + model loaded |

Why a tiny model first? On 24 GB, big coding models plus long context fill RAM fast. A 2B-class model answers the real product question: is Bionic usable, or only impressive with huge cloud weights?

### Coding test: portfolio site in Astro

I created a Code workspace pointed at an Astro project and asked for a multi-section portfolio with components and SVGs.

What worked:

- Agent loop started, reasoned, and created files
- Context meter was visible (for example ~8K used of a large max window)
- Tool/activity view showed what it was doing
- Build commands could be attempted from the agent

What did not work well with the small model:

- Incomplete Astro component wiring (missing imports / page composition)
- Weak framework knowledge (Astro specifics)
- No clear modified-file highlighting like a mature IDE agent
- Limited editor experience (view more than edit)
- Some shell/file write attempts failed or fell back to dumping full file content in chat
- Speed was slower than expected for a 2B model on M4 Pro once thinking and tools kicked in

When I asked it to fall back to a static `index.html`, it still struggled with clean file creation and speed. The output was &quot;chat-usable,&quot; not &quot;ship-this-site.&quot;

&lt;Notice type=&quot;warning&quot; title=&quot;Model quality is the bottleneck&quot;&gt;
Bionic can orchestrate tools, but a 2B local model is not a Codex replacement for multi-file web frameworks. For serious local coding, use a stronger model tier (see recommendations below). For Astro work specifically, terminal agents with better models still win today.
&lt;/Notice&gt;

### Work test: vision and files

In a Work workspace, the same small vision-capable model:

- Described an uploaded image quickly and accurately
- Could attach external files and folders for context
- Read a video transcript once the path was available to the agent

That side felt more practical on limited hardware. Document and image tasks need less multi-step code correctness than repo edits.

### Voice with Voxtral

Voice is one of the strongest early features. Bionic ships local transcription with **Voxtral** (Mistral). Plan for roughly **3 GB** extra model weight.

In testing:

- Listening / transcription felt fast after the model loaded
- Useful for dictating prompts without leaving the app
- Memory pressure rises again when voice + main LLM are both resident

If you mainly want system-wide Mac dictation rather than an agent keyboard, also look at [FluidVoice](/fluidvoice-mac-dictation/).

### MCP and connected apps

Under connected apps you can wire external tools and **MCP servers** (for example Notion). That is the right direction for a desktop agent: local model + tools + your data sources.

If MCP is new to you, start with the [MCP beginners guide](/mcp-introduction-beginners/).

## Settings worth knowing

| Area | Notes |
| --- | --- |
| Model library | Download local models, prefer MLX on Apple Silicon |
| Context size | Increase carefully; RAM fills with context + model + tools |
| Temperature / system prompt | Per-model knobs still matter for agents |
| Web search | Cloud-plan feature in the current build |
| Voice | Enable Voxtral when you need dictation |
| Linked devices | Waitlist during early release |
| Loaded instances | Shows RAM use (for example ~4.3 GB for a small tools+vision model) |

## Model recommendations for Bionic (2026)

Bionic is only as good as the model. Use recent open models, not year-old 7B chat fine-tunes.

### Local on ~24 GB Mac (MLX)

| Goal | Model direction | Why |
| --- | --- | --- |
| Fast UI testing | Small Gemma 4 / similar ~2B–4B tools model | Instant feel-check of Bionic itself |
| Better local coding | [Qwen 3.6 35B-A3B](/qwen36-ai-coding-agents/) or Qwen 3.6 27B | Strong coding for the size; MoE A3B is friendlier on unified memory |
| Vision + light agent | Gemma 4 class multimodal MLX builds | Image + tools without cloud |
| Voice | Voxtral (built-in path) | Local transcription |

On 24 GB, leave headroom for OS, browser, recording, and agent overhead. A &quot;usable&quot; coding model is often more valuable than the biggest model that barely loads.

### Stronger coding (local if you have RAM/VRAM, else cloud)

For agent coding quality closer to daily Codex/Claude work, use the current open coding stack covered in [best open source Claude alternatives](/best-open-source-llms-claude-alternative/):

- **GLM-5.2** — strongest overall open coding flagship right now
- **Qwen 3.6 Plus / Max** — excellent agent coding via API or OpenCode Go
- **MiniMax M3** — cheap long sessions
- **MiMo V2.5 Pro** — solid coding agent model
- **Kimi K2 / K2.x Code** — long context and coding (see [Kimi K2](/kimi-k2-ai-model/)); official Bionic materials also call out Kimi K2.7 Code for agent work

Bionic&apos;s Secure Cloud is useful when your laptop cannot host those weights. For API-style multi-model routing outside Bionic, [OpenCode Go](/opencode-go-plan/) and the [Codex any-model config](/codex-app-any-model/) remain strong options.

### Local stack alternatives

If you want pure local inference without Bionic&apos;s agent UI:

- [Ollama Docker install](/ollama-docker-install/) for a simple local server
- [OpenClaw + Ollama](/openclaw-ollama-local-models/) for a messaging/assistant workflow on local models
- [AI programming beginners guide](/ai-programming-beginners-guide/) if you are still choosing between chat apps, IDEs, and agents

## Bionic vs Codex vs OpenCode

| Feature | LM Studio Bionic | Codex app | OpenCode |
| --- | --- | --- | --- |
| Focus | Local-first open model agent | Polished coding agent UI | Open-source terminal agent |
| Local models | First-class | Possible via custom providers | Via Ollama / local endpoints |
| Cloud open models | Secure Cloud + optional plan features | ChatGPT plan + any OpenAI-compatible | Many providers + Go plan |
| Best today | Private local agent + docs/voice | Highest polish coding UX | Flexible CLI workflows |
| Weak today | Early UI, small-model coding quality | Local is not the default story | Less &quot;desktop product&quot; feel |

Honest take after the Mac Mini session: **Bionic is the right product shape for private open-model agents**, but it is early. For production coding this week, I still reach for Codex or OpenCode with a strong model. For private chat, vision, notes, and local experimentation, Bionic is already interesting.

## Pros and cons

### Pros

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;True local-first agent path for open models&lt;/li&gt;
&lt;li&gt;Clear Code vs Work project split&lt;/li&gt;
&lt;li&gt;MLX path on Apple Silicon&lt;/li&gt;
&lt;li&gt;Local voice with Voxtral&lt;/li&gt;
&lt;li&gt;MCP / connected apps direction&lt;/li&gt;
&lt;li&gt;Optional Secure Cloud with zero data retention claim&lt;/li&gt;
&lt;li&gt;Available on Mac, Windows, and Linux&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Cons

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Early UX: file change visibility and editing still thin&lt;/li&gt;
&lt;li&gt;Small local models struggle with real multi-file frameworks&lt;/li&gt;
&lt;li&gt;RAM pressure is real on 24 GB once model + voice + agent run&lt;/li&gt;
&lt;li&gt;Web search and some power features lean on cloud plan&lt;/li&gt;
&lt;li&gt;Not yet as polished as Codex for day-to-day coding&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Who should try Bionic now?

**Try it if you:**

- Want a desktop agent that prefers local open models
- Care about privacy for notes, docs, and light coding
- Already run MLX/GGUF models and want an agent shell around them
- Need local voice dictation inside an AI workspace

**Wait or use something else if you:**

- Need reliable multi-file production coding today
- Only have 8–16 GB RAM and expect large coding models
- Prefer a mature terminal agent ([OpenCode](/opencode-setup-guide/)) or Codex UI ([any-model setup](/codex-app-any-model/))

## Getting started checklist

1. Download Bionic from [lmstudio.ai](https://lmstudio.ai/)
2. Install a model that fits your RAM (MLX on Mac)
3. Create a **Code** project for a simple folder first, not your monorepo
4. Create a **Work** project for docs/images
5. Optionally enable **Voxtral** voice if you have spare RAM
6. Add MCP apps only after basic chat/tools feel stable
7. For hard coding tasks, switch to a stronger local or Secure Cloud model

## FAQ

&lt;Accordion label=&quot;Is LM Studio Bionic free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Local models run on your machine. The app is free to download. Cloud models, web search, and plan-tied features can require an LM Studio account and billing. Always check current pricing on the official site.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Bionic the same as LM Studio?&quot; group=&quot;faq&quot;&gt;
No. Bionic is a separate agent app. Classic LM Studio remains useful for lower-level model management and chat. You can use both.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can it replace Codex or Claude Code?&quot; group=&quot;faq&quot;&gt;
Not yet for serious multi-file work, especially with small local models. With stronger open coding models (GLM-5.2, Qwen 3.6, Kimi Code class), it becomes a realistic private alternative for many tasks. For max polish today, Codex/OpenCode still lead.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What Mac RAM do you need?&quot; group=&quot;faq&quot;&gt;
16 GB is the practical floor for light models. 24 GB worked for a small tools+vision model plus agent overhead, but memory sat near the top during testing. 32 GB+ is more comfortable if you want better coding models and voice at the same time.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does it support MCP?&quot; group=&quot;faq&quot;&gt;
Yes. Connected apps / MCP servers are part of the product direction, so you can attach tools like Notion-style integrations. See our [MCP intro](/mcp-introduction-beginners/) for the protocol basics.
&lt;/Accordion&gt;

## Final verdict

LM Studio Bionic is the biggest product step LM Studio has taken toward **doing work**, not only chatting with local models. Code and Work workspaces, local voice, MCP hooks, and optional Secure Cloud form a coherent local-first agent story.

On an M4 Pro 24 GB machine, the app itself is usable. A tiny Gemma-class model is fine for demos, vision, and light Work tasks, but not for shipping real Astro/React projects. Pair Bionic with a modern coding model (or Secure Cloud) and it becomes much more interesting.

Watch the full walkthrough here:

&lt;YouTubeEmbed url=&quot;https://youtu.be/9Pm_DnTRJZM&quot; label=&quot;LM Studio Bionic full video walkthrough&quot; /&gt;

Next reads if you are building a local AI stack:

- [Best open source LLMs for coding](/best-open-source-llms-claude-alternative/)
- [Qwen 3.6 for coding agents](/qwen36-ai-coding-agents/)
- [OpenCode setup](/opencode-setup-guide/)
- [Codex app with any model](/codex-app-any-model/)
- [Ollama Docker install](/ollama-docker-install/)
- [FluidVoice Mac dictation](/fluidvoice-mac-dictation/)</content:encoded><category>ai</category><category>ai-tools</category><category>llm</category><category>local-ai</category></item><item><title>PM2 Environment Variables: Setup, Update &amp; Best Practices</title><link>https://www.bitdoze.com/pm2-env-vars/</link><guid isPermaLink="true">https://www.bitdoze.com/pm2-env-vars/</guid><description>Master PM2 environment variables: learn to set, update, and secure them using ecosystem files, --update-env, filter_env, and more. Avoid common pitfalls.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

PM2 environment variables are one of those things that seem simple until they bite you. You set a variable, restart the process, and nothing changes. Or you use the wrong flag and wonder why your app is running on the wrong port. I&apos;ve seen these mistakes trip up experienced developers, and the old PM2 documentation doesn&apos;t help much.

This guide covers the correct way to set, update, and secure environment variables in PM2 (currently at version 7.0.3, requiring Node.js 18+). If you&apos;re looking for a broader overview of process management, check [managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/) first. For operators running multiple apps on a VPS, the [best self-hosted server management panels](https://www.bitdoze.com/best-self-hosted-panels/) can help with visibility across your stack.

&lt;Notice type=&quot;error&quot; title=&quot;Common Misconception&quot;&gt;
The `--env` flag in PM2 does **not** set individual environment variables. It selects named environment blocks from an ecosystem file (e.g., `--env production`). Many articles, including older versions of this one, show incorrect syntax like `pm2 start app.js --env PORT=3000`. That doesn&apos;t work.
&lt;/Notice&gt;

## Setting environment variables via the command line in PM2

The correct way to pass environment variables when starting a PM2 process uses [standard Unix environment variable syntax](https://www.bitdoze.com/linux-commands/): prefix the command with `VAR=value`.

&lt;Tabs&gt;
&lt;Tab name=&quot;Wrong way (does not work)&quot;&gt;
```sh
# ❌ This is NOT how PM2 env vars work
pm2 start app.js --env PORT=3000
pm2 start app.js --env PORT=3000,DB_URL=mongodb://localhost:27017/mydb
```
The `--env` flag selects environment **blocks** from an ecosystem file, not individual variables. Passing `--env PORT=3000` will either be ignored or cause an error.
&lt;/Tab&gt;
&lt;Tab name=&quot;Correct way (Unix prepend)&quot;&gt;
```sh
# Single variable:
PORT=3000 pm2 start app.js

# Multiple variables (space-separated):
PORT=3000 DB_URL=mongodb://localhost:27017/mydb SECRET=abc123 pm2 start app.js
```
This is standard Unix. The variables are set in the environment before PM2 spawns your application.
&lt;/Tab&gt;
&lt;/Tabs&gt;

**Verify it worked:**

```sh
# Find your process ID first
pm2 list

# Then inspect the environment (replace 0 with your process id)
pm2 env 0
```

You should see `PORT=3000` in the output. If it&apos;s missing, double-check the prepend syntax. The variables must come *before* `pm2 start`.

For [PM2 cluster mode](https://www.bitdoze.com/pm2-fork-cluster/) with multiple instances, you&apos;ll typically want different ports per instance. `increment_var` handles that automatically (see the advanced section).

## Setting environment variables with PM2 ecosystem files

For any production application, the ecosystem file is the right approach. It gives you a single config file that defines your app, its environment variables, and how PM2 should run it.

### Basic env, env_production, and env_development blocks

```js
// ecosystem.config.js
module.exports = {
  apps: [
    {
      name: &quot;my-app&quot;,
      script: &quot;./app.js&quot;,
      // Default env. Used when no --env flag is passed
      env: {
        NODE_ENV: &quot;development&quot;,
        PORT: 3000,
        APP_LOG_LEVEL: &quot;debug&quot;,
      },
      // Used with: pm2 start ecosystem.config.js --env production
      env_production: {
        NODE_ENV: &quot;production&quot;,
        PORT: 3000,
        APP_LOG_LEVEL: &quot;info&quot;,
      },
      // Used with: pm2 start ecosystem.config.js --env staging
      env_staging: {
        NODE_ENV: &quot;staging&quot;,
        PORT: 3000,
        APP_LOG_LEVEL: &quot;warn&quot;,
      },
    },
  ],
};
```

Each `env_*` block corresponds to a name you pass with `--env`. The base `env` block is the default when no `--env` flag is used.

### Switching environments with --env [name]

```sh
# Start with development env (default):
pm2 start ecosystem.config.js

# Start with production env:
pm2 start ecosystem.config.js --env production

# Start with staging env:
pm2 start ecosystem.config.js --env staging
```

&lt;Notice type=&quot;info&quot; title=&quot;Ecosystem env vars update automatically on restart&quot;&gt;
When you use an ecosystem file, PM2 re-reads the file on `pm2 restart`. So if you edit the `env_production` block and run `pm2 restart ecosystem.config.js --env production`, the new values are picked up. This is different from CLI-set variables, which are conservative by default (more on that below).
&lt;/Notice&gt;

### Modern ecosystem file formats and scaffolding

PM2 supports more than just `.js` files:

- `ecosystem.config.js` / `ecosystem.config.cjs` / `ecosystem.config.mjs`
- `ecosystem.json` / `ecosystem.json5`
- `ecosystem.yaml` / `ecosystem.yml`

To scaffold a starter config:

```sh
pm2 init simple
```

This creates a basic `ecosystem.config.js` you can fill in.

**Verify:**

```sh
pm2 start ecosystem.config.js --env production
pm2 env 0   # Confirm NODE_ENV=production appears
```

If the wrong env block values appear, the `--env` name doesn&apos;t match your ecosystem file key. `--env prod` will **not** match `env_production`. The name must match exactly.

## How to update environment variables for a running PM2 application

This is where most people get stuck. PM2 is conservative by default: environment variables are essentially immutable once a process starts.

&lt;Notice type=&quot;warning&quot; title=&quot;Restarts are conservative by default&quot;&gt;
From the official PM2 docs: &quot;Via CLI, the environment is conservative meaning that, when you will run different process management actions (restart, reload, stop/start), new environment variables will not be updated into your application.&quot; You must explicitly opt in with `--update-env`.
&lt;/Notice&gt;

### The --update-env flag

To change a variable for a CLI-started process:

```sh
# Set the new value and restart with --update-env
PORT=4000 pm2 restart my-app --update-env
```

To confirm the change took effect:

```sh
pm2 env 0   # Look for PORT=4000
```

For ecosystem file changes, just restart with the same `--env` flag:

```sh
# Edit env_production in ecosystem.config.js, then:
pm2 restart ecosystem.config.js --env production
```

Ecosystem file variables are always updated on restart. You don&apos;t need `--update-env` for those.

### Full delete + restart for stubborn variables

Some variables, most notably `NODE_ENV`, are read once at startup and won&apos;t change even with `--update-env`. When that happens, delete and re-add is the only reliable path:

```sh
pm2 delete my-app
pm2 start ecosystem.config.js --env production
```

This is a known behavior. Community reports (GitHub issues #3192, #4135, #5591) confirm that `--update-env` can be unreliable in certain edge cases. When in doubt, delete and re-add.

### The pm2 save / pm2 resurrect footgun

If you use `pm2 save` to persist your process list for auto-restart on reboot (via `pm2 startup`), be aware: `pm2 save` freezes the current environment variables into the dump file. When you later run `pm2 resurrect`, those saved values are used, even if you&apos;ve changed the ecosystem file.

The fix: after changing env vars, delete and re-add your processes, then save again:

```sh
pm2 delete my-app
pm2 start ecosystem.config.js --env production
pm2 save
```

&lt;Notice type=&quot;error&quot; title=&quot;pm2 set Does NOT Update Application Env Vars&quot;&gt;
`pm2 set` is for PM2&apos;s **internal configuration system** (e.g., `pm2 set pm2:sysmonit true` to toggle host monitoring). It does not set application environment variables and does not send any signal to your process. If you&apos;ve been using `pm2 set app:KEY value`, that value is not reaching your application.
&lt;/Notice&gt;

## Advanced PM2 environment variable features

These features matter most when running multiple instances or deploying to multiple environments on the same server.

### filter_env: prevent global env var leaks

By default, your PM2 process inherits **all** environment variables from the parent shell. That includes system vars, vars from other apps, and anything exported in your `.bashrc`. `filter_env` lets you whitelist a prefix.

```js
module.exports = {
  apps: [
    {
      name: &quot;my-app&quot;,
      script: &quot;./app.js&quot;,
      // Only pass env vars that start with APP_ or DB_
      filter_env: [&quot;APP_&quot;, &quot;DB_&quot;],
    },
  ],
};
```

To drop all global env vars entirely:

```js
filter_env: true,
```

This is a security feature. It prevents accidental leakage of secrets or variables from other processes. See the security section for more.

### increment_var: auto-increment PORT per cluster instance

When running in [PM2 cluster mode](https://www.bitdoze.com/pm2-fork-cluster/), each instance needs its own port. Instead of hardcoding ports, use `increment_var`:

```js
module.exports = {
  apps: [
    {
      name: &quot;api&quot;,
      script: &quot;./api.js&quot;,
      instances: 4,
      exec_mode: &quot;cluster&quot;,
      increment_var: &quot;PORT&quot;,
      env: {
        PORT: 3000,
      },
    },
  ],
};
```

This produces: instance 0 gets `PORT=3000`, instance 1 gets `PORT=3001`, instance 2 gets `PORT=3002`, instance 3 gets `PORT=3003`.

### instance_var: customize NODE_APP_INSTANCE

PM2 sets `NODE_APP_INSTANCE` for each cluster instance (0, 1, 2, ...). This conflicts with the `node-config` library, which uses the same variable for its own purposes. Rename it:

```js
instance_var: &quot;INSTANCE_ID&quot;,
```

Now each instance gets `INSTANCE_ID=0`, `INSTANCE_ID=1`, etc. instead of `NODE_APP_INSTANCE`.

### append_env_to_name: multi-environment on one server

If you want to run the same application in development and production on a single machine:

```js
module.exports = {
  apps: [
    {
      name: &quot;my-app&quot;,
      script: &quot;./app.js&quot;,
      append_env_to_name: true,
      env: { NODE_ENV: &quot;development&quot; },
      env_production: { NODE_ENV: &quot;production&quot; },
    },
  ],
};
```

With `pm2 start ecosystem.config.js --env production`, PM2 names the process `my-app-production`. With no flag, it&apos;s `my-app-development`. Both can run simultaneously.

## Listing and inspecting environment variables in PM2

To see all environment variables for a running process:

```sh
# By process ID
pm2 env 0

# By process name
pm2 env my-app
```

This shows every environment variable the process can see, both the ones you set and all the inherited system variables.

For JSON output (useful for scripting):

```sh
pm2 jlist | jq &apos;.[0].pm2_env&apos;
```

For a formatted view of all processes:

```sh
pm2 prettylist
```

&lt;Notice type=&quot;warning&quot; title=&quot;Secrets Are Visible in pm2 env Output&quot;&gt;
Anyone with shell access can run `pm2 env &lt;id&gt;` and see all environment variables, including database passwords, API keys, and tokens. This is another reason to use `filter_env` and to restrict SSH access. For broader guidance on [monitoring your application processes](https://www.bitdoze.com/sever-monitoring/) and server security, see the linked guides.
&lt;/Notice&gt;

## Security best practices for PM2 environment variables

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Never hardcode secrets in ecosystem files committed to git&lt;/li&gt;
&lt;li&gt;Use `filter_env` to limit which variables your process can see&lt;/li&gt;
&lt;li&gt;Inject secrets at deploy time, not at development time&lt;/li&gt;
&lt;li&gt;Restrict SSH access. `pm2 env` exposes everything to anyone with a shell&lt;/li&gt;
&lt;li&gt;Rotate secrets regularly and restart with `--update-env` or delete+start&lt;/li&gt;
&lt;li&gt;Audit your environment periodically with `pm2 env &lt;id&gt;`&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Never hardcode secrets in ecosystem files

Your `ecosystem.config.js` should be in version control. That means it should never contain real passwords, API keys, or tokens. Instead, inject them at deploy time:

**Option 1: Wrapper script that sources a .env file**

```sh
#!/bin/bash
# start-app.sh
set -a
source /etc/my-app/secrets.env
set +a
pm2 start ecosystem.config.js --env production
```

**Option 2: Systemd environment file**

If PM2 is managed by systemd (via `pm2 startup`), you can set variables in `/etc/default/my-app` and reference them in the unit file.

**Option 3: Secrets manager**

For teams and more complex setups, tools like Infisical, Doppler, or HashiCorp Vault can inject secrets at deploy time. For simple solo setups, a `.env` file with restricted permissions (`chmod 600`) sourced by a wrapper script is usually enough.

For readers managing secrets in containerized environments, see [Docker Compose secrets management](https://www.bitdoze.com/docker-compose-secrets/) for a comparison of approaches.

### Using filter_env to limit exposure

If your server runs multiple PM2 applications, `filter_env` prevents one app from seeing another app&apos;s variables. Prefix-based filtering is the most practical:

```js
filter_env: [&quot;MYAPP_&quot;],
```

Now only variables starting with `MYAPP_` reach your process. Everything else is stripped.

### Broader server security

Environment variables are only one part of the picture. If you haven&apos;t already, review [securing your SSH server in Linux](https://www.bitdoze.com/secure-ssh-server-linux/). If someone gets shell access, all your env vars are exposed regardless of how well you manage them in PM2.

## Common PM2 environment variable pitfalls (and how to avoid them)

&lt;Accordion label=&quot;--env selects environment blocks, not individual variables&quot; group=&quot;pitfalls&quot; expanded=&quot;true&quot;&gt;
`pm2 start app.js --env PORT=3000` does **not** set `PORT`. The `--env` flag selects a named environment block from an ecosystem file. Use `--env production` to use the `env_production` block. To set individual variables on the command line, use the Unix prepend syntax: `PORT=3000 pm2 start app.js`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Restarts don&apos;t pick up new env vars&quot; group=&quot;pitfalls&quot;&gt;
PM2 is conservative by default. Running `pm2 restart my-app` will **not** pick up new environment variables from the shell. You must add `--update-env`: `PORT=4000 pm2 restart my-app --update-env`. For ecosystem file variables, restart with the ecosystem file and `--env` flag. Those are always refreshed.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;NODE_ENV won&apos;t change with --update-env&quot; group=&quot;pitfalls&quot;&gt;
`NODE_ENV` is read once at application startup. Even `--update-env` won&apos;t change it. The only reliable way is the nuclear option: `pm2 delete my-app` followed by `pm2 start ecosystem.config.js --env production` (or the CLI equivalent with the new `NODE_ENV` value).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;pm2 save freezes env vars into the dump file&quot; group=&quot;pitfalls&quot;&gt;
When you run `pm2 save`, PM2 dumps the current process list, including all environment variables, to `~/.pm2/dump.pm2`. On `pm2 resurrect` (often triggered automatically by `pm2 startup`), those saved values are used. If you changed env vars in the ecosystem file after saving, the resurrected processes will still use the old values. Delete, re-add, and save again.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Secrets are visible in pm2 env output&quot; group=&quot;pitfalls&quot;&gt;
Any user with shell access can run `pm2 env &lt;id&gt;` and see all environment variables, including database passwords and API keys. Use `filter_env` to limit what your process inherits, and restrict SSH access to the server.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;pm2 set is for PM2 internal config, not application vars&quot; group=&quot;pitfalls&quot;&gt;
`pm2 set pm2:sysmonit true` toggles PM2&apos;s host monitoring. `pm2 set myapp:PORT 3000` does **not** set an application environment variable. There is no `pm2 set` command that affects `process.env` in your application. Use ecosystem files or CLI prepend + `--update-env` instead.
&lt;/Accordion&gt;

## Conclusion

PM2 environment variables come down to a few core rules:

1. **Use ecosystem files for production.** They&apos;re explicit, version-controllable, and PM2 re-reads them on restart.
2. **Use `--update-env` when changing CLI-set variables.** Without it, `pm2 restart` is conservative and won&apos;t pick up changes.
3. **Delete and re-start for `NODE_ENV` changes.** `--update-env` doesn&apos;t work for variables read at startup.
4. **Use `filter_env` to limit exposure.** Don&apos;t let your app inherit the entire server environment.
5. **Test with `pm2 env &lt;id&gt;`.** Always verify what your process actually sees.

For the full process management picture, see the [complete guide to managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/) and [choosing between fork and cluster mode](https://www.bitdoze.com/pm2-fork-cluster/). And if your Node.js dependencies are behind, [keeping your Node.js environment up to date](https://www.bitdoze.com/nodejs-update-dependencies/) is worth the effort. PM2 7.x requires Node.js 18+.

If you&apos;re looking for an affordable VPS to run your PM2-managed apps, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers solid price-to-performance for European and US regions. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with NVMe storage.</content:encoded><category>tools</category><category>pm2</category><category>node</category><category>environment-variables</category></item><item><title>PM2 Process Manager: Complete Guide to Managing Apps</title><link>https://www.bitdoze.com/pm2-manage-apps/</link><guid isPermaLink="true">https://www.bitdoze.com/pm2-manage-apps/</guid><description>Learn how to use the PM2 process manager to run Node.js, Python &amp; Bun apps in production. Covers setup, clustering, log management, auto-restarts, and more.</description><pubDate>Fri, 17 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/01/pm2-monit.png&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

PM2 is a production process manager for Node.js, Python, and Bun applications. It runs your apps as background daemons, handles auto-restarts on crashes, provides clustering with load balancing, and gives you a terminal dashboard to monitor everything. If you&apos;re deploying apps on a VPS, whether it&apos;s a [Hetzner Cloud](https://go.bitdoze.com/hetzner) box or a [Hostinger VPS](https://go.bitdoze.com/hostinger-vps), PM2 is one of the fastest ways to get production-grade process management without the overhead of containers or orchestration.

I use PM2 on VPS instances where I run multiple Node.js or Python apps and need them to survive crashes and reboots. It&apos;s not a replacement for Docker or Kubernetes, but for a single-server setup it covers the basics well: process supervision, clustering, log management, and boot persistence. The v7.x release added Bun support, OpenTelemetry tracing, and improved the CLI layout. All worth knowing about if you haven&apos;t updated in a while.

&lt;Notice type=&quot;info&quot; title=&quot;Updated for PM2 7.x&quot;&gt;
This guide was originally published in January 2024 and has been updated for PM2 7.x (latest: 7.0.3). Key changes include Bun runtime support, Node.js 18+ requirement, OpenTelemetry tracing, and an improved CLI with adaptive layout.
&lt;/Notice&gt;

## What is the PM2 process manager?

[PM2](https://pm2.keymetrics.io/) is an open-source process manager for Node.js/Bun applications that also manages Python, Ruby, PHP, and other runtimes. It has over 600 million downloads on npm and 43,000+ GitHub stars. Companies like Microsoft, Netflix, NASA, and IBM use it in production.

PM2 runs your applications as daemon processes, so they don&apos;t block your terminal. It supports cluster mode for load balancing across CPU cores, automatic restarts when processes crash or exceed memory limits, centralized log management, and a monitoring dashboard. It&apos;s licensed under AGPL 3.0.

The key thing PM2 solves is process supervision. Without a process manager, if your Node.js app crashes at 3 AM, it stays down until you manually restart it. PM2 watches for crashes and brings the process back automatically. It also handles the plumbing of running multiple instances across CPU cores. Without PM2, you&apos;d need to write cluster code yourself or configure systemd with multiple service units.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Run apps as background daemons (no terminal required)&lt;/li&gt;
&lt;li&gt;Cluster mode with load balancing across CPU cores&lt;/li&gt;
&lt;li&gt;Automatic restarts on crash, memory limit, or cron schedule&lt;/li&gt;
&lt;li&gt;Centralized log viewing and rotation&lt;/li&gt;
&lt;li&gt;Terminal monitoring dashboard (CPU, memory, event loop)&lt;/li&gt;
&lt;li&gt;Multi-runtime: Node.js, Python, Bun, Deno, Ruby, PHP&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## How to install PM2

PM2 is a Node.js module, so you need Node.js and npm on your system first. PM2 v7.x requires **Node.js &gt;= 18.0.0** (it dropped Node 16 support). If you don&apos;t have Node.js yet, you can [install Node.js using NVM](/install-nodejs-using-nvm-macos-ubuntu/). NVM is the recommended way to install Node.js on a server because it lets you switch versions without affecting the system.

Install PM2 globally:

```sh
npm install -g pm2
```

Verify the installation:

```sh
pm2 --version
```

Expected output:

```
[PM2] PM2 Successfully daemonized
7.0.3
```

&lt;Notice type=&quot;warning&quot; title=&quot;command not found?&quot;&gt;
If you get `command not found`, your global npm bin directory is not in `$PATH`. Run `npm config get prefix` and add the `/bin` directory to your PATH.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Upgrading from an older version&quot;&gt;
If you already have PM2 installed, use `pm2 update` instead of just reinstalling. It saves the process list, kills the old daemon, and restores everything under the new version with no dropped processes.
&lt;/Notice&gt;

## Managing applications with PM2

This section covers running apps via the command line and config files, with best practices for each runtime.

### Quick start: running apps from the command line

The fastest way to start an app with PM2:

```sh
# Node.js
pm2 start app.js --name my-node-app

# Python
pm2 start app.py --name my-python-app --interpreter python3

# Streamlit
pm2 start &apos;streamlit run app.py&apos; --name my-streamlit-app
```

Terminal output:

```
[PM2] Starting /usr/bin/bash in fork_mode (1 instance)
[PM2] Done.
┌────┬─────────────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id │ name                │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├────┼─────────────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0  │ my-streamlit-app    │ default     │ N/A     │ fork    │ 9484     │ 0s     │ 0    │ online    │ 0%       │ 10.3mb   │ root     │ disabled │
└────┴─────────────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
```

For more on deploying Streamlit apps this way, see [deploying Streamlit on a VPS with PM2](/streamlit-deploy-vps-cloudflare/).

### Best practices for Node.js apps

Node.js is the native runtime for PM2. Here&apos;s what to keep in mind:

- **Cluster mode is opt-in, not the default.** PM2 runs in `fork` mode by default (one process). To use [cluster mode](/pm2-fork-cluster/), you need to explicitly set `exec_mode: &quot;cluster&quot;` and `instances: &quot;max&quot;` in your config or use `-i max` on the command line. This is a common source of confusion: `pm2 start app.js` gives you a single process, not a cluster.
- **Use `--watch` during development** to auto-reload on code changes. Don&apos;t use it in production. It adds filesystem overhead and can cause unexpected restarts if temp files or logs are written inside the watched directory.
- **Use `ecosystem.config.js`** for production (see the next section). It&apos;s the recommended config format and supports environment switching (`env`, `env_production`).
- **Set `max_memory_restart`** to catch memory leaks. For a typical Express/Fastify app, `&quot;300M&quot;` to `&quot;512M&quot;` is a reasonable starting point. The app will restart cleanly when it hits the threshold.
- **For HTTP servers in cluster mode**, use `pm2 reload` instead of `pm2 restart`. Reload does a rolling restart of workers so there&apos;s no downtime for in-flight requests.

### Best practices for Python apps

PM2 can run any Python app: Flask, FastAPI, Streamlit, scripts, and more:

- **Specify the interpreter**: Use `--interpreter python3` (or the full path if you have multiple Python versions). If you don&apos;t specify, PM2 may pick the wrong one.
- **Use fork mode**: Python&apos;s GIL makes cluster mode pointless. Multiple Python processes in cluster mode don&apos;t share memory or balance load the way Node.js workers do. Stick with `fork` mode and use a fixed instance count if you need multiple workers.
- **Disable auto-restart for one-shot scripts**: Use `--no-autorestart` to prevent infinite restart loops on scripts that exit normally with exit code 0. Without this flag, PM2 will restart any script that exits, which is usually not what you want for batch jobs or data processing scripts.
- **Set `max_memory_restart`** for long-running Python web apps. Flask and FastAPI apps can accumulate memory over time, especially with large dataframes or caching. A value like `&quot;500M&quot;` is a reasonable starting point.

### Bun and Deno support in PM2

PM2 v7.x has native Bun support. TypeScript files (`.ts`, `.tsx`) auto-detect Bun when it&apos;s installed:

```sh
# TypeScript files auto-detect Bun
pm2 start app.ts

# Explicit Bun interpreter
pm2 start index.js --interpreter bun

# Bun cluster mode (requires Bun &gt;= 1.1.25)
bunx --bun pm2 start app.ts -i max

# Deno
pm2 start main.ts --interpreter deno --interpreter-args &quot;run --allow-net&quot;
```

Bun support is still relatively new. Cluster mode works on Bun &gt;= 1.1.25, and the `bunx --bun` prefix ensures PM2 itself runs under Bun rather than Node.js. Deno support works through the `--interpreter` flag with arguments passed via `--interpreter-args`.

&lt;Notice type=&quot;warning&quot; title=&quot;Runtime lock&quot;&gt;
The PM2 daemon keeps the runtime it was first started with. If you switch between Node.js and Bun, run `pm2 kill` first to restart the daemon with the new runtime.
&lt;/Notice&gt;

If you&apos;re exploring Bun as a runtime, see [Bun package manager](/bun-package-manager/) for a full comparison with npm, yarn, and pnpm.

## PM2 ecosystem configuration files

For anything beyond quick testing, use an ecosystem config file. This is the recommended way to declare your apps. The ecosystem file is a JavaScript module that exports your app configuration. It supports multiple apps, environment-specific variables, and is easier to maintain than ad-hoc CLI commands.

### Using `pm2 init simple` to generate a config

Run this in your project directory:

```sh
pm2 init simple
```

It generates a starter `ecosystem.config.js`:

```js
module.exports = {
  apps: [{
    name: &quot;my-app&quot;,
    script: &quot;./app.js&quot;,
    instances: &quot;max&quot;,
    exec_mode: &quot;cluster&quot;,
    env: {
      NODE_ENV: &quot;development&quot;
    },
    env_production: {
      NODE_ENV: &quot;production&quot;
    }
  }]
}
```

Start with a specific environment:

```sh
pm2 start ecosystem.config.js --env production
```

### JSON vs JavaScript ecosystem files

PM2 supports both formats. Here&apos;s what each looks like for a multi-app setup:

&lt;Tabs&gt;
&lt;Tab name=&quot;ecosystem.config.js (recommended)&quot;&gt;
```js
module.exports = {
  apps: [
    {
      name: &quot;api-server&quot;,
      script: &quot;./server.js&quot;,
      instances: &quot;max&quot;,
      exec_mode: &quot;cluster&quot;,
      env: {
        NODE_ENV: &quot;development&quot;
      },
      env_production: {
        NODE_ENV: &quot;production&quot;
      }
    },
    {
      name: &quot;streamlit-app&quot;,
      script: &quot;streamlit&quot;,
      args: &quot;run app.py&quot;,
      cwd: &quot;/path/to/streamlit/app&quot;,
      interpreter: &quot;python3&quot;,
      instances: 1,
      max_memory_restart: &quot;1G&quot;,
      error_file: &quot;/var/log/pm2/streamlit-error.log&quot;,
      out_file: &quot;/var/log/pm2/streamlit-out.log&quot;,
      merge_logs: true
    }
  ]
}
```
&lt;/Tab&gt;
&lt;Tab name=&quot;JSON config (still works)&quot;&gt;
```json
{
  &quot;apps&quot;: [
    {
      &quot;name&quot;: &quot;api-server&quot;,
      &quot;script&quot;: &quot;./server.js&quot;,
      &quot;instances&quot;: &quot;max&quot;,
      &quot;exec_mode&quot;: &quot;cluster&quot;,
      &quot;env&quot;: {
        &quot;NODE_ENV&quot;: &quot;development&quot;
      }
    },
    {
      &quot;name&quot;: &quot;streamlit-app&quot;,
      &quot;script&quot;: &quot;streamlit&quot;,
      &quot;args&quot;: [&quot;run&quot;, &quot;app.py&quot;],
      &quot;cwd&quot;: &quot;/path/to/streamlit/app&quot;,
      &quot;interpreter&quot;: &quot;python3&quot;,
      &quot;instances&quot;: 1,
      &quot;max_memory_restart&quot;: &quot;1G&quot;,
      &quot;log_date_format&quot;: &quot;YYYY-MM-DD HH:mm Z&quot;,
      &quot;error_file&quot;: &quot;/var/log/pm2/streamlit-error.log&quot;,
      &quot;out_file&quot;: &quot;/var/log/pm2/streamlit-out.log&quot;,
      &quot;merge_logs&quot;: true
    }
  ]
}
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Key config fields explained:

- `name`: Process name shown in `pm2 list`. Pick something descriptive. You&apos;ll use it in every PM2 command.
- `script`: Entry point file that PM2 executes.
- `interpreter`: Runtime (`python3`, `bun`, `node`, etc.). PM2 auto-detects this for `.js` files but you need to specify it for Python and other runtimes.
- `instances`: Number of instances. Use `&quot;max&quot;` to match CPU cores (only useful with cluster mode). Default is `1`.
- `exec_mode`: `&quot;fork&quot;` (default) or `&quot;cluster&quot;` (load balancing, Node.js/Bun only).
- `env` / `env_production`: [Environment variables](/pm2-env-vars/) per environment. Use `--env production` to select. This is one of the main advantages of ecosystem files over CLI commands.
- `max_memory_restart`: Restart when memory exceeds this. Accepts human-readable strings: `&quot;300M&quot;`, `&quot;1G&quot;`, `&quot;512K&quot;`. A bare number is treated as **bytes**, not megabytes. Default: `0` (disabled).
- `watch`: Auto-restart on file changes. Useful for development, not for production.

&lt;Notice type=&quot;info&quot; title=&quot;filter_env for security&quot;&gt;
PM2 supports a `filter_env` option to strip environment variables from child processes. Use `filter_env: [&quot;SECRET_&quot;]` to filter by prefix, or `filter_env: true` to drop all globals. Useful for hardening production deployments.
&lt;/Notice&gt;

## Command-line process management

PM2&apos;s CLI is the primary way to interact with your processes. The commands are short and consistent. Once you learn the pattern, it becomes muscle memory.

### Start, stop, restart, reload, and delete

```sh
pm2 start &lt;name|id&gt;       # Start a stopped process
pm2 stop &lt;name|id&gt;        # Stop gracefully
pm2 restart &lt;name|id&gt;     # Hard restart (drops in-flight requests)
pm2 reload &lt;name|id&gt;      # Graceful reload: zero-downtime in cluster mode
pm2 delete &lt;name|id&gt;      # Remove from PM2 entirely
pm2 kill                  # Stop the daemon and ALL managed processes
```

The difference between `restart` and `reload` matters in cluster mode: `reload` does a rolling restart of workers so there&apos;s no downtime. `restart` kills and respawns immediately. Use `reload` in production. Use `pm2 kill` when troubleshooting or switching runtimes. It&apos;s the nuclear option that stops everything.

### Checking status with `pm2 list` and `pm2 describe`

```sh
pm2 list                  # All processes (adaptive layout in v7.x)
pm2 describe &lt;name|id&gt;    # Detailed info: config, env vars, restarts, memory
pm2 env &lt;id&gt;              # Environment variables for a specific process
```

PM2 v7.x adapts its `pm2 list` output to your terminal width. Full table on wide terminals, condensed on narrow ones, and a compact `mini` mode in tight spaces. It also shows a host-metrics line (CPU, RAM, network) by default. Toggle it with:

```sh
pm2 set pm2:sysmonit true    # Enable system metrics in pm2 ls
pm2 set pm2:sysmonit false   # Disable
```

`pm2 describe` is useful for debugging — it shows the full config, environment variables, restart count, and memory/CPU stats for a specific process. Use `pm2 env &lt;id&gt;` when you need to check what environment variables a process is actually running with (useful when `.env` files aren&apos;t loading correctly).

## PM2 log management and rotation

### Viewing logs

PM2 collects stdout and stderr from all managed processes into log files (by default in `~/.pm2/logs/`). You can view them with:

```sh
pm2 logs                      # Tail all logs
pm2 logs &lt;name|id&gt;            # Tail specific app
pm2 logs &lt;name|id&gt; --lines 100  # Last 100 lines
pm2 logs &lt;name|id&gt; --err      # Only stderr
pm2 logs &lt;name|id&gt; --out      # Only stdout
pm2 flush                     # Flush all log files (clear them)
```

`pm2 flush` immediately clears all log files. It&apos;s useful when logs have grown too large and you want to start fresh after setting up rotation.

### Installing and configuring pm2-logrotate

&lt;Notice type=&quot;warning&quot; title=&quot;Not npm install&quot;&gt;
`pm2 install pm2-logrotate` installs a PM2 **module**, not an npm package. Do NOT use `npm install pm2-logrotate`. This is the correct way to set up log rotation in PM2 — there is no built-in `pm2 logrotate` command.
&lt;/Notice&gt;

```sh
# Install the module
pm2 install pm2-logrotate

# Configure
pm2 set pm2-logrotate:max_size 10M       # Rotate when file reaches 10MB
pm2 set pm2-logrotate:retain 30          # Keep 30 rotated files
pm2 set pm2-logrotate:compress true      # Compress rotated files
pm2 set pm2-logrotate:rotateInterval &apos;0 0 * * *&apos;  # Rotate daily at midnight

# View current config
pm2 conf
```

Without log rotation, PM2 log files will grow until they fill your disk. If that&apos;s already happened, see [cleaning up disk space](/cleanup-all-docker-things/) for recovery steps.

## Monitoring performance with PM2

```sh
pm2 monit
```

This opens a terminal dashboard showing CPU, memory, event loop latency, and network usage for all managed processes. You can navigate between processes and view their logs in real time. It&apos;s useful for quick debugging — if one process is eating all the CPU, you&apos;ll see it immediately.

&lt;Picture
  src={imag1}
  alt=&quot;PM2 process manager terminal dashboard showing CPU, memory, and process metrics&quot;
/&gt;

For broader monitoring beyond PM2 (system-level CPU, memory, disk, Docker containers), see [monitoring server and Docker resources](/sever-monitoring/). PM2&apos;s built-in monitoring is process-level only — it won&apos;t tell you about disk space, Docker containers, or system-level issues.

## PM2 production deployment: auto-restarts and startup

### Restart strategies

PM2 offers several ways to keep your apps alive. Understanding these is important for production reliability. A misconfigured restart strategy can either leave your app down or create a restart loop that hammers your database.

**Basic restart fields** (in config or CLI):

- `autorestart` — Enable/disable automatic restarts. Default: `true`. Set to `false` for batch scripts or one-shot tasks that should run once and exit.
- `max_memory_restart` — Restart when memory exceeds threshold. Accepts human-readable strings: `&quot;300M&quot;`, `&quot;1G&quot;`, `&quot;512K&quot;`. A bare number is **bytes**, not megabytes. Default: `0` (disabled). This is your main defense against memory leaks.
- `min_uptime` — If the app exits before this (ms), it&apos;s considered a crash. Default: `1000` (1 second). Increase this if your app takes a while to initialize (database connections, cache warming, etc.).
- `max_restarts` — Max restart attempts within a time window. Default: `15`. If your app crashes 15 times in 15 minutes, PM2 stops trying and marks it as errored. This prevents infinite restart loops.

**Advanced strategies**:

```sh
# Exponential backoff — prevents thundering herd on DB outages
pm2 start app.js --exp-backoff-restart-delay 100

# Skip auto-restart for specific exit codes (e.g., clean shutdown)
pm2 start app.js --stop-exit-codes 0

# Cron-based restart (restart every day at midnight)
pm2 start app.js --cron-restart &quot;0 0 * * *&quot;

# Fixed delay between restarts (5 seconds)
pm2 start app.js --restart-delay 5000
```

Exponential backoff is especially useful when your app depends on a database or external service that might be temporarily unavailable. Instead of hammering it with restarts, PM2 backs off exponentially.

### Persisting apps across reboots with `pm2 startup`

This is what makes PM2 practical for production. Without this, your apps die when the server reboots and you have to manually SSH in and restart everything.

```sh
pm2 startup
pm2 save
```

`pm2 startup` generates a systemd service that starts PM2 on boot. `pm2 save` snapshots the current process list to `~/.pm2/dump.pm2` so PM2 knows what to restore when it starts.

For a different user:

```sh
sudo env PATH=$PATH:/usr/bin pm2 startup systemd -u your_user --hp /home/your_user
```

&lt;Notice type=&quot;warning&quot; title=&quot;Apps not surviving reboot?&quot;&gt;
You must run BOTH `pm2 startup` AND `pm2 save`. Missing either one means processes won&apos;t restore. Verify with: `systemctl status pm2-&lt;user&gt;`.
&lt;/Notice&gt;

**Upgrading Node.js?** Run `pm2 unstartup` before upgrading, then `pm2 startup` + `pm2 save` again after. Otherwise the systemd service will point to the old Node.js binary.

`pm2 save --force` lets you save an empty process list. Useful for resetting the startup state.

## Advanced PM2 features

### Multi-app ecosystem config

You can manage multiple apps in a single `ecosystem.config.js`. This is the real power of ecosystem files. One command to start, stop, or reload everything. The Streamlit example from earlier shows this pattern. Add as many app objects to the `apps` array as you need:

```js
module.exports = {
  apps: [
    { name: &quot;api&quot;, script: &quot;./api.js&quot;, instances: &quot;max&quot;, exec_mode: &quot;cluster&quot; },
    { name: &quot;worker&quot;, script: &quot;./worker.py&quot;, interpreter: &quot;python3&quot;, instances: 1 },
    { name: &quot;scheduler&quot;, script: &quot;./cron.js&quot;, cron_restart: &quot;0 * * * *&quot; }
  ]
}
```

Start all apps: `pm2 start ecosystem.config.js`

Start only one app by name: `pm2 start ecosystem.config.js --only api`

### Static file serving with `pm2 serve`

PM2 can serve static files without a separate web server like Nginx or Caddy. This is useful for SPAs, documentation sites, or any static build output:

```sh
# Basic static file server
pm2 serve /path/to/build 3000 --name my-spa --spa

# Directory listing (v7.0.0+)
pm2 serve /path/to/files 8080 --ftp
```

The `--spa` flag routes all 404s to `index.html` for single-page applications. The `--ftp` flag (new in v7.0.0) enables directory listing, similar to Python&apos;s `http.server`.

This is fine for development or internal tools, but for production traffic you&apos;d typically put Nginx or Caddy in front as a reverse proxy with TLS termination.

### PM2 in Docker with `pm2-runtime`

`pm2-runtime` is a drop-in replacement for `node` designed for containers. It handles graceful shutdown (SIGINT forwarding) and proper log formatting:

```dockerfile
CMD [&quot;pm2-runtime&quot;, &quot;ecosystem.config.js&quot;]
```

You can also pass log format options:

```sh
pm2-runtime ecosystem.config.js --json     # JSON log output
pm2-runtime ecosystem.config.js --raw      # Raw output (no timestamps)
```

Why use PM2 inside Docker? Clustering on multi-core containers, graceful restarts, and consistent log formatting. If you&apos;re running single-process containers with Docker&apos;s restart policies, you probably don&apos;t need PM2 — but for multi-core containers or apps that benefit from PM2&apos;s restart strategies, it&apos;s useful. The main advantage over Docker&apos;s `restart: always` is that PM2 can do exponential backoff, memory-based restarts, and cluster mode — Docker&apos;s restart policy just blindly restarts.

For more on Python apps in containers, see [running Python apps in Docker](/docker-run-python/).

### Namespaces for process grouping

Namespaces let you group related processes and view their logs together:

```sh
pm2 start app.js --namespace api
pm2 start worker.js --namespace workers
pm2 start scheduler.js --namespace workers
pm2 logs api          # View logs for all &quot;api&quot; processes
pm2 logs workers      # View logs for all &quot;workers&quot; processes
```

This is cleaner than managing process names individually when you have many apps on the same server.

### OpenTelemetry tracing (v7.0+)

PM2 v7 includes `@opentelemetry/api` and `@opentelemetry/sdk-node` as direct dependencies. This enables distributed tracing for Node.js apps, though the integration still requires configuration with a collector backend (Jaeger, Grafana Tempo, etc.). The dependency is bundled — you don&apos;t need to install it separately — but you&apos;ll need to set up the collector and configure instrumentation for your specific framework. This is a relatively new feature and the documentation is still evolving.

## Troubleshooting common PM2 issues

&lt;Accordion label=&quot;PM2 daemon won&apos;t start&quot; group=&quot;pm2-troubleshooting&quot;&gt;

Check your Node.js version — PM2 v7.x requires Node.js &gt;= 18.0.0. Run `node --version` to verify. If Node.js is fine, try killing the daemon and restarting:

```sh
pm2 kill
pm2 start app.js
```

If you&apos;re using nvm, make sure the correct Node version is active in your current shell session.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Apps not surviving reboot&quot; group=&quot;pm2-troubleshooting&quot;&gt;

Verify you ran BOTH commands:

```sh
pm2 startup    # Creates the systemd service
pm2 save       # Saves the process list
```

Check the systemd service:

```sh
systemctl status pm2-$(whoami)
```

If the service is missing or pointing to the wrong Node.js binary, run `pm2 unstartup` then `pm2 startup` + `pm2 save` again.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Logs filling disk space&quot; group=&quot;pm2-troubleshooting&quot;&gt;

Install the pm2-logrotate module (see [Log Management section](#installing-and-configuring-pm2-logrotate)):

```sh
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 30
pm2 set pm2-logrotate:compress true
```

To immediately clear logs: `pm2 flush`

&lt;/Accordion&gt;

&lt;Accordion label=&quot;pm2 ls shows wrong user or stale info&quot; group=&quot;pm2-troubleshooting&quot;&gt;

Refresh the daemon:

```sh
pm2 update
```

If that doesn&apos;t help:

```sh
pm2 kill
pm2 resurrect
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Upgrading Node.js breaks PM2&quot; group=&quot;pm2-troubleshooting&quot;&gt;

The systemd startup script points to a specific Node.js binary. Before upgrading:

```sh
pm2 unstartup
# upgrade Node.js here
pm2 startup
pm2 save
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Switching between Node.js and Bun&quot; group=&quot;pm2-troubleshooting&quot;&gt;

The PM2 daemon retains its runtime. You must kill it first:

```sh
pm2 kill
pm2 start app.ts    # Now uses Bun if installed
```

&lt;/Accordion&gt;

## PM2 vs alternatives: when to use it

PM2 isn&apos;t the only way to manage processes on a Linux server. Here&apos;s an honest assessment of when it makes sense and when you should look at other options.

&lt;Tabs&gt;
&lt;Tab name=&quot;Use PM2 when&quot;&gt;
- You run multiple Node.js, Bun, or Python apps on a single VPS
- You need clustering with load balancing on one machine without writing cluster code
- You want a quick monitoring dashboard (`pm2 monit`) without setting up Grafana or similar
- You&apos;re prototyping or running a small-to-medium production stack on a budget VPS
- You want `pm2 startup` + `pm2 save` for reboot persistence without writing systemd units
- You need easy log rotation, memory-based restarts, and cron-based restarts out of the box
&lt;/Tab&gt;
&lt;Tab name=&quot;Skip PM2 when&quot;&gt;
- You&apos;re already using Docker or Kubernetes with restart policies — PM2 adds an unnecessary layer
- You run single-process containers (Docker handles restarts natively with `restart: always`)
- You prefer systemd for simplicity and don&apos;t need clustering or the monitoring dashboard
- You need process isolation — PM2 processes share the same filesystem and user
- You&apos;re deploying through a [self-hosted server panel](/best-self-hosted-panels/) like Coolify, Dokploy, or CloudPanel that handles process management for you
&lt;/Tab&gt;
&lt;/Tabs&gt;

Systemd is the other common approach for boot persistence and auto-restart on Linux. It&apos;s more verbose (you write a `.service` file for each app) but it&apos;s built into every Linux distro and has no runtime dependency. If you only have one app and don&apos;t need clustering, systemd is simpler and more predictable. PM2 earns its keep when you have multiple apps, want cluster mode, or need the monitoring dashboard.

For those deploying Node.js through a panel, see [deploying Node.js apps with CloudPanel and PM2](/install-cloudpanel-host-nodejs/) for a combined approach.

&lt;Notice type=&quot;info&quot; title=&quot;AGPL 3.0 license&quot;&gt;
PM2 is licensed under AGPL 3.0. This is fine for internal use and running your own apps. If you&apos;re embedding PM2 in a commercial product or distributing it as part of a service, check the license terms.
&lt;/Notice&gt;

## Conclusion

PM2 is a solid, battle-tested process manager that covers most of what you need to run apps in production on a single VPS: auto-restarts, clustering, log management, monitoring, and boot persistence. Start with `pm2 init simple` to generate an ecosystem config file, use cluster mode for Node.js apps that benefit from multi-core utilization, and set up pm2-logrotate early — before your disk fills up.

The v7.x release made PM2 relevant for Bun projects too, and the improved CLI layout makes day-to-day use more pleasant. It&apos;s not a replacement for proper container orchestration if you&apos;re running a fleet of services, but for a solo operator managing a handful of apps on a VPS, it does the job well.

## Frequently asked questions

&lt;Accordion label=&quot;What is the PM2 process manager used for?&quot; group=&quot;pm2-faq&quot;&gt;

PM2 runs Node.js, Python, and Bun apps in production as background daemons. It handles auto-restart on crashes, clustering with load balancing, log management, monitoring, and persistence across server reboots. Think of it as a lightweight process supervisor — it keeps your apps running even when things go wrong.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does PM2 support Bun?&quot; group=&quot;pm2-faq&quot;&gt;

Yes. PM2 v7.x has native Bun support. TypeScript files (`.ts`, `.tsx`) auto-detect Bun when installed. For cluster mode with Bun, you need Bun &gt;= 1.1.25 and should launch with `bunx --bun pm2 start app.ts -i max`. Note that the PM2 daemon retains its runtime — if you switch from Node.js to Bun, you need to run `pm2 kill` first.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the difference between PM2 fork mode and cluster mode?&quot; group=&quot;pm2-faq&quot;&gt;

Fork mode (the default) runs one process per app in an isolated child process. Cluster mode runs multiple instances of your app across all CPU cores with automatic load balancing using Node.js&apos;s built-in `cluster` module. Cluster mode only works with Node.js and Bun — not Python, due to the Global Interpreter Lock (GIL). For Python apps, use fork mode with multiple instances if you need parallelism.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I update PM2?&quot; group=&quot;pm2-faq&quot;&gt;

```sh
npm install pm2@latest -g
pm2 update
```

`pm2 update` is the important part — it saves the process list, kills the old daemon, and restores everything on the new version. Just running `npm install` alone leaves a stale daemon running the old code. Always use `pm2 update` after upgrading.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does PM2 work in Docker?&quot; group=&quot;pm2-faq&quot;&gt;

Yes. Use `pm2-runtime` instead of `pm2` in your Dockerfile. It&apos;s designed for container environments with proper signal forwarding (SIGINT/SIGTERM) and log formatting:

```dockerfile
CMD [&quot;pm2-runtime&quot;, &quot;ecosystem.config.js&quot;]
```

`pm2-runtime` runs in the foreground (doesn&apos;t daemonize), which is what containers expect. You get PM2&apos;s clustering and restart strategies inside the container, plus structured log output that Docker can collect.

&lt;/Accordion&gt;</content:encoded><category>tools</category><category>pm2</category><category>node</category><category>process-manager</category></item><item><title>Mastra vs Eve: Best TypeScript AI Agent Framework in 2026</title><link>https://www.bitdoze.com/mastra-vs-eve-typescript-ai-agents/</link><guid isPermaLink="true">https://www.bitdoze.com/mastra-vs-eve-typescript-ai-agents/</guid><description>Mastra vs Eve compared: pricing, lock-in, benchmarks, and developer experience. A practical guide to choosing a TypeScript AI agent framework in 2026.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you&apos;re building AI agents in TypeScript, two frameworks own the conversation in mid-2026: **Mastra** and **Vercel Eve**.

I already ship real work on Mastra. I built a full assistant with file tools, shell, web search, browser automation, and persistent memory, and wrote a [step-by-step Mastra guide](/build-ai-agent-mastra/) with the [code on GitHub](https://github.com/bitdoze/mastra-assistant). So this comparison is not theoretical on the Mastra side. Eve is the new option I wanted to evaluate honestly against that experience.

Mastra is the mature option. v1.0 shipped in January 2026, it has 26K+ GitHub stars, ~1.1M weekly NPM downloads, and runs in production at Replit (96% task success rate), PayPal, and Marsh McLennan. It&apos;s code-based, portable, and deploys anywhere Node.js runs.

Eve is the new contender from Vercel. Launched June 17, 2026 at the Vercel Ship conference, it takes a filesystem-first approach: your agent is a directory. Vercel runs 100+ agents internally on Eve, including a data-analysis agent handling 30K+ Slack queries per month.

The decision is mostly: **maximum portability, or maximum convenience on Vercel?**

This is a head-to-head on pricing, lock-in, maturity, and DX. If you already know Mastra from the [build guide](/build-ai-agent-mastra/), skip to the [head-to-head comparison](#mastra-vs-eve-head-to-head-on-key-decision-factors).

&lt;Notice type=&quot;info&quot; title=&quot;Who This Guide Is For&quot;&gt;
TypeScript developers evaluating AI agent frameworks. Both Mastra and Eve are TypeScript-only. If you need Python, look at LangGraph, CrewAI, or Pydantic AI.
&lt;/Notice&gt;

&lt;Button text=&quot;Build a Mastra Agent (Tutorial)&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;View My Mastra Assistant on GitHub&quot; link=&quot;https://github.com/bitdoze/mastra-assistant&quot; variant=&quot;outline&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

## At a glance: Mastra vs Eve comparison table

| Dimension | Mastra | Eve |
|---|---|---|
| **GitHub Stars** | ~26,197 | ~250 |
| **Maturity** | v1.0 (Jan 2026), 18+ months | v0.11.4 beta (Jun 2026), &amp;lt;1 month |
| **Deployment** | Any Node.js host (Vercel, Netlify, Cloudflare, self-hosted) | Vercel only (adapter pattern exists but not frictionless) |
| **Lock-in** | Zero platform lock-in | Strong Vercel coupling |
| **Agent Definition** | `new Agent({ id, instructions, model, tools, memory })` | Directory: `instructions.md` + `tools/*.ts` + `agent.ts` |
| **Workflows** | Graph-based `.then()/.branch()/.parallel()` with suspend/resume | Durable via Vercel Workflow SDK, checkpointed steps |
| **Memory** | 4-tier (messages, working, semantic, RAG) + Memory Gateway | Session persistence via Vercel Workflows; no standalone memory service |
| **Sandbox** | Not built-in (relies on platform) | Built-in (Vercel Sandbox, Docker, microsandbox, bash) |
| **Multi-agent** | `.network()` routing, supervisor agents, agent-as-tool | Subagents directory, parent delegates to specialists |
| **Evals** | Built-in `evaluate()`, model-graded/rule-based/statistical | Built-in eval suites with scoring rubrics |
| **Observability** | Built-in Studio (local + cloud), traces, logs, metrics | Agent Runs dashboard, OpenTelemetry traces |
| **Channels** | Via integration (CopilotKit, custom) | Built-in: Slack, Discord, Teams, Telegram, Twilio, GitHub, Linear, HTTP |
| **MCP** | Author + consume MCP servers | Consume MCP via Vercel Connect |
| **Model Routing** | Own router (40-124 providers) | Vercel AI Gateway |
| **Frontend** | AI SDK UI, CopilotKit | `eve/react`, `eve/vue`, `eve/svelte` (early) |
| **Pricing Model** | SaaS platform (free tier → $250/mo → enterprise) | Vercel infrastructure usage ($20/mo Pro + usage) |
| **License** | Apache 2.0 + Enterprise License | Apache 2.0 |
| **Best For** | TypeScript teams building agent products; need portability | Vercel-native teams who want fastest path to deployed agent |

The two rows that matter most: **deployment/lock-in** and **maturity**. Eve&apos;s best features are tied to Vercel infrastructure. Mastra is 18 months ahead in production hardening. Everything else is details.

&lt;Notice type=&quot;warning&quot; title=&quot;Eve Is in Public Beta&quot;&gt;
Eve is currently v0.11.4 (public beta, launched June 2026). APIs may change before GA. Mastra reached v1.0 in January 2026.
&lt;/Notice&gt;

## What is Mastra?

Mastra was created by Sam Bhagwat and the Gatsby founding team. It raised a $13M seed round (YC W25) with investors including Paul Graham and Guillermo Rauch (the Vercel CEO). That last detail is interesting given the competition.

The philosophy is code-based composition. Think ORM or database client mental model: you define agents, tools, and workflows as typed objects in code. Configuration is explicit. You see everything in front of you.

![Mastra architecture diagram](../../assets/images/26/07/mastra-architecture.svg)

### Mastra key features: agents, workflows, memory, and evals

**Agents** are the core primitive. You create an `Agent` class with instructions, tools, memory, and a model, then call `generate()` or `stream()`:

```typescript
import { Agent } from &apos;@mastra/core/agent&apos;;
import { openai } from &apos;@ai-sdk/openai&apos;;
import { Memory } from &apos;@mastra/memory&apos;;

const weatherAgent = new Agent({
  id: &apos;weather-agent&apos;,
  name: &apos;Weather Bot&apos;,
  instructions: &apos;You are a helpful weather assistant.&apos;,
  model: openai(&apos;gpt-4o-mini&apos;),
  tools: { getWeather },
  memory: new Memory({ storage: { type: &apos;postgres&apos; } }),
});

const response = await weatherAgent.generate(&apos;What is the weather in Tokyo?&apos;);
```

**Workflows** use a graph-based builder pattern with `.then()`, `.branch()`, and `.parallel()`. They support suspend/resume for human-in-the-loop scenarios:

```typescript
import { Workflow, Step } from &apos;@mastra/core/workflow&apos;;

export const onboardingWorkflow = new Workflow({
  name: &apos;onboarding&apos;,
  triggerSchema: z.object({ accountId: z.string() }),
})
  .step(new Step({ id: &apos;verify&apos;, execute: verifyAccount }))
  .step(new Step({ id: &apos;enrich&apos;, execute: enrichWithCRM }))
  .parallel([
    new Step({ id: &apos;send_welcome&apos;, execute: sendWelcomeEmail }),
    new Step({ id: &apos;schedule_call&apos;, execute: scheduleKickoff }),
  ])
  .commit();
```

**Memory** is where Mastra really shines. It has four tiers: message history, working memory, semantic recall (95% on the LongMemEval benchmark), and RAG. The [Memory Gateway](https://mastra.ai/blog/announcing-mastra-platform) runs as a standalone service. If you care about agent memory, read our comparison of [agent memory systems](/cognee-vs-hindsight/).

**Evals** let you score agent performance with model-graded, rule-based, and statistical scorers:

```typescript
import { evaluate } from &apos;@mastra/evals&apos;;

await evaluate(refundAgent, {
  testCases: refundGoldenSet,
  scorers: [policyComplianceScorer, costScorer],
  threshold: 0.9,
});
```

**MCP support** lets you both author and consume MCP servers. If you&apos;re unfamiliar with the protocol, see [what MCP is and how it works](/mcp-introduction-beginners/). For when native Mastra tools beat always-on MCP (and the RAM cost of stdio servers), see [Mastra tools vs MCP](/mastra-tools-vs-mcp/).

**Observability** includes a local Studio browser UI, a cloud platform, and full logs/traces/metrics. In my own assistant, Studio is what I use day to day for chat and traces while developing.

&lt;Notice type=&quot;info&quot;&gt;
Mastra supports 40-124 model providers and 600-4,000+ models through its own router. Eve uses Vercel AI Gateway instead.
&lt;/Notice&gt;

### Mastra pricing: free tier to enterprise

| Tier | Cost | What You Get |
|---|---|---|
| **Starter** | Free | 100K observability events, 24 CPU hours, 15-day retention, unlimited users/deployments |
| **Teams** | $250/team/mo | 1M events, 250 CPU hours, 6-month retention, SSO, SOC 2 |
| **Enterprise** | Custom | RBAC, audit logs, SLAs, dedicated support |
| **Memory Gateway** | Free → $250/team/mo | 100K tokens / 250MB → 1M tokens / 1GB |

Token markup through the Gateway is market rate + 5.5%.

Key point: the core framework is Apache 2.0. You can self-host Mastra for free. The paid tiers are for the cloud platform (Studio, Memory Gateway, observability). If you want full control, pair it with an affordable VPS provider like [Hetzner](https://go.bitdoze.com/hetzner) and you&apos;ll spend under $10/month on infrastructure.

### Mastra pros and cons

**Pros:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Zero platform lock-in. Deploys anywhere (Vercel, Netlify, Cloudflare, self-hosted Hono/Express/Next.js)&lt;/li&gt;
&lt;li&gt;Production-proven: Replit (96% task success), PayPal, Marsh McLennan (75K employees), SoftBank&lt;/li&gt;
&lt;li&gt;SOC 2 Type II compliant&lt;/li&gt;
&lt;li&gt;4-tier memory system with standalone Memory Gateway&lt;/li&gt;
&lt;li&gt;Strong community: 26K GitHub stars, ~1.1M NPM downloads/week&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Cons:**

- Documentation lags behind rapid code velocity. Documented patterns can be outdated
- TypeScript-only. No Python support
- Opinionated patterns can feel restrictive when your use case diverges
- June 2026 supply-chain incident (dormant contributor account hijacked, 140+ malicious packages)
- Memory compression runs background LLM calls at additional cost
- Integration ecosystem still growing vs LangGraph

## What is Vercel Eve?

Eve launched June 17, 2026 at the Vercel Ship conference. It&apos;s built by Vercel, the team behind Next.js.

The philosophy is filesystem-first, convention over configuration. Your agent is a directory. Drop files in the right folders and the framework figures out the rest. No registration, no boilerplate wiring. The filename becomes the tool name.

Vercel is running Eve in production. 100+ internal agents power everything from data analysis to sales automation.

### Eve&apos;s filesystem-first architecture

An Eve agent is a directory with a specific structure:

```
my-agent/
└── agent/
    ├── agent.ts            # model and runtime config
    ├── instructions.md     # always-on system prompt
    ├── tools/              # typed functions the model can call
    │   └── get_weather.ts
    ├── skills/             # procedures loaded on demand
    │   └── plan_a_trip.md
    ├── channels/           # message channels
    │   └── slack.ts
    ├── schedules/          # recurring cron jobs
    │   └── weekly_recap.ts
    └── subagents/          # specialized child agents
```

![Vercel Eve directory structure](../../assets/images/26/07/eve-directory-structure.svg)

A tool is just a file in `tools/`:

```typescript
import { defineTool } from &apos;eve/tools&apos;;
import { z } from &apos;zod&apos;;

export default defineTool({
  description: &apos;Return mock weather data for a city.&apos;,
  inputSchema: z.object({ city: z.string().min(1) }),
  async execute({ city }) {
    return { city, condition: &apos;Sunny&apos;, temperatureF: 72 };
  },
});
```

Agent configuration is minimal:

```typescript
import { defineAgent } from &apos;eve&apos;;

export default defineAgent({
  model: &apos;anthropic/claude-sonnet-4.6&apos;,
});
```

And your system prompt is just a markdown file:

```markdown
# Identity
You are an expert weather assistant.
You can fetch the weather for any city in the world.
```

Other key features:

- **Durable execution** (Vercel Workflow SDK): Checkpointed steps survive crashes, redeploys, and cold starts.
- **Sandboxed compute**: Vercel Sandbox (isolated VM) for production, plus local backends: Docker, microsandbox, bash. If you&apos;re interested in [running AI tools in isolated containers](/docker-podman-ai-cli-tools-safe-environment/), Eve&apos;s sandbox approach is worth studying.
- **Human-in-the-loop**: Add `needsApproval: true` on any tool. The session parks until someone approves.
- **Multi-channel**: Slack, Discord, Teams, Telegram, Twilio, GitHub, Linear, HTTP API. Same agent, all channels.
- **Vercel Connect**: Brokered OAuth. The model never sees credentials.
- **AI Gateway**: Model routing with provider fallbacks.

Vercel&apos;s internal production examples show what Eve can do:

| Agent | Role | Scale |
|---|---|---|
| **d0** | Data analysis | 30K+ Slack queries/month, enforces data permissions |
| **Lead Agent** | Autonomous SDR | ~$5K/year cost, 32x ROI, 1 part-time engineer |
| **Vertex** | Support agent | 92% auto-resolve rate |
| **Athena** | Sales cockpit | Built by RevOps in 6 weeks without engineering |

&lt;Notice type=&quot;info&quot; title=&quot;Filesystem-First Is a Growing Pattern&quot;&gt;
Eve isn&apos;t alone in this approach. Flue also uses a filesystem-as-API pattern. It&apos;s a broader trend in 2026 agent frameworks.
&lt;/Notice&gt;

### Eve pricing: Vercel infrastructure costs

Eve itself is free (Apache 2.0). The costs come from Vercel infrastructure:

- **Vercel Pro plan:** $20/mo (includes $20 credit for resources)
- **Functions:** Standard Vercel Functions pricing
- **Workflows:** Vercel Workflows pricing
- **Sandbox:** ~$0.128/hr for sandboxed compute
- **AI Gateway:** Model provider tokens + Vercel markup
- **Model tokens:** Pass-through to provider

There&apos;s no separate &quot;Eve&quot; line item on your bill. Everything is billed against your Vercel plan resources.

The catch: cost predictability. Mastra&apos;s pricing is tiered and fixed. Eve&apos;s pricing is usage-based and scales with compute. For always-on agents, Sandbox compute at $0.128/hr adds up (~$92/month per always-on agent).

### Eve pros and cons

**Pros:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Fastest path from zero to deployed agent on Vercel&lt;/li&gt;
&lt;li&gt;Filesystem-first DX is intuitive if you know Next.js conventions&lt;/li&gt;
&lt;li&gt;Multi-channel out of the box (Slack, Discord, Teams, etc.). No custom integration needed&lt;/li&gt;
&lt;li&gt;Built-in sandboxed compute. No separate sandboxing setup&lt;/li&gt;
&lt;li&gt;Vercel&apos;s internal production usage (100+ agents) provides credibility despite beta status&lt;/li&gt;
&lt;li&gt;Durable execution handles crashes and cold starts automatically&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Cons:**

- Strong Vercel lock-in: durable execution, sandbox, connectors, gateway all require Vercel
- Very new / beta (v0.11.4). APIs may change before GA
- Dependency drift: pre-release packages (`@ai-sdk`, `@vercel/connect`) can break. Must pin versions
- Thin observability for pre-run failures (webhook misconfiguration etc.)
- Silent trigger-path gotcha: Slack integration requires a specific flag or it fails silently
- Cron limits tighter than once-a-day require a paid plan
- No Python support

&lt;Notice type=&quot;warning&quot; title=&quot;Lock-In Warning&quot;&gt;
Eve&apos;s best features (durable execution, sandbox, connectors, AI Gateway) are tightly coupled to Vercel infrastructure. Porting to a non-Vercel runtime is technically possible via adapter pattern, but expect significant friction.
&lt;/Notice&gt;

## Mastra vs Eve: head-to-head on key decision factors

Features are similar on paper. The real differences emerge when you look at deployment, maturity, cost, and developer experience under real conditions.

### Deployment flexibility and vendor lock-in

**Mastra** deploys anywhere: Vercel, Netlify, Cloudflare Workers, standalone Hono/Express/Next.js, your own VPS. Zero platform lock-in. The core is Apache 2.0. If you want to self-host on a $5/month Hetzner box, go ahead. That is exactly how I run my [Mastra assistant](/build-ai-agent-mastra/) in practice. If you want something even leaner for self-hosted agents, see the [Hermes agent setup guide](/hermes-agent-setup-guide/).

**Eve** requires Vercel for production. Local development is possible with Docker or microsandbox backends, but durable execution, sandbox, connectors, and the AI Gateway all need Vercel infrastructure.

The real-world impact: if Vercel changes pricing or you need to move to a different cloud, Mastra is portable. Eve requires significant refactoring. Community sentiment on Reddit reflects this. Developers burned by Next.js coupling are skeptical of another Vercel lock-in.

&lt;Notice type=&quot;warning&quot;&gt;
If platform independence matters to your team, this is the single most important difference between Mastra and Eve.
&lt;/Notice&gt;

If you&apos;re self-hosting, affordable VPS providers like [Hetzner](https://go.bitdoze.com/hetzner) make Mastra deployments cost-effective.

### Maturity and production readiness

**Mastra:** v1.0 since January 2026. 18+ months of active development. 26K GitHub stars. ~1.1M weekly NPM downloads. SOC 2 Type II compliant. Production users include Replit (96% task success rate), PayPal, Marsh McLennan (75K employees), and SoftBank. Ranked #1 TypeScript framework in AgentMail&apos;s 9-framework benchmark.

**Eve:** v0.11.4 beta. Launched less than one month ago (June 2026). ~250 GitHub stars. Vercel runs 100+ agents internally, but those are backed by Vercel&apos;s own engineering team, not external developers.

The key risk: Eve&apos;s APIs may change significantly before GA. Building production systems on beta APIs carries migration risk. The counter-argument is that Vercel&apos;s internal dogfooding (d0, Lead Agent, Vertex) suggests real production stability, even if the public API is still in flux.

### Developer experience and learning curve

**Mastra** is code-based. It&apos;s familiar if you&apos;ve used ORMs, SDK clients, or typed APIs. Configuration is explicit. Steeper initial setup but more predictable. You define everything in code and see it all in front of you.

**Eve** is filesystem-first. It&apos;s familiar if you know Next.js App Router conventions. Drop files in directories and things work. Lower ceremony for simple agents. But it can be confusing when conventions aren&apos;t documented yet.

Scaffolding commands tell the story:

&lt;Tabs&gt;
&lt;Tab name=&quot;Mastra DX&quot;&gt;
```bash
# Create project
npm create mastra@latest

# Define agent in code
const agent = new Agent({
  id: &apos;my-agent&apos;,
  instructions: &apos;You are helpful.&apos;,
  model: openai(&apos;gpt-4o-mini&apos;),
  tools: { myTool },
});
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Eve DX&quot;&gt;
```bash
# Create project
npx eve@latest init my-agent

# Agent is a directory:
# agent/instructions.md    ← system prompt
# agent/tools/my_tool.ts   ← just a file
# agent/agent.ts           ← model config
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Documentation quality is a concern for both. Mastra docs lag behind code velocity. Documented patterns can be dead in current versions. Eve docs are new, but Vercel&apos;s documentation infrastructure is generally strong.

### Pricing at scale: what you&apos;ll actually pay

**Mastra** has predictable tiers. Free for self-hosted. $250/month for the Teams cloud platform. Token markup at market rate + 5.5%.

**Eve** is pay-as-you-go via Vercel. Pro plan is $20/month. Sandbox at ~$0.128/hr. Functions, Workflows, and AI Gateway all add cost. Harder to predict monthly spend.

Here&apos;s what the math looks like at different scales:

| Scenario | Mastra | Eve |
|---|---|---|
| **Solo dev, 1 agent, low traffic** | Free (self-hosted) | Free (Vercel free/hobby tier) |
| **Small team, 5 agents, moderate traffic** | $250/mo flat | $50-$300/mo (varies with usage) |
| **Production fleet, 20+ agents, high traffic** | Enterprise (custom) | Scales with compute, potentially significant |

&lt;Notice type=&quot;info&quot; title=&quot;Cost Tip&quot;&gt;
For cost-conscious teams, Mastra&apos;s self-hosted option (Apache 2.0) gives you full control over infrastructure spend. Pair it with an affordable VPS provider like [Hetzner](https://go.bitdoze.com/hetzner) to keep costs minimal and predictable.
&lt;/Notice&gt;

Mastra is more predictable. Eve can be cheaper for bursty workloads but expensive for always-on agents.

### Memory, state, and durable execution

**Mastra** has a 4-tier memory system: message history, working memory, semantic recall (95% on LongMemEval benchmark), and RAG. The Memory Gateway runs as a standalone service. Memory compression via background LLM calls does add cost. For a deeper look at agent memory approaches, see [Cognee vs Hindsight](/cognee-vs-hindsight/).

**Eve** handles session persistence via Vercel Workflow SDK. Checkpointed steps survive crashes, redeploys, and cold starts. There&apos;s no standalone memory service. Eve&apos;s approach is more about execution state than long-term semantic recall.

The practical difference: Mastra is better for agents that need to remember and recall across sessions (customer support, personalization). Eve is better for agents that need to survive infrastructure events (crashes, redeploys) within a session.

Both offer durable execution. Mastra does it via workflow suspend/resume. Eve does it via Vercel Workflow SDK checkpoints.

## Code comparison: building the same agent in Mastra and Eve

Let&apos;s build a simple weather agent in both frameworks to show the DX difference.

&lt;Tabs&gt;
&lt;Tab name=&quot;Mastra&quot;&gt;
```bash
# Scaffold
npm create mastra@latest
```

```typescript
// src/agents/weather.ts
import { Agent } from &apos;@mastra/core/agent&apos;;
import { openai } from &apos;@ai-sdk/openai&apos;;
import { createTool } from &apos;@mastra/core/tools&apos;;
import { z } from &apos;zod&apos;;

const getWeather = createTool({
  id: &apos;get-weather&apos;,
  description: &apos;Return weather data for a city.&apos;,
  inputSchema: z.object({ city: z.string() }),
  execute: async ({ context }) =&gt; {
    return { city: context.city, condition: &apos;Sunny&apos;, temp: 72 };
  },
});

export const weatherAgent = new Agent({
  id: &apos;weather-agent&apos;,
  name: &apos;Weather Bot&apos;,
  instructions: &apos;You are a helpful weather assistant.&apos;,
  model: openai(&apos;gpt-4o-mini&apos;),
  tools: { getWeather },
});

// Usage
const response = await weatherAgent.generate(&apos;What is the weather in Tokyo?&apos;);
```

One file. Everything explicit. You see the agent, tools, model, and usage in one place.
&lt;/Tab&gt;
&lt;Tab name=&quot;Eve&quot;&gt;
```bash
# Scaffold
npx eve@latest init my-agent
```

```markdown
&lt;!-- agent/instructions.md --&gt;
# Identity
You are an expert weather assistant.
You can fetch the weather for any city in the world.
```

```typescript
// agent/tools/get_weather.ts
import { defineTool } from &apos;eve/tools&apos;;
import { z } from &apos;zod&apos;;

export default defineTool({
  description: &apos;Return mock weather data for a city.&apos;,
  inputSchema: z.object({ city: z.string().min(1) }),
  async execute({ city }) {
    return { city, condition: &apos;Sunny&apos;, temperatureF: 72 };
  },
});
```

```typescript
// agent/agent.ts
import { defineAgent } from &apos;eve&apos;;

export default defineAgent({
  model: &apos;anthropic/claude-sonnet-4.6&apos;,
});
```

Three files. Each is small and focused. The filename becomes the tool name. No registration needed.
&lt;/Tab&gt;
&lt;/Tabs&gt;

The tradeoff: Mastra is more explicit and self-contained, good for quick iteration and seeing everything at once. Eve spreads logic across files, better when different people own tools vs instructions vs channels, but more cognitive overhead for a simple agent.

Want the full walkthrough I used for my own assistant? See the [complete Mastra tutorial](/build-ai-agent-mastra/) or clone the [mastra-assistant repo](https://github.com/bitdoze/mastra-assistant).

&lt;Button text=&quot;Full Mastra Tutorial&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Try Eve Docs&quot; link=&quot;https://vercel.com/eve&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Decision framework: when to pick Mastra vs Eve

No universal winner. Infrastructure, team, and risk tolerance decide it.

![Decision flowchart: Mastra vs Eve](../../assets/images/26/07/decision-flowchart.svg)

### Pick Mastra if...

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;You need to deploy outside Vercel (AWS, GCP, self-hosted, Cloudflare, Netlify)&lt;/li&gt;
&lt;li&gt;Platform independence is a requirement (enterprise, regulated industries)&lt;/li&gt;
&lt;li&gt;You need a mature, proven framework with SOC 2 compliance&lt;/li&gt;
&lt;li&gt;Your agents need memory across sessions (4-tier memory, semantic recall)&lt;/li&gt;
&lt;li&gt;You want predictable pricing (fixed tiers vs usage-based)&lt;/li&gt;
&lt;li&gt;You&apos;re building agent products and need portability&lt;/li&gt;
&lt;li&gt;You want to self-host everything, including observability&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Pick Eve if...

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Your infrastructure is already on Vercel (Next.js app, Vercel Functions, etc.)&lt;/li&gt;
&lt;li&gt;You want the fastest path from idea to deployed agent&lt;/li&gt;
&lt;li&gt;Multi-channel support (Slack, Discord, Teams) out of the box matters&lt;/li&gt;
&lt;li&gt;You prefer convention over configuration (filesystem-first DX)&lt;/li&gt;
&lt;li&gt;Your team knows Next.js conventions and wants a similar mental model&lt;/li&gt;
&lt;li&gt;You need built-in sandboxed compute for code execution&lt;/li&gt;
&lt;li&gt;You&apos;re comfortable with beta APIs and early-adopter risk&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Consider alternatives if...

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;You need Python. Look at LangGraph, CrewAI, or Pydantic AI&lt;/li&gt;
&lt;li&gt;You only need streaming UI and basic tool calls. Vercel AI SDK alone may be enough&lt;/li&gt;
&lt;li&gt;You want maximum ecosystem maturity. LangGraph (Python) still leads on integrations&lt;/li&gt;
&lt;li&gt;You like filesystem-first DX but not Vercel lock-in. Watch Flue (platform-agnostic)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Accordion label=&quot;What about Vercel AI SDK vs Eve?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Vercel AI SDK is the UI/model-routing layer. Eve is the agent framework built on top of it. If you just need streaming UI and model calls, AI SDK is enough. If you need agents with tools, memory, workflows, and channels, you need Eve (or Mastra).
&lt;/Accordion&gt;

## Alternatives worth considering

Beyond Mastra and Eve, a few options are worth knowing:

**Vercel AI SDK** is for simpler use cases. Streaming UI, model routing, basic tool calls. No agent abstractions, no memory, no workflows. If that is all you need, skip a full agent framework.

**LangGraph.js** is the TypeScript port of LangGraph. Heavier, but a better fit if your team also runs Python LangGraph and wants the same mental model.

**Flue** is new, filesystem-first like Eve but platform-agnostic. Worth watching if you like Eve&apos;s DX without Vercel lock-in.

The &quot;agent as a directory&quot; pattern is common in 2026. Eve and Flue both use it. It is a reaction against the boilerplate-heavy style of earlier frameworks.

For a similar style of framework comparison, see [Astro vs Next.js vs TanStack Start](/astro-vs-nextjs-vs-tanstack-start-which-wins-2026/).

If you want an AI coding agent rather than a framework to build agents, [OpenCode Go](https://go.bitdoze.com/opencode-go) is a different path. For multi-model routing without a full agent framework, [Agent Router](https://go.bitdoze.com/agentrouter) unifies Claude Code, OpenAI Codex, and Gemini CLI.

## Final verdict: Mastra vs Eve in 2026

The tradeoff is still **portability vs Vercel-native convenience**.

For most TypeScript teams shipping production agents today, **Mastra is the safer bet**. It is mature, portable, SOC 2 compliant, and proven at scale. The 4-tier memory system is strong. You can deploy anywhere and move infrastructure without rewriting agents. That is why I built my own [files/web/browser assistant on Mastra](/build-ai-agent-mastra/) and keep improving it in the open.

For Vercel-native teams who want the fastest DX and can live with beta risk, **Eve is compelling**. Filesystem-first is a nice DX. Multi-channel out of the box saves integration work. Vercel&apos;s internal dogfooding (100+ agents) suggests it will mature quickly after GA.

Eve post-GA may close the maturity gap. Mastra&apos;s docs and ecosystem still need work. Competition here is good for both.

&lt;Notice type=&quot;success&quot; title=&quot;Bottom Line&quot;&gt;
Mastra for portability and production readiness. Eve for Vercel-native speed and DX. Both are Apache 2.0, both are TypeScript-only, and both are moving fast.
&lt;/Notice&gt;

&lt;Button text=&quot;Build with Mastra (Tutorial)&quot; link=&quot;/build-ai-agent-mastra/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Clone My Mastra Assistant&quot; link=&quot;https://github.com/bitdoze/mastra-assistant&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;lg&quot; icon=&quot;github&quot; /&gt;
&lt;Button text=&quot;Try Eve&quot; link=&quot;https://vercel.com/eve&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;

## FAQ

&lt;Accordion label=&quot;Is Mastra free to use?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Mastra&apos;s core is Apache 2.0 and can be self-hosted at no cost. The Mastra Platform (cloud) has a free Starter tier with 100K observability events and 24 CPU hours. Teams tier is $250/team/month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Eve without Vercel?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
For local development, yes. Eve supports Docker, microsandbox, and bash backends. For production features like durable execution, sandbox, and multi-channel connectors, you need Vercel infrastructure.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which framework has better TypeScript support?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Both are TypeScript-only. Mastra provides more explicit type definitions through its Agent/Workflow/Step classes. Eve uses Zod schemas for tool input validation and relies on convention. Both have good IDE support.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Eve&apos;s lock-in as bad as Next.js?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
It&apos;s similar in pattern. Eve&apos;s open-source core (Apache 2.0) means you can read and fork the code. But the features that make Eve powerful (durable execution, sandbox, connectors, AI Gateway) are Vercel-specific services. If you&apos;re already on Vercel, this is a feature, not a bug.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about the Mastra supply-chain incident?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
In June 2026, a dormant Mastra contributor account was hijacked and 140+ malicious packages were published. The Mastra team resolved it, but it&apos;s a reminder to pin dependencies and audit packages. That advice applies to any npm ecosystem project, not just Mastra.
&lt;/Accordion&gt;</content:encoded><category>ai</category><category>ai-agents</category><category>mastra</category><category>typescript</category></item><item><title>Meetily Review: Self-Hosted AI Meeting Assistant Guide</title><link>https://www.bitdoze.com/meetily-self-hosted-ai-meeting-assistant/</link><guid isPermaLink="true">https://www.bitdoze.com/meetily-self-hosted-ai-meeting-assistant/</guid><description>Meetily is a free open-source AI meeting assistant that transcribes and summarizes meetings locally. Setup guide, Otter.ai comparison, and privacy benefits.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you&apos;re paying Otter.ai, Fireflies, or Granola $10-18 per month to transcribe your meetings, you&apos;re spending $120-360 a year for something a free, open-source tool can do locally on your machine. Meetily is an MIT-licensed AI meeting assistant with ~25,000 GitHub stars and 308,000+ downloads that captures, transcribes, and summarizes meetings entirely on-device. No meeting bot joins your call, no audio leaves your machine.

I&apos;ve already covered [FluidVoice, the free open-source Mac dictation app](https://www.bitdoze.com/fluidvoice-mac-dictation/) for single-speaker voice typing. Meetily is its meeting-length sibling: dual-channel capture (system audio + microphone), local transcription with Parakeet or Whisper, and AI summaries via Ollama or your preferred cloud LLM. Together, they form a $0/month local-first communication stack that replaces Wispr Flow + Otter.ai without any subscription.

&lt;Notice type=&quot;info&quot; title=&quot;Already using FluidVoice?&quot;&gt;
If you followed the FluidVoice dictation guide, you already have Parakeet or Whisper installed locally. Meetily uses the same transcription models, so half the stack is already in place. The comparison section below breaks down exactly how the two tools complement each other.
&lt;/Notice&gt;

This guide covers what Meetily actually does under the hood, how to install and configure it, how it compares to Otter.ai and other cloud meeting tools, and the honest gotchas you should know before committing.

## What is Meetily? (overview and key features)

Meetily is a desktop application built with Tauri (Rust backend + Next.js frontend) that captures your meeting audio, transcribes it with local AI models, and generates structured summaries. Developed by Zackriya Solutions (India), it hit v0.4.0 in June 2026.

The pitch is simple: everything stays on your machine. Transcription happens with Parakeet (NVIDIA) or Whisper (OpenAI) models running locally. Summarization can use Ollama (also local) or cloud LLMs you connect with your own API keys. Data is stored in an embedded SQLite database. No cloud account required, no data retention policies to worry about.

What it does:

- **Dual-channel audio capture**: system audio (Zoom, Meet, Teams) + your microphone, processed separately
- **No meeting bot**: other participants never see a &quot;Recording Bot has joined&quot; notification
- **Multiple transcription engines**: Parakeet (~4x faster than Whisper, 25 languages) or Whisper (99 languages)
- **Pluggable AI summaries**: Ollama (local), Anthropic Claude, Groq, OpenRouter, or any OpenAI-compatible endpoint
- **Built-in Qwen 3.5 models** (v0.4.0) for lightweight summarization without external LLMs
- **Rich editor**: BlockNote editor with Markdown export (Pro adds PDF/DOCX)
- **Meeting templates**: 7 built-in templates for different meeting types
- **GPU acceleration**: Apple Silicon Metal/CoreML, NVIDIA CUDA, AMD/Intel Vulkan
- **MIT license**: free for the Community Edition

### How Meetily captures meetings without a bot

When you start a recording, Meetily taps directly into your system audio (what you hear through your speakers/headphones) and your microphone simultaneously. It uses WASAPI on Windows and Core Audio on macOS.

Other meeting tools (Otter.ai, Fireflies, Fathom) work by joining your call as a bot participant. Everyone in the meeting sees &quot;Otter.ai Notetaker has joined&quot; or similar. This is awkward with clients, and in some industries it raises compliance questions about who else is in the call.

Meetily avoids this entirely. It captures audio from your system, same as if you pressed record on a screen capture tool. Dual-channel processing separates your voice (microphone) from other participants (system audio) before transcription, which helps with speaker attribution. Intelligent ducking lowers system audio volume when you speak to reduce crosstalk, and clipping prevention handles volume spikes from people who yell into their mics.

![Meetily architecture diagram](../../assets/images/26/07/meetily-architecture.svg)

### Transcription engines: Whisper vs Parakeet

Meetily gives you two local STT options. The choice depends on your hardware and language needs.

&lt;Tabs&gt;
&lt;Tab name=&quot;Parakeet (recommended)&quot;&gt;
NVIDIA Parakeet is the faster option, roughly 4x faster than Whisper on comparable hardware with lower resource usage. The Parakeet TDT v3 ONNX model (~800 MB) supports 25 languages and led the Open ASR Leaderboard for word error rate.

**Requirements:** Apple Silicon (macOS) or NVIDIA GPU. Does not run on Intel Macs.

**When to use:** Default choice if your hardware supports it. Better accuracy, faster transcription, lower CPU/memory load.

**Setup:** On first launch, select Parakeet TDT v3 from the model dropdown. It downloads once (~800 MB) to your app data directory.
&lt;/Tab&gt;
&lt;Tab name=&quot;Whisper&quot;&gt;
OpenAI Whisper supports 99 languages and runs on virtually any hardware, including Intel Macs. Model sizes range from tiny (~75 MB, fast but less accurate) to large-v3 (~2.9 GB, best accuracy but slow on CPU).

**Requirements:** Any modern CPU. No GPU required (but GPU helps a lot for large-v3).

**When to use:** Intel Macs, non-English languages not covered by Parakeet, or when you need the absolute broadest language support.

**Setup:** Select a Whisper model size from the dropdown. Recommendation: `medium` for the best accuracy-to-speed ratio on CPU; `large-v3` if you have a GPU.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## How Meetily&apos;s self-hosted meeting transcription works

Since v0.1.1, Meetily uses a unified Tauri architecture. No separate backend server, no extra ports to manage. The entire application is a single binary with an embedded SQLite database. This simplified the architecture from the early days (v0.0.x) when you needed a separate Python backend running alongside the frontend.

### System audio + microphone: dual-channel capture

Dual-channel works by capturing two audio streams simultaneously:

1. **System audio**: what comes through your speakers/headphones. This is other participants in a Zoom/Meet/Teams call, shared screen audio, or any audio playing on your system.
2. **Microphone**: your voice.

The channels are processed separately through the transcription engine, then merged in the transcript. This separation helps the transcription engine distinguish speakers (your mic is clean, system audio has other voices mixed with call artifacts).

Intelligent ducking lowers system audio volume when the microphone detects you speaking, reducing the chance of crosstalk transcription errors. Clipping prevention smooths out volume spikes.

### Local AI models: Ollama, Claude, Groq, and OpenRouter

After transcription, Meetily can generate AI summaries of your meeting. You have several options:

**Local (Ollama):** Free, fully private. Requires installing Ollama separately (it is NOT bundled with Meetily). For good summary quality on longer meetings, use a model with 32B+ parameters. Quality drops on hour-long multi-topic calls with smaller models. See the [Hermes Agent setup guide](https://www.bitdoze.com/hermes-agent-setup-guide/) for a walkthrough of running AI models locally with Ollama.

**Built-in Qwen 3.5 (v0.4.0):** Lightweight option that doesn&apos;t require Ollama. Good for quick summaries but less capable than larger models on complex meetings.

**Cloud BYOK (bring your own key):** Anthropic Claude, Groq, OpenRouter, or any custom OpenAI-compatible endpoint. Better summary quality, especially on long meetings, but sends transcript text off-device.

&lt;Notice type=&quot;warning&quot; title=&quot;Cloud LLM privacy risk&quot;&gt;
If you switch from Ollama to a cloud provider (Claude, Groq, OpenRouter) mid-session, the full transcript gets sent off-device. There&apos;s no clear warning UI for this transition. If privacy is your primary reason for using Meetily, double-check your LLM provider selection before every summary generation.
&lt;/Notice&gt;

### GPU acceleration: Metal, CUDA, and Vulkan

GPU support makes a noticeable difference on transcription speed, especially with larger models.

| Platform | GPU Backend | Status |
|----------|-------------|--------|
| macOS (Apple Silicon) | Metal + CoreML | Auto-enabled, best experience |
| macOS (Intel) | CPU only | No GPU acceleration |
| Windows (NVIDIA) | Vulkan | Community Edition (v0.4.0+) |
| Windows (AMD/Intel) | Vulkan | Community Edition (v0.4.0+) |
| Linux (NVIDIA) | CUDA | Build from source with CUDA toolkit |
| Linux (AMD) | Vulkan/ROCm | Build from source with SDK |
| Any platform | OpenBLAS CPU | Fallback, no GPU needed |

**Note:** Windows GPU acceleration uses Vulkan in the Community Edition (v0.4.0+). Linux GPU works via source builds but requires the full CUDA toolkit or Vulkan SDK installed. Having GPU drivers alone is not enough.

## How to set up local AI meeting notes with Meetily

Follow the steps for your platform.

### Installing Meetily on macOS and Windows

&lt;Tabs&gt;
&lt;Tab name=&quot;macOS (recommended)&quot;&gt;
macOS has the smoothest experience, especially on Apple Silicon.

**Homebrew (recommended):**

```bash
brew tap zackriya-solutions/meetily
brew install --cask meetily
```

**Or download the DMG** from [GitHub Releases](https://github.com/Zackriya-Solutions/meetily/releases/latest). Look for `meetily_0.4.0_aarch64.dmg` (Apple Silicon) or `meetily_0.4.0_x64.dmg` (Intel).

**Verify:** Launch Meetily. A system tray icon should appear. Open Settings and confirm the transcription model dropdown is populated.

**macOS Sequoia 15.6+ note:** Some users report audio capture issues. Workaround: go to System Preferences → Sound → Output, temporarily adjust the Hz setting lower, then back to 48000 Hz.
&lt;/Tab&gt;
&lt;Tab name=&quot;Windows&quot;&gt;
1. Download `meetily_0.4.0_x64-setup.exe` from [GitHub Releases](https://github.com/Zackriya-Solutions/meetily/releases/latest)
2. Right-click the installer → Properties → check &quot;Unblock&quot; → OK
3. Run the installer

**Verify:** Launch Meetily. Confirm the system tray icon appears and the transcription model dropdown is populated.

**Note:** GPU acceleration on Windows requires the Pro edition. Community Edition runs CPU-only transcription.
&lt;/Tab&gt;
&lt;Tab name=&quot;Linux (source build)&quot;&gt;
Linux has no native installer. You must build from source. This is the weakest platform experience.

**Prerequisites:** Rust (latest stable), Node.js 18+, pnpm, CMake, build tools (gcc-c++, make).

```bash
git clone https://github.com/Zackriya-Solutions/meetily
cd meetily/frontend
pnpm install

# Auto-detect GPU and build
./build-gpu.sh
```

The output AppImage lands at `src-tauri/target/release/bundle/appimage/Meetily_&lt;version&gt;_amd64.AppImage`.

**GPU-specific builds:**

```bash
# Force CUDA (NVIDIA)
TAURI_GPU_FEATURE=cuda ./build-gpu.sh

# Force CPU-only
TAURI_GPU_FEATURE=&quot;&quot; ./build-gpu.sh
```

For NVIDIA CUDA, install the toolkit first (drivers alone are not enough):

```bash
sudo apt install nvidia-driver-550 nvidia-cuda-toolkit
```

**Verify:** Run the AppImage. Confirm the window launches and system tray icon appears.
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Configuring your first transcription model

On first launch, Meetily prompts you to download a transcription model. My recommendation:

- **Apple Silicon or NVIDIA GPU:** Parakeet TDT v3 (~800 MB), faster and lower resource usage
- **Intel Mac or non-English languages:** Whisper medium or large-v3

The model downloads to your app data directory. Make sure you have enough disk space (Parakeet ~800 MB, Whisper large-v3 ~2.9 GB).

**Verify:** After the model downloads, play a YouTube video or any audio on your system. Start a test recording in Meetily, let it run for 30-60 seconds, then stop. Confirm a transcript appears with reasonable accuracy. If you see &quot;Transcription model not ready,&quot; restart the app and re-download the model.

### Connecting an LLM for AI summaries

&lt;Notice type=&quot;info&quot; title=&quot;Ollama is not bundled&quot;&gt;
Ollama must be installed and running separately before you can use local AI summaries. This is the biggest friction point from community feedback. People install Meetily, expect AI summaries out of the box, and get nothing because Ollama isn&apos;t there.
&lt;/Notice&gt;

**Path 1: Ollama (local, free, private)**

1. Install Ollama from [ollama.ai](https://ollama.ai)
2. Pull a model: `ollama pull llama3.1:8b` (start here for testing; use 32B+ for better summary quality on long meetings)
3. Verify Ollama is running: `ollama serve` (or check `curl http://localhost:11434/api/tags`)
4. In Meetily Settings → AI Provider, select Ollama and choose your model

**Path 2: Cloud BYOK**

1. Get an API key from Anthropic (Claude), Groq, OpenRouter, or any OpenAI-compatible provider
2. In Meetily Settings → AI Provider, select your provider and paste the API key
3. Choose a model (Claude Sonnet recommended for best quality-to-cost ratio)

**Verify:** Record a short test meeting (2-3 minutes). After stopping, click &quot;Generate Summary.&quot; Confirm the AI summary appears. If using Ollama, check the terminal running `ollama serve` for request logs. You should see the model processing the transcript.

## Meetily vs Otter.ai: how they compare

Cloud meeting tools offer convenience. Meetily offers control. Here&apos;s how they actually compare.

### Privacy: local processing vs cloud upload

Meetily processes everything locally. Transcription runs on your hardware, and if you use Ollama, summaries do too. Otter.ai, Fireflies.ai, Granola, and Fathom all upload your audio to their cloud servers for processing.

Implications:

- **GDPR/HIPAA/SOC2:** Cloud tools store your audio on third-party servers. Meetily keeps it on your machine. For regulated industries, this matters.
- **The &quot;no bot&quot; advantage:** No awkward &quot;Recording Bot has joined&quot; notification in client calls. Meetily captures audio directly from your system.
- **Data retention:** Cloud tools retain your audio according to their policies (and policies change). With Meetily, you control the data.

&lt;Notice type=&quot;warning&quot; title=&quot;Verify the privacy claim&quot;&gt;
Meetily markets &quot;100% local&quot; but this hasn&apos;t been independently verified with packet capture. If privacy is critical for compliance (healthcare, legal, finance), verify with Wireshark or Little Snitch before trusting. Also check Settings. Analytics were changed to opt-in by default in v0.4.0.
&lt;/Notice&gt;

### Pricing: free Community Edition vs $8-18/month SaaS

The cost math is straightforward:

| Tool | Monthly Cost | 3-Year Cost |
|------|-------------|-------------|
| **Meetily CE** | **$0** | **$0** |
| Meetily Pro | $10/mo | $360 |
| Otter Pro | $8.33-16.99/mo | $300-612 |
| Fireflies | $10-18/mo | $360-648 |
| Granola | $14/mo | $504 |
| Fathom | $15-19/mo | $540-684 |

![Meetily vs cloud meeting tools 3-year cost comparison](../../assets/images/26/07/meetily-cost-comparison.svg)

Meetily Community Edition is free forever. Even if you upgrade to Pro ($10/month, use coupon `LAUNCH20` for 20% off), you&apos;re competitive with the cheapest cloud option. And you own your data.

### Meetily vs Fireflies.ai and Granola: feature comparison

| Feature | Meetily CE | Otter.ai | Fireflies.ai | Granola | Fathom |
|---------|-----------|----------|--------------|---------|--------|
| **Price** | Free | $8.33-17/mo | $10-18/mo | $18/mo | Free-$19/mo |
| **Privacy** | Local only | Cloud | Cloud | Cloud | Cloud |
| **Bot-free recording** | Yes | No (bot joins) | No (bot joins) | Yes | No (bot joins) |
| **Platforms** | macOS, Win, Linux | Web, mobile | Web, mobile | macOS | Web, Win, Mac |
| **AI summary** | Yes (local or BYOK) | Yes | Yes | Yes | Yes |
| **Export** | Markdown (Pro: PDF/DOCX) | Multiple | Multiple | Multiple | Multiple |
| **Meeting templates** | 7 | No | Yes | No | No |
| **Speaker diarization** | Basic (CE) | Good | Good | Good | Good |
| **Calendar integration** | Pro only | Yes | Yes | Yes | Yes |
| **Mobile app** | No | Yes | Yes | No | Yes |
| **Search across meetings** | No | Yes | Yes | Yes | Yes |

The table tells the story: Meetily wins on privacy and cost. Cloud tools win on polish, cross-device access, and multi-speaker accuracy.

## Meetily and FluidVoice: your local AI meeting and dictation stack

If you read the [FluidVoice guide](https://www.bitdoze.com/fluidvoice-mac-dictation/), you know I&apos;m a fan of local-first audio tools. Meetily and FluidVoice aren&apos;t competitors. They&apos;re complementary tools that cover different use cases with the same underlying tech.

| | FluidVoice | Meetily |
|---|---|---|
| **Purpose** | Single-speaker dictation (type anywhere by voice) | Meeting capture + transcription + summary |
| **License** | Open source (MIT) | Open source (MIT) |
| **Platforms** | macOS | macOS, Windows, Linux |
| **STT models** | Parakeet, Whisper | Parakeet, Whisper |
| **AI enhancement** | Fluid-1 smart formatting | Meeting summary templates |
| **Audio source** | Microphone only | System audio + microphone |
| **Output** | Typed text in any app | Transcript + summary + Markdown export |
| **GitHub stars** | ~2,000+ | ~25,000+ |
| **Price** | Free | Free (CE) |
| **Mobile** | No | No |

**FluidVoice** is for dictation. You speak, it types into whatever app you&apos;re focused on, with smart formatting via the Fluid-1 model. Think &quot;voice-to-text replacement for typing.&quot;

**Meetily** is for meetings. It captures both sides of a conversation (system audio + mic), produces a full transcript, and generates structured summaries with action items. Think &quot;local Otter.ai replacement.&quot;

Together, they replace Wispr Flow ($10-12/month) + Otter.ai ($10-17/month) for $0/month, all running on local models. If you care about privacy tools, you might also be interested in [self-hosted privacy tools like Chatto](https://www.bitdoze.com/chatto-self-hosted/) for keeping your team&apos;s messages private.

## Meetily Pro: is the paid tier worth it?

### Community vs Pro feature breakdown

| Feature | Community (Free) | Pro ($10/mo) |
|---------|-----------------|--------------|
| Local transcription (Parakeet/Whisper) | Yes | Yes |
| AI summaries (Ollama + cloud BYOK) | Yes | Yes |
| Markdown export | Yes | Yes |
| PDF/DOCX export | No | Yes |
| Custom summary templates | 7 built-in | Custom templates |
| Windows GPU acceleration | No (CPU only) | Yes |
| Auto-detect meetings (calendar) | No | Yes |
| Enhanced speaker diarization | Basic | Enhanced |
| Priority support | No | Yes |
| OTA updates | Yes | Yes |

14-day free trial for Pro, no credit card required. Coupon `LAUNCH20` gets 20% off.

&lt;Accordion label=&quot;When is Community Edition enough?&quot; group=&quot;pro-decision&quot; expanded=&quot;true&quot;&gt;

Community Edition is the right choice if:

- You&apos;re a solo user (no team workspace needed)
- You&apos;re on macOS (Metal/CoreML GPU works in CE) or Linux
- Markdown export is sufficient for your workflow
- You don&apos;t need calendar integration. Starting and stopping recording manually is fine
- You&apos;re privacy-focused and want to use Ollama exclusively

&lt;/Accordion&gt;

&lt;Accordion label=&quot;When should I upgrade to Pro?&quot; group=&quot;pro-decision&quot;&gt;

Pro makes sense if:

- You&apos;re on Windows and need GPU-accelerated transcription (CE is CPU-only on Windows)
- Calendar auto-detect is critical for your workflow (you forget to start recording)
- You need PDF or DOCX export for sharing with non-technical stakeholders
- Enhanced speaker diarization matters (multi-speaker meetings with many participants)
- You want priority support and SLA

&lt;/Accordion&gt;

## Limitations and honest gotchas

No tool is perfect, and Meetily has real weaknesses you should know about before committing. This isn&apos;t a hit piece. It&apos;s the &quot;know before you commit&quot; section.

&lt;Notice type=&quot;warning&quot; title=&quot;Known issues as of v0.4.0&quot;&gt;
The three biggest gotchas: **(1)** speaker diarization is weak in Community Edition. Multi-speaker meetings produce messy transcripts. **(2)** Ollama summarization quality drops on hour-long meetings. Small models struggle with multi-topic calls. **(3)** there&apos;s no mobile app. You can&apos;t review notes on your phone. See the full list below.
&lt;/Notice&gt;

**1. Speaker diarization is weak in CE.** Multi-speaker meetings produce transcripts with wrong speaker labels or no speaker attribution at all. Enhanced diarization is a Pro feature. If you mostly have 1-on-1 or small meetings, this isn&apos;t a problem. For large group calls, it&apos;s a real limitation.

**2. Ollama summarization quality drops on long meetings.** Local small models (8B, 13B) handle short focused calls well but struggle on hour-long multi-topic meetings. The summaries get vague or miss key decisions. Fix: use a larger model (32B+) or switch to a cloud LLM for long meetings (but remember the privacy tradeoff).

**3. No search across meetings.** Each meeting is self-contained in the SQLite database. You can&apos;t query &quot;what did we discuss about Project X last month?&quot; across sessions. You&apos;d need to export meetings and search them externally.

**4. No mobile app.** Meetily is a desktop-only application. No iOS or Android client exists. You can&apos;t review meeting notes on your phone. You&apos;d need to export to Markdown and sync via your preferred notes app.

**5. No calendar integration in CE.** Auto-detect and auto-join meetings is Pro-only. Community Edition requires manual start/stop recording.

**6. No API or CLI.** You can&apos;t automate Meetily, query it from other tools, or integrate it into CI/CD pipelines. If you need programmatic access, this is a blocker.

**7. Linux requires source build.** No `apt install`, no Flatpak, no Docker one-liner. The source build works but requires Rust, Node.js, pnpm, CMake, and build tools. GPU auto-detection needs the actual SDK installed (CUDA toolkit, ROCm, or Vulkan SDK), not just GPU drivers.

**8. macOS Sequoia 15.6+ audio capture issues.** Known issue with a workaround: temporarily adjust the Hz setting in System Preferences → Sound → Output lower, then back to 48000 Hz.

**9. Summary cloud leakage risk.** Easy to misconfigure and accidentally send transcripts to a cloud LLM when you intended to use Ollama. No clear warning UI when switching providers mid-session.

### Common errors and fixes

| Error | Cause | Fix |
|-------|-------|-----|
| &quot;Transcription model not ready&quot; | Model download failed or corrupted | Restart app, re-download model from settings |
| Ollama not connecting | Ollama not running or wrong port | Verify `ollama serve` is running, check `curl http://localhost:11434/api/tags` |
| macOS audio not captured | Audio output routing issue | Check System Preferences → Sound → Output is set correctly; try the Hz workaround |
| Windows installer blocked | Windows SmartScreen | Right-click installer → Properties → Unblock → OK |
| Linux GPU not detected | Missing SDK (not just drivers) | Install CUDA toolkit, ROCm, or Vulkan SDK |
| Summary quality poor | Small Ollama model on long meeting | Use 32B+ model or cloud LLM for summaries |

## Real-world workflow: recording a Zoom call end-to-end

Here&apos;s the &quot;run this tonight&quot; scenario: what a typical meeting workflow looks like from start to finish.

1. **Launch Meetily.** System tray icon appears. The app sits in the background waiting.
2. **Start your Zoom/Meet/Teams call.** Join as normal.
3. **Click &quot;Start Recording&quot; in Meetily.** It begins capturing system audio (other participants) and your microphone simultaneously.
4. **During the call.** Real-time transcription appears in the Meetily window. You can keep it minimized.
5. **End the call.** Click &quot;Stop Recording&quot; in Meetily.
6. **AI summary generates automatically.** If Ollama or a cloud LLM is configured, Meetily processes the transcript and produces a structured summary with key points and action items.
7. **Review and edit.** The built-in BlockNote editor lets you clean up the transcript and summary.
8. **Export.** Markdown for Community Edition, PDF/DOCX for Pro.

**Verify after your first real meeting:**

- Is the transcript reasonably accurate? (Perfect accuracy isn&apos;t realistic. Expect 85-95% depending on audio quality and accent.)
- Does the summary capture the main points and action items?
- Does export work? (Test Markdown export)
- Check the SQLite database: `~/.local/share/meetily/` (Linux), `~/Library/Application Support/meetily/` (macOS), or `%APPDATA%/meetily/` (Windows)

If you&apos;re considering running Ollama on a server for a team setup, check the [best self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) for managing the deployment. A [Hetzner VPS](https://go.bitdoze.com/hetzner) with a decent GPU or a [dedicated mini PC](https://go.bitdoze.com/asus-dc510) works well for always-on Ollama serving.

## Backup, updates, and maintenance

Meetily stores everything in a local SQLite database. Back it up if the data matters to you.

**Database locations:**

- **Linux:** `~/.local/share/meetily/`
- **macOS:** `~/Library/Application Support/meetily/`
- **Windows:** `%APPDATA%/meetily/`

The SQLite file contains all your meeting transcripts, summaries, and metadata. Copy it somewhere safe periodically. The [meetily-exporter](https://github.com/Zackriya-Solutions/meetily) community tool can auto-export to Obsidian/Notion if you want a more structured backup.

**Transcription models** are stored in the app data directory. Ollama models live in `~/.ollama/`. Back this up too if you&apos;ve pulled large models and don&apos;t want to re-download.

**Updates:**

- OTA updates are supported since v0.2.0. Meetily will notify you when a new version is available
- Homebrew users: `brew upgrade --cask meetily`
- Manual: download the latest release from GitHub

**Crash recovery:** Since v0.2.0, Meetily automatically recovers transcripts from interrupted recordings. Verify this works after your first few recordings by checking that partial transcripts persist if you force-quit the app.

## Final verdict: who should use Meetily?

Meetily is a real, functional tool, not a GitHub curiosity. With 25k stars, 308k+ downloads, and active development (v0.4.0 in June 2026), it&apos;s the most mature open-source meeting assistant available. It&apos;s not perfect, but for the right use case, it&apos;s the best option.

&lt;ListCheck&gt;
Meetily is a great fit if you need:

- Privacy-first meeting transcription. No audio on third-party servers
- Freedom from $10-18/month subscription fees for meeting notes
- Local processing for regulated industries (healthcare, legal, finance)
- A complement to FluidVoice for a complete local-first communication stack
- macOS (best experience) or Windows desktop use
- Bot-free recording. No &quot;Recording Bot has joined&quot; awkwardness

&lt;/ListCheck&gt;

**Skip Meetily (for now) if:**

- You need reliable multi-speaker diarization on large calls. CE is weak here
- You need mobile access to meeting notes. No iOS/Android app exists
- You&apos;re on Linux and unwilling to build from source
- You need team collaboration or shared meeting workspaces
- You want turnkey zero-configuration setup

The sweet spot for Meetily right now is a solo operator or small team on macOS who wants to stop paying for Otter.ai or Fireflies and is comfortable with a tool that&apos;s functional but still rough around the edges. Paired with FluidVoice for dictation and [self-hosted alternatives to cloud services](https://www.bitdoze.com/executor-sh-vs-composio/) for other workflows, you can build a privacy-respecting stack without subscriptions.

If you&apos;re into [building your own AI agent with Mastra](https://www.bitdoze.com/build-ai-agent-mastra/) or running [Hermes Agent for self-hosted AI workflows](https://www.bitdoze.com/hermes-agent-setup-guide/), Meetily fits the same philosophy: own your data, run it locally, accept the tradeoffs of early-stage open source.

&lt;Button text=&quot;Download Meetily from GitHub&quot; link=&quot;https://github.com/Zackriya-Solutions/meetily/releases/latest&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>meetily</category><category>self-hosted</category><category>ai-tools</category></item><item><title>Deploy Slash Link Shortener with Docker and Dockge</title><link>https://www.bitdoze.com/slash-docker-deploy/</link><guid isPermaLink="true">https://www.bitdoze.com/slash-docker-deploy/</guid><description>Deploy Slash link shortener with Docker Compose and Dockge. Self-host bookmarks with custom short URLs, collections, analytics, and Cloudflare Tunnel access.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/slash-link-shortner.png&quot;;
import imag2 from &quot;../../assets/images/24/01/slash-extension.png&quot;;
import imag3 from &quot;../../assets/images/24/01/dockge-slash.png&quot;;
import imag4 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag5 from &quot;../../assets/images/24/01/slash-add-links.png&quot;;

Slash is a self-hosted link shortener and bookmark manager you can deploy with Docker and Dockge in under 15 minutes. Since its initial release, the project has grown to over 3,200 GitHub stars and 100K+ Docker Hub pulls. It gives you full control over your short links. No third-party tracking, no monthly fees, and no rate limits.

Link shortening has real benefits for anyone managing links regularly:

- Makes URLs user-friendly and memorable
- Saves space in social media posts, emails, and messages
- Lets you redirect users based on device, location, or time
- Tracks clicks, conversions, and engagement with built-in analytics
- Keeps your original URLs and parameters hidden from the public

If you&apos;re comparing options, also check out [Sink, another self-hosted link shortener](https://www.bitdoze.com/sink-install/) that takes a different approach.

## What is Slash (A Self-Hosted Bookmark &amp; Link Manager)

[Slash](https://github.com/yourselfhosted/slash) is an open source, self-hosted bookmarks and link sharing platform. It&apos;s built with Go on the backend and TypeScript/React on the frontend. The platform lets you organize links with tags, share them with custom shortened URLs, and track performance through analytics.

&lt;Picture
  src={imag1}
  alt=&quot;Slash link shortener dashboard showing bookmark and link management interface&quot;
/&gt;

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Custom short URLs: personalize links with your own paths (`s/link-url`)&lt;/li&gt;
&lt;li&gt;Collections: group related shortcuts into virtual folders (`c/collection-name`)&lt;/li&gt;
&lt;li&gt;Analytics: view referrers, browsers, operating systems, and click data&lt;/li&gt;
&lt;li&gt;Dark mode: toggle via Profile -&gt; Preference -&gt; Color Theme&lt;/li&gt;
&lt;li&gt;Team sharing: share link libraries with teammates&lt;/li&gt;
&lt;li&gt;API access tokens: programmatic access to all Slash features&lt;/li&gt;
&lt;li&gt;PostgreSQL support: alternative to the default SQLite for larger deployments&lt;/li&gt;
&lt;li&gt;Browser extensions: Chrome and Firefox for saving links directly from your browser&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;License&quot;&gt;
Slash is licensed under AGPL-3.0. You can self-host and modify it freely, but changes to the source code must be shared under the same license.
&lt;/Notice&gt;

The **Collections** feature (added in v0.5.0) deserves a closer look. Collections are virtual folders that group related shortcuts. They have their own URL scheme (`c/collection-name`), visibility controls, and can be shared publicly or with teammates. This was the biggest improvement over the flat link list the original version offered.

Slash also supports internationalization (i18n) with Chinese translation as of v0.4.5, and API access tokens for programmatic access since v0.4.1.

### Browser extensions

Slash offers extensions for both Chrome and Firefox:

- [Chrome Web Store](https://chrome.google.com/webstore/detail/slash/ebaiehmkammnacjadffpicipfckgeobg)
- [Firefox Add-ons](https://addons.mozilla.org/firefox/addon/your-slash/)

Both extensions let you save and shorten links directly from your browser, customize short URLs, and access Slash&apos;s bookmark management without leaving the page.

&lt;Picture
  src={imag2}
  alt=&quot;Slash browser extension for Chrome and Firefox, save and shorten links directly&quot;
/&gt;

&gt; If you are interested in more free, open source, self-hosted apps, check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Deploy Slash with Docker Compose and Dockge

This guide uses Docker Compose for containerization, [Dockge](https://www.bitdoze.com/dockge-install/) for GUI-based stack management, and Cloudflare Tunnels for secure remote access. The modern Docker CLI uses `docker compose` (with a space). The old `docker-compose` (hyphen) was fully removed in April 2025.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/HswSqueUAQc&quot;
  label=&quot;How to Deploy Your Link Shortener with Slash, Docker, and Dockge&quot;
/&gt;

### 1. Prerequisites

Before you begin, make sure you have the following in place:

- A VPS where you can host Slash. You can use [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), or use an [ASUS Mini PC](https://go.bitdoze.com/asus-dc510) as a [home server](https://www.bitdoze.com/best-mini-pc-home-server/)
- [Dockge installed](https://www.bitdoze.com/dockge-install/) on your server for Docker Compose management
- Cloudflare Tunnels configured for your VPS (details are covered in the Dockge install article)

&lt;Notice type=&quot;warning&quot; title=&quot;Docker Compose v1 is gone&quot;&gt;
Docker Compose v1 (`docker-compose` with hyphen) was removed in April 2025. Use `docker compose` (space) instead. The `version:` field in compose files is also obsolete and should be removed.
&lt;/Notice&gt;

&gt; You can also [use Traefik as a reverse proxy](https://www.bitdoze.com/traefik-proxy-docker/) for your apps instead of Cloudflare Tunnels. I have a full tutorial with Dockge install that covers the setup.

### 2. Add the Docker Compose file in Dockge

Below is the Docker Compose file for Slash. Note there&apos;s no `version:` line. That field is obsolete in modern Docker Compose.

&lt;Tabs&gt;
&lt;Tab name=&quot;Basic (SQLite)&quot;&gt;

```yaml
services:
  slash:
    image: yourselfhosted/slash:latest
    container_name: slash
    ports:
      - 5006:5231
    volumes:
      - ./slash:/var/opt/slash
    restart: unless-stopped
```

This pulls the latest Slash image and creates a volume in the local path where the Dockge stack is located. You can change port `5006` to whatever you prefer.

&lt;/Tab&gt;
&lt;Tab name=&quot;With PostgreSQL&quot;&gt;

```yaml
services:
  slash:
    image: yourselfhosted/slash:latest
    container_name: slash
    ports:
      - 5006:5231
    volumes:
      - ./slash:/var/opt/slash
    environment:
      - SLASH_DRIVER=postgres
      - SLASH_DSN=postgresql://user:password@db:5432/slash
    restart: unless-stopped
  db:
    image: postgres:16
    container_name: slash-db
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=password
      - POSTGRES_DB=slash
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    restart: unless-stopped
```

PostgreSQL support was added in Slash v0.5.1. It&apos;s useful for larger deployments or if you&apos;re already running PostgreSQL. You can also configure these options via CLI flags: `--driver postgres --dsn &apos;postgresql://...&apos;`. See our guide on how to [configure environment variables in Docker](https://www.bitdoze.com/docker-env-vars/) for more details.

&lt;/Tab&gt;
&lt;/Tabs&gt;

Slash also maintains an [official docker-compose.yml](https://github.com/yourselfhosted/slash/raw/main/docker-compose.yml) in the repository if you want a reference.

In Dockge, hit compose and save. After that, you can choose the Dockge-default external network:

&lt;Picture src={imag3} alt=&quot;Dockge interface showing Slash Docker Compose stack deployment&quot; /&gt;

Hit save and start the container. It should start running without issues.

### 3. Configure Cloudflare Tunnels for secure access

You need to tell Cloudflare Tunnel which port Slash is using. Go to your Cloudflare Zero Trust dashboard and configure the tunnel:

&lt;Notice type=&quot;info&quot; title=&quot;Updated navigation&quot;&gt;
Cloudflare Zero Trust dashboard navigation was restructured. The new path is: **Zero Trust -&gt; Networks -&gt; Connectors -&gt; Cloudflare Tunnels** (the old &quot;Access -&gt; Tunnels&quot; path no longer exists).
&lt;/Notice&gt;

Add a public hostname that maps a domain or subdomain to your Slash container&apos;s port (5006 in our example):

&lt;Picture src={imag4} alt=&quot;Cloudflare Zero Trust Tunnel configuration, adding a public hostname for Slash&quot; /&gt;

Enter your server IP and the port you chose in the compose file.

&gt; You can also check [CloudPanel as a reverse proxy](https://www.bitdoze.com/cloudpanel-setup-dockge/) or [use Traefik as a reverse proxy](https://www.bitdoze.com/traefik-proxy-docker/) as alternatives to Cloudflare Tunnels. See also our [Cloudreve Docker guide](https://www.bitdoze.com/cloudreve-docker-setup/) for a similar Cloudflare Tunnel setup with another self-hosted app.

### 4. Access Slash and organize links with collections

Access Slash using the domain you configured in Cloudflare. On first visit, you&apos;ll be asked to create an admin account.

&lt;Picture
  src={imag5}
  alt=&quot;Slash interface for adding and organizing links and collections&quot;
/&gt;

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Create your admin account on first login&lt;/li&gt;
&lt;li&gt;Add your first shortcut with `s/shortcut-name`&lt;/li&gt;
&lt;li&gt;Create a collection with `c/collection-name` to group related links&lt;/li&gt;
&lt;li&gt;Install the Chrome or Firefox browser extension&lt;/li&gt;
&lt;li&gt;Toggle dark mode in Profile -&gt; Preference -&gt; Color Theme&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Collections are the standout feature since the original version of this article. You can create collections like `c/dev-tools` or `c/social` to organize links into logical groups. Each collection has its own URL, can be set to public or private, and can be shared with teammates.

## Conclusion

Slash has matured since its early releases. With 3,200+ GitHub stars, PostgreSQL support, collections, API access tokens, and an active Discord community, it&apos;s a solid choice for anyone who wants a self-hosted link shortener with full control over their data.

The deployment is straightforward: Docker Compose handles the container, Dockge gives you a clean web UI to manage it, and Cloudflare Tunnels expose it securely without opening ports on your firewall. Slash is also available as a one-click deploy on Coolify if you prefer that workflow.

&lt;Button text=&quot;Explore More Docker Containers&quot; link=&quot;https://www.bitdoze.com/docker-containers-home-server/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>docker</category><category>slash</category><category>self-hosted</category></item><item><title>Deploy Streamlit on a VPS and Proxy to Cloudflare Tunnels</title><link>https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/</link><guid isPermaLink="true">https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/</guid><description>Deploy Streamlit on a VPS and proxy it through Cloudflare Tunnels. Step-by-step guide with config.toml, PM2/systemd, venv setup, and troubleshooting for production.</description><pubDate>Thu, 16 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare_tunnels.png&quot;;
import imag2 from &quot;../../assets/images/24/01/cloudflare-strimlit-tunnel.jpeg&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Streamlit](https://streamlit.io/) is a Python framework for building interactive web apps with minimal code. You can use it to build data dashboards, machine learning demos, internal tools, anything where a Python backend and a reactive UI make sense. I&apos;ve used it for quick data exploration apps and internal dashboards where spinning up a full React frontend would be overkill.

Streamlit has a simple syntax: Python functions and decorators define your layout and logic. `st.title` adds a heading, `st.dataframe` renders a pandas table, `st.slider` adds interactive controls. The hot-reloading feature updates your app as you edit code, no browser refresh needed. You write Python, and Streamlit handles the web server, the WebSocket communication, and the reactive UI updates.

&gt; In case you are interested in checking the best Python web frameworks see: [Best Python Web Frameworks](https://www.bitdoze.com/best-python-web-frameworks/).
&gt; If you want to see how Streamlit stacks up against another Python UI framework, check the [Streamlit vs NiceGUI comparison](https://www.bitdoze.com/streamlit-vs-nicegui/).

If you want to deploy Streamlit or any Python app to Docker, you can check: [How To Run Any Python App in Docker with Docker Compose](https://www.bitdoze.com/docker-run-python/)

This guide walks you through how to deploy Streamlit on a VPS and proxy it through Cloudflare Tunnels. The total cost is around $4 to $7/month for the VPS. Cloudflare Tunnels are free. You get HTTPS, DDoS protection, and no inbound ports open on your server.

## Streamlit Cloud free deployment

[Streamlit Community Cloud](https://streamlit.io/cloud) is the zero-config option. Connect a GitHub repo, and it deploys your app on a `*.streamlit.io` URL. No server to manage, no SSH keys, no process managers. You push to GitHub, and the app updates automatically.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/M7ZMSX6DA7E&quot;
  label=&quot;Streamlit Deploy video&quot;
/&gt;
It&apos;s fine for demos and prototypes, but the limits are real:

- 1 GB of memory. Complex or data-heavy apps will hit this ceiling fast.
- No custom domains. Your app lives on a random `*.streamlit.io` URL.
- No scaling. You get one container with 1 GB, take it or leave it.
- Paid tiers are permanently gone. Streamlit is developing a joint product with Snowflake instead.
- The free tier now allows one private repo (previously it was public repos only).
- Cold starts: Community Cloud spins down idle apps. First load after inactivity can take 10 to 30 seconds.

If you need a custom domain, more memory, faster cold starts, or production reliability, read on.

## How to deploy Streamlit on your VPS and proxy through Cloudflare Tunnels

The architecture is straightforward:

1. Your VPS runs Streamlit locally on `localhost:8501`
2. `cloudflared` creates an encrypted tunnel to Cloudflare&apos;s edge network
3. Your domain resolves through Cloudflare. You get HTTPS, DDoS protection, firewall, and no inbound ports open on the VPS

The tunnel connects outward from your server. Nobody can probe your VPS ports because nothing is listening on a public interface. That&apos;s the main security win over a traditional reverse proxy setup.

## Prerequisites

Before you start, make sure you have:

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;A VPS running Ubuntu 22.04 or 24.04 (minimum 1 vCPU, 2 GB RAM recommended)&lt;/li&gt;
  &lt;li&gt;A domain name added to your Cloudflare account (free plan works)&lt;/li&gt;
  &lt;li&gt;A GitHub repository containing your Streamlit app with a &lt;code&gt;requirements.txt&lt;/code&gt;&lt;/li&gt;
  &lt;li&gt;SSH access to your VPS (root or sudo user)&lt;/li&gt;
  &lt;li&gt;Python 3.10+ (comes with Ubuntu 22.04+)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you don&apos;t have a VPS yet, you can [set up a VPS for development](https://www.bitdoze.com/vps-ai-coding-setup/) or check the section below.

### 1. Create a VPS

Choose a VPS with at least 2 GB RAM. Ubuntu 22.04 or 24.04 as the OS. Cloudflare Tunnels is free. The only recurring cost is the VPS itself, which starts around $4 to $5/month.

I use [Hetzner](https://go.bitdoze.com/hetzner) for most of my servers. Good performance, EU locations, fair pricing. [Hostinger](https://go.bitdoze.com/hostinger-vps) is a solid budget alternative. You can [compare VPS providers](https://www.bitdoze.com/digitalocean-vs-vultr-vs-hetzner/) to see what fits your needs.

### 2. Update and add swap to the VPS

Once you have SSH access, update the system and add swap space as a safety net. Swap acts as virtual memory when your physical RAM is full. It won&apos;t save a badly written app, but it prevents an OOM kill when your Streamlit app loads a large dataframe or model into memory.

```bash
# Update the system
sudo apt update &amp;&amp; sudo apt -y upgrade

# Add 2 GB of swap space
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | sudo tee -a /etc/fstab
```

Verify swap is active:

```bash
sudo swapon --show
```

You should see `/swapfile` listed with its size. If you don&apos;t see it, check that `fallocate` created the file correctly. Some VPS providers or filesystems don&apos;t support `fallocate`, in which case use `dd if=/dev/zero of=/swapfile bs=1M count=2048` instead.

&lt;Notice type=&quot;info&quot;&gt;
Swap prevents OOM kills on small VPS instances, but it&apos;s 100x slower than RAM. Monitor with `htop` the first few days. If your app consistently uses swap, upgrade the VPS. Swap is not a substitute for RAM. You can [check which processes use swap](https://www.bitdoze.com/swap-usage-linux/) to spot problems early.
&lt;/Notice&gt;

### 3. Set up a Python virtual environment and install Streamlit

Don&apos;t install Streamlit globally with `pip3 install streamlit`. It can conflict with system Python packages and makes dependency management painful. Use a virtual environment instead.

```bash
# Install Python venv support (as root)
sudo apt install python3-pip python3-venv
```

Create a dedicated user for the app (we&apos;ll lock it down further in the next step):

```bash
sudo useradd -m streamlit
```

Now switch to the `streamlit` user and create the venv:

```bash
sudo su - streamlit

# Create and activate a virtual environment
python3 -m venv /home/streamlit/venv
source /home/streamlit/venv/bin/activate

# Install Streamlit inside the venv
pip install streamlit
```

Verify the installation:

```bash
streamlit --version
```

You should see version 1.59.x or later. Streamlit requires Python 3.10+ since version 1.51.0.

&lt;Notice type=&quot;info&quot;&gt;
If you prefer faster dependency resolution, you can use [uv to set up a Python project](https://www.bitdoze.com/uv-get-start/) as an alternative to pip + venv. The venv approach works fine for a single-app deployment, but uv shines when managing multiple Python projects.
&lt;/Notice&gt;

### 4. Create a dedicated user for your Streamlit app

We already created the `streamlit` user in the previous step. Running the app under a dedicated non-root user means that if the app is ever compromised, the attacker doesn&apos;t have root access to the server. This is basic least-privilege. No reason to skip it.

The user&apos;s home is `/home/streamlit`, and that&apos;s where everything will live: the virtual environment, the app code, and the config files. Keeping it all under one user&apos;s home directory makes backups and permissions straightforward.

If you need to grant another admin access to manage the app, add them to the `streamlit` group rather than sharing the user&apos;s password:

```bash
sudo usermod -aG streamlit your-username
```

### 5. Get the Streamlit app on your VPS

You need to get your app code onto the VPS. The easiest way is `git clone` from your GitHub repository. If your repo is private, you&apos;ll need to set up an SSH key or a personal access token. GitHub has docs for both.

Clone your repository as the `streamlit` user:

```bash
sudo su - streamlit

# Create a directory for the app
mkdir -p $HOME/streamlit-app &amp;&amp; cd $HOME/streamlit-app

# Clone your repository
git clone https://github.com/username/repo-name

cd repo-name
```

Verify the clone worked and your app file is there:

```bash
ls -la
# You should see app.py (or whatever your main file is called)
```

If your repo is private and you used HTTPS, Git will prompt for credentials. For automated deploys later, set up SSH keys instead:

```bash
ssh-keygen -t ed25519 -C &quot;deploy-key&quot;
cat ~/.ssh/id_ed25519.pub
# Add this as a deploy key in your GitHub repo settings
```

### 6. Install requirements with pip

Your app likely depends on packages beyond Streamlit itself: pandas, matplotlib, plotly, scikit-learn, etc. These should be listed in a `requirements.txt` file in your repo. If your repo doesn&apos;t have one, create it first:

```bash
# On your local machine (not the VPS), in your project directory:
pip freeze &gt; requirements.txt
```

Make sure your venv is activated (you should see `(venv)` in your prompt), then install:

```bash
# With the venv activated:
pip install -r requirements.txt
```

This installs everything your app needs in the isolated venv. If a dependency fails to build (common with packages that need C libraries like `psycopg2` or `lxml`), install the system library first:

```bash
# Example: for psycopg2
sudo apt install libpq-dev

# Example: for lxml
sudo apt install libxml2-dev libxslt1-dev
```

Verify streamlit and your key dependencies are installed:

```bash
pip list | grep streamlit
pip list | grep pandas
```

### 7. Configure Streamlit for production — `.streamlit/config.toml`

This is the step most guides skip, and it&apos;s the reason deployments behind Cloudflare Tunnels break.

&lt;Notice type=&quot;warning&quot;&gt;
Without `enableCORS = false` and `enableXsrfProtection = false`, your Streamlit app will show &quot;Connection error&quot; or hang on &quot;Please wait...&quot; behind Cloudflare Tunnels. This is the #1 deployment mistake.
&lt;/Notice&gt;

Create a `.streamlit/config.toml` file inside your app directory:

```bash
mkdir -p /home/streamlit/streamlit-app/repo-name/.streamlit
cat &gt; /home/streamlit/streamlit-app/repo-name/.streamlit/config.toml &lt;&lt; &apos;EOF&apos;
[server]
# Run headless (no browser auto-open, no email prompt)
headless = true

# Bind to localhost only — Cloudflare Tunnel connects locally
address = &quot;localhost&quot;
port = 8501

# Disable CORS/XSRF when behind a reverse proxy/tunnel
# Cloudflare Tunnel handles HTTPS; the connection is local
enableCORS = false
enableXsrfProtection = false

# WebSocket keep-alive — prevents &quot;Connection error&quot; disconnects
websocketPingInterval = 30

[browser]
# Set to your actual domain so Streamlit generates correct URLs
serverAddress = &quot;app.example.com&quot;
serverPort = 443
EOF
```

Replace `app.example.com` with your actual domain name. The `[browser]` section tells Streamlit what URL to generate for the client — without it, the browser may try to connect to `localhost` instead of your domain.

Here&apos;s what each setting does:

- **`headless = true`** — Disables the browser auto-open and the email prompt that Streamlit shows by default. Essential for server deployments where there&apos;s no desktop browser.
- **`address = &quot;localhost&quot;`** — Streamlit only listens on the loopback interface. This is important: even without UFW (step 11), the app isn&apos;t accessible from the internet. Cloudflare Tunnel connects locally, so this is all you need.
- **`enableCORS = false`** — Cross-Origin Resource Sharing. When your domain is `app.example.com` but Streamlit thinks it&apos;s running on `localhost`, the browser&apos;s CORS policy blocks the WebSocket connection. Setting this to `false` tells Streamlit to trust the proxy.
- **`enableXsrfProtection = false`** — Same reasoning. The XSRF token validation fails when the request comes through the tunnel because the origin doesn&apos;t match. Disabling it is safe here because Cloudflare Tunnel already provides the security layer.
- **`websocketPingInterval = 30`** — Sends a ping every 30 seconds to keep the WebSocket connection alive through the tunnel. Without this, Cloudflare may close idle connections, causing the &quot;Connection error&quot; message.

These settings work with both the legacy Tornado backend and the new Starlette/Uvicorn backend (Streamlit 1.57.0+). The Starlette migration was a major change under the hood, but the config flags are the same.

### 8. Run and verify the app

Before wiring up a process manager, test that Streamlit starts correctly:

```bash
# As the streamlit user, with venv activated:
source /home/streamlit/venv/bin/activate
streamlit run /home/streamlit/streamlit-app/repo-name/app.py
```

In another terminal, verify the health endpoint:

```bash
curl -s http://localhost:8501/_stcore/health
```

You should get `ok` back. If you get &quot;Connection refused&quot;, check that `address = &quot;localhost&quot;` and `port = 8501` match in your `config.toml`.

&lt;Notice type=&quot;info&quot;&gt;
Streamlit exposes `/_stcore/health` which returns HTTP 200 when the app is running. Bookmark this endpoint — it&apos;s your go-to verification command for any deployment.
&lt;/Notice&gt;

Once verified, stop the running app (`Ctrl+C`) and move on to setting up a process manager.

### 9. Set up a process manager — PM2 or systemd

Your SSH session will end, and without a process manager, Streamlit dies with it. You need something that keeps it running in the background and restarts it on crash.

Two good options: PM2 (feature-rich, requires Node.js) or systemd (already on your Linux system, zero extra dependencies). Pick whichever you prefer — both work.

&lt;Tabs&gt;
&lt;Tab name=&quot;Option A: PM2&quot;&gt;

&lt;Notice type=&quot;info&quot;&gt;
PM2 is a Node.js tool managing a Python process. If you&apos;d rather not install Node.js just for this, switch to the systemd tab — it has fewer moving parts.
&lt;/Notice&gt;

**Install Node.js 22 LTS** (as root):

```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
```

**Install PM2 globally:**

```bash
sudo npm install pm2@latest -g
```

For more PM2 details, see the [complete guide on managing applications with PM2](https://www.bitdoze.com/pm2-manage-apps/).

**Start Streamlit with PM2** (as the `streamlit` user):

```bash
sudo su - streamlit
source /home/streamlit/venv/bin/activate

pm2 start &apos;/home/streamlit/venv/bin/streamlit run /home/streamlit/streamlit-app/repo-name/app.py&apos; \
  --name my-streamlit-app
```

Or use a PM2 ecosystem file for cleaner config:

```bash
cat &gt; /home/streamlit/ecosystem.config.js &lt;&lt; &apos;EOF&apos;
module.exports = {
  apps: [{
    name: &apos;my-streamlit-app&apos;,
    script: &apos;/home/streamlit/venv/bin/streamlit&apos;,
    args: &apos;run /home/streamlit/streamlit-app/repo-name/app.py&apos;,
    interpreter: &apos;none&apos;,
    env: {
      PATH: &apos;/home/streamlit/venv/bin:&apos; + process.env.PATH
    }
  }]
};
EOF

pm2 start /home/streamlit/ecosystem.config.js
```

**Set PM2 to start on boot:**

```bash
# As the streamlit user:
pm2 startup systemd
# Copy and run the command it prints (this runs as root)
pm2 save
```

**Verify:**

```bash
pm2 list
```

Your app should show as &quot;online&quot;. Check logs with `pm2 logs my-streamlit-app`.

PM2 stores logs in `~/.pm2/logs/`. Over time these can grow — set up log rotation:

```bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Option B: systemd&quot;&gt;

systemd is already on every Ubuntu system. No extra packages to install, no Node.js dependency. Fewer moving parts.

**Create a service file** (as root):

```bash
sudo cat &gt; /etc/systemd/system/streamlit.service &lt;&lt; &apos;EOF&apos;
[Unit]
Description=Streamlit App
After=network.target

[Service]
User=streamlit
WorkingDirectory=/home/streamlit/streamlit-app/repo-name
ExecStart=/home/streamlit/venv/bin/streamlit run app.py --server.port=8501 --server.address=localhost --server.headless=true
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF
```

**Enable and start the service:**

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now streamlit
```

**Verify:**

```bash
sudo systemctl status streamlit
```

You should see `active (running)`. If it says `failed`, check the logs for the error:

```bash
journalctl -u streamlit -n 50 --no-pager
```

Common issues: wrong path to the venv&apos;s streamlit binary, or the app file doesn&apos;t exist at the specified `WorkingDirectory`.

To follow logs in real time:

```bash
journalctl -u streamlit -f
```

systemd handles log rotation automatically via journald, so you don&apos;t need to set that up separately.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### 10. Create a Cloudflare Tunnel and install cloudflared

Cloudflare Tunnels expose your app to the internet without opening any ports. The tunnel connects outward from your VPS to Cloudflare&apos;s edge. You get HTTPS, DDoS protection, and WAF rules for free.

**Sign up for Cloudflare** and add your domain name. Point your domain&apos;s nameservers to Cloudflare&apos;s DNS. Follow the instructions on Cloudflare&apos;s dashboard.

Go to **Zero Trust** &gt; **Networks** &gt; **Tunnels** and create a tunnel. After you give it a name, you&apos;ll get an install command:

&lt;Picture
  src={imag1}
  alt=&quot;Cloudflare Zero Trust dashboard showing tunnel creation with install command for cloudflared&quot;
/&gt;

**Option 1: Install from Cloudflare dashboard** (copy the command shown):

```bash
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb &amp;&amp;
sudo dpkg -i cloudflared.deb &amp;&amp;
sudo cloudflared service install &lt;token&gt;
```

Replace `&lt;token&gt;` with the actual token from the Cloudflare dashboard.

**Option 2: Install from Cloudflare&apos;s apt repository** (better for ongoing updates):

```bash
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg &gt;/dev/null
echo &quot;deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main&quot; | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update &amp;&amp; sudo apt-get install cloudflared
```

With the apt repository, updating is just `sudo apt update &amp;&amp; sudo apt upgrade cloudflared`. The `.deb` method works but requires manually downloading new versions each time.

After installing cloudflared with the dashboard token, verify the service is running:

```bash
sudo systemctl status cloudflared
sudo systemctl enable cloudflared
```

The `service install` command from the dashboard already registers cloudflared as a systemd service, but `enable` ensures it starts on boot.

Now configure the public hostname in the Cloudflare dashboard. Point your domain (e.g., `app.example.com`) to `http://localhost:8501`:

&lt;Picture
  src={imag2}
  alt=&quot;Cloudflare Tunnel public hostname configuration pointing domain to localhost port 8501 for Streamlit&quot;
/&gt;

After saving, visit `https://app.example.com` in your browser. If you see the Streamlit app loading, the tunnel is working. The Cloudflare dashboard should show the tunnel as &quot;Healthy.&quot;

For more on self-hosting with Cloudflare Tunnels, see [how to self-host Cloudreve with Docker and Cloudflare Tunnels](https://www.bitdoze.com/cloudreve-docker-setup/).

### 11. Configure UFW firewall (defense in depth)

Since Cloudflare Tunnel means no inbound ports are needed, lock down the VPS with UFW. Even if Streamlit accidentally binds to a public interface instead of localhost, UFW blocks external access. Defense in depth — don&apos;t rely on a single layer.

&lt;Notice type=&quot;warning&quot;&gt;
Make sure SSH (port 22) is allowed before enabling UFW, or you&apos;ll lock yourself out of the VPS. Double-check with `sudo ufw status` before `sudo ufw enable`.
&lt;/Notice&gt;

```bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw enable
```

Verify:

```bash
sudo ufw status verbose
```

Only port 22 should be listed as allowed. Everything else is denied. If you later need to expose another service (say, a second Streamlit app on port 8502 through a separate tunnel), you still don&apos;t need to open that port — the tunnel connects locally.

For a more robust setup with intrusion detection and automatic banning of brute-force IPs, you can [secure your VPS with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/) on top of UFW.

## Troubleshooting common issues

&lt;Accordion label=&apos;&quot;Connection error&quot; / WebSocket stuck on &quot;Please wait...&quot;&apos; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;
**Cause:** Missing `enableCORS = false` and `enableXsrfProtection = false` in `.streamlit/config.toml`.

**Fix:** Add both settings to your `config.toml` (see step 7), restart Streamlit, and clear your browser cache. Also verify that `browser.serverAddress` is set to your actual domain, not `localhost`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Streamlit loads but widgets don&apos;t respond&quot; group=&quot;troubleshooting&quot;&gt;
**Cause:** WebSocket proxy issue. Cloudflare Tunnel supports WebSockets by default, but the ping interval may need tuning.

**Fix:** Set `server.websocketPingInterval = 30` in `config.toml`. In the Cloudflare dashboard, under your tunnel&apos;s public hostname HTTP settings, set `connectionTimeout` to 300 seconds for long-running operations.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;App crashes on large file uploads&quot; group=&quot;troubleshooting&quot;&gt;
**Cause:** Default upload limit is 200 MB, but the VPS may not have enough memory.

**Fix:** Increase `server.maxUploadSize` in `config.toml`. Ensure swap is enabled (step 2). For very large files, consider S3-based upload instead of in-memory processing.
&lt;/Accordion&gt;

&lt;Accordion label=&apos;Cloudflared shows &quot;version outdated&quot;&apos; group=&quot;troubleshooting&quot;&gt;
**Cause:** Installed via `.deb` download instead of the apt repository.

**Fix:** If using the apt repo: `sudo apt update &amp;&amp; sudo apt upgrade cloudflared`. If using the `.deb` method: re-download and `sudo dpkg -i cloudflared.deb`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;After Streamlit 1.57.0+ upgrade — auth or session issues&quot; group=&quot;troubleshooting&quot;&gt;
**Cause:** Streamlit 1.57.0 migrated from Tornado to Starlette/Uvicorn. Some regressions in auth cookie persistence and CORS behavior were reported in 1.57.x.

**Fix:** Ensure you&apos;re on Streamlit 1.58.0+ which fixed most regressions. If using `st.login()` for OIDC authentication, test after any Streamlit version upgrade.
&lt;/Accordion&gt;

## Alternative: Docker deployment

Docker is now the officially recommended deployment method by Streamlit. If you already use Docker for other services on your VPS, this is the cleaner path — it handles dependency isolation, restart policies, and removes the need for PM2 or systemd. The tradeoff is Docker itself as a dependency and a bit more complexity for debugging (container logs instead of local files).

Here&apos;s a minimal Dockerfile:

```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update &amp;&amp; apt-get install -y build-essential curl git &amp;&amp; rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY . .
EXPOSE 8501
HEALTHCHECK CMD curl --fail http://localhost:8501/_stcore/health
ENTRYPOINT [&quot;streamlit&quot;, &quot;run&quot;, &quot;app.py&quot;, &quot;--server.port=8501&quot;, &quot;--server.address=0.0.0.0&quot;]
```

Build and run:

```bash
docker build -t my-streamlit-app .
docker run -d -p 8501:8501 --name my-streamlit-app --restart unless-stopped my-streamlit-app
```

You still need the `.streamlit/config.toml` with CORS/XSRF disabled if proxying through Cloudflare Tunnels. Either copy it into the Docker image during build or mount it as a volume:

```bash
docker run -d -p 8501:8501 \
  -v /home/streamlit/streamlit-app/repo-name/.streamlit:/app/.streamlit \
  --name my-streamlit-app \
  --restart unless-stopped \
  my-streamlit-app
```

With Docker Compose, you can pin the version and add resource limits:

```yaml
version: &quot;3.8&quot;
services:
  streamlit:
    build: .
    ports:
      - &quot;8501:8501&quot;
    volumes:
      - ./.streamlit:/app/.streamlit
    restart: unless-stopped
    mem_limit: 1g
```

The `mem_limit` prevents a runaway app from eating all your VPS RAM — something to watch for if your app loads large datasets.

Verify the container is healthy:

```bash
docker ps
curl -s http://localhost:8501/_stcore/health
```

For a complete Docker + Python guide, see [How To Run Any Python App in Docker with Docker Compose](https://www.bitdoze.com/docker-run-python/).

## Keeping your deployment updated

Once the app is running, you&apos;ll need to update three things over time: the app code, Streamlit itself, and cloudflared. Here&apos;s the routine:

**Update the app code:**

```bash
sudo su - streamlit
cd /home/streamlit/streamlit-app/repo-name
git pull
```

If your `requirements.txt` changed, activate the venv and reinstall:

```bash
source /home/streamlit/venv/bin/activate
pip install -r requirements.txt
```

Then restart the process manager:

```bash
# PM2
pm2 restart my-streamlit-app

# systemd
sudo systemctl restart streamlit
```

**Update Streamlit itself:**

```bash
sudo su - streamlit
source /home/streamlit/venv/bin/activate
pip install --upgrade streamlit
```

Restart after upgrading.

**Update cloudflared** (if using apt repo):

```bash
sudo apt update &amp;&amp; sudo apt upgrade cloudflared
```

**Monitor logs:**

```bash
# PM2
pm2 logs my-streamlit-app

# systemd
journalctl -u streamlit -f
```

Consider setting up [Uptime Kuma for monitoring](https://www.bitdoze.com/install-uptime-kuma/) — point it at `https://app.example.com/_stcore/health` to get alerted if the app goes down.

**Backup:** Your app code lives in Git (safe). But `.streamlit/config.toml` and any data files on the VPS should be backed up separately. If you&apos;re running multiple apps, each needs a different port (8501, 8502, etc.) and separate Cloudflare Tunnel hostname entries.

&lt;Notice type=&quot;info&quot;&gt;
Cloudflare Tunnels are free. Your only recurring cost is the VPS (~$4–7/month). No bandwidth charges, no per-request fees.
&lt;/Notice&gt;

## Conclusion

You now have a production-ready Streamlit deployment: a dedicated user with a virtual environment, a proper `config.toml` for Cloudflare Tunnel compatibility, a process manager to keep it alive, and a locked-down firewall. The total cost is $4–7/month for the VPS — Cloudflare Tunnels, HTTPS, DDoS protection, and the WAF are all free.

The same pattern works for other Python web frameworks like Flask, FastAPI, or Gradio. The key pieces are always the same: bind to localhost, use a process manager, proxy through Cloudflare Tunnel, lock down the firewall.

If you need to restrict access to your app, Streamlit 1.42.0+ added `st.login()` and `st.logout()` for native OIDC authentication. You can use this with Google or any OIDC provider to gate access without building a custom auth layer. For internal tools that shouldn&apos;t be public, this is the first thing I&apos;d set up after the basic deployment is working.

&lt;Button text=&quot;Deploy Python Apps with Docker&quot; link=&quot;/docker-run-python/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>web-development</category><category>streamlit</category><category>python</category><category>cloudflare</category></item><item><title>Add a Contact Form to Any Static Website (2025 Guide)</title><link>https://www.bitdoze.com/add-contact-form-static-websites/</link><guid isPermaLink="true">https://www.bitdoze.com/add-contact-form-static-websites/</guid><description>Learn how to add a contact form to any static website for free. Compare FormSubmit, Static Forms, Web3Forms, and more, with step-by-step setup and privacy tips.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

Static websites built with Astro, Hugo, Jekyll, or plain HTML don&apos;t have a backend, so you can&apos;t just write a PHP script to handle form submissions. You need an external service to act as your form handler.

In an earlier tutorial I covered [adding a contact form to Astro](/add-contact-form-astro/) using OpnForms, but that approach has changed since then (email notifications moved behind a paywall). More options have appeared since then, with multiple free and privacy-first services now available.

This guide covers the simplest free option (FormSubmit.co), its known issues, the best alternatives like Static Forms and Web3Forms, a feature comparison table, privacy considerations, and a self-hosted path for advanced users. Whether you&apos;re [building a free blog with Astro and Cloudflare](/build-astro-blog-free/) or running any static site, you&apos;ll find a solution that fits.

## Why add a contact form to a static website

A contact form adds credibility and trust to any website. Instead of publishing your email address where bots can scrape it, visitors fill out a form and the submission gets forwarded to you. This is especially important for:

- **Lead generation**: potential clients or collaborators can reach you directly
- **User trust**: a contact form signals that you&apos;re approachable and professional
- **Spam reduction**: forms with CAPTCHA are harder to abuse than a raw email address
- **Structured data**: you get consistent fields (name, email, message) instead of freeform emails

Since static sites have no server-side code, you need a **static website form handler**: either a third-party service or a lightweight self-hosted backend. There are several free options that require zero backend code.

## Method 1: FormSubmit.co (simplest free option)

FormSubmit.co was the go-to recommendation when this article was first published. It&apos;s still the simplest option. No signup required, just point your form&apos;s `action` attribute to their endpoint. Here&apos;s how it works.

### 1. Create your HTML form

Design a form using standard HTML elements. Include a `name` attribute on every field you want to receive data for:

```html
&lt;form id=&quot;contact-form&quot;&gt;
  &lt;label for=&quot;name&quot;&gt;Name:&lt;/label&gt;
  &lt;input type=&quot;text&quot; id=&quot;name&quot; name=&quot;name&quot; required /&gt;
  &lt;label for=&quot;email&quot;&gt;Email:&lt;/label&gt;
  &lt;input type=&quot;email&quot; id=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;label for=&quot;message&quot;&gt;Message:&lt;/label&gt;
  &lt;textarea id=&quot;message&quot; name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

### 2. Point the form action to FormSubmit

Add the FormSubmit endpoint as your form&apos;s `action` attribute with your email address:

```html
&lt;form
  id=&quot;contact-form&quot;
  action=&quot;https://formsubmit.co/your@email.com&quot;
  method=&quot;POST&quot;
&gt;
  &lt;label for=&quot;name&quot;&gt;Name:&lt;/label&gt;
  &lt;input type=&quot;text&quot; id=&quot;name&quot; name=&quot;name&quot; required /&gt;
  &lt;label for=&quot;email&quot;&gt;Email:&lt;/label&gt;
  &lt;input type=&quot;email&quot; id=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;label for=&quot;message&quot;&gt;Message:&lt;/label&gt;
  &lt;textarea id=&quot;message&quot; name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

### 3. Verify your email address

Submit the form once (or visit the FormSubmit URL in your browser). You&apos;ll receive a confirmation email with a link. Click it to verify that you own the email address. This is a one-time step.

### 4. Customize form options

FormSubmit supports several hidden input fields to customize behavior:

**Redirect after submission:**

```html
&lt;input
  type=&quot;hidden&quot;
  name=&quot;_next&quot;
  value=&quot;https://yourdomain.com/thanks.html&quot;
/&gt;
```

**Carbon copy to additional recipients:**

```html
&lt;input
  type=&quot;hidden&quot;
  name=&quot;_cc&quot;
  value=&quot;another@email.com,yetanother@email.com&quot;
/&gt;
```

**Add reCAPTCHA:**

```html
&lt;input type=&quot;hidden&quot; name=&quot;_captcha&quot; value=&quot;true&quot; /&gt;
```

You can check all available options in the [FormSubmit.co documentation](https://formsubmit.co/documentation).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/vSABo52iPAs&quot;
  label=&quot;Add Contact Form To Static Websites&quot;
/&gt;

## FormSubmit.co issues to know about

FormSubmit is easy to set up, but it has real problems you should be aware of before committing to it for a production site.

### Spam protection limitations

Users on Reddit report that spam gets through even with reCAPTCHA and honeypot enabled. One user in r/webdev noted: &quot;I had the captcha and honeypot options enabled and still got daily spam messages.&quot; Others report that reCAPTCHA can cause 30+ second load times on form pages, which hurts user experience.

### Email address exposed in source code (GDPR risk)

Your email address is baked directly into the HTML source code via the form action URL:

```html
&lt;form action=&quot;https://formsubmit.co/your@email.com&quot;&gt;
```

This makes it harvestable by email scraping bots. It&apos;s also a potential GDPR liability. You&apos;re exposing a personal email address in a way that&apos;s trivially easy to collect automatically. If you&apos;re concerned about this, check our guide on how to [stop AI crawlers from scraping your website](/block-ai-crawlers/).

### Reliability concerns

The FormSubmit.co website was unreachable during multiple test requests. Users on Reddit report inconsistencies and wasted debugging time. Since FormSubmit has no paid plans (it&apos;s &quot;free forever&quot;), there&apos;s a question about long-term sustainability and support.

&lt;Notice type=&quot;warning&quot; title=&quot;Important caveat&quot;&gt;
FormSubmit works for quick prototypes and low-stakes personal sites. For production websites where you can&apos;t afford to lose submissions, consider one of the alternatives below that offer dashboards, submission logs, and transparent uptime guarantees.
&lt;/Notice&gt;

## Best FormSubmit alternatives for static sites

More form backend services have launched since 2024. Several now offer better spam protection, privacy guarantees, dashboards, and sustainable business models, many with generous free tiers. If you&apos;re interested in self-hosted form tools, you might also want to look at [OpnForm, an open-source form builder](/opnform-open-source/).

Before choosing a service, here&apos;s what to look for:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Privacy: does it expose your email in HTML source?&lt;/li&gt;
&lt;li&gt;Spam protection: reCAPTCHA, Turnstile, Altcha, or ML-based filtering&lt;/li&gt;
&lt;li&gt;Free tier limits: how many submissions per month before you pay&lt;/li&gt;
&lt;li&gt;Dashboard: can you view past submissions without digging through email?&lt;/li&gt;
&lt;li&gt;Integrations: Slack, Google Sheets, Zapier, webhooks&lt;/li&gt;
&lt;li&gt;File uploads: can users attach files to form submissions?&lt;/li&gt;
&lt;li&gt;Auto-responders: does the submitter get a confirmation email?&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Static Forms (best free tier, 500/mo)

[Static Forms](https://www.staticforms.dev/) offers the most generous free tier at 500 submissions/month. It supports reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, and Altcha (privacy-first, GDPR-friendly, no user tracking). You also get webhooks, file uploads, a submission dashboard, and AI-powered auto-replies that draft responses based on a knowledge base.

The setup is similar to FormSubmit, point your form action to their endpoint, but uses an access key instead of your email address. Paid plans start at $7.50/month.

### Web3Forms (best for privacy, no email exposure)

[Web3Forms](https://web3forms.com/) uses an access key instead of your email address in the HTML, so your email stays hidden from scrapers. Free tier includes 250 submissions/month. It supports file uploads, reCAPTCHA, and is GDPR compliant by design.

Setup is straightforward. Create an account, get your access key, and add it as a hidden field:

```html
&lt;form action=&quot;https://api.web3forms.com/submit&quot; method=&quot;POST&quot;&gt;
  &lt;input type=&quot;hidden&quot; name=&quot;access_key&quot; value=&quot;YOUR_ACCESS_KEY&quot; /&gt;
  &lt;input type=&quot;text&quot; name=&quot;name&quot; required /&gt;
  &lt;input type=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;textarea name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

### Formspree (best for integrations)

[Formspree](https://formspree.io/) has been around since 2014 and is one of the most established players. Free tier is limited to 50 submissions/month, but the $10/month plan adds ML-based spam filtering, a dashboard, and integrations with Slack, Google Sheets, and Zapier. Good choice if you need submissions to flow into your existing workflow tools.

### Netlify Forms (best if hosted on Netlify)

If your site is already on Netlify, [Netlify Forms](https://docs.netlify.com/manage/forms/setup/) is built in. No extra service needed. Free tier includes 100 submissions/month with built-in spam filtering. Add `netlify` to your form tag and it just works:

```html
&lt;form name=&quot;contact&quot; method=&quot;POST&quot; data-netlify=&quot;true&quot;&gt;
  &lt;input type=&quot;text&quot; name=&quot;name&quot; required /&gt;
  &lt;input type=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;textarea name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

### EmailJS (best for client-side only)

[EmailJS](https://www.emailjs.com/) lets you send emails directly from client-side JavaScript, with no form action redirect. Free tier includes 200 emails/month. It works with email templates and supports attachments. Good for single-page apps or sites where you want AJAX form submission without a page redirect.

## Feature comparison table: free contact form services

| Service | Free Submissions/mo | Spam Protection | File Uploads | Dashboard | Webhooks | Email Exposed? | Paid From |
|---------|-------------------|----------------|--------------|-----------|----------|----------------|-----------|
| FormSubmit | Unlimited* | reCAPTCHA, honeypot | No | No | No | Yes | Free only |
| Static Forms | 500 | reCAPTCHA v2/v3, Turnstile, Altcha | Yes | Yes | Yes | No (access key) | $7.50/mo |
| Web3Forms | 250 | reCAPTCHA | Yes | Yes | Yes | No (access key) | Paid plans |
| Formspree | 50 | ML filtering | Yes | Yes | Yes | No (form ID) | $10/mo |
| Netlify Forms | 100 | Built-in spam filter | Yes | Yes | Yes | No (form ID) | $9/mo |
| EmailJS | 200 | reCAPTCHA | Yes (templates) | Yes | Yes | No (service ID) | $9/mo |
| Forminit | 100 | reCAPTCHA | Yes (25MB) | Yes | Yes | No (form ID) | $19/mo |

*\*FormSubmit claims unlimited, but users report reliability issues.*

&lt;Notice type=&quot;info&quot; title=&quot;Tip&quot;&gt;
If you expect fewer than 50 submissions/month, Formspree works fine. For higher volume, Static Forms or Web3Forms offer the best free tiers. If you&apos;re already on Netlify, their built-in forms are the path of least resistance.
&lt;/Notice&gt;

## Privacy and GDPR considerations for contact forms

If you serve EU visitors (or care about privacy in general), the contact form service you choose has real implications for compliance.

### Email exposure in HTML source

FormSubmit embeds your email address directly in the HTML `action` attribute. This is visible to anyone who views source, and to every bot that crawls your site. Services like Web3Forms, Formspree, Forminit, and Static Forms use access keys or form IDs instead, keeping your email address server-side only.

&lt;Notice type=&quot;warning&quot; title=&quot;GDPR alert&quot;&gt;
Exposing a personal email address in HTML source code can be considered a GDPR compliance issue. If your site serves EU visitors, use a service that uses form IDs or access keys instead of email addresses in the form action.
&lt;/Notice&gt;

For broader privacy protection on your site, consider pairing a privacy-respecting form service with [Plausible Analytics](/install-plausible-analytics/), a privacy-focused alternative to Google Analytics.

### Privacy-first CAPTCHA options

Traditional Google reCAPTCHA tracks users across sites and loads heavy JavaScript. Newer alternatives are more privacy-friendly:

- **Cloudflare Turnstile**: invisible CAPTCHA that doesn&apos;t require user interaction and doesn&apos;t track users across sites. Static Forms supports it natively.
- **Altcha**: open-source, GDPR-friendly CAPTCHA with no user tracking. Works by solving a proof-of-work challenge in the browser. Supported by Static Forms.
- **Honeypot fields**: a hidden form field that bots fill in but humans don&apos;t. Simple and effective against basic spam bots, used by most services.

If privacy is a priority, choose a service that supports Turnstile or Altcha over Google reCAPTCHA.

## How to add a contact form in Astro

If you&apos;re using Astro with ViewTransitions, you may hit this error when submitting a form:

&gt; Make sure your form has the method=&quot;POST&quot; attribute

This happens because Astro&apos;s ViewTransitions intercept form submissions. The fix is to add the `data-astro-reload` attribute to your form element. This tells Astro to handle the form submission as a regular page navigation instead of intercepting it.

&lt;Notice type=&quot;info&quot; title=&quot;Astro tip&quot;&gt;
This fix works regardless of which form service you use (FormSubmit, Web3Forms, Static Forms, or any other). The `data-astro-reload` attribute is an Astro-specific workaround, not tied to any particular form backend.
&lt;/Notice&gt;

```html
&lt;form
  id=&quot;contact-form&quot;
  action=&quot;https://formsubmit.co/your@email.com&quot;
  method=&quot;POST&quot;
  data-astro-reload
&gt;
  &lt;label for=&quot;name&quot;&gt;Name:&lt;/label&gt;
  &lt;input type=&quot;text&quot; id=&quot;name&quot; name=&quot;name&quot; required /&gt;
  &lt;label for=&quot;email&quot;&gt;Email:&lt;/label&gt;
  &lt;input type=&quot;email&quot; id=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;label for=&quot;message&quot;&gt;Message:&lt;/label&gt;
  &lt;textarea id=&quot;message&quot; name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

If you&apos;re deploying your Astro site, check our guides on how to [deploy Astro on Cloudflare](/deploy-astrojs-cloudflare/) or [deploy a static Astro website on VPS](/deploy-astro-on-vps/). You might also find our tutorials on how to [display YouTube videos on your Astro blog](/add-youtube-videos-astro-blog/) and [add responsive YouTube videos to Astro MDX](/responsive-youtube-astrojs/) useful for other parts of your site.

## Self-hosted contact form option (advanced)

If you don&apos;t want to depend on a third-party service, or you need full control over your data, you can run your own form backend.

&lt;Notice type=&quot;success&quot; title=&quot;For self-hosters&quot;&gt;
A self-hosted form handler gives you complete control over submissions, storage, and privacy. No third-party dependencies, no submission limits, no email exposure. You own the entire pipeline.
&lt;/Notice&gt;

### Docker-based Go form backend

The [caffsoft/hugo-contact](https://github.com/caffsoft/hugo-contact) project is a lightweight Go-based contact form backend that runs in Docker using approximately 10MB of RAM. It&apos;s MIT licensed and designed for static sites.

The general approach:

1. Deploy the container on a VPS
2. Point your form action to the container&apos;s URL
3. The backend processes submissions and forwards them via email or stores them

If you need a VPS to run this, [affordable VPS from Hetzner](https://go.bitdoze.com/hetzner) starts at around €4/month with excellent performance. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget option with NVMe storage.

For more self-hosted solutions, see our guide on [self-hosted alternatives to popular SaaS tools](/coolify-install-heroku-alternative/).

### Cloudflare Workers approach

If you&apos;re already on Cloudflare, you can build a serverless form handler using Cloudflare Workers. The approach is:

1. Create a Worker that receives POST requests from your form
2. Forward submissions to your email (via SendGrid or Mailgun) or store them in Airtable/D1
3. Deploy the Worker alongside your Cloudflare Pages site

This gives you a form backend that runs at the edge with zero server management. It&apos;s free for up to 100,000 requests/day on Cloudflare&apos;s free plan.

## How to choose the right form service for your site

Different sites have different needs. Here&apos;s a quick decision guide:

&lt;Tabs&gt;
&lt;Tab name=&quot;Just need it working&quot;&gt;
**Use FormSubmit.co**

The fastest way to get a contact form live. No signup, no API keys, just add your email to the form action and verify once. Works for personal blogs and low-traffic sites where occasional spam or downtime isn&apos;t critical.

```html
&lt;form action=&quot;https://formsubmit.co/your@email.com&quot; method=&quot;POST&quot;&gt;
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Best free tier&quot;&gt;
**Use Static Forms**

500 free submissions/month, plus a dashboard, file uploads, webhooks, and support for Turnstile and Altcha CAPTCHA. The most generous free tier with the most features. Sign up at [staticforms.dev](https://www.staticforms.dev/) to get your access key.
&lt;/Tab&gt;
&lt;Tab name=&quot;Privacy-first&quot;&gt;
**Use Web3Forms**

Access key instead of email in HTML, GDPR compliant, 250 free submissions/month. Your email address never appears in the page source. Sign up at [web3forms.com](https://web3forms.com/).
&lt;/Tab&gt;
&lt;Tab name=&quot;Full control&quot;&gt;
**Self-host with Docker**

Run your own form backend on a VPS with caffsoft/hugo-contact or build a Cloudflare Worker. No submission limits, no third-party dependencies, complete data ownership. Requires a server. Consider [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps).
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Can I add a contact form to a static website without a backend?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes, that&apos;s exactly what form backend services like FormSubmit, Web3Forms, and Static Forms do. They provide an endpoint URL that you set as your form&apos;s `action` attribute. When someone submits the form, the service receives the data and forwards it to your email (or stores it in a dashboard). No server-side code needed on your end.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is FormSubmit.co still free in 2025?&quot; group=&quot;faq&quot;&gt;
Yes, FormSubmit.co is still free with no paid plans. However, users report reliability issues, spam getting through reCAPTCHA, and the service being intermittently unreachable. For production sites, consider Static Forms or Web3Forms which have more robust free tiers and transparent business models.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which free form service has the highest submission limit?&quot; group=&quot;faq&quot;&gt;
Static Forms offers 500 submissions/month on its free tier, the highest among the services compared in this article. Web3Forms comes second at 250/month, followed by EmailJS at 200/month and Netlify Forms at 100/month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need a CAPTCHA on my contact form?&quot; group=&quot;faq&quot;&gt;
Yes, you should add some form of spam protection. The best options are Cloudflare Turnstile (invisible, no user tracking), Altcha (open-source, GDPR-friendly), or reCAPTCHA v3 (invisible scoring). Avoid standard reCAPTCHA v2 with checkboxes if possible. It hurts user experience and loads heavy JavaScript.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use these form services with Astro, Hugo, or Jekyll?&quot; group=&quot;faq&quot;&gt;
Yes. These services work with any static site generator because they&apos;re just standard HTML forms. Point the `action` attribute to the service&apos;s endpoint and it works. If you&apos;re using Astro with ViewTransitions, add `data-astro-reload` to the form element to avoid the POST method error.
&lt;/Accordion&gt;

## Conclusion

Adding a contact form to a static website is easier than ever. FormSubmit.co remains the quickest option for a prototype or personal site, just point and go. But for production use, Static Forms and Web3Forms offer better spam protection, privacy guarantees, and dashboards at no cost.

If privacy is your priority, choose a service that uses access keys instead of email addresses (Web3Forms, Static Forms). If you need integrations with Slack or Google Sheets, Formspree is solid. And if you want zero dependencies, a self-hosted Docker backend gives you full control.

If you&apos;re setting up a new Astro site, start with our guide to [adding a contact form to Astro](/add-contact-form-astro/). For broader privacy tooling, [Plausible Analytics](/install-plausible-analytics/) pairs well with a privacy-respecting form service.

&lt;Button text=&quot;Explore Static Forms&quot; link=&quot;https://www.staticforms.dev/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Try Web3Forms&quot; link=&quot;https://web3forms.com/&quot; variant=&quot;outline&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category><category>static-sites</category></item><item><title>How to Add a Custom Domain to Carrd (Step-by-Step Guide)</title><link>https://www.bitdoze.com/carrd-add-domain/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-add-domain/</guid><description>Learn how to connect a custom domain to Carrd.co step by step. Covers DNS records, Cloudflare setup, SSL, troubleshooting tips, and Carrd Pro plans.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/domain-settings-carrd.png&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you want to know how to add a custom domain to Carrd, you&apos;re in the right place. [Carrd.co](https://go.bitdoze.com/carrd) is one of the best tools for [building a one-page website on a budget](https://www.bitdoze.com/build-one-page-website-budget/). It&apos;s fast, cheap, and dead simple to use. But the default `yourname.carrd.co` URL doesn&apos;t exactly scream professionalism.

A custom domain fixes that. It makes your site look legitimate, helps with brand recall, and gives you better control over SEO. The whole setup takes about 10 minutes once you have a domain name.

You need at least the **Pro Standard** plan ($19/year) to connect a custom domain. Carrd also offers a **7-day free Pro trial** with no credit card required, so you can test the full workflow before committing. If you&apos;re still evaluating Carrd, check out [our Carrd.co review](https://www.bitdoze.com/carrd-review/) for a full breakdown of what you get at each plan level.

## Key takeaways

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;You need a &lt;strong&gt;Pro Standard ($19/year) or Pro Plus ($49/year)&lt;/strong&gt; plan. Pro Lite does NOT support custom domains&lt;/li&gt;
&lt;li&gt;Register a domain from &lt;strong&gt;Cloudflare, Namecheap, or Porkbun&lt;/strong&gt; (Carrd&apos;s officially recommended providers)&lt;/li&gt;
&lt;li&gt;Carrd shows your specific IP addresses in the Publish settings. &lt;strong&gt;Copy them from there&lt;/strong&gt;, not from blog posts&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;two A records&lt;/strong&gt; for @ and &lt;strong&gt;one CNAME record&lt;/strong&gt; for www in your DNS settings&lt;/li&gt;
&lt;li&gt;Carrd &lt;strong&gt;automatically provides SSL&lt;/strong&gt; via Let&apos;s Encrypt. No Cloudflare SSL configuration needed&lt;/li&gt;
&lt;li&gt;If using Cloudflare for DNS, keep records set to &lt;strong&gt;DNS Only&lt;/strong&gt; (grey cloud), not proxied&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/wwRzzLGSc2U&quot;
  label=&quot;How To Add Custom Domain to Carrd.co Website&quot;
/&gt;
Some Carrd tutorials you might find useful:

- [Add Sticky Header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add Cookie Notice to Carrd](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [How to Add Pricing Table to Carrd](https://www.bitdoze.com/carrd-add-pricing-table/)
- [How to Add Accordion FAQs to Carrd](https://www.bitdoze.com/add-accordion-carrd/)
- [Carrd Mobile Responsive Navbar](https://www.bitdoze.com/carrd-mobile-navbar/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Back to Top Button on Carrd](https://www.bitdoze.com/carrd-back-to-top-button/)
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list of Carrd plugins, themes, and tutorials is on **[carrdme.com](https://carrdme.com/)**.

## How to register a domain name for your Carrd site

The first step is getting a domain name, the address people type into their browser to reach your site, like `example.com`. Carrd doesn&apos;t sell domains, so you need to register one with a domain provider.

Carrd&apos;s official docs recommend three providers: **Cloudflare**, **Namecheap**, and **Porkbun**. Here&apos;s how they compare:

- **Cloudflare** sells domains at wholesale cost, no markup. If you&apos;re already using Cloudflare for DNS or CDN, it&apos;s the obvious choice. The downside: the domain management UI is minimal.
- **Namecheap** runs frequent sales and has a polished dashboard. Renewal prices are higher than registration prices, which is standard for most registrars.
- **Porkbun** has become a community favorite for transparent pricing. What you pay to register is what you pay to renew. They also include free WHOIS privacy.

&lt;Notice type=&quot;info&quot; title=&quot;Try Before You Buy&quot;&gt;
Carrd offers a 7-day free Pro trial with no credit card required. It includes custom domain support, so you can test the full setup before paying for a plan.
&lt;/Notice&gt;

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Start Carrd Free Trial&quot; /&gt;

Tips for choosing a domain name:

- Keep it short and memorable. Avoid hyphens and numbers
- Pick an extension that fits your audience: `.com` for general use, `.io` for tech, `.co` for startups
- Check availability using your registrar&apos;s search tool
- Avoid trademarked names or anything too close to existing brands

For more guidance, here are some [tips on choosing a domain name](https://www.wpdoze.com/choose-domain-name/).

## Step 2: Configure DNS records for your custom domain

This is where most people get stuck, but it&apos;s straightforward once you understand what&apos;s happening.

When someone types your domain into their browser, the browser asks DNS (Domain Name System) where to find your site. DNS looks up your records and tells the browser which server to connect to. For Carrd, you need to set up records that point your domain to Carrd&apos;s servers.

Here&apos;s what the flow looks like:

```
User&apos;s Browser
    ↓ types domain.com
DNS Resolver
    ↓ looks up A records
Carrd Server (IP from Publish settings)
    ↓ serves the site
Browser displays Carrd site
```

You need three DNS records total:

1. **Two A records** for `@` (your root domain), each pointing to one of the two IP addresses Carrd assigns to your site
2. **One CNAME record** for `www`, pointing to `@` (your root domain)

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t copy IP addresses from the internet&quot;&gt;
Carrd assigns IP addresses dynamically per site. The IPs in this article or any other blog post may not be correct for your site. Always copy the IP addresses from your own Carrd Publish settings (covered in Step 3 below). Using outdated or wrong IPs will cause errors.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Remove AAAA records&quot;&gt;
If your domain has any AAAA (IPv6) records for @, remove them. Carrd doesn&apos;t use IPv6, and these records can cause connection issues or slow loading.
&lt;/Notice&gt;

The exact steps depend on your domain provider. Choose your provider below:

&lt;Tabs&gt;
&lt;Tab name=&quot;Cloudflare (Recommended)&quot;&gt;

Cloudflare is the best option if you want free DNS management with fast propagation. It&apos;s what most Bitdoze readers use.

**Steps:**

1. Log in to your Cloudflare dashboard and select your domain.
2. Go to **DNS** → **Records**.
3. Click **Add record**.
4. Add the first A record:
   - **Type:** A
   - **Name:** `@`
   - **IPv4 address:** *(paste the first IP from your Carrd Publish settings)*
   - **Proxy status:** DNS Only (grey cloud). **This is critical.**
   - **TTL:** Auto
5. Click **Save**.
6. Add the second A record:
   - **Type:** A
   - **Name:** `@`
   - **IPv4 address:** *(paste the second IP from your Carrd Publish settings)*
   - **Proxy status:** DNS Only (grey cloud)
   - **TTL:** Auto
7. Click **Save**.
8. Add the CNAME record:
   - **Type:** CNAME
   - **Name:** `www`
   - **Target:** `@`
   - **Proxy status:** DNS Only (grey cloud)
   - **TTL:** Auto
9. Click **Save**.

&lt;Notice type=&quot;error&quot; title=&quot;Cloudflare users: disable proxy&quot;&gt;
Carrd runs on Cloudflare&apos;s infrastructure. If you enable the proxy (orange cloud) on your DNS records, you&apos;re proxying through Cloudflare twice. This causes Error 1000, redirect loops, or the site not loading at all. Keep all Carrd-related records set to &lt;strong&gt;DNS Only&lt;/strong&gt; (grey cloud).

If you absolutely need to proxy through Cloudflare, go to &lt;strong&gt;SSL/TLS&lt;/strong&gt; → set encryption mode to &lt;strong&gt;Full (Strict)&lt;/strong&gt; to avoid redirect loops. But DNS Only is the safer option.
&lt;/Notice&gt;

&lt;/Tab&gt;
&lt;Tab name=&quot;Namecheap&quot;&gt;

Namecheap has a straightforward DNS editor. The interface is a bit dated, but it works fine.

**Steps:**

1. Log in to your Namecheap account.
2. Go to **Domain List** and click **Manage** next to your domain.
3. Select the **Advanced DNS** tab.
4. Add the first A record:
   - **Type:** A Record
   - **Host:** `@`
   - **Value:** *(paste the first IP from your Carrd Publish settings)*
   - **TTL:** Automatic
5. Add the second A record:
   - **Type:** A Record
   - **Host:** `@`
   - **Value:** *(paste the second IP from your Carrd Publish settings)*
   - **TTL:** Automatic
6. Add the CNAME record:
   - **Type:** CNAME Record
   - **Host:** `www`
   - **Value:** *(your domain name, e.g., `example.com`)*
   - **TTL:** Automatic
7. Click the green checkmark to save all changes.

DNS changes on Namecheap usually propagate within a few minutes but can take up to 30 minutes.

&lt;/Tab&gt;
&lt;Tab name=&quot;Porkbun&quot;&gt;

Porkbun&apos;s DNS editor is clean and easy to navigate. This is a newer recommendation from Carrd.

**Steps:**

1. Log in to your Porkbun account.
2. Go to **Domain List** and click the gear icon next to your domain.
3. Click **Edit DNS Records**.
4. Add the first A record:
   - **Type:** A
   - **Host:** *(leave blank or enter `@`)*
   - **Answer:** *(paste the first IP from your Carrd Publish settings)*
   - **TTL:** 600 (default is fine)
5. Add the second A record:
   - **Type:** A
   - **Host:** *(leave blank or enter `@`)*
   - **Answer:** *(paste the second IP from your Carrd Publish settings)*
   - **TTL:** 600
6. Add the CNAME record:
   - **Type:** CNAME
   - **Host:** `www`
   - **Answer:** *(your domain name, e.g., `example.com`)*
   - **TTL:** 600
7. Click **Submit** to save.

Porkbun&apos;s DNS propagation is usually fast, under 10 minutes in most cases.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Step 3: Publish your Carrd site with a custom domain

Now that your DNS records are set, you need to tell Carrd which domain to use.

Here&apos;s how:

1. Open your site in the Carrd editor (or go to your Carrd dashboard and click the site).
2. Click the **Publish** icon (the arrow icon in the top toolbar).
3. Under **Publishing**, set the action to **Publish to a custom domain**.
4. Enter your domain name. Use `domain.ext` (e.g., `example.com`). Carrd automatically redirects between the root domain and `www`, so it doesn&apos;t matter which one you enter.
5. Scroll down to the **Host records** section. **This is where you find the IP addresses for your A records.** Copy them from here if you haven&apos;t already.
6. Click **Publish Changes**.
7. Wait for Carrd to initialize your domain. This usually takes a few minutes but can take up to 1 hour.

&lt;Picture src={imag1} alt=&quot;Carrd domain settings showing the publish options&quot; /&gt;

Once initialization is complete, your site will be live on your custom domain.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd Free for 7 Days&quot; /&gt;

### SSL is automatic

You don&apos;t need to configure SSL separately. Carrd automatically issues a free SSL certificate via **Let&apos;s Encrypt** for every custom domain. The certificate is provisioned once your DNS records are in place and have propagated.

&lt;Notice type=&quot;success&quot; title=&quot;SSL is automatic&quot;&gt;
Carrd provides a free SSL certificate via Let&apos;s Encrypt for all custom domains. No additional configuration is needed. You do NOT need to set up Cloudflare SSL or buy a certificate. The entire process is hands-off.
&lt;/Notice&gt;

If you&apos;re using Cloudflare with the proxy enabled (not recommended, see the Cloudflare warning above), make sure your Cloudflare SSL/TLS mode is set to **Full (Strict)** to avoid conflicts with Carrd&apos;s own certificate.

## Carrd Pro plans: which one supports custom domains?

There are three Pro tiers, and only the higher two support custom domains. This is a common source of confusion.

| Plan | Price | Custom Domains | Sites | Key Extras |
|------|-------|---------------|-------|------------|
| **Pro Lite** | $9/year | No | 3 | Basic Pro features |
| **Pro Standard** | $19/year | Yes | 10 | Custom domains, embeds, forms |
| **Pro Plus** | $49/year | Yes | 25 | Advanced forms (Zapier/Make/n8n/Airtable), password protection, redirects, canonical URLs, variables |

Both Pro Standard and Pro Plus allow expanding the site limit. You can get 25, 50, 100, 250, 500, or even 1,000 sites at higher price tiers.

&lt;Notice type=&quot;warning&quot; title=&quot;Pro Lite won&apos;t work&quot;&gt;
You need Pro Standard ($19/year) or Pro Plus ($49/year) to use a custom domain. Pro Lite does not include this feature. If you&apos;re on Pro Lite and try to connect a domain, the option simply won&apos;t appear.
&lt;/Notice&gt;

The 7-day free trial includes Pro Standard features, so you can test custom domains before committing. For a detailed plan comparison, see our [full Carrd.co review](https://www.bitdoze.com/carrd-review/).

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Start Carrd Pro Trial&quot; /&gt;

## Troubleshooting common Carrd custom domain issues

If your custom domain isn&apos;t working, one of these is usually the cause.

&lt;Accordion label=&quot;Domain still &apos;initializing&apos; after 1 hour&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;

This is the most common issue. Here&apos;s what to check:

- **Verify your DNS records.** You need exactly two A records for `@` and one CNAME for `www`. Missing or extra records will block initialization.
- **Remove conflicting records.** Old A records, AAAA (IPv6) records, or domain parking/forwarding/masking services can interfere. Delete anything that isn&apos;t the three records Carrd needs.
- **Check that the IP addresses match.** The IPs in your A records must match what Carrd shows in your Publish settings. Don&apos;t use IPs from old blog posts or tutorials.
- **Wait a bit longer.** DNS propagation usually takes minutes but can take up to 48 hours in rare cases.
- **Try a different network** or clear your browser cache to rule out local caching issues.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cloudflare Error 1000 or redirect loop&quot; group=&quot;troubleshooting&quot;&gt;

If you&apos;re using Cloudflare and getting Error 1000 (Prohibited IP) or a redirect loop:

- **Cause:** Your DNS records have the proxy enabled (orange cloud). Carrd already runs on Cloudflare&apos;s infrastructure, so proxying through Cloudflare again causes collisions.
- **Fix:** Set all Carrd-related A and CNAME records to **DNS Only** (grey cloud).
- If you need to keep the proxy enabled for some reason, go to **SSL/TLS** → set encryption mode to **Full (Strict)**.
- Clear your browser cache after making the change, then test again.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;&apos;Not Secure&apos; SSL warning&quot; group=&quot;troubleshooting&quot;&gt;

- Carrd&apos;s Let&apos;s Encrypt certificate may still be provisioning. Wait up to 1 hour after your DNS records propagate.
- If the warning persists after 1 hour, contact Carrd support. There may be an issue with certificate issuance.
- Make sure no Cloudflare &quot;Flexible&quot; SSL mode is interfering. If you&apos;re using Cloudflare, set SSL/TLS to **Full (Strict)** or disable the proxy entirely.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Site only works with or without www&quot; group=&quot;troubleshooting&quot;&gt;

If your site loads at `example.com` but not `www.example.com` (or vice versa):

- You&apos;re missing a DNS record. Verify you have **both** A records (for `@`) **and** the CNAME record (for `www`).
- Carrd automatically redirects between `domain.ext` and `www.domain.ext`, but both records must exist in your DNS settings for this to work.
- Use a tool like [DNSChecker.org](https://dnschecker.org/) to verify all three records are visible globally.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Slow loading or intermittent access&quot; group=&quot;troubleshooting&quot;&gt;

- Check for conflicting AAAA (IPv6) records. Remove them. Carrd doesn&apos;t use IPv6.
- Disable any domain forwarding, masking, or parking services at your registrar. These can intercept requests before they reach Carrd.
- If using Cloudflare, make sure the proxy is off (DNS Only).

&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Can I use a custom domain on Carrd for free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

No. You need at least Pro Standard ($19/year) to connect a custom domain. However, Carrd offers a **7-day free Pro trial** with no credit card required that includes custom domain support. You can test the full setup during the trial before deciding to pay.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What DNS records do I need for a Carrd custom domain?&quot; group=&quot;faq&quot;&gt;

You need three records:
- **Two A records** for `@` pointing to the IP addresses Carrd shows in your Publish settings
- **One CNAME record** for `www` pointing to `@`

Copy the IP addresses from your Carrd dashboard. Don&apos;t use values from tutorials or blog posts, as they may be outdated.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Carrd provide SSL for custom domains?&quot; group=&quot;faq&quot;&gt;

Yes. Carrd automatically issues a free SSL certificate via Let&apos;s Encrypt for every custom domain. No additional setup is required. The certificate is provisioned once your DNS records are in place.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Cloudflare with Carrd?&quot; group=&quot;faq&quot;&gt;

Yes, but with an important caveat: keep your DNS records set to **DNS Only** (grey cloud). Do NOT proxy through Cloudflare. Carrd already runs on Cloudflare&apos;s infrastructure, and double-proxying causes Error 1000 or redirect loops.

If you must proxy, set SSL/TLS to **Full (Strict)** in your Cloudflare dashboard. But DNS Only is the recommended and safest configuration.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How long does it take for a Carrd custom domain to work?&quot; group=&quot;faq&quot;&gt;

Usually a few minutes. Carrd&apos;s docs say it can take up to 1 hour. In rare cases, DNS propagation may take up to 48 hours. If it&apos;s been more than an hour, double-check your DNS records and remove any conflicting entries.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the difference between Pro Lite, Pro Standard, and Pro Plus?&quot; group=&quot;faq&quot;&gt;

- **Pro Lite ($9/year):** 3 sites, no custom domain support
- **Pro Standard ($19/year):** 10 sites, custom domains, embeds, forms
- **Pro Plus ($49/year):** 25 sites, everything in Standard plus advanced forms (Zapier/Make/n8n/Airtable), password protection, redirects, canonical URLs, and variables

For a detailed comparison, see our [full Carrd.co review](https://www.bitdoze.com/carrd-review/).

&lt;/Accordion&gt;

## Conclusion

Adding a custom domain to Carrd is a three-step process: register a domain, configure your DNS records, and publish through Carrd&apos;s settings. Carrd handles SSL automatically via Let&apos;s Encrypt, so there&apos;s no certificate to buy or install.

The most important thing to remember: **get your IP addresses from your own Carrd dashboard**, not from blog posts. And if you&apos;re using Cloudflare, keep the proxy off.

If you haven&apos;t committed to a plan yet, start with the [7-day free Pro trial](https://go.bitdoze.com/carrd). It includes custom domain support, so you can verify everything works before paying.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Get Started with Carrd&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

Once your domain is live, here are some Carrd tutorials to take your site further:

- [Add a sticky header to your Carrd site](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add a cookie notice to Carrd](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [Add a pricing table to Carrd](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Add an accordion FAQ to Carrd](https://www.bitdoze.com/add-accordion-carrd/)
- [Create a mobile responsive navbar in Carrd](https://www.bitdoze.com/carrd-mobile-navbar/)
- [Add a dark mode toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add a back to top button on Carrd](https://www.bitdoze.com/carrd-back-to-top-button/)
- [Add smooth scroll and anchor links to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)

For the full list of Carrd plugins, themes, and tutorials, visit **[carrdme.com](https://carrdme.com/)**.</content:encoded><category>web-development</category><category>carrd</category><category>custom-domain</category><category>dns</category></item><item><title>Executor.sh: Self-Hosted Open-Source Composio Alternative</title><link>https://www.bitdoze.com/executor-sh-vs-composio/</link><guid isPermaLink="true">https://www.bitdoze.com/executor-sh-vs-composio/</guid><description>Executor.sh is the open-source, self-hosted MCP gateway replacing Composio for AI agent tool calling. Feature comparison, pricing, and Docker deployment guide.</description><pubDate>Wed, 15 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Every AI agent needs tools. Whether it is sending Slack messages, querying a database, or calling a third-party API, the agent has to connect to external services somehow. Composio built a business around this: 1,000+ pre-built toolkits, managed OAuth, and a hosted runtime that handles everything for you. It works, and it works well for teams that want a fully managed platform.

But if you want to self-host, inspect the runtime, or avoid per-call pricing at scale, Composio&apos;s story falls apart. The runtime is closed source. Self-hosting requires an Enterprise contract. And credentials flow through their cloud on self-serve plans.

That is where [Executor.sh](https://executor.sh/) comes in. It is a fully open-source MCP gateway. Configure your integrations once, and every MCP-compatible agent shares them through one endpoint. You can run it on your own infrastructure with a single Docker command. No sales calls. No vendor lock-in. No per-call fees when self-hosted.

This article gives you a direct comparison between the two platforms, honest trade-offs, and a step-by-step guide to self-hosting Executor.sh with Docker.

&lt;Notice type=&quot;info&quot; title=&quot;First of its kind&quot;&gt;
As of July 2026, this is the first direct Executor.sh vs Composio comparison on the web. Most existing comparison articles predate Executor or do not include it.
&lt;/Notice&gt;

## What is Composio?

Composio launched in 2024 and became the default platform for AI agent tool calling. It raised a $25M Series A from Lightspeed Ventures in July 2025 (total funding: ~$29M) and has roughly 28,700 GitHub stars. The pitch: give your AI agent access to 1,000+ pre-built, pre-authenticated SaaS toolkits (Gmail, Slack, GitHub, Notion, Jira, Salesforce, and hundreds more) without writing integration code.

The platform handles OAuth flows end-to-end, manages token refresh, and scopes credentials per user. It provides SDKs for 25+ frameworks (OpenAI, Anthropic, LangChain, CrewAI, Vercel AI SDK, and the [Mastra AI agent framework](/build-ai-agent-mastra/)), a Tool Router for intent-based discovery, a sandboxed workbench for remote Python/JS execution, and bidirectional triggers for real-time communication with connected apps.

Composio is SOC 2 Type II and ISO 27001 certified. If your team needs compliance credentials out of the box, this is a real advantage.

Under the hood, Composio is a managed platform. The backend that stores credentials and executes tool calls is proprietary. The open-source GitHub repository contains SDKs and CLI tooling only, not the runtime. Your credentials and execution data flow through Composio&apos;s cloud infrastructure on all self-serve plans.

## Why look for a Composio alternative?

Composio is not bad software. But it has structural limitations that push developers to look elsewhere:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Closed-source runtime. The GitHub repo contains SDKs only, not the platform that stores and executes tool calls&lt;/li&gt;
&lt;li&gt;Enterprise-only self-hosting. GitHub Issue #291 requesting self-serve self-hosting has been open since 2024 with no resolution&lt;/li&gt;
&lt;li&gt;Credentials pass through Composio&apos;s cloud on self-serve plans, a non-starter for teams with compliance requirements&lt;/li&gt;
&lt;li&gt;Per-call pricing scales poorly. $229/month for 2M calls, with $0.249 per additional 1K calls, adds up fast in multi-agent workflows&lt;/li&gt;
&lt;li&gt;Custom integrations are marked &quot;experimental.&quot; You cannot inspect, fork, or modify the integration runtime code&lt;/li&gt;
&lt;li&gt;Context window bloat. Loading thousands of tool definitions consumes significant tokens even with the Tool Router&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If any of these hit close to home, [open-source alternatives](/best-open-source-llms-claude-alternative/) deserve a serious look.

## What is Executor.sh?

Executor.sh is an open-source MCP gateway built by Rhys Sullivan, a former OpenCode engineer. It went through Y Combinator&apos;s S26 batch and has around 2,600 GitHub stars as of July 2026. The entire platform is MIT licensed.

The core idea: configure your integrations once (MCP servers, OpenAPI specs, GraphQL endpoints, custom JS tools), and every MCP-compatible agent shares them through one endpoint. Claude Code, Cursor, Codex, VS Code extensions, anything that speaks [MCP (Model Context Protocol)](/mcp-introduction-beginners/) works out of the box.

![Executor.sh architecture diagram: MCP clients connect through the Executor gateway to external APIs via SES sandbox with host-side credential injection](../../assets/images/26/07/executor-architecture.svg)

### Key differentiators

**Context efficiency.** Instead of showing an agent thousands of tool definitions (~278,800 tokens), Executor shows one tool (~1,044 tokens). Tool schemas load dynamically only when needed. This can cut API costs noticeably on long agent sessions.

**Protocol-level depth.** Native connectors for MCP, OpenAPI, GraphQL, and Google Discovery. Point Executor at any spec and it auto-indexes every endpoint as a typed tool.

**Semantic tool discovery.** Agents search for tools by intent rather than memorizing names:

```js
tools.discover({ query: &quot;send a slack message&quot; })
```

**SES sandbox.** Tool calls run in an isolated QuickJS sandbox. Credentials are injected host-side at call time and never enter the sandbox heap. This is a real security improvement over platforms where credentials live in the execution environment.

**Pause/resume.** Execution pauses for OAuth flows or human approval, then resumes cleanly. No hacky workarounds needed.

**Policy engine.** Each tool can be allowed, gated behind approval, or blocked. Policies are derived from spec semantics: `GET` vs `DELETE` for OpenAPI, `destructiveHint` for MCP.

**Five deployment options:** Cloud (hosted), Desktop app (Mac/Windows/Linux), CLI (`npm i -g executor`), Docker, and Cloudflare Workers.

&lt;Notice type=&quot;success&quot; title=&quot;Fully open source&quot;&gt;
Every deployment option, including Docker and CLI self-hosting, is MIT licensed. You can inspect, modify, and fork the entire codebase.
&lt;/Notice&gt;

You can add [MCP server integrations like BrightData](/brightdata-mcp-guide/) or any other MCP server to extend what your agents can do. And if you need to manage [essential Docker commands](/docker-commands/) for your deployment, we have a guide for that too.

## Executor.sh vs Composio: head-to-head comparison

| Dimension | Executor.sh | Composio |
|-----------|------------|----------|
| **License** | MIT (fully open source) | MIT (SDK only); runtime closed |
| **Self-hosting** | Docker / Cloudflare (free, self-serve) | Enterprise only (sales-gated) |
| **Architecture** | MCP gateway / integration proxy | Managed toolkit platform |
| **Tool sources** | MCP, OpenAPI, GraphQL, custom JS | 1,000+ pre-built SaaS toolkits |
| **Context efficiency** | Dynamic loading (1 tool shown) | Tool Router for catalog search |
| **Security model** | SES sandbox + host-side credential injection | Managed OAuth + sandboxed workbench |
| **Custom integrations** | Add any OpenAPI/GraphQL/MCP spec | Custom tools marked experimental |
| **Framework support** | Any MCP-compatible client | 25+ framework adapters |
| **Pricing model** | Per-execution + per-member | Per-tool-call |
| **Free tier** | 3 members, 10K execs/month | 20K tool calls/month |
| **Entry paid** | $150/org/month (unlimited members) | $29/month (200K calls) |
| **Stars** | ~2,600 | ~28,700 |
| **Maturity** | Feb 2026 (5 months old) | 2024+ (2+ years) |
| **SOC 2/ISO** | On request (Enterprise) | SOC 2 Type II, ISO 27001 |

The most important differences are architectural. Composio gives you a curated catalog of pre-built integrations: you pick what you need and the platform handles auth, execution, and monitoring. Executor gives you a protocol-level proxy: you bring your own specs and it indexes, sandboxes, and exposes them as MCP tools.

Composio wins on breadth of pre-built integrations and framework adapter coverage. Executor wins on openness, self-hosting, context efficiency, and security model (credentials never touching the sandbox heap).

### Executor.sh pricing vs Composio

At low volumes, Composio&apos;s $29/month plan is cheaper than Executor&apos;s $150/org/month Team plan. But the math flips as you scale:

&lt;Tabs&gt;
&lt;Tab name=&quot;Low usage (&lt; 200K calls)&quot;&gt;

| Platform | Plan | Monthly Cost |
|----------|------|-------------|
| Composio | Free | $0 (20K calls) |
| Executor | Free | $0 (10K execs, 3 members) |
| Composio | Starter | $29/mo (200K calls) |
| Executor | Team | $150/org/mo (250K execs) |

At low volumes, Composio is cheaper. If you are a solo developer with light tool-calling needs, Composio&apos;s free or $29/mo plan is hard to beat.

&lt;/Tab&gt;
&lt;Tab name=&quot;Scale (500K+ calls)&quot;&gt;

| Volume | Composio Cost | Executor Cloud | Executor Self-Hosted |
|--------|--------------|---------------|---------------------|
| 500K calls/mo | $229/mo | $150/org/mo | $0 (infra only) |
| 1M calls/mo | $229/mo + overages | $150/org/mo | $0 (infra only) |
| 2M calls/mo | $229/mo | $150/org/mo | $0 (infra only) |
| 5M calls/mo | Enterprise (custom) | Enterprise (custom) | $0 (infra only) |

Self-hosted Executor has zero per-call costs. You pay only for the server it runs on. At 2M calls/month, that is a $229+/month saving over Composio.

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Pricing changes&quot;&gt;
Both platforms may update their pricing. Check the [Executor pricing page](https://executor.sh/pricing) and [Composio pricing page](https://composio.dev/pricing) for current numbers.
&lt;/Notice&gt;

![Pricing comparison chart: Executor.sh vs Composio cost at different call volumes](../../assets/images/26/07/pricing-comparison.svg)

## Self-hosting Executor.sh with Docker

This is the section that matters most. If you are reading this article, you probably want to run this on your own infrastructure. The good news: it is genuinely simple.

### Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Docker installed on your machine or VPS&lt;/li&gt;
&lt;li&gt;A VPS or local machine with 1GB+ RAM&lt;/li&gt;
&lt;li&gt;A domain name (optional, for reverse proxy with HTTPS)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you need a VPS, [Hetzner Cloud](https://go.bitdoze.com/hetzner) offers affordable European servers starting at around €4.49/month, more than enough for running Docker containers like this. [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) is another budget-friendly option with NVMe SSD storage if you prefer a different provider.

For managing your Docker deployments, [Dokploy](/dokploy-install/) or [Coolify](/coolify-install-heroku-alternative/) give you a self-hosted PaaS experience with web dashboards and automatic HTTPS. You can also check our guide on [self-hosted backends with Docker](/convex-self-host/) for more patterns.

### Quick start with Docker run

One command gets you running:

```bash
docker run -d \
  --name executor-selfhost \
  -p 4788:4788 \
  -v executor-data:/data \
  ghcr.io/rhyssullivan/executor-selfhost:latest
```

That is it. Here is what each flag does:

- `-p 4788:4788`: exposes the Executor web console and MCP endpoint on port 4788
- `-v executor-data:/data`: persists all data (SQLite database, credentials, config) in a named Docker volume
- The image bundles everything: typed API, MCP server, authentication, QuickJS code execution, and web console

Open `http://localhost:4788` in your browser. The first account you create becomes the owner. All subsequent users join via single-use invite links.

For headless setups (CI/CD, automated provisioning), pass bootstrap credentials:

```bash
docker run -d \
  --name executor-selfhost \
  -p 4788:4788 \
  -v executor-data:/data \
  -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=admin@example.com \
  -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD=your-secure-password \
  -e EXECUTOR_WEB_BASE_URL=https://executor.yourdomain.com \
  ghcr.io/rhyssullivan/executor-selfhost:latest
```

### Docker Compose setup

For a more maintainable setup, use Docker Compose:

```yaml
services:
  executor:
    image: ghcr.io/rhyssullivan/executor-selfhost:latest
    container_name: executor-selfhost
    restart: unless-stopped
    ports:
      - &quot;4788:4788&quot;
    volumes:
      - executor-data:/data
    environment:
      - PORT=4788
      - EXECUTOR_DATA_DIR=/data
      # Optional: set your public URL
      # - EXECUTOR_WEB_BASE_URL=https://executor.yourdomain.com
      # Optional: headless bootstrap
      # - EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=admin@example.com
      # - EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD=changeme

volumes:
  executor-data:
```

Save this as `docker-compose.yml` and run:

```bash
docker compose up -d
```

Backing up is straightforward. Snapshot the `data.db` file inside the `/data` volume. That single SQLite database contains everything: config, credentials, tool definitions, and user accounts.

If you are looking for more [Docker-based AI deployments](/cognee-self-host/) to run alongside Executor, there are plenty of options.

### Reverse proxy with Caddy

For HTTPS on a custom domain, put Caddy in front:

```
executor.yourdomain.com {
    reverse_proxy localhost:4788
}
```

Caddy automatically provisions and renews TLS certificates. No config needed beyond the reverse proxy line.

### Deploying with Dokploy

If you want a web UI to manage your Executor.sh deployment instead of SSH-ing into a server and editing compose files manually, [Dokploy](https://dokploy.com/) is a solid option. It is an open-source, self-hostable PaaS that runs on top of Docker and Traefik. You get a dashboard for deployments, automatic HTTPS, monitoring, and database backups without touching the command line after initial setup.

If you do not have Dokploy installed yet, follow our [Dokploy installation guide](/dokploy-install/) to get it running on your VPS first.

**Step 1: Create a new project and compose service**

In the Dokploy dashboard, create a new project, then add a **Docker Compose** service. Paste the following compose file:

```yaml
services:
  executor:
    image: ghcr.io/rhyssullivan/executor-selfhost:latest
    container_name: executor-selfhost
    networks:
      - dokploy-network
    restart: unless-stopped
    volumes:
      - executor-data:/data
    environment:
      - PORT=4788
      - EXECUTOR_DATA_DIR=/data
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.executor.rule=Host(`executor.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.executor.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.executor.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.executor.loadbalancer.server.port=4788&quot;

volumes:
  executor-data:

networks:
  dokploy-network:
    external: true
```

Replace `executor.yourdomain.com` with your actual domain. The `dokploy-network` is created automatically when Dokploy is installed.

**Step 2: Set environment variables**

Switch to the **Environment** tab and add any bootstrap variables you need:

```
EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=admin@yourdomain.com
EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD=your-secure-password
EXECUTOR_WEB_BASE_URL=https://executor.yourdomain.com
```

**Step 3: Point your domain and deploy**

Add an A record in your DNS pointing `executor.yourdomain.com` to your server IP. Then go to the **Domains** tab in Dokploy, add the domain, and enable HTTPS. Hit **Deploy** and Dokploy handles the rest: pulling the image, starting the container, and configuring Traefik with a TLS certificate.

For a full walkthrough on deploying Docker Compose apps in Dokploy, including domain setup and Traefik label configuration, see our guide on [deploying a Docker Compose app in Dokploy](/dokploy-docker-compose-app/).

Once deployed, open `https://executor.yourdomain.com` in your browser. The first account you create becomes the owner, just like the standalone Docker setup.

Keeping the deployment up to date is simple. Dokploy lets you redeploy from the dashboard whenever a new image is pushed. For detailed update strategies including automated updates with Tugtainer, see our guide on [updating Docker Compose stacks in Dokploy](/dokploy-update-docker-compose/).

&lt;Notice type=&quot;info&quot; title=&quot;Dokploy advantages&quot;&gt;
Dokploy gives you a web dashboard to manage deployments, view logs, monitor resource usage, and configure automatic backups. If you are running multiple self-hosted services alongside Executor, it keeps everything in one place. See our [Dokploy backups with Cloudflare R2 guide](/dokploy-backups-cloudflare-r2/) for setting up automated backups.
&lt;/Notice&gt;

### Connecting AI agents

Once your instance is running, point any MCP client at it. For Claude Code:

```bash
claude mcp add executor --transport sse https://executor.yourdomain.com/mcp
```

Or use the generic `npx add-mcp` command:

```bash
npx add-mcp https://executor.yourdomain.com/mcp
```

For Cursor, add the MCP server URL in Settings → MCP Servers. Any client that supports the MCP protocol works. The self-hosted endpoint behaves identically to the cloud version.

&lt;Notice type=&quot;info&quot; title=&quot;First user becomes owner&quot;&gt;
The first account created on your self-hosted instance becomes the organization owner. Invite team members through single-use invite links from the admin panel. The self-hosted version supports unlimited members, with no per-seat charges.
&lt;/Notice&gt;

## Self-hosting Executor.sh on Cloudflare Workers

If you do not want to manage a VPS, Executor also deploys to Cloudflare Workers. The architecture: a single Cloudflare Worker + D1 storage + Cloudflare Access for authentication.

```bash
git clone https://github.com/UsefulSoftwareCo/executor.git
cd executor/apps/host-cloudflare
bun run deploy:setup
```

The MCP endpoint lives at `/mcp`, gated by Cloudflare Access. No separate login app needed. Cloudflare handles authentication.

&lt;Notice type=&quot;info&quot; title=&quot;Zero infrastructure cost&quot;&gt;
Cloudflare&apos;s free tier covers low-volume Executor deployments. No server to manage, no Docker to maintain. The trade-off is less control and a dependency on Cloudflare&apos;s infrastructure.
&lt;/Notice&gt;

This option works well for personal use or small teams. For production workloads where you need full control over the runtime environment, Docker is the better path.

## Adding integrations to your self-hosted Executor

Deploying Executor is step one. Connecting it to actual services is where the value comes from.

&lt;Tabs&gt;
&lt;Tab name=&quot;OpenAPI&quot;&gt;

Point Executor at any OpenAPI spec and it auto-indexes every endpoint as a typed tool:

```bash
executor call executor openapi addIntegration \
  --spec-url https://api.example.com/openapi.json \
  --name &quot;my-api&quot; \
  --base-url https://api.example.com
```

All `GET`, `POST`, `PUT`, `DELETE` endpoints become callable tools. The policy engine automatically gates destructive operations.

&lt;/Tab&gt;
&lt;Tab name=&quot;MCP Server&quot;&gt;

Add any MCP server to your Executor instance:

```bash
executor call executor mcp addServer \
  --name &quot;my-mcp-server&quot; \
  --command &quot;npx&quot; \
  --args &apos;[&quot;@my-org/mcp-server&quot;]&apos;
```

You can add [MCP server integrations like BrightData](/brightdata-mcp-guide/) for web data access, or any community MCP server from npm.

&lt;/Tab&gt;
&lt;Tab name=&quot;GraphQL&quot;&gt;

Connect a GraphQL endpoint and every query and mutation becomes a tool:

```bash
executor call executor graphql addIntegration \
  --endpoint https://api.example.com/graphql \
  --name &quot;my-graphql-api&quot; \
  --headers &apos;{&quot;Authorization&quot;: &quot;Bearer $TOKEN&quot;}&apos;
```

The schema introspection runs automatically, so no manual tool definitions needed.

&lt;/Tab&gt;
&lt;/Tabs&gt;

Semantic tool discovery means agents do not need to know tool names. They describe what they want to do and Executor finds the right tool:

```bash
executor tools search &quot;send email&quot;
```

If you are [creating your own AI agent](/create-your-own-ai-agent/) or building with the [Mastra framework](/build-ai-agent-mastra/), Executor gives you a single MCP endpoint that bundles all your integrations. For routing between multiple AI coding agents like Claude Code and Codex, [Agent Router](https://go.bitdoze.com/agentrouter) provides unified access alongside your Executor gateway. And for web data integrations, [Bright Data](https://go.bitdoze.com/brightdata) offers structured data APIs you can connect via MCP.

## When to choose Executor.sh (and when to stick with Composio)

This is not a &quot;Composio bad, Executor good&quot; article. Both platforms serve different needs. Here is an honest breakdown:

&lt;Tabs&gt;
&lt;Tab name=&quot;Choose Executor.sh&quot;&gt;

- You need self-hosting for compliance, cost, or data sovereignty reasons
- You want full source code access and the ability to modify the runtime
- You bring your own API specs (OpenAPI, GraphQL, MCP) rather than needing 1,000+ pre-built toolkits
- You want to minimize context window usage for your agents
- You are running multi-agent workflows at scale and per-call pricing is a concern
- You prefer the MCP protocol standard over framework-specific adapters

&lt;/Tab&gt;
&lt;Tab name=&quot;Choose Composio&quot;&gt;

- You need 1,000+ pre-built, pre-authenticated SaaS integrations out of the box
- You need managed OAuth handling for many third-party apps with zero config
- You require SOC 2 Type II / ISO 27001 compliance today (not on request)
- Your team uses LangChain, CrewAI, or other frameworks that need dedicated adapters
- You prefer a fully managed platform with no infrastructure to maintain
- You are a small team or solo developer who needs the cheapest entry point

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Honest trade-offs&quot;&gt;
Executor.sh is 5 months old (as of July 2026). It has a smaller community, fewer production deployments, and less battle-testing than Composio. The self-hosted Docker option is stable, but evaluate it for your specific workload before committing to production.
&lt;/Notice&gt;

For teams exploring self-hosted AI infrastructure, guides on [running AI agents on your own server](/hermes-agent-setup-guide/) can help you build the broader picture.

## Frequently asked questions

&lt;Accordion label=&quot;Is Executor.sh really fully open source?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Every deployment option (Cloud, Desktop, CLI, Docker, and Cloudflare Workers) is MIT licensed. The full source code is on [GitHub](https://github.com/UsefulSoftwareCo/executor). You can inspect, modify, and fork any part of it.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I migrate from Composio to Executor.sh?&quot; group=&quot;faq&quot;&gt;
There is no direct migration tool. The integration models are different: Composio uses pre-built SaaS toolkits, while Executor uses spec-based integrations (OpenAPI, GraphQL, MCP). You would bring your own API specs and configure them in Executor. If your workflows depend heavily on Composio&apos;s pre-built toolkits, migration takes more effort.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Executor.sh support MCP?&quot; group=&quot;faq&quot;&gt;
Yes. Executor is built as an MCP gateway. Any MCP-compatible client (Claude Code, Cursor, Codex, VS Code extensions) connects directly. See our [beginner&apos;s guide to MCP](/mcp-introduction-beginners/) if you are new to the protocol.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Executor.sh handle credentials and secrets?&quot; group=&quot;faq&quot;&gt;
Credentials are stored in the SQLite database (encrypted). The SES sandbox uses host-side injection: credentials are injected into tool calls at execution time and never enter the sandbox heap. This is a real security boundary. Even if a tool call is compromised, the sandbox cannot access raw credentials.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Executor.sh production-ready?&quot; group=&quot;faq&quot;&gt;
It is young but backed by Y Combinator S26. Self-hosted Docker deployments are stable for small-to-medium workloads. The core functionality (MCP gateway, OpenAPI/GraphQL indexing, SES sandbox) works well. For critical production systems, run a thorough evaluation first. The project&apos;s momentum (2,600 stars in 5 months) suggests active development and growing adoption.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I self-host Composio instead?&quot; group=&quot;faq&quot;&gt;
Only on Enterprise plans through a sales process. The GitHub repository contains only SDKs and CLI tooling, not the platform runtime. [GitHub Issue #291](https://github.com/ComposioHQ/composio/issues/291) requesting self-serve self-hosting has been open since July 2024 with no resolution.
&lt;/Accordion&gt;

## Is Executor.sh the right Composio alternative for you?

If you are a self-hosting enthusiast, an open-source advocate, or a cost-conscious developer running multi-agent workflows, Executor.sh is worth a serious look. It solves the core pain points that push developers away from Composio: closed runtime, no self-hosting, and per-call pricing that scales poorly.

The self-hosted Docker deployment takes under a minute. You get a fully MIT-licensed MCP gateway with dynamic tool loading, sandboxed execution, and credential isolation, running entirely on your infrastructure.

Composio remains the stronger choice if you need 1,000+ pre-built integrations and managed OAuth without touching infrastructure. That is a valid need, and Composio fills it well.

But if you want control, transparency, and zero per-call costs at scale, Executor.sh is the open-source alternative worth trying.

&lt;Button text=&quot;Try Executor.sh Self-Hosted&quot; link=&quot;https://executor.sh/docs/hosted/docker&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;View on GitHub&quot; link=&quot;https://github.com/UsefulSoftwareCo/executor&quot; variant=&quot;outline&quot; color=&quot;gray&quot; size=&quot;md&quot; /&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>mcp</category></item><item><title>Best Hermes Agent Dashboards &amp; Mobile Apps (2026 Guide)</title><link>https://www.bitdoze.com/best-hermes-dashboards/</link><guid isPermaLink="true">https://www.bitdoze.com/best-hermes-dashboards/</guid><description>Compare the best Hermes Agent dashboards, desktop apps, and mobile apps for Android, iOS, and messaging. Find the right UI for your workflow.</description><pubDate>Tue, 14 Jul 2026 10:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

Hermes Agent by Nous Research is the fastest-growing open-source AI agent on GitHub, with 214K+ stars. The core agent is CLI-first, running from your terminal with `hermes` or in a full TUI with `hermes --tui`. That&apos;s great for developers who live in the terminal. But you also need to check on your agent from a browser tab at work, a phone on the couch, or a tablet at a coffee shop.

The ecosystem has responded with dashboard web UIs, desktop apps, Android apps, iOS apps, and messaging integrations. There are now 10+ ways to interact with Hermes Agent outside the terminal. This guide compares every major option so you can pick the right one for your workflow.

&lt;Notice type=&quot;info&quot; title=&quot;New to Hermes Agent?&quot;&gt;
If you haven&apos;t installed Hermes yet, start with [our Hermes Agent setup guide](/hermes-agent-setup-guide/). It covers installation, model configuration, and first steps before you pick a dashboard.
&lt;/Notice&gt;

## Quick comparison: all Hermes Agent UIs at a glance

Here&apos;s the 30-second overview of every major Hermes Agent UI.

| Name | Type | Stars | Key Strength | Price | Best For |
|---|---|---|---|---|---|
| Official Dashboard | Web | Built-in | Full REST API, 150+ config fields | Free | Self-hosted management |
| Hermes WebUI (nesquena) | Web | 16K | Zero-dependency, lightweight | Free | Minimal resource usage |
| Hermes Studio (EKKOLearnAI) | Web + Desktop | 9.1K | Most features, group chat | Free | Power users, teams |
| Hermes Workspace | Web | 6.1K | IDE-like workspace + terminal | Free | Full agent ops in browser |
| Hermes One (fathah) | Desktop | 13.3K | 3D visual interface, 22 commands | Free | Cross-platform desktop |
| Hermes Control Interface | Web | 800 | RBAC, security-hardened admin | Free | Team access control |
| Scarf | macOS + iOS | 727 | Native Swift, SQLite direct | Free | macOS users |
| Hermes Agent Android | Mobile (Android) | — | Full runtime on-device | Freemium | Android power users |
| Hermes Android Client | Mobile (Android) | 88 | Lightweight Flutter client | Free | Remote server access |
| ScarfGo | Mobile (iOS) | — | SSH-based, TestFlight | Free | iOS + macOS users |
| Onepilot | Mobile (iOS) | — | Dev cockpit, terminal + git | Freemium | iOS developers |
| Telegram Gateway | Messaging | — | Zero install, instant | Free | Everyone |

![Hermes Agent UI ecosystem overview](../../assets/images/26/07/ecosystem-overview.svg)

The diagram is a high-level map of the main categories. The comparison table above includes additional options such as Hermes Workspace and Hermes Control Interface.

&lt;Button text=&quot;Jump to Dashboards&quot; link=&quot;#best-hermes-agent-web-dashboards&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;sm&quot; /&gt;
&lt;Button text=&quot;Jump to Mobile Apps&quot; link=&quot;#best-hermes-agent-mobile-apps-for-android&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;sm&quot; /&gt;

## Best Hermes Agent web dashboards

Web dashboards run in a browser and are ideal for remote management from any device. You can self-host them on a VPS, run them locally, or tunnel them through SSH. The main options below cover everything from the official built-in panel to full IDE-style workspaces.

### Official Hermes Web Dashboard

The built-in dashboard is part of the core Hermes package (ships with Hermes Agent v0.16+). Launch it with `hermes dashboard` and you get a FastAPI/Uvicorn backend with a React 19 + TypeScript + Tailwind frontend on port 9119.

**What you get:**

- **Status page:** live overview with auto-refresh every 5 seconds
- **Chat:** embedded PTY-backed TUI via WebSocket, full terminal experience in the browser
- **Config editor:** form-based YAML editor covering 150+ fields, no more manual file editing
- **Sessions:** browse, search (FTS5), export, and prune conversation history
- **Logs:** agent, gateway, and error logs with level/component filtering and live tail
- **Analytics:** 7/30/90-day token usage, cost tracking, per-model breakdown
- **Cron jobs:** schedule, pause/resume, trigger, and edit scheduled tasks
- **Skills &amp; MCP:** browse, search, toggle skills; add/test/enable/disable MCP servers
- **Profiles:** create, switch, clone, manage agent profiles
- **API Keys:** environment variable manager with redacted display values
- **Gateway health:** monitor the status of 20+ messaging platform connections

Auth options include OAuth via Nous Portal, username/password, and self-hosted OIDC. Six built-in themes are included, plus a custom theme system.

Prerequisites: install the web and PTY extras first.

```bash
pip install -e &quot;.[web,pty]&quot;
```

&lt;Tabs&gt;
&lt;Tab name=&quot;CLI command&quot;&gt;
```bash
# Default — opens browser, port 9119
hermes dashboard

# Custom port, server mode (no browser auto-open)
hermes dashboard --port 9120 --no-open

# Remote-accessible (pair with auth!)
hermes dashboard --host 0.0.0.0 --no-open
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;
```yaml
# docker-compose.yml
services:
  hermes:
    image: nousresearch/hermes-agent:latest
    ports:
      - &quot;9119:9119&quot;
    volumes:
      - ~/.hermes:/root/.hermes
    command: hermes dashboard --host 0.0.0.0
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Custom host/port&quot;&gt;
```bash
# Bind to all interfaces on a custom port
hermes dashboard --host 0.0.0.0 --port 8080 --no-open

# Use with a reverse proxy (Caddy, Nginx)
# See our Hermes Agent dashboard setup guide for
# SSH tunnel, Caddy, and Docker security setup
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

The official dashboard has a full REST API, so you can script any management operation programmatically. If you want the simplest setup with no extra dependencies, this is it. For full security configuration including SSH tunnels and reverse proxy setup, see our [Hermes Agent dashboard setup guide](/hermes-dashboard-guide/).

### Hermes WebUI by nesquena

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/nesquena/hermes-webui&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

The most popular community dashboard with 16K+ GitHub stars (MIT). Its defining trait: zero dependencies. No Node.js, no bundler, no framework. Just Python&apos;s standard-library HTTP server and vanilla JavaScript. Active as of July 2026 with 1,200+ releases.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Streaming chat via SSE with multi-provider model support&lt;/li&gt;
&lt;li&gt;Session management: pin, archive, projects, tags, search, export/import&lt;/li&gt;
&lt;li&gt;Mobile-responsive design (hamburger sidebar, 44px touch targets)&lt;/li&gt;
&lt;li&gt;Voice input via Web Speech API&lt;/li&gt;
&lt;li&gt;Workspace file browser with git detection&lt;/li&gt;
&lt;li&gt;Kanban board for task management&lt;/li&gt;
&lt;li&gt;CLI session bridge (import sessions from the terminal)&lt;/li&gt;
&lt;li&gt;6+ themes with a skin system&lt;/li&gt;
&lt;li&gt;Extension system for injecting custom scripts and styles&lt;/li&gt;
&lt;li&gt;Password, WebAuthn/passkeys, and OIDC authentication&lt;/li&gt;
&lt;li&gt;Docker and Nix support&lt;/li&gt;
&lt;li&gt;~11,500 tests across ~1,150 files&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Launch commands:

```bash
# Clone first, then bootstrap (recommended)
git clone https://github.com/nesquena/hermes-webui.git
cd hermes-webui
python3 bootstrap.py

# Or use the shell script after clone
./start.sh

# Docker
docker pull ghcr.io/nesquena/hermes-webui:latest
docker run -d -p 8787:8787 -v ~/.hermes:/home/hermeswebui/.hermes \
  ghcr.io/nesquena/hermes-webui:latest
# Opens http://localhost:8787
```

The mobile-responsive design makes this the best web dashboard for phone-based access — the hamburger sidebar and touch targets are designed for it. It also has a built-in Kanban board (see the [Hermes Kanban setup guide](/hermes-kanban-setup-guide/) for details) and web search capabilities that pair well with [TinyFish free search for AI coding agents](/tinyfish-free-search-coding-agents/).

If you want to extend Hermes with web search and page fetching for your agent, the [TinyFish web search API](https://go.bitdoze.com/tinyfish) integrates cleanly. It gives your agent 30 search/min and 150 fetch/min for free, which complements the WebUI&apos;s built-in extraction capabilities.

**Best for:** Users who want a lightweight, zero-dependency web UI that works on mobile browsers out of the box.

### Hermes Workspace

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/outsourc-e/hermes-workspace&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

Hermes Workspace is the IDE-style option: chat, embedded terminal, file browser, memory viewer, skills hub, MCP management, and multi-agent orchestration in one React/TypeScript UI. 6.1K stars, MIT licensed.

The Conductor / Swarm features are unique here. You describe a complex task, and the workspace breaks it into subtasks, dispatches specialized agents, and merges results. No other dashboard on this list ships that orchestration UI as a first-class surface.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Embedded web terminal (xterm.js) — full shell without a separate SSH session&lt;/li&gt;
&lt;li&gt;File/workspace browser with edit support&lt;/li&gt;
&lt;li&gt;Conductor / Swarm multi-agent task decomposition&lt;/li&gt;
&lt;li&gt;Skills hub and full MCP catalog/marketplace&lt;/li&gt;
&lt;li&gt;Mobile PWA + Tailscale-friendly remote access&lt;/li&gt;
&lt;li&gt;Multiple themes (Hermes, Nous, Bronze, Slate, Mono)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
# Docker Compose (recommended)
git clone https://github.com/outsourc-e/hermes-workspace.git
cd hermes-workspace
docker compose up -d
# Workspace on port 3000, Hermes gateway on 8642
```

**Best for:** Users who want a full agent operations console (terminal + files + multi-agent), not only chat.

### Hermes Studio by EKKOLearnAI

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/EKKOLearnAI/hermes-studio&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; /&gt;

The most feature-rich option. 9.1K stars. Built with Vue 3 + TypeScript + Vite + Naive UI, with a Koa backend and Socket.IO for real-time chat. The repo was renamed from `hermes-web-ui` to `hermes-studio`; the npm package is still `hermes-web-ui`.

**What sets it apart:**

- **Platform channels:** configure 8 platforms (Telegram, Discord, Slack, WhatsApp, Matrix, Feishu, WeChat, WeCom) from the UI
- **Group chat:** multi-agent rooms with @mention routing between agents
- **Coding agent integrations:** Codex and Claude Code integration built in
- **Web terminal:** node-pty + xterm.js, full terminal in the browser
- **Voice/TTS/STT:** browser Web Speech, Edge TTS, OpenAI-compatible, MiMo
- **File browser:** supports local, Docker, SSH, and Singularity backends
- **Model management:** auto-discover providers, add/update/delete models
- **Multi-profile:** clone, export, import profiles
- **Usage analytics:** token usage, cost, model distribution charts
- **Kanban board:** profile-aware task management (see [Hermes Kanban setup guide](/hermes-kanban-setup-guide/))

Launch commands:

```bash
# npm global install
npm install -g hermes-web-ui &amp;&amp; hermes-web-ui start   # Port 8648

# Docker
WEBUI_IMAGE=ekkoye8888/hermes-web-ui docker compose up -d   # Port 6060
```

&lt;Notice type=&quot;warning&quot; title=&quot;License Note&quot;&gt;
Hermes Studio uses the BSL-1.1 license, not MIT. For personal use this is fine. For commercial use, review the license terms. BSL-1.1 typically converts to MIT after a set period (often 2-4 years), but the commercial use restrictions apply during the BSL period.
&lt;/Notice&gt;

**Best for:** Power users and teams who want the richest feature set, especially multi-agent rooms and platform channel management.

### Hermes Control Interface (HCI)

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/xaspx/hermes-control-interface&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

HCI is the security-focused admin panel. 800 stars, MIT, vanilla JS + Vite + Express (minimal frontend framework surface). It prioritizes RBAC, CSRF, rate limiting, and auditability over visual polish.

**What you get:**

- Password gate with bcrypt hashing, CSRF on mutating endpoints, rate limiting
- ~20 permissions across admin / viewer / custom roles
- Browser terminal (xterm.js), file explorer, sessions, cron, system metrics
- Multi-agent / Office swarm monitor with kanban-style task views
- PWA installable to the homescreen

```bash
git clone https://github.com/xaspx/hermes-control-interface.git
cd hermes-control-interface
cp .env.example .env   # set HERMES_CONTROL_PASSWORD + HERMES_CONTROL_SECRET
npm install &amp;&amp; npm run build
node server.js         # http://localhost:10274
```

Requires Node.js 20+ and the `hermes` CLI on PATH on the same machine.

**Best for:** Teams that need granular access control and a hardened admin surface more than a pretty chat UI.

### Scarf dashboard — macOS native

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/awizemann/scarf&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; /&gt;

A native macOS app (Swift/SwiftUI) that reads Hermes SQLite directly for real-time data. 727 stars, MIT licensed, requires macOS 14.6+ (Sonoma). Latest releases ship as notarized universal binaries from [GitHub Releases](https://github.com/awizemann/scarf/releases) (not the App Store, because Scarf needs non-sandboxed access to `~/.hermes/` and the `hermes` binary).

**Features:** Dashboard, analytics, sessions browser, activity feed, live chat (Rich ACP or terminal), memory editor, skills browser, platforms GUI, personalities, cron manager, health monitoring, gateway control, and custom project dashboards that agents can auto-generate via JSON widgets.

Multi-window support lets you monitor multiple Hermes servers simultaneously (local + remote over SSH). A menu bar status icon keeps agent health visible at all times. ScarfGo is the iOS companion (covered in the mobile section).

**Best for:** macOS users who want a native app experience instead of a browser tab. Not a web dashboard in the traditional sense. It&apos;s a proper macOS application that happens to do everything a dashboard does.

## Best Hermes Agent desktop apps

Desktop apps offer tighter OS integration than browser dashboards. They have system tray icons, native notifications, keyboard shortcuts, and offline access to cached data. Some tools (Hermes Studio, Scarf) appear in both the web dashboard and desktop sections because they ship as both.

### Official Hermes Desktop

The official Electron-based desktop app, available since Hermes v0.16. Launch with `hermes desktop`. It&apos;s built into the core package.

&lt;YouTubeEmbed url=&quot;https://www.youtube.com/embed/YBp_PXBbe80&quot; label=&quot;Hermes Agent Desktop App walkthrough&quot; /&gt;

It tracks Hermes releases closely, so you always get the latest features. Documentation is at [hermes-agent.nousresearch.com/docs/user-guide/desktop](https://hermes-agent.nousresearch.com/docs/user-guide/desktop).

**Best for:** Users who want the official, maintained-by-Nous-Research desktop experience.

### Hermes One by fathah

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/fathah/hermes-desktop&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

The most popular community desktop app, with 13.3K stars, MIT licensed, runs on Windows, macOS, and Linux. Repo: `fathah/hermes-desktop`. Product site: [hermesone.org](https://hermesone.org/). Not affiliated with Nous Research.

&lt;Notice type=&quot;info&quot; title=&quot;Not affiliated with Nous Research&quot;&gt;
Hermes One is community-maintained. It uses the official Hermes install script under the hood but is independently developed by fathah.
&lt;/Notice&gt;

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Guided first-run install with progress tracking&lt;/li&gt;
&lt;li&gt;Local or remote backend support (API URL + API key)&lt;/li&gt;
&lt;li&gt;Streaming chat via SSE with tool progress and markdown rendering&lt;/li&gt;
&lt;li&gt;Token usage tracking with live cost estimates&lt;/li&gt;
&lt;li&gt;22 slash commands for quick actions&lt;/li&gt;
&lt;li&gt;Session management with FTS5 full-text search&lt;/li&gt;
&lt;li&gt;14 toolsets with profile switching&lt;/li&gt;
&lt;li&gt;Memory system editor and persona (SOUL.md) editor&lt;/li&gt;
&lt;li&gt;Cron job builder with 15 delivery targets&lt;/li&gt;
&lt;li&gt;16 messaging gateways&lt;/li&gt;
&lt;li&gt;Hermes Office (Claw3d): a 3D visual interface&lt;/li&gt;
&lt;li&gt;Backup/import/debug dump&lt;/li&gt;
&lt;li&gt;Secrets provider (KeePassXC, 1Password, Bitwarden, GnuPG, pass)&lt;/li&gt;
&lt;li&gt;Auto-updater via electron-updater&lt;/li&gt;
&lt;li&gt;Supports local providers: LM Studio, Ollama, vLLM, llama.cpp&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Best for:** Cross-platform desktop users who want the richest standalone desktop experience with local model support.

### Hermes Studio desktop (Electron)

Hermes Studio also ships as an Electron desktop app. It stores Hermes Agent data in the native location (`~/.hermes`) and provides managed command shims: `hermes-studio cli`, `hermes-studio web`, and `hermes-studio-mcp`.

Auto-update works through Cloudflare download endpoints with GitHub fallback. Cross-reference [Hermes Studio by EKKOLearnAI](#hermes-studio-by-ekkolearnai) for the full feature list. The desktop version has the same capabilities as the web dashboard.

**Best for:** Users who want Hermes Studio&apos;s feature set in a standalone desktop window.

## Best Hermes Agent mobile apps for Android

Android&apos;s openness makes it the richer mobile platform for Hermes. There are two types of apps: full Hermes runtimes that execute the agent directly on the device, and lightweight clients that connect to a remote Hermes instance over the network.

### Hermes Agent Android (Google Play)

By Hen Works. [Google Play listing](https://play.google.com/store/apps/details?id=com.hermesagent.android): 4.5 stars with 1.98K reviews, 10K+ downloads. Free with an in-app purchase (Hermes Pro) to remove ads.

&lt;Notice type=&quot;info&quot; title=&quot;Full runtime, not a client&quot;&gt;
This app runs the complete Hermes Agent on your Android device. It&apos;s not connecting to a server — it IS the server. You need your own API key and about 200MB of storage for the initial setup.
&lt;/Notice&gt;

**What it does:**

- Multi-model support — OpenAI, Anthropic, Google, OpenRouter, LiteRT for local models
- Built-in Linux terminal (bash, Python, git)
- Code execution directly on the device
- Multi-platform gateway (Telegram, Slack, Discord)
- Web search and page extraction
- AI image generation via Fal.ai
- Text-to-speech via Edge TTS
- Memory system across conversations
- Session management with resume
- Dashboard web UI for monitoring
- Chat Skin option (Labs) — terminal UI or chat bubbles with tool cards and photo attachments

**Best for:** Android users who want the full Hermes experience without a separate server.

### Hermes Android Client by rusty4444

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/rusty4444/hermes-android&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

A lightweight Flutter/Dart client (88 stars, MIT, v1.0.10+) that connects to a remote Hermes instance via the Gateway API (port 8642) and dashboard API (port 9119). Download APKs from [GitHub Releases](https://github.com/rusty4444/hermes-android/releases).

```
Android app (Flutter)
├─ Gateway API Server, port 8642
│  ├─ GET /api/sessions
│  ├─ GET /api/sessions/{id}/messages
│  └─ POST /v1/chat/completions (SSE streaming)
└─ Hermes dashboard, port 9119
   ├─ /api/memory
   ├─ /api/cron/jobs
   ├─ /api/skills
   └─ /api/model/*
```

Features include voice chat (mic dictation + TTS replies), streaming SSE responses, theme toggle (Dark/Light/System), full CRUD cron management, skills browser, memory viewer, Tailscale support for remote access, and HTTPS connections.

It also handles password-protected dashboards (basic-auth login flow) and reverse-proxy path prefixes.

**Best for:** Users who already have Hermes running on a server or desktop and want a lightweight Android client.

### Long-running tasks and push notifications

There is no widely adopted standalone &quot;Hermes Dispatch&quot; app with a public install path that matches the major dashboards above. For background work and phone notifications, use the official stack instead:

- **Hermes Kanban / async subagents** on the server for long-running jobs that survive phone disconnects (see the [Hermes Kanban setup guide](/hermes-kanban-setup-guide/))
- **ntfy** as a Hermes messaging channel for push alerts to Android/iOS ([official ntfy docs](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/ntfy))
- **Telegram / Discord gateway** for fire-and-forget task messages without installing another client

If you need the agent to control an Android device (ADB-style remote control), look at community bridge projects such as [raulvidis/hermes-android](https://github.com/raulvidis/hermes-android) — that is a device-control bridge, not a chat dashboard.

**Best for:** Server-side long jobs with phone alerts, without depending on a niche mobile-only product.

## Best Hermes Agent mobile apps for iOS

&lt;Notice type=&quot;warning&quot; title=&quot;iOS limitation&quot;&gt;
iOS sandboxing prevents running Hermes Agent directly on an iPhone or iPad. All iOS apps are clients that connect to a remote Hermes instance via SSH or API. You need Hermes running somewhere else first. If you need a free API key to get started, see how to set up [Hermes Agent with free models on Nous Portal](/hermes-agent-mimo-v2-pro/).
&lt;/Notice&gt;

### ScarfGo

The native iOS companion to the Scarf macOS app ([awizemann/scarf](https://github.com/awizemann/scarf)). Available via TestFlight, requires iOS 18.0+. Uses SSH for connectivity via the Citadel library — no ssh binary needed on the device.

**Features:**

- Ed25519 keypair stored in iOS Keychain
- Multi-server support
- Project-scoped chat
- Session resume
- Memory editor, cron list, skills tree
- Profile switching (ScarfGo v2.13+)
- Voice chat support
- Per-server connection pooling

**Best for:** Users who already run Scarf on macOS and want the same experience on iPhone. The macOS app and iOS companion are designed to work together.

### Onepilot

A native iOS app on the App Store (freemium): [Onepilot — AI agents &amp; SSH](https://apps.apple.com/am/app/onepilot-ai-agents-ssh/id6759485908). Supports iPhone and iPad. Docs: [onepilotapp.com/agents/hermes](https://onepilotapp.com/agents/hermes). This is the closest thing to a full development cockpit on iOS.

**What it does:**

- Deploys Hermes over SSH with a guided wizard
- Real terminal (not just a chat interface)
- Syntax-highlighted file browser
- Git tab with diffs
- Cron management
- Also supports OpenClaw, Claude Code, and Codex CLI
- Wires Telegram/Discord/Slack during deployment

As the developers put it: &quot;There is no official Hermes app&quot; — Onepilot positions itself as the next best thing. The SSH tunnel gives you full host access (files, shell, git), making it more than just a chat client.

**Best for:** iOS users who want a full development environment on their phone or tablet, not just a chat interface.

### Hermes AI: Personal Agent

An independent iOS app by Ilya Vishneuski ([App Store](https://apps.apple.com/us/app/hermes-ai-personal-agent/id6759341434)). Requires iOS 17.0+ (also listed for Mac with Apple silicon and visionOS). 11.9 MB download. Not affiliated with Nous Research.

**Features:** Real-time chat, task tracking and output review, approve/reject sensitive actions, secure session management across devices. Offers a managed agent subscription option for users who do not want to self-host.

&lt;Notice type=&quot;warning&quot; title=&quot;Premium pricing&quot;&gt;
App Store in-app purchases currently list Premium at about $19.99 and $199.99 (tiers can change). Free tier is limited. Compare that cost carefully against free self-hosted clients like ScarfGo or Hermes Mobile before committing.
&lt;/Notice&gt;

**Best for:** Users who want a managed, subscription-style Hermes experience on iOS rather than connecting to their own server.

### Hermes Mobile by uzairansar

A native iOS client in TestFlight beta ([uzairansar.com/hermes-mobile](https://www.uzairansar.com/hermes-mobile)), built with SwiftUI. Connects to a self-hosted Hermes WebUI instance — it does not provide a hosted backend.

**Features:**

- Reopen sessions from iPhone
- Stream responses live
- Attach photos, files, and share-sheet content
- Choose model, reasoning level, workspace, and run options
- Browse project files
- View tasks, skills, memory, and usage stats

**Best for:** Users who already self-host Hermes WebUI and want a native iOS client to interact with it.

## Mobile access via messaging channels

The simplest way to get Hermes on your phone: use the built-in messaging gateway. No extra apps to install — just chat with your agent through Telegram, Discord, WhatsApp, Signal, or Slack.

&lt;Notice type=&quot;info&quot; title=&quot;Fastest path to mobile access&quot;&gt;
Telegram is the quickest way to get Hermes on your phone. Setup takes under 5 minutes and you get cross-device sync, voice memos, and the same chat app you probably already have open.
&lt;/Notice&gt;

| Channel | Setup | Pros | Cons |
|---|---|---|---|
| Telegram | Low | Fastest path, voice memos, cross-device sync | Single session view, message queuing delay |
| Discord | Low | Reactions, threads, channel per topic | More setup than Telegram |
| WhatsApp | Medium | Everyone has it | Requires phone number and pairing |
| Signal | Medium | Privacy-focused | Less feature-rich |
| Slack | Medium | Team-friendly, channels | Requires workspace |

Setup is straightforward:

```bash
# Configure the messaging gateway
hermes gateway setup
# Follow prompts for your platform (e.g., Telegram bot token)

# Start the gateway
hermes gateway start
```

**Telegram limitations** worth knowing (from community feedback): you only get a single session view, there&apos;s a message queuing delay, no visible token count or model info, and context window resets are not communicated. For casual use it&apos;s great. For heavy development work, use a proper dashboard or desktop app.

## How to choose the right Hermes Agent UI

| Your situation | Recommended tool | Why |
|---|---|---|
| macOS user who wants a native app | Scarf | Native Swift, reads SQLite directly, menu bar icon |
| Cross-platform desktop user | Hermes One or Hermes Studio | Rich features, Windows/macOS/Linux, MIT or BSL |
| Browser-first, self-hosted, simple | Official Dashboard | Built-in, zero setup beyond `hermes dashboard` |
| Browser-first, minimal resources | Hermes WebUI | No build step, vanilla JS, mobile-responsive |
| Browser-first, max features | Hermes Studio | Group chat, multi-agent, 8 platform configs |
| Browser-first, terminal + files + multi-agent | Hermes Workspace | IDE-like workspace, Conductor/Swarm |
| Security / team RBAC first | Hermes Control Interface | CSRF, bcrypt, granular permissions |
| Android user, want on-device agent | Hermes Agent Android | Full runtime, no server needed |
| Android user, connect to server | Hermes Android Client | Lightweight, Tailscale support |
| iOS user, want dev cockpit | Onepilot | Terminal, file browser, git, SSH |
| iOS user, want chat only | ScarfGo or Hermes Mobile | SSH or WebUI client |
| Just want mobile chat, no install | Telegram/Discord gateway | Zero install, works now |
| Team use | Hermes Studio or HCI | Group chat / multi-agent rooms, or RBAC admin |
| Budget-conscious | Official Dashboard + Telegram | Both free, both built-in |

Ready to get started? Follow [our Hermes Agent setup guide](/hermes-agent-setup-guide/) to install Hermes and configure your first model. For the [cheapest AI models for Hermes Agent](/best-cheap-models-hermes-agent/), we have a separate comparison of budget-friendly API providers.

&lt;Button text=&quot;Get started with Hermes Agent&quot; link=&quot;/hermes-agent-setup-guide/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Security considerations for remote access

Any dashboard or mobile app exposed to the network needs proper security. This is non-negotiable — a Hermes dashboard without auth gives anyone full control of your agent, your API keys, and potentially your server.

&lt;Notice type=&quot;error&quot; title=&quot;Never expose without auth&quot;&gt;
Running `hermes dashboard --host 0.0.0.0` without enabling authentication is an open backdoor. Always pair public-facing dashboards with auth and HTTPS.
&lt;/Notice&gt;

### SSH tunnels (easiest)

The simplest secure approach — tunnel the dashboard port through SSH:

```bash
# Tunnel WebUI (port 8787)
ssh -N -L 8787:127.0.0.1:8787 user@your-server

# Tunnel Official Dashboard (port 9119)
ssh -L 9119:localhost:9119 user@your-vps

# Then open http://localhost:8787 or http://localhost:9119 locally
```

### Tailscale (most secure for regular use)

Install Tailscale on your server and phone/laptop. Access the dashboard via the Tailscale IP. No ports exposed to the public internet. The Hermes Android Client has built-in Tailscale support.

### Password protection

```bash
# Password protect the official dashboard
cat &gt;&gt; ~/.hermes/.env &lt;&lt;&apos;EOF&apos;
HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=your-strong-password
HERMES_DASHBOARD_BASIC_AUTH_SECRET=$(openssl rand -base64 32)
EOF
chmod 600 ~/.hermes/.env
hermes dashboard --host 0.0.0.0 --no-open
```

### Auth support by dashboard

| Dashboard | Password | OIDC | WebAuthn/Passkeys |
|---|---|---|---|
| Official Dashboard | Yes | Yes (self-hosted) | No |
| Hermes WebUI | Yes | Yes | Yes |
| Hermes Studio | Yes | No | No |
| Hermes Workspace | Yes | Varies by deploy | No |
| Hermes Control Interface | Yes (bcrypt + RBAC) | No | No |
| Scarf | macOS Keychain | No | No |

For full security setup including Caddy reverse proxy configuration and Docker networking, see our [Hermes Agent dashboard setup guide](/hermes-dashboard-guide/).

## FAQ

&lt;Accordion label=&quot;Is there an official Hermes Agent mobile app?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. There is no official mobile app from Nous Research. The entire mobile ecosystem is community-built. Official channels are CLI, the web dashboard (`hermes dashboard`), and the desktop app (`hermes desktop`). All Android and iOS apps listed in this guide are third-party projects.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run Hermes Agent on my phone?&quot; group=&quot;faq&quot;&gt;
On Android, yes — Hermes Agent Android (Google Play) runs the full Hermes runtime directly on the device. You need your own API key and about 200MB of storage. On iOS, no — iOS sandboxing prevents running Hermes directly. All iOS apps connect to a remote Hermes instance via SSH or API.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the best free Hermes dashboard?&quot; group=&quot;faq&quot;&gt;
The official built-in dashboard (`hermes dashboard`) and Hermes WebUI by nesquena are both free and excellent. The official dashboard is the simplest to set up — it&apos;s already included with Hermes. Hermes WebUI is lighter on resources (no build step, no framework) and has better mobile responsiveness. If you also need a free API key, see how to set up [Hermes Agent with free models on Nous Portal](/hermes-agent-mimo-v2-pro/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I access my Hermes dashboard remotely?&quot; group=&quot;faq&quot;&gt;
Three options, from easiest to most robust: (1) SSH tunnel — `ssh -L 9119:localhost:9119 user@your-vps` then open localhost:9119. (2) Tailscale — install on both devices, access via Tailscale IP. (3) Reverse proxy with auth — Caddy or Nginx with HTTPS and basic auth or OIDC. Never expose a dashboard publicly without authentication.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which Hermes dashboard uses the least resources?&quot; group=&quot;faq&quot;&gt;
Hermes WebUI by nesquena. It uses Python&apos;s standard-library HTTP server and vanilla JavaScript — no Node.js, no bundler, no framework. The startup is fast and memory usage is minimal compared to the React/Vue-based alternatives.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use multiple dashboards at the same time?&quot; group=&quot;faq&quot;&gt;
Yes. Dashboards are independent web applications running on different ports. You can run the official dashboard on port 9119, Hermes WebUI on 8787, Hermes Studio on 8648, Hermes Workspace on 3000, and HCI on 10274 simultaneously. They all read from the same Hermes data directory, so sessions, config, and profiles are shared.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Hermes Studio&apos;s BSL-1.1 license a problem?&quot; group=&quot;faq&quot;&gt;
For personal use, no. For commercial use, review the license terms. BSL-1.1 (Business Source License) typically restricts commercial use for a set period (often 2–4 years), after which it converts to MIT. If you need a fully MIT-licensed dashboard for commercial use, choose the official dashboard or Hermes WebUI instead.
&lt;/Accordion&gt;

## Conclusion

There&apos;s no single &quot;best&quot; Hermes Agent UI — the right choice depends on your device, workflow, and how much control you need. Here&apos;s the short version:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;The official dashboard is the safest starting point — free, built-in, well-maintained&lt;/li&gt;
&lt;li&gt;Hermes WebUI is the lightest web option with excellent mobile responsiveness (16K stars)&lt;/li&gt;
&lt;li&gt;Hermes Studio packs the most features, including group chat and multi-agent rooms (9.1K)&lt;/li&gt;
&lt;li&gt;Hermes Workspace is the best full workspace (terminal + files + Conductor/Swarm, 6.1K)&lt;/li&gt;
&lt;li&gt;Hermes Control Interface is the pick when RBAC and hardened admin matter most&lt;/li&gt;
&lt;li&gt;For macOS, Scarf is the polished native experience; Hermes One wins for cross-platform desktop&lt;/li&gt;
&lt;li&gt;For Android, Hermes Agent Android gives you the full runtime on-device; rusty4444&apos;s client is best for remote servers&lt;/li&gt;
&lt;li&gt;For iOS, Onepilot is the most capable dev cockpit; ScarfGo and Hermes Mobile are lighter chat clients&lt;/li&gt;
&lt;li&gt;Telegram gateway is the fastest path to mobile access — no extra app needed&lt;/li&gt;
&lt;li&gt;Security is not optional — always use auth, SSH tunnels, or Tailscale for remote access&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Pick one option, set it up, and iterate. Most of these tools are free or have free tiers, so there&apos;s no cost to experimenting.

To get Hermes installed and running, start with [our Hermes Agent setup guide](/hermes-agent-setup-guide/). For the built-in dashboard security setup, see the [Hermes dashboard guide](/hermes-dashboard-guide/). For free models, check the [MIMO V2 Pro guide](/hermes-agent-mimo-v2-pro/), and for budget paid models see [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/).

&lt;Button text=&quot;Set up Hermes Agent now&quot; link=&quot;/hermes-agent-setup-guide/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>hermes</category><category>self-hosted</category></item><item><title>Plausible Analytics for Astro with Cloudflare Workers</title><link>https://www.bitdoze.com/astro-plausible-cloudflare-workers/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-plausible-cloudflare-workers/</guid><description>Proxy Plausible Analytics through Cloudflare Workers on your Astro site. Free 100K requests/day setup that bypasses ad blockers for accurate, real visitor stats.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import imag1 from &quot;../../assets/images/23/12/create-worker.png&quot;;
import imag2 from &quot;../../assets/images/23/12/deploy-worker.png&quot;;
import imag3 from &quot;../../assets/images/23/12/cf-worker-edit-code.png&quot;;
import imag4 from &quot;../../assets/images/23/12/worker-code.png&quot;;
import imag5 from &quot;../../assets/images/23/12/worker-routes.png&quot;;

import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Plausible Analytics](https://plausible.io/) is a privacy-first, open-source alternative to Google Analytics. It&apos;s lightweight (under 1KB script), uses no cookies, and tracks no personal data. With over 19,000 paying subscribers and 260 billion pageviews tracked, it&apos;s the most popular privacy-focused analytics tool among developers.

You can self-host Plausible on your own servers. Check [Install Plausible With One Click](https://www.bitdoze.com/install-plausible-analytics/) for a quick setup, or use one of the [best self-hosted server panels](https://www.bitdoze.com/best-self-hosted-panels/) for easy deployment.

[Astro](https://astro.build/) is a web framework built for speed, and it now runs natively on Cloudflare. After [Cloudflare acquired Astro in January 2026](https://blog.cloudflare.com/astro-joins-cloudflare/), the `@astrojs/cloudflare` adapter dropped Pages support entirely. v13+ deploys exclusively to Cloudflare Workers. You can [deploy your Astro site on Cloudflare](https://www.bitdoze.com/deploy-astrojs-cloudflare/) or [build a free blog with Astro &amp; Cloudflare](https://www.bitdoze.com/build-astro-blog-free/) to get started. For a deeper look at framework choices, see the [Astro vs Next.js vs TanStack Start comparison](https://www.bitdoze.com/astro-vs-nextjs-vs-tanstack-start-which-wins/).

In this guide, you&apos;ll set up Plausible Analytics for your Astro site using Cloudflare Workers as a proxy. The result: accurate analytics that bypass ad blockers, served from your own domain, on a free plan.

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;Track real visitor data without ad-blocker interference&lt;/li&gt;
    &lt;li&gt;Proxy Plausible scripts through your own domain via Cloudflare Workers&lt;/li&gt;
    &lt;li&gt;Free setup (100,000 requests/day on Cloudflare&apos;s free plan)&lt;/li&gt;
    &lt;li&gt;Works with Plausible Cloud or self-hosted Plausible Community Edition&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

## Why proxy Plausible Analytics through Cloudflare Workers?

Ad blockers and privacy-focused browsers routinely block requests to `plausible.io`. This can cause you to lose 20-30% of your analytics data. If you&apos;re using tools like [NextDNS](https://go.bitdoze.com/nextdns) or browser-level ad blockers on your own devices, you already know how aggressively they block analytics scripts.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EJtSVbqyXWU&quot;
  label=&quot;Proxying Plausible Through Cloudflare Workers&quot;
/&gt;
A proxy solves this by routing Plausible requests through your own domain. To your visitors&apos; browsers, the analytics script looks like part of your site, not a third-party tracker. Ad blockers can&apos;t distinguish it from regular site traffic.

&lt;Notice type=&quot;info&quot; title=&quot;Works with any static site&quot;&gt;
This proxy approach works with any static site generator or framework, not just Astro. Hugo, Next.js, Eleventy, plain HTML. If you can add a script tag to your head, you can use this setup. The Cloudflare Worker handles the proxying regardless of your build tool.
&lt;/Notice&gt;

For more on blocking ads and trackers at the DNS level, see [how to block ads and tracking with DNS protection](https://www.bitdoze.com/block-ads-malware-dns-protection/).

## Why use Cloudflare Workers instead of redirects?

If you&apos;re deploying to Cloudflare Pages, you might think you can use the `_redirects` file to proxy Plausible requests. You can&apos;t. Cloudflare Pages only supports relative-path redirects. It cannot proxy to external URLs.

Attempting to add a proxy redirect like this will fail:

```sh
Found invalid redirect lines:
09:59:12.187	  - #1: /js/script.js https://domain.com/js/plausible.outbound-links.js 200
09:59:12.187	    Proxy (200) redirects can only point to relative paths. Got https://domain.com/js/plausible.outbound-links.js
09:59:12.187	  - #2: /api/event https://domain.com/api/event 200
09:59:12.187	    Proxy (200) redirects can only point to relative paths. Got https://domain.com/api/event
```

Cloudflare Workers don&apos;t have this limitation. They can fetch from any external URL and return the response, making them the right tool for proxying Plausible on Cloudflare.

## Proxying Plausible through Cloudflare Workers (step by step)

Cloudflare Workers offers a free plan with 100,000 requests per day. That&apos;s more than enough for most sites. All you need is a free Cloudflare account.

&lt;Notice type=&quot;success&quot; title=&quot;Free tier is enough&quot;&gt;
Cloudflare Workers free plan includes 100,000 requests/day. A typical site loading the Plausible script once per pageview will stay well within this limit.
&lt;/Notice&gt;

### Step 1: Create a Worker

Go to the **Workers &amp; Pages** section in your Cloudflare dashboard and click **Create application**. Then click **Create Worker** in the Workers tab. Give your worker a name that doesn&apos;t suggest analytics. Something like `theme-assets` or `cdn-helper` works well. Click **Deploy**.

**Create Cloudflare Worker:**

&lt;Picture
  src={imag1}
  alt=&quot;Create Cloudflare Worker&quot;
/&gt;

**Deploy Cloudflare Worker:**

&lt;Picture
  src={imag2}
  alt=&quot;Deploy Cloudflare Worker&quot;
/&gt;

### Step 2: Add the Worker code (ES Modules)

Click **Edit Code**, delete the default code, and replace it with the code below.

&lt;Notice type=&quot;info&quot; title=&quot;Service Worker syntax is deprecated&quot;&gt;
The old `addEventListener(&apos;fetch&apos;, ...)` syntax is deprecated by Cloudflare. The code below uses the recommended ES Modules format. If you&apos;re updating from an older setup, replace your entire worker script.
&lt;/Notice&gt;

&lt;Tabs&gt;
  &lt;Tab name=&quot;Cloud Proxy (Plausible.io)&quot;&gt;
    Use this version if you&apos;re on Plausible&apos;s hosted cloud service.

```js
// Replace &apos;pa-XXXXX.js&apos; with your site-specific script ID
// Find it in Plausible: Site Settings → General → Data Snippets
const ProxyScript = &apos;https://plausible.io/js/pa-XXXXX.js&apos;;
const ScriptPath = &apos;/theone/script&apos;;
const Endpoint = &apos;/theone/event&apos;;

export default {
  async fetch(request, env, ctx) {
    ctx.passThroughOnException();
    const url = new URL(request.url);
    const pathname = url.pathname;

    if (pathname.startsWith(ScriptPath)) {
      return getScript(request, ctx);
    } else if (pathname === Endpoint) {
      return postData(request);
    }

    return new Response(null, { status: 404 });
  }
}

async function getScript(request, ctx) {
  let response = await caches.default.match(request);
  if (!response) {
    response = await fetch(ProxyScript);
    ctx.waitUntil(caches.default.put(request, response.clone()));
  }
  return response;
}

async function postData(request) {
  const req = new Request(request);
  req.headers.delete(&apos;cookie&apos;);
  return await fetch(&apos;https://plausible.io/api/event&apos;, req);
}
```

Replace `pa-XXXXX.js` with your actual site-specific script ID. You can find it in your Plausible dashboard under **Site Settings → General → Data Snippets**. The `theone` path segment can be anything you want. Just keep it consistent across `ScriptPath` and `Endpoint`.

  &lt;/Tab&gt;
  &lt;Tab name=&quot;Self-Hosted (Plausible CE)&quot;&gt;
    Use this version if you&apos;re running Plausible Community Edition on your own server.

```js
// Replace with your self-hosted Plausible domain
const ProxyScript = &apos;https://plausible.yourdomain.com/js/pa-XXXXX.js&apos;;
const ScriptPath = &apos;/theone/script&apos;;
const Endpoint = &apos;/theone/event&apos;;

export default {
  async fetch(request, env, ctx) {
    ctx.passThroughOnException();
    const url = new URL(request.url);
    const pathname = url.pathname;

    if (pathname.startsWith(ScriptPath)) {
      return getScript(request, ctx);
    } else if (pathname === Endpoint) {
      return postData(request);
    }

    return new Response(null, { status: 404 });
  }
}

async function getScript(request, ctx) {
  let response = await caches.default.match(request);
  if (!response) {
    response = await fetch(ProxyScript);
    ctx.waitUntil(caches.default.put(request, response.clone()));
  }
  return response;
}

async function postData(request) {
  const req = new Request(request);
  req.headers.delete(&apos;cookie&apos;);
  return await fetch(&apos;https://plausible.yourdomain.com/api/event&apos;, req);
}
```

Replace `plausible.yourdomain.com` with your actual self-hosted Plausible domain, and `pa-XXXXX.js` with your site-specific script ID.

  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Performance optimization (optional)&quot;&gt;
You can reduce proxy latency from ~140ms to ~9ms by returning an immediate 202 response and forwarding the event asynchronously. In the `postData` function, replace the final `return` with `ctx.waitUntil(fetch(...))` and return a `new Response(&apos;OK&apos;, { status: 202 })`. Note: some users have reported issues with request stream access after the response is sent, so test this carefully.
&lt;/Notice&gt;

**Edit Worker:**

&lt;Picture
  src={imag3}
  alt=&quot;Edit Cloudflare Worker&quot;
/&gt;

**Add Code:**

&lt;Picture
  src={imag4}
  alt=&quot;Add Cloudflare Worker Code&quot;
/&gt;

Once you&apos;ve added the code, click **Save and Deploy** in the top right.

### Step 3: Verify the Worker is working

Test your worker by accessing it directly:

```sh
https://your-worker-name.your-cloudflare-username.workers.dev/theone/script
```

You should see JavaScript code returned (the Plausible script). If you get a 404, double-check the `ScriptPath` variable in your worker code matches the URL path you&apos;re testing.

### Step 4: Run the proxy as a subdirectory

Running the proxy under a subdirectory of your main domain (e.g., `example.com/theone/`) is better than using a separate subdomain. It keeps requests first-party, which avoids cookie restrictions and looks cleaner in network logs.

To set this up, add a Worker route in the **Routes** section of your Worker settings:

**Route**: `*example.com/theone/*`

Replace `theone` with whatever path segment you chose in your worker code. Select your domain in the zone dropdown.

&lt;Picture
  src={imag5}
  alt=&quot;Add Cloudflare Worker route&quot;
/&gt;

### Step 5: Add the Plausible snippet to your Astro site

&lt;Notice type=&quot;warning&quot; title=&quot;Snippet format changed&quot;&gt;
The old `data-domain` / `data-api` format is deprecated. Plausible now uses a site-specific script (`pa-XXXXX.js`) with a `plausible.init()` call for the endpoint. Use the format below.
&lt;/Notice&gt;

Add this snippet to your Astro site&apos;s `&lt;head&gt;`. In Astro, you can place it in your main layout file (e.g., `src/layouts/Layout.astro`):

```html
&lt;script async src=&quot;https://yourdomain.com/theone/pa-XXXXX.js&quot;&gt;&lt;/script&gt;
&lt;script&gt;
  window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)};
  plausible.init=plausible.init||function(i){plausible.o=i||{}};
  plausible.init({
    endpoint: &quot;https://yourdomain.com/theone/event&quot;
  })
&lt;/script&gt;
```

Replace the following:
- **`yourdomain.com`**: your actual site domain
- **`theone`**: the subdirectory path you configured in Steps 2 and 4
- **`pa-XXXXX.js`**: your site-specific script ID from Plausible (Site Settings → General → Data Snippets)

The `endpoint` URL must match the `Endpoint` variable in your Worker code, and the script `src` must match the `ScriptPath`. The script URL goes through your Worker (serving the cached Plausible script), and the endpoint URL routes analytics events through your Worker to Plausible&apos;s API.

You should now have Plausible Analytics proxied through Cloudflare Workers, served from your own domain.

## Plausible pricing (2025)

Plausible offers several cloud tiers. All plans include the core analytics features: pageviews, visitors, bounce rate, visit duration, referral sources, and more.

| Plan | Price | Pageviews | Sites | Notable Features |
|------|-------|-----------|-------|------------------|
| Starter | $9/mo | 10,000 | 1 | Core analytics |
| Growth | $14/mo | 10,000 | 3 | 3 team members |
| Business | $19/mo | 10,000 | 10 | Funnels, revenue goals, Stats API |
| Enterprise | Custom | Custom | Custom | SSO, Sites API, Managed Proxy |

Higher pageview tiers are available at each level. The **Enterprise** plan includes a [Managed Proxy](https://plausible.io/docs/proxy/guides/cloudflare) where Plausible handles the proxy for you via a CNAME record. No Worker needed.

If you want to skip the cloud pricing entirely, the Community Edition is free.

## Plausible Community Edition vs Cloud

Plausible Community Edition (CE) is a free, self-hosted version under the AGPL license. It follows an open-core model. CE includes the core analytics engine, but some advanced features are exclusive to the cloud Business and Enterprise plans.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Plausible Cloud&quot;&gt;
    **Pros:**
    - Fully managed, no server to maintain
    - All features including funnels, revenue goals, and SSO
    - Managed Proxy option (Enterprise plan)
    - Automatic updates and security patches
    - Stats API V2 for custom integrations

    **Cons:**
    - Monthly cost starting at $9/mo
    - Data stored on Plausible&apos;s infrastructure
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Plausible Community Edition&quot;&gt;
    **Pros:**
    - Completely free, self-hosted
    - Full control over your data
    - AGPL licensed, open source
    - Core analytics features included

    **Cons:**
    - No funnels, revenue goals, or SSO
    - Self-managed server required
    - Manual updates and maintenance
    - No Managed Proxy option

    If you&apos;re self-hosting Plausible CE, affordable VPS providers like [Hetzner Cloud](https://go.bitdoze.com/hetzner) or [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) offer solid performance for running your instance. See [Install Plausible With One Click](https://www.bitdoze.com/install-plausible-analytics/) for deployment instructions.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Accordion label=&quot;Can I use this Cloudflare Worker proxy with Plausible CE?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. The Worker code is the same. You just change the `ProxyScript` URL and the `postData` fetch URL to point to your self-hosted Plausible domain instead of `plausible.io`. Use the &quot;Self-Hosted (Plausible CE)&quot; tab in Step 2 for the correct code.
&lt;/Accordion&gt;

## Troubleshooting common issues

&lt;Accordion label=&quot;Analytics data not showing up&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Open your browser&apos;s developer tools (Network tab) and reload your site. Look for requests to your subdirectory path (e.g., `/theone/script` and `/theone/event`). If the script request returns 200 but the event request fails, check that the `endpoint` URL in your snippet matches the Worker&apos;s `Endpoint` variable exactly. If neither request appears, verify your snippet is in the `&lt;head&gt;` and not blocked by Content-Security-Policy headers.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Worker returns 404&quot; group=&quot;faq&quot;&gt;
Verify the Worker route pattern matches your subdirectory. The route should be `*example.com/theone/*` (note the trailing `/*`). Also check that the `ScriptPath` and `Endpoint` variables in your Worker code match the paths in your snippet. Make sure the Worker is deployed (not just saved as a draft).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;CORS errors in the console&quot; group=&quot;faq&quot;&gt;
Since the proxy runs on your own domain, requests should be same-origin and CORS shouldn&apos;t trigger. If you see CORS errors, your Worker route is likely misconfigured. The requests might be hitting Plausible directly instead of going through the Worker. Double-check the route pattern and ensure the snippet URLs point to your domain, not `plausible.io`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Plausible dashboard shows 0 visitors&quot; group=&quot;faq&quot;&gt;
First, verify you&apos;re looking at the correct site in your Plausible dashboard. If you recently switched to the new snippet format, make sure the old `data-domain` snippet is completely removed. Having both can cause conflicts. Also check that your own browser isn&apos;t blocking the requests during testing (disable your ad blocker for your own domain).
&lt;/Accordion&gt;

## What&apos;s new in Plausible since 2023

Plausible has shipped a lot of features since this article was first published. Here are the highlights:

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;Automatic scroll depth tracking (no setup required)&lt;/li&gt;
    &lt;li&gt;AI Assistants traffic channel: tracks visits from ChatGPT, Claude, Gemini, Perplexity&lt;/li&gt;
    &lt;li&gt;User Journeys: visualize how visitors navigate your site&lt;/li&gt;
    &lt;li&gt;Automatic form submission tracking (toggle-on)&lt;/li&gt;
    &lt;li&gt;Revenue goals and ecommerce attribution (Business plan)&lt;/li&gt;
    &lt;li&gt;Funnels for multi-step conversion analysis (Business plan)&lt;/li&gt;
    &lt;li&gt;Stats API V2 with simpler querying and multi-dimension support&lt;/li&gt;
    &lt;li&gt;Search Console integration: keyword data in your dashboard&lt;/li&gt;
    &lt;li&gt;Google Analytics import (both UA and GA4)&lt;/li&gt;
    &lt;li&gt;Traffic drop alerts via email or Slack&lt;/li&gt;
    &lt;li&gt;2FA and SSO security enhancements&lt;/li&gt;
    &lt;li&gt;Improved &quot;Time on Page&quot; metric based on engagement signals&lt;/li&gt;
    &lt;li&gt;Site-specific script format (`pa-XXXXX.js`) replacing the generic `script.js`&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re also working on your Astro site&apos;s performance, check [how to optimize Astro build speeds](https://www.bitdoze.com/astro-ssg-build-optimization/) or [migrate Astro to Bun on Cloudflare](https://www.bitdoze.com/migrate-astro-bun/) for faster builds.

&lt;Button text=&quot;View Full Plausible Changelog&quot; link=&quot;https://plausible.io/changelog&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Does this work with frameworks other than Astro?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. The Cloudflare Worker proxy is completely framework-agnostic. It works with any static site generator (Hugo, Eleventy, Next.js, Gatsby) or even plain HTML. The only requirement is that you can add a script tag to your site&apos;s `&lt;head&gt;`. The Worker handles all the proxying regardless of what generated the HTML.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is Plausible&apos;s Managed Proxy?&quot; group=&quot;faq&quot;&gt;
Plausible&apos;s Managed Proxy is an Enterprise-only feature. Instead of setting up and maintaining your own Cloudflare Worker, you add a CNAME record pointing to Plausible&apos;s proxy infrastructure. They handle the rest. It&apos;s a good option for teams that want the proxy benefit without managing Workers, but it requires an Enterprise plan.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much traffic can the free Workers plan handle?&quot; group=&quot;faq&quot;&gt;
Cloudflare Workers free plan allows 100,000 requests per day. Since the Plausible script is cached by the Worker, most pageviews only count as one request (the event POST). For a site with average traffic, 100K requests translates to roughly 80,000 to 100,000 pageviews per day. That covers the vast majority of sites. If you need more, Cloudflare&apos;s paid plan ($5/mo) includes 10 million requests per month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Cloudflare Zaraz instead?&quot; group=&quot;faq&quot;&gt;
Cloudflare Zaraz is a tag manager that loads third-party scripts through Cloudflare&apos;s infrastructure. As of now, Plausible is not officially supported as a Zaraz integration. The Worker proxy approach described in this article remains the recommended way to proxy Plausible on Cloudflare. If Zaraz adds Plausible support in the future, it could simplify the setup.
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>astro</category><category>plausible</category><category>cloudflare-workers</category></item><item><title>FluidVoice: Free Open-Source Mac Dictation App Guide</title><link>https://www.bitdoze.com/fluidvoice-mac-dictation/</link><guid isPermaLink="true">https://www.bitdoze.com/fluidvoice-mac-dictation/</guid><description>FluidVoice is a free, open-source Mac dictation app with local AI models. Compare it to Wispr Flow, learn setup, and see why it beats paid dictation tools.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

A solo developer got frustrated with paying $15/month for Wispr Flow and decided to build his own dictation app. Less than a year later, that project, FluidVoice, has ~2,600 GitHub stars, 100,000+ downloads, and an active Discord community.

The pitch is simple: FluidVoice runs speech-to-text models directly on your Mac. No cloud servers. No subscriptions. It supports 99 languages through Whisper, has a custom AI model called Fluid-1 for smart formatting, and it&apos;s free under the GPL-3.0 license.

If you&apos;ve been dealing with subscription fatigue or privacy concerns about cloud dictation, this is worth trying. This guide covers installation, features, comparisons with Wispr Flow and other tools, five developer workflows, and an honest assessment of where FluidVoice falls short.

&lt;Button text=&quot;View on GitHub&quot; link=&quot;https://github.com/altic-dev/FluidVoice&quot; variant=&quot;outline&quot; color=&quot;gray&quot; size=&quot;md&quot; /&gt;

## What is FluidVoice?

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/IFuT-BsdHT4&quot;
  label=&quot;Your Mac&apos;s New Superpower Local AI Transcription&quot;
/&gt;

FluidVoice is a free, open-source dictation app for macOS built in Swift. It runs speech-to-text models entirely on your Mac. Audio never leaves the device for transcription. The app supports Apple Silicon (all models) and Intel Macs (Whisper models only), and requires macOS 15.0 Sequoia or later.

The project launched in September 2025 and grew fast. It went from 300 stars to ~2,600 in about ten months. The developer (altic-dev on GitHub) maintains the project solo with community support through GitHub Sponsors.

Key specs: 99 languages via Whisper, fewer through other models (~40 via Nemotron, 25 via Parakeet TDT v3, 14 via Cohere). Perceived latency is under 100ms. The app inserts text directly into any application through macOS accessibility APIs.

&lt;Notice type=&quot;info&quot; title=&quot;Open source vs. private runtime&quot;&gt;
FluidVoice the app is GPL-3.0. You can read, audit, and modify the code. However, Fluid Intelligence (the AI enhancement model for smart formatting) is a privately maintained runtime and is **not** open source. This distinction matters if code auditability is a priority for you.
&lt;/Notice&gt;

The [local-first philosophy](/why-need-home-server/) behind FluidVoice fits the same mindset as self-hosting: keep your data on hardware you control.

## How to install FluidVoice on Mac

Two install paths: Homebrew (fastest) or manual download from GitHub. If you want to contribute or audit the code, you can build from source.

### Install via Homebrew

If you use [Homebrew in your terminal](/enable-syntax-highlighting-zsh/), this is the cleanest path:

```bash
brew install --cask fluidvoice
```

That&apos;s it. Homebrew handles the download, places the app in `/Applications`, and keeps it updated with `brew upgrade`.

For manual download, grab the latest `.dmg` from [GitHub Releases](https://github.com/altic-dev/FluidVoice/releases/latest).

To build from source:

```bash
git clone https://github.com/altic-dev/FluidVoice.git
cd FluidVoice
open Fluid.xcodeproj
```

This opens the project in Xcode. Build and run from there.

### First-run setup and permissions

When you first launch FluidVoice, macOS will ask for several permissions:

1. **Microphone access.** Required for speech capture. No way around this.
2. **Accessibility permissions.** Required for FluidVoice to type into other applications. Go to System Settings → Privacy &amp; Security → Accessibility and enable FluidVoice.
3. **Notification permissions.** Optional, for status updates.

After permissions, you&apos;ll see the onboarding flow. Pick a voice model:

- **Parakeet Flash (Beta):** Fast, English-focused. Good starting point for English users.
- **Nemotron Speech 3.5:** Better multilingual support, slightly slower.
- **Whisper Small/Medium:** Works on Intel Macs too, but slower than the other models.

You can change models later in settings. The base model download is roughly 1 GB.

If you want Fluid Intelligence (the AI formatting model), you can download it separately. It&apos;s about 3.5 GB. It&apos;s optional but makes a noticeable difference in output quality.

&lt;Notice type=&quot;warning&quot; title=&quot;macOS 15 required&quot;&gt;
FluidVoice requires macOS 15.0 Sequoia or later. Users on older macOS versions cannot run it. Intel Macs get Whisper-only support. The faster models (Parakeet, Nemotron) require Apple Silicon.
&lt;/Notice&gt;

## Key features at a glance

FluidVoice has more features than most free apps.

### Local speech-to-text models

All transcription runs on your Mac. No internet required. These are the available models:

| Model | Best For | Languages | Speed | Size |
|-------|----------|-----------|-------|------|
| Parakeet Flash (Beta) | English dictation | English (primarily) | Fastest | ~800 MB |
| Parakeet TDT v3 | Multilingual dictation | 25 | Fast | ~800 MB |
| Nemotron Speech 3.5 | Multilingual, balanced | ~40 | Fast | ~1 GB |
| Nemotron 3.5 Multilingual | Non-English languages | ~40 | Fast | ~1 GB |
| Cohere Transcribe | Alternative multilingual | 14 | Medium | ~1 GB |
| Apple Speech | System integration | System languages | Fast | Built-in |
| Whisper Tiny/Base/Small | Lightweight fallback | 99 | Slowest | 200 MB to 1 GB |
| Whisper Medium/Large | Best accuracy, any language | 99 | Slow | 1.5 to 3 GB |

For most English users on Apple Silicon, start with Parakeet Flash. Switch to Nemotron if you need multilingual support.

&lt;ListCheck&gt;

**What you need to get started:**
- macOS 15.0 Sequoia or later
- Apple Silicon Mac (recommended) or Intel Mac (Whisper only)
- ~1 GB disk space for voice model
- Microphone access granted in System Settings
- Accessibility permissions enabled

&lt;/ListCheck&gt;

### Fluid Intelligence (Fluid-1)

Fluid-1 is a custom-trained local AI model based on Gemma. The developer trained it on 100,000+ real-world dictation examples. It handles smart formatting, capitalization, punctuation, and cleanup, turning raw speech-to-text output into polished text.

On the developer&apos;s evaluation set of 10,000 examples, Fluid-1 scored 77.31%. For context, GPT-5.4 scored 56.73% and the base Gemma model scored 34.72%. These are task-specific benchmarks (dictation cleanup), not general LLM benchmarks, so take them with a grain of salt. In daily use, the difference is noticeable. Raw Whisper output reads like a transcript, while Fluid-1 output reads like something you&apos;d actually send.

Size: ~3.5 GB download. A smaller Fluid-1 Mini (~1 GB) is planned but not yet released.

&lt;Notice type=&quot;info&quot; title=&quot;Fluid-1 is not open source&quot;&gt;
This is worth repeating: while FluidVoice itself is GPL-3.0, Fluid Intelligence is a private, separately maintained runtime. The developer hasn&apos;t announced monetization plans, but keeping Fluid-1 closed-source leaves the door open for future commercial licensing.
&lt;/Notice&gt;

### Command Mode and Write Mode

FluidVoice has two advanced modes beyond standard dictation.

&lt;Tabs&gt;
&lt;Tab name=&quot;Command Mode&quot;&gt;
Voice-control your Mac. Press the hotkey and speak a command:

- **&quot;Open Safari and search for FluidVoice&quot;** launches Safari and performs the search
- **&quot;Create a new note in Bear&quot;** opens Bear with a new note
- **&quot;Send a Slack message to #team saying the deploy is done&quot;** composes and sends
- **&quot;Run my deployment script&quot;** triggers macOS Shortcuts or scripts
- **&quot;Ask Mastra to search for the latest GitHub stars&quot;** triggers [AI agent workflows](/build-ai-agent-mastra/)

Command Mode can launch apps, run macOS Shortcuts, trigger system actions, and automate multi-step workflows through voice alone.
&lt;/Tab&gt;
&lt;Tab name=&quot;Write Mode&quot;&gt;
Dictate new content or rewrite existing text in any application:

1. Select text in any app (Slack, email, Cursor, Notion, anywhere)
2. Press the global hotkey
3. Say: **&quot;Make this more concise&quot;** or **&quot;Rewrite in a professional tone&quot;**
4. FluidVoice replaces the selected text in place

Write Mode works with per-app prompt sets. You can configure different behavior for different apps: formal tone for email, concise for Slack, technical for code editors. This goes beyond Wispr Flow&apos;s limited context-awareness.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## FluidVoice vs Wispr Flow

FluidVoice directly positions itself as the free alternative to Wispr Flow. The differences are real.

| Feature | FluidVoice | Wispr Flow |
|---------|-----------|------------|
| **Price** | $0 forever | $15/mo or $144/yr (Pro). Free tier: 2,000 words/week |
| **Processing** | On-device (local) | Cloud (OpenAI/Meta servers) |
| **Open source** | Yes (GPL-3.0) | No |
| **Offline** | Yes | No |
| **Platforms** | Mac only | Mac, Windows, iOS, Android |
| **Languages** | Up to 99 (Whisper) | 100+ |
| **AI enhancement** | Fluid-1 local or BYOK cloud | Cloud AI (fine-tuned Llama) |
| **Command Mode** | Yes (free) | Yes (Pro only) |
| **Per-app prompts** | Full custom prompt sets | Limited context-awareness |
| **Funding** | Solo dev, GitHub Sponsors | $81M raised, $700M valuation |
| **Team size** | 1 developer | ~50 employees |
| **Trustpilot** | N/A (free) | 2.7/5 |

The fundamental tradeoff: Wispr Flow is multi-platform and has better cloud AI for some use cases. FluidVoice is local, free, and open source.

Wispr Flow&apos;s Trustpilot rating (2.7/5) reflects common complaints about reliability after the trial period and aggressive upselling. FluidVoice has no paid tier to complain about.

![FluidVoice vs Wispr Flow architecture: local on-device processing vs cloud server processing](../../assets/images/25/07/fluidvoice-vs-wispr-flow-architecture.svg)

The data flow is straightforward. Wispr Flow sends your audio to cloud servers for processing. FluidVoice keeps everything on your Mac. If you care about [subscription fatigue](/freebuff-free-ai-coding-agent/) and data privacy, FluidVoice is the better fit. If you need Windows or iOS support today, Wispr Flow is your only option from these two.

## FluidVoice vs SuperWhisper and VoiceInk

Wispr Flow isn&apos;t the only alternative. Two other tools compete in this space.

**VoiceInk** is the closest open-source competitor. GPL-3.0, 5,514 GitHub stars, $25-49 one-time purchase (or free if you build from source). It runs on-device Whisper via whisper.cpp and uses Parakeet models too. The main gap: no Fluid-1 equivalent. VoiceInk needs external LLM API keys for AI enhancement, which means either paying for an API or running a local LLM separately. macOS 14+ minimum (one version older than FluidVoice).

**SuperWhisper** is the popular paid option. $84.99/year or $249.99 lifetime. It has deep customization with intelligent modes and multiple model choices. But it stores API keys in plaintext and saves audio recordings by default with no opt-out, which is concerning for a tool that captures everything you say. Rated 4.9/5 on Product Hunt.

| Feature | FluidVoice | VoiceInk | SuperWhisper |
|---------|-----------|----------|--------------|
| **Price** | $0 | $25-49 one-time (or free build) | $84.99/yr or $249.99 lifetime |
| **License** | GPL-3.0 | GPL-3.0 | Proprietary |
| **macOS** | 15.0+ | 14.0+ | 13.0+ |
| **AI enhancement** | Fluid-1 (included) | External LLM keys needed | External LLM keys needed |
| **Offline** | Yes | Yes | Yes (with local models) |
| **API key storage** | macOS Keychain | Varies | Plaintext |
| **Audio recording** | Local, optional | Local | Saved by default |

FluidVoice&apos;s edge is Fluid-1. It&apos;s the only free tool that includes a local AI model for smart formatting without requiring API keys or separate LLM setup.

&lt;Accordion label=&quot;What about MacWhisper and Apple Dictation?&quot; group=&quot;faq&quot;&gt;

**MacWhisper** focuses on file transcription, turning audio files, podcasts, and meeting recordings into text. It&apos;s a different use case from real-time dictation. If you need to transcribe a recorded meeting, MacWhisper is great. If you need to dictate emails and messages in real time, FluidVoice is the better tool.

**Apple Dictation** is the free baseline built into macOS. It works, but accuracy is lower, customization is minimal, and there&apos;s no AI formatting. It&apos;s fine for quick voice memos. For professional daily use, FluidVoice is a significant upgrade.

&lt;/Accordion&gt;

## Real-world workflows for developers

Features are nice, but workflows matter more. These are five concrete ways developers use FluidVoice day-to-day.

&lt;Tabs&gt;
&lt;Tab name=&quot;Code Comments &amp; Commits&quot;&gt;
Press your hotkey in VS Code or Cursor. Dictate commit messages, code comments, or documentation directly.

Set up a per-app prompt for your code editor to format output as conventional commits:

```
git commit -m &quot;feat: add user authentication flow with OAuth2 support&quot;
```

Or dictate inline code comments without taking your hands off the keyboard for mouse navigation. FluidVoice inserts text at the cursor position in any text field.
&lt;/Tab&gt;
&lt;Tab name=&quot;Emails &amp; Slack&quot;&gt;
Dictate long emails, Slack messages, or Notion notes. Fluid-1 handles formatting, punctuation, and professional tone automatically.

No more typing three-paragraph Slack messages. Press hotkey, talk for 30 seconds, get formatted text. The per-app prompt system lets you configure different tones: formal for email, concise for Slack, technical for Notion.
&lt;/Tab&gt;
&lt;Tab name=&quot;AI Agent Voice Control&quot;&gt;
Use Command Mode to trigger [AI agent workflows](/build-ai-agent-mastra/). Voice-to-agent pipelines are becoming practical: speak a command, have an AI agent execute a multi-step task.

Example: &quot;Search my notes for the deployment checklist and summarize it.&quot; If you&apos;ve built agent workflows with tools like Mastra, FluidVoice becomes a voice interface for those agents.

For the broader voice and audio tools ecosystem, [Fish Audio&apos;s AI voice cloning](/fish-audio-review/) covers the text-to-speech side if you need output as well as input.
&lt;/Tab&gt;
&lt;Tab name=&quot;Meeting Notes&quot;&gt;
Dictate meeting notes in real-time while you&apos;re in a call. FluidVoice handles the transcription. Then use Write Mode to clean up and reformat the raw notes into structured documentation.

Select the messy notes → hotkey → &quot;Format as meeting notes with action items&quot; → done.
&lt;/Tab&gt;
&lt;Tab name=&quot;Text Rewriting&quot;&gt;
Select any text in any app. Press hotkey. Say &quot;make this more concise&quot; or &quot;rewrite in a professional tone&quot; or &quot;fix the grammar.&quot;

Fluid-1 processes the rewrite locally. No API calls, no latency, no data leaving your Mac. Works in Slack, email, documents, code editors, anywhere you can select text.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Cost comparison over time

This is where the cost difference gets hard to argue against.

| Tool | Year 1 | Year 2 | Year 3 |
|------|--------|--------|--------|
| **FluidVoice** | $0 | $0 | $0 |
| **VoiceInk** | $39 | $39 | $39 |
| **SuperWhisper (annual)** | $84.99 | $169.98 | $254.97 |
| **SuperWhisper (lifetime)** | $249.99 | $249.99 | $249.99 |
| **Wispr Flow (annual)** | $144 | $288 | $432 |
| **Wispr Flow (monthly)** | $180 | $360 | $540 |

Over three years, FluidVoice saves you $432-$540 compared to Wispr Flow. Even compared to VoiceInk&apos;s $39 one-time purchase, FluidVoice&apos;s included Fluid-1 model adds value that VoiceInk can&apos;t match without external API keys, which cost money per token.

![FluidVoice vs paid dictation tools cost comparison over 3 years](../../assets/images/25/07/fluidvoice-cost-comparison-chart.svg)

The &quot;free&quot; here isn&apos;t a teaser. There are no paid tiers, no word limits, no feature gates, and no &quot;upgrade to Pro&quot; popups. Everything the app does is available at $0. If you&apos;re tired of [subscription fatigue](/freebuff-free-ai-coding-agent/), this is what the alternative looks like.

## Privacy and data handling

If you handle sensitive information (client data, proprietary code, confidential communications), privacy isn&apos;t optional. This is what FluidVoice does with your data.

&lt;ListCheck&gt;

**Privacy checklist:**
- On-device transcription: audio never leaves your Mac for STT
- Audio history stored locally with budget controls and ZIP export
- API keys stored in macOS Keychain (not plaintext)
- Optional anonymous analytics: can be fully disabled in settings
- No voice data or transcript data collected, ever
- GPL-3.0 auditable code: verify these claims yourself

&lt;/ListCheck&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Local-first by default&quot;&gt;
Unlike Wispr Flow, where your audio goes to cloud servers by default, FluidVoice processes everything on your Mac. You have to actively opt in to any cloud processing (OpenAI, Groq, or custom providers). The default is fully local.
&lt;/Notice&gt;

Compare this to the competition:
- **Wispr Flow** sends audio to OpenAI and Meta cloud servers. No local processing option.
- **SuperWhisper** saves audio recordings by default with no opt-out and stores API keys in plaintext.

FluidVoice fits the same [local-first philosophy](/why-need-home-server/) that drives the self-hosting community. Keep your data on hardware you control.

## Honest limitations and caveats

FluidVoice isn&apos;t perfect. These are the tradeoffs to know before switching.

1. **Mac-only.** No Windows, iOS, or Android versions yet. All three are on the waitlist. Linux is planned but no timeline.
2. **Solo developer risk.** One person maintains this. He&apos;s responsive and shipping fast, but it&apos;s a single point of failure. No SLA. Community Discord only.
3. **Fluid-1 is closed source.** The most differentiated feature is not open source. Future monetization is unknown.
4. **macOS 15.0 requirement.** Users on older macOS versions are locked out.
5. **Intel Mac degradation.** Whisper-only support on Intel. Slower, less accurate than Apple Silicon models.
6. **3.5 GB model download.** Fluid Intelligence is substantial. The Mini version (~1 GB) is planned but not released.
7. **Language gaps.** Parakeet Flash is English-only. Multilingual coverage varies by model. Not all 99 languages get the same quality.
8. **Competing with $81M.** Wispr Flow has massive resources and 50 employees. FluidVoice has goodwill and GitHub Sponsors.

&lt;Notice type=&quot;warning&quot; title=&quot;The solo-dev tradeoff&quot;&gt;
A solo developer means passionate, responsive, and building fast. It also means one person&apos;s burnout, health issues, or career change could stall the project. The GPL-3.0 license means the code survives even if development stops. Someone could fork it. But active maintenance and feature development depend on one person. Go in with eyes open.
&lt;/Notice&gt;

## Should you switch to FluidVoice?

**Switch if:**
- You&apos;re on a Mac with macOS 15+
- You want to save $144+/year on dictation
- Privacy matters: you don&apos;t want audio going to cloud servers
- You want local AI enhancement without API keys
- You&apos;re comfortable with a solo-dev project
- You value open-source transparency

**Don&apos;t switch if:**
- You need Windows, iOS, or Android support today
- You need enterprise support with an SLA
- You want the absolute best cloud AI accuracy (Wispr Flow&apos;s fine-tuned Llama is still better for some use cases)
- You&apos;re on macOS 14 or older

**Try it if:**
- You&apos;re curious. It&apos;s free. The only cost is your time. Install via Homebrew, use it for a week, and decide for yourself.

If you find FluidVoice useful, consider supporting the developer through [GitHub Sponsors](https://github.com/sponsors/altic-dev). A solo developer building a credible alternative to an $81M-funded product deserves support.

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot;&gt;

**Does FluidVoice work offline?**
Yes. All speech-to-text models run on-device. No internet connection required for transcription. Fluid Intelligence (the AI formatting model) also runs locally. The only time you need internet is for the initial model download and optional cloud AI enhancement (if you choose to enable it).

**Can I use FluidVoice for coding?**
Yes. FluidVoice works in any text field, including VS Code, Cursor, and other code editors. You can dictate code comments, commit messages, documentation, and even code snippets. Set up per-app prompts to configure formatting behavior for your editor.

**What languages does FluidVoice support?**
Up to 99 languages via Whisper, ~40 via Nemotron, 25 via Parakeet TDT v3, and 14 via Cohere Transcribe. Apple Speech adds system language support. English gets the best performance across all models. Non-English users should test Nemotron or Whisper Medium/Large for their specific language.

**How does the solo developer sustain this?**
GitHub Sponsors and community support. There are no paid tiers currently. Fluid Intelligence being privately maintained (not open source) leaves the door open for future monetization, possibly a hosted API, premium model tiers, or enterprise licensing. But nothing has been announced.

&lt;/Accordion&gt;

## Get started with FluidVoice

FluidVoice is a genuinely free, local-first, open-source dictation app that makes subscription dictation feel overpriced. It&apos;s not perfect. Mac-only, solo developer, 3.5 GB model download. But for Mac users who value privacy and hate subscriptions, it&apos;s the best option available right now.

Pair it with other Mac tools like [Shottr for screenshots](/shottr-mac-screenshot-tool/) and [Screen Studio for screen recordings](https://go.bitdoze.com/screen-studio) to round out your local-first Mac toolkit.

Install it. Use it for a week. See if it sticks.

&lt;Button text=&quot;Install FluidVoice via Homebrew&quot; link=&quot;https://github.com/altic-dev/FluidVoice/releases/latest&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Button text=&quot;View on GitHub&quot; link=&quot;https://github.com/altic-dev/FluidVoice&quot; variant=&quot;outline&quot; color=&quot;gray&quot; size=&quot;md&quot; /&gt;</content:encoded><category>tools</category><category>mac</category><category>dictation</category><category>open-source</category></item><item><title>Hermes Agent Setup Guide (2026): Self-Improving AI on Your Server</title><link>https://www.bitdoze.com/hermes-agent-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/hermes-agent-setup-guide/</guid><description>Install Hermes Agent v0.18 on Linux with free models from OpenRouter. Covers Docker Compose, Telegram, Discord, Slack, WhatsApp, and the built-in web dashboard. Updated July 2026.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I have been testing [Hermes Agent](https://github.com/NousResearch/hermes-agent) from Nous Research alongside my [OpenClaw setup](/clawdbot-setup-guide/) and [OpenFang instance](/openfang-setup-guide/) for months. The thing that hooked me is the learning loop. Hermes creates skills from tasks it completes, improves those skills during later use, and remembers who you are across sessions. It also migrates your existing OpenClaw config, memories, and skills with a single command.

&lt;Button text=&quot;Hermes Agent GitHub&quot; link=&quot;https://github.com/NousResearch/hermes-agent&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing Hermes Agent on a Linux VPS via the one-line installer&lt;/li&gt;
&lt;li&gt;Running Hermes with Docker Compose (gateway + dashboard)&lt;/li&gt;
&lt;li&gt;Choosing providers (Nous Portal, OpenRouter free tier, local models)&lt;/li&gt;
&lt;li&gt;Setting up Telegram, Discord, Slack, WhatsApp, Signal, Teams, and Matrix&lt;/li&gt;
&lt;li&gt;Memory, skills, MCP servers, and the built-in web dashboard&lt;/li&gt;
&lt;li&gt;Voice mode for CLI and messaging platforms&lt;/li&gt;
&lt;li&gt;Scheduled tasks with natural-language cron jobs&lt;/li&gt;
&lt;li&gt;Troubleshooting and how Hermes differs from OpenClaw, nanobot, and OpenFang&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted AI assistant options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup covers several projects including NanoClaw, nanobot, PicoClaw, ZeroClaw, NullClaw, and OpenFang. For security considerations when running any of these, see the [OpenClaw security guide](/openclaw-security-guide/). For the best web UIs and dashboards to manage your Hermes Agent from a browser, see the [best Hermes dashboards](/best-hermes-dashboards/) roundup.

## What Hermes Agent actually is

Nous Research built Hermes Agent as what they call a &quot;self-improving&quot; AI assistant. That label actually means something here: the agent watches what it does, extracts reusable skills from complex tasks, and refines those skills the next time it runs them. Most other assistants in this category forget everything between sessions. Hermes carries forward what it learned.

The architecture:

```
You (CLI / TUI / Telegram / Discord / Slack / WhatsApp / Signal / Email / Teams)
    ↓
Hermes Gateway (single process or Docker s6 supervision)
    ↓
AI Agent (session store, memory, skills, MCP, tools)
    ↓
LLM Provider (OpenRouter, Nous Portal, OpenAI, Anthropic, Ollama, custom)
    ↓
40+ Tools + MCP servers (terminal, web search, browser, files, code, TTS)
```

Messages arrive from whatever platform you use. The gateway routes them through a per-chat session store, the agent processes them with access to 40+ built-in tools, and everything runs from a single process. No microservices, no database server.

### How it compares to OpenClaw and others

| Feature | Hermes Agent | OpenClaw | nanobot | OpenFang |
|---|---|---|---|---|
| **Built by** | Nous Research | Community | HKUDS | RightNow AI |
| **Language** | Python | TypeScript | Python | Rust |
| **Install** | `curl` / Docker / Desktop | `curl` one-liner | pip | `curl` one-liner |
| **Learning loop** | Yes (skill creation + improvement) | No | No | No |
| **Channels** | 12+ (Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Teams, Home Assistant, Mattermost, Matrix, DingTalk, CLI) | Telegram, WhatsApp, Slack, Discord | Telegram, Discord, WhatsApp, Slack, Feishu, DingTalk, Email, QQ | 40 adapters |
| **Memory** | MEMORY.md + USER.md + FTS5 session search + Honcho user modeling | File-based + semantic search | Built-in | SQLite + vector |
| **Skills** | Auto-created from experience, self-improving, Skills Hub | Community skills | Built-in | Agent templates |
| **Voice mode** | CLI mic + messaging TTS + Discord voice channels | No | No | No |
| **Terminal backends** | Local, Docker, SSH, Daytona, Singularity, Modal | Local | Local | Local, Docker |
| **Scheduled tasks** | Natural-language cron with platform delivery | Cron | Cron | Cron + autonomous Hands |
| **OpenClaw migration** | Built-in (`hermes claw migrate`) | N/A | No | No |
| **Personality system** | SOUL.md + 14 built-in presets + custom | System prompt | System prompt | System prompt |
| **License** | MIT | MIT | MIT | MIT |

The learning loop is the real differentiator. After you ask Hermes to do something complex (say, deploy a Docker service), it extracts that workflow into a skill. Next time you ask for something similar, it uses and refines that skill. The FTS5 session search means it can recall details from conversations weeks ago. And voice mode in Discord voice channels is something none of the other projects offer.

OpenClaw still has the larger community, more third-party dashboards, and a longer track record. If OpenClaw already works for you, the migration command makes switching painless whenever you&apos;re ready.

## Installation

The one-line installer handles Python, Node.js, dependencies, and the `hermes` command. Works on Linux, macOS, WSL2, and native Windows (PowerShell). Prefer Docker Compose if you want a containerized gateway and dashboard on a VPS.

&lt;Tabs&gt;
&lt;Tab name=&quot;One-line install&quot;&gt;

The recommended approach for bare-metal or VPS installs.

```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
```

After it finishes, reload your shell:

```bash
source ~/.bashrc   # or source ~/.zshrc
```

Verify the install:

```bash
hermes --version
```

On native Windows (PowerShell):

```powershell
iex (irm https://hermes-agent.nousresearch.com/install.ps1)
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker Compose&quot;&gt;

Best for a 24/7 VPS. The official image mounts `~/.hermes` at `/opt/data` and runs the gateway under s6 supervision.

```bash
mkdir -p ~/.hermes

# First-time setup wizard (writes API keys to ~/.hermes/.env)
docker run -it --rm \
  -v ~/.hermes:/opt/data \
  nousresearch/hermes-agent setup
```

Then create a `docker-compose.yml` (or clone the [repo file](https://github.com/NousResearch/hermes-agent/blob/main/docker-compose.yml)):

```yaml
services:
  hermes:
    image: nousresearch/hermes-agent:latest
    container_name: hermes
    restart: unless-stopped
    command: gateway run
    ports:
      - &quot;8642:8642&quot;   # gateway API
      - &quot;9119:9119&quot;   # dashboard (with HERMES_DASHBOARD=1)
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_DASHBOARD=1
      # Match host ownership of ~/.hermes (required on most VPS/NAS mounts)
      - HERMES_UID=${HERMES_UID:-1000}
      - HERMES_GID=${HERMES_GID:-1000}
      # Dashboard auth (required when bound beyond loopback)
      # - HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
      # - HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=change-me
      # Optional OpenAI-compatible API server (do not expose publicly without a key):
      # - API_SERVER_ENABLED=true
      # - API_SERVER_HOST=0.0.0.0
      # - API_SERVER_KEY=${API_SERVER_KEY}
    deploy:
      resources:
        limits:
          memory: 4G
          cpus: &quot;2.0&quot;
```

Start it:

```bash
export HERMES_UID=&quot;$(id -u)&quot;
export HERMES_GID=&quot;$(id -g)&quot;
docker compose up -d
docker compose logs -f
```

Open the dashboard at `http://127.0.0.1:9119` (use an SSH tunnel for remote access: `ssh -L 9119:localhost:9119 user@your-server`).

Full Docker details are in the [Docker Compose section](#docker-compose-deployment) below.

&lt;/Tab&gt;
&lt;Tab name=&quot;From source&quot;&gt;

For contributors or if you want the latest development version:

```bash
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
cd &quot;${HERMES_HOME:-$HOME/.hermes}/hermes-agent&quot;
uv pip install -e &quot;.[all,dev]&quot;
```

Manual clone fallback (keep the venv outside the source tree so the agent cannot wipe its own runtime):

```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent
curl -LsSf https://astral.sh/uv/install.sh | sh
uv venv ~/.hermes/venvs/hermes-dev --python 3.11
source ~/.hermes/venvs/hermes-dev/bin/activate
uv pip install -e &quot;.[all,dev]&quot;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Installing on a Hetzner VPS

If you want Hermes running 24/7, a cheap VPS does the job. I use a [Hetzner CX22](https://www.bitdoze.com/hetzner-cloud-review/) (2 vCPU, 4GB RAM) for €3.99/month.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started with Hetzner&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers about 5 months of running Hermes Agent.
&lt;/Notice&gt;

SSH into your server and run:

```bash
ssh root@YOUR_SERVER_IP
apt update &amp;&amp; apt upgrade -y
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
source ~/.bashrc
```

Or skip the bare install and use [Docker Compose](#docker-compose-deployment) instead.

## Docker Compose deployment

Running Hermes in Docker keeps the install tree immutable under `/opt/hermes` and stores all user state (config, keys, sessions, skills, memories) in `~/.hermes` mounted at `/opt/data`. You can upgrade by pulling a new image without losing config.

The official repo ships a [`docker-compose.yml`](https://github.com/NousResearch/hermes-agent/blob/main/docker-compose.yml) with separate `gateway` and `dashboard` services on host networking. The docs also show a single-service layout with bridge networking and published ports. Both work; pick based on how you want to expose the dashboard.

### Option A: Single service (bridge networking)

Simple and portable. Good default for most VPS setups.

```bash
mkdir -p ~/.hermes

# One-time interactive setup
docker run -it --rm \
  -v ~/.hermes:/opt/data \
  nousresearch/hermes-agent setup
```

Create `docker-compose.yml`:

```yaml
services:
  hermes:
    image: nousresearch/hermes-agent:latest
    container_name: hermes
    restart: unless-stopped
    command: gateway run
    ports:
      - &quot;8642:8642&quot;   # gateway OpenAI-compatible API + health
      - &quot;9119:9119&quot;   # web dashboard
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_DASHBOARD=1
      - HERMES_UID=${HERMES_UID:-1000}
      - HERMES_GID=${HERMES_GID:-1000}
      # Required when dashboard binds beyond loopback:
      # - HERMES_DASHBOARD_BASIC_AUTH_USERNAME=admin
      # - HERMES_DASHBOARD_BASIC_AUTH_PASSWORD=change-me
    deploy:
      resources:
        limits:
          memory: 4G
          cpus: &quot;2.0&quot;
```

```bash
export HERMES_UID=&quot;$(id -u)&quot;
export HERMES_GID=&quot;$(id -g)&quot;
docker compose up -d
```

### Option B: Official repo compose (host networking)

Matches the [upstream docker-compose.yml](https://github.com/NousResearch/hermes-agent/blob/main/docker-compose.yml). Gateway and dashboard share the host network and PID namespace so the dashboard can detect gateway liveness.

```bash
git clone https://github.com/NousResearch/hermes-agent.git
cd hermes-agent

mkdir -p ~/.hermes
# Run setup once if ~/.hermes is empty
docker run -it --rm -v ~/.hermes:/opt/data nousresearch/hermes-agent setup

export HERMES_UID=&quot;$(id -u)&quot;
export HERMES_GID=&quot;$(id -g)&quot;
docker compose up -d --build
```

Core of that file (abbreviated):

```yaml
services:
  gateway:
    build: .
    image: hermes-agent
    container_name: hermes
    restart: unless-stopped
    network_mode: host
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_UID=${HERMES_UID:-10000}
      - HERMES_GID=${HERMES_GID:-10000}
      # Optional API server (needs a key if you bind beyond localhost):
      # - API_SERVER_HOST=0.0.0.0
      # - API_SERVER_KEY=${API_SERVER_KEY}
    command: [&quot;gateway&quot;, &quot;run&quot;]

  dashboard:
    image: hermes-agent
    container_name: hermes-dashboard
    restart: unless-stopped
    network_mode: host
    depends_on:
      - gateway
    volumes:
      - ~/.hermes:/opt/data
    environment:
      - HERMES_UID=${HERMES_UID:-10000}
      - HERMES_GID=${HERMES_GID:-10000}
    # Localhost-only. For remote access: ssh -L 9119:localhost:9119 user@host
    command: [&quot;dashboard&quot;, &quot;--host&quot;, &quot;127.0.0.1&quot;, &quot;--no-open&quot;]
```

### Useful Docker commands

```bash
docker compose logs -f                  # Live logs
docker logs --tail 50 hermes            # Recent gateway output
docker exec hermes hermes gateway status
docker exec hermes hermes doctor
docker exec -it hermes hermes           # Interactive CLI against the same data dir
docker compose pull &amp;&amp; docker compose up -d   # Upgrade image
```

Gateway logs are also written under `~/.hermes/logs/gateways/&lt;profile&gt;/current` on the host volume, so they survive container restarts.

### Docker security notes

&lt;Notice type=&quot;warning&quot; title=&quot;Do not expose the dashboard unauthenticated&quot;&gt;
The built-in dashboard stores API keys. Keep it on `127.0.0.1` and use an SSH tunnel, or put it behind auth (basic auth env vars, OAuth, or a reverse proxy). `HERMES_DASHBOARD_INSECURE` is a deprecated no-op; non-loopback binds require a real auth provider.
&lt;/Notice&gt;

- Set `HERMES_UID` / `HERMES_GID` (or `PUID` / `PGID`) to the host user that owns `~/.hermes` so files stay readable after the container drops privileges.
- Never run two gateway containers against the same `~/.hermes` directory at once (session and memory files are not multi-writer safe).
- If you enable the OpenAI-compatible API server, set `API_SERVER_KEY` (min 8 chars) and do not publish it on the public internet without a reverse proxy.
- For browser tools (Playwright), add `--shm-size=1g` or the Compose equivalent under `shm_size: 1gb`.

### Resource sizing

| Resource | Minimum | Recommended |
|---|---|---|
| Memory | 1 GB | 2–4 GB |
| CPU | 1 core | 2 cores |
| Disk (data volume) | 500 MB | 2+ GB (grows with sessions/skills) |

Browser automation is the memory hog. Without it, 1 GB is fine. With Playwright, plan for at least 2 GB.

Official reference: [Hermes Docker docs](https://hermes-agent.nousresearch.com/docs/user-guide/docker).

## Choosing a model provider

Hermes is provider-agnostic. Secrets go in `~/.hermes/.env`; non-secret settings go in `~/.hermes/config.yaml`. The interactive picker puts values in the right place:

```bash
hermes model
```

On a fresh install, `hermes setup` offers three modes:

| Mode | Best for |
|---|---|
| **Quick Setup (Nous Portal)** | Fastest path: OAuth login, 300+ models, Tool Gateway (web search, image gen, TTS, cloud browser) under one subscription |
| **Full Setup** | Bring your own keys (OpenRouter, Anthropic, OpenAI, etc.) and walk every option |
| **Blank Slate** | Minimal agent (file ops + terminal only); opt in to tools later |

```bash
hermes setup --portal   # Nous Portal + Tool Gateway in one shot
hermes setup            # Interactive: pick Quick / Full / Blank Slate
```

&lt;Notice type=&quot;info&quot; title=&quot;Minimum context: 64K tokens&quot;&gt;
Hermes needs a model with at least **64,000 tokens** of context for multi-step tool calling. Most hosted models meet this. For local models (Ollama, llama.cpp), set context to at least 64K (for example `-c 65536` or `--ctx-size 65536`).
&lt;/Notice&gt;

Common provider choices:

| Provider | Setup | Notes |
|---|---|---|
| **Nous Portal** | `hermes setup --portal` | One sub for models + tools |
| **OpenRouter** | API key + `openrouter/free` or paid model | Free tier available; see below |
| **Anthropic / OpenAI** | API key or OAuth via `hermes model` | Direct vendor access |
| **Ollama / vLLM / custom** | Custom endpoint base URL + model name | Fully local or self-hosted |
| **MiniMax, DeepSeek, xAI, Gemini, etc.** | Configure with `hermes model` | Large catalog; see [providers docs](https://hermes-agent.nousresearch.com/docs/integrations/providers) |

For cheap production models (MiniMax, MiMo, GLM, and friends), see our [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide.

## Setting up OpenRouter with free models

OpenRouter routes to 200+ models through a single API key. They also have a [free tier](https://openrouter.ai/openrouter/free) with several models at zero cost, which is the cheapest way to get started without a subscription.

### Get your API key

1. Go to [openrouter.ai](https://openrouter.ai) and create an account
2. Navigate to **Keys** in your dashboard
3. Click **Create Key** and copy the key

### Configure Hermes Agent

**Interactive setup (recommended):**

```bash
hermes setup
# or just:
hermes model
```

When it asks for a provider, select **OpenRouter** and paste your key.

**Manual setup:**

Add your API key to `~/.hermes/.env`:

```bash
echo &quot;OPENROUTER_API_KEY=sk-or-v1-your-key-here&quot; &gt;&gt; ~/.hermes/.env
```

Set the model in `~/.hermes/config.yaml`:

```yaml
model:
  provider: &quot;openrouter&quot;
  default: &quot;openrouter/free&quot;
```

The `openrouter/free` model routes to the best available free model. You can also set it with:

```bash
hermes config set model openrouter/free
```

### Switch models on the fly

```bash
hermes model
```

Or mid-conversation:

```
/model openrouter/free
```

### Using paid models later

```bash
hermes config set model anthropic/claude-sonnet-4.6
# or
hermes config set model openai/gpt-5.4
```

OpenRouter handles the routing. Your API key stays the same.

## First conversation

Start chatting (classic CLI or the newer TUI):

```bash
hermes          # classic CLI
hermes --tui    # modern TUI (modal overlays, mouse selection)
```

You&apos;ll see a welcome banner showing your model, available tools, and skills. Type a message and press Enter:

```
❯ What can you help me with?
```

Some things to try right away:

```
❯ What&apos;s my disk usage? Show the top 5 largest directories.
❯ Search the web for the latest Docker release and summarize it.
❯ Create a Python script that monitors CPU usage and alerts me above 80%.
```

The agent runs terminal commands, searches the web, reads and edits files, and executes code. No extra configuration needed.

### Useful slash commands

Type `/` to see an autocomplete dropdown. The ones you&apos;ll use most:

| Command | What it does |
|---|---|
| `/help` | Show all available commands |
| `/model` | Switch models interactively |
| `/tools` | List available tools |
| `/personality pirate` | Try a fun personality |
| `/new` or `/reset` | Start a fresh conversation |
| `/save` | Save the conversation |
| `/compress` | Compress context when it gets long |
| `/usage` | Show token usage for this session |
| `/skills` | Browse installed skills |
| `/retry` / `/undo` | Retry or undo the last turn |
| `/voice on` | Enable voice mode |

### Multi-line input and interrupts

Press `Alt+Enter` or `Ctrl+J` to add a new line. `Shift+Enter` also works in terminals that support the Kitty keyboard protocol (Kitty, foot, WezTerm, Ghostty; iTerm2 / Alacritty / VS Code with the protocol enabled).

If the agent is taking too long, type a new message and press Enter — it interrupts the current task and switches to your new instructions. `Ctrl+C` also works. On messaging platforms, send `/stop` or a new message.

### Resume a session

```bash
hermes --continue    # Resume the most recent session
hermes -c            # Short form
hermes sessions list # List saved sessions
```
## Memory system

Hermes has three layers of memory that work together.

### MEMORY.md — Agent&apos;s personal notes

The agent writes its own notes about your environment, conventions, and things it learned. Stored in `~/.hermes/memories/MEMORY.md`. Limited to 2,200 characters (~800 tokens) to keep the system prompt bounded.

The agent manages this automatically. When it discovers that your server runs Ubuntu 22.04 with Docker installed, it saves that. When it learns you prefer concise responses, it saves that too. You don&apos;t need to tell it to remember — it watches for useful facts and stores them.

### USER.md — Your profile

Information about you: name, preferences, communication style, timezone. Stored in `~/.hermes/memories/USER.md`. Limited to 1,375 characters (~500 tokens).

### Session search

Beyond the two markdown files, Hermes stores all past conversations in SQLite with FTS5 full-text search. It can search through weeks of old conversations to find something you discussed before:

```
❯ What did we discuss about the Nginx configuration last week?
```

The agent searches its session history, summarizes the relevant parts, and gives you the answer. No other assistant in the OpenClaw family does this.

### Configure memory

In `~/.hermes/config.yaml`:

```yaml
memory:
  memory_enabled: true
  user_profile_enabled: true
  memory_char_limit: 2200
  user_char_limit: 1375
```

&lt;Notice type=&quot;info&quot; title=&quot;Upgrade to vector-based memory&quot;&gt;
Hermes can use [Hindsight](/hindsight-docker-deploy/) as its memory backend instead of the built-in MEMORY.md and session search. Hindsight stores memories as vector embeddings with entity extraction, which means better recall for complex queries and cross-session learning. See the [Hindsight integration](https://hindsight.vectorize.io/integrations) for setup details.
&lt;/Notice&gt;

## Skills system

This is the feature I keep coming back to. Skills are not pre-built plugins you install from a hub. The agent creates them from tasks it completes.

### How skill creation works

1. You ask Hermes to do something complex (deploy a service, set up a CI pipeline, configure Nginx)
2. Hermes completes the task using its tools
3. After finishing, it extracts the workflow into a reusable skill
4. Next time you ask for something similar, it uses and refines that skill

Skills also self-improve. Each time one runs, the agent checks whether the steps could be better and updates the skill if so.

### Browse and install community skills

```bash
hermes skills browse                      # list hub skills
hermes skills search kubernetes
hermes skills search react --source skills-sh
hermes skills install openai/skills/k8s   # security scan runs first
```

Installed skills become slash commands automatically (`/k8s deploy the staging manifest`). Or use `/skills` inside chat.

### Skills Hub

Community-contributed skills live at [agentskills.io](https://agentskills.io). You can browse and install them, or publish your own.

## MCP servers

Hermes can load [Model Context Protocol](https://modelcontextprotocol.io/) servers so the agent gains external tools (GitHub, databases, browsers, and more) without custom code.

Add them in `~/.hermes/config.yaml`:

```yaml
mcp_servers:
  github:
    command: npx
    args: [&quot;-y&quot;, &quot;@modelcontextprotocol/server-github&quot;]
    env:
      GITHUB_PERSONAL_ACCESS_TOKEN: &quot;ghp_your_token&quot;
```

Restart the CLI or gateway after editing. Keep tokens in `.env` when possible and only put non-secret wiring in `config.yaml`.

## Built-in web dashboard

Hermes ships a web dashboard (default port **9119**) for config, sessions, and provider settings. On Docker, enable it with `HERMES_DASHBOARD=1` (see [Docker Compose](#docker-compose-deployment)). On a bare install:

```bash
hermes dashboard
# or bind loopback only for remote SSH tunnels:
hermes dashboard --host 127.0.0.1 --no-open
```

Always put auth in front of a non-loopback bind. For a deeper walkthrough of the built-in UI and third-party options, see the [Hermes dashboard guide](/hermes-dashboard-guide/) and [best Hermes dashboards](/best-hermes-dashboards/).

## Setting up messaging platforms

Hermes talks to Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Microsoft Teams, Home Assistant, Mattermost, Matrix, and DingTalk through a single gateway process.

### Quick setup

```bash
hermes gateway setup
```

The interactive wizard walks you through each platform. It shows what&apos;s already configured and offers to start the gateway when done.

&lt;Tabs&gt;
&lt;Tab name=&quot;Telegram&quot;&gt;

1. Talk to [@BotFather](https://t.me/BotFather) on Telegram and create a new bot
2. Copy the bot token
3. Add it to `~/.hermes/.env`:

```bash
TELEGRAM_BOT_TOKEN=your-bot-token-here
TELEGRAM_ALLOWED_USERS=your-telegram-user-id
```

To find your user ID, talk to [@userinfobot](https://t.me/userinfobot) on Telegram.

4. Start the gateway:

```bash
hermes gateway start
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Discord&quot;&gt;

1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Create a new application and add a Bot
3. Under **Privileged Gateway Intents**, enable Message Content Intent
4. Copy the bot token and add it to `~/.hermes/.env`:

```bash
DISCORD_BOT_TOKEN=your-bot-token-here
DISCORD_ALLOWED_USERS=your-discord-user-id
```

5. Invite the bot to your server with this URL (replace `YOUR_APP_ID`):

```
https://discord.com/oauth2/authorize?client_id=YOUR_APP_ID&amp;scope=bot+applications.commands&amp;permissions=274878286912
```

6. Start the gateway:

```bash
hermes gateway start
```

&lt;/Tab&gt;
&lt;Tab name=&quot;WhatsApp / Signal&quot;&gt;

WhatsApp and Signal both work through the gateway. Run the setup wizard:

```bash
hermes gateway setup
```

Select the platform you want and follow the prompts. WhatsApp uses the Baileys library for web client pairing. Signal requires the Signal CLI.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Run the gateway as a service

On a VPS, you want the gateway running at boot:

```bash
# Install as a systemd service
hermes gateway install

# Start it
hermes gateway start

# Check status
hermes gateway status

# View logs
journalctl --user -u hermes-gateway -f

# Enable lingering so it survives logout
sudo loginctl enable-linger $USER
```

On a headless VPS, use the system service instead:

```bash
sudo hermes gateway install --system
sudo hermes gateway start --system
```

### Security

The gateway denies all users by default. Only users in the allowlist can interact:

```bash
# In ~/.hermes/.env
TELEGRAM_ALLOWED_USERS=123456789
DISCORD_ALLOWED_USERS=123456789012345678
```

As an alternative to allowlists, you can use DM pairing. Unknown users get a one-time pairing code when they message the bot:

```bash
hermes pairing approve telegram XKGH5N7P
hermes pairing list
hermes pairing revoke telegram 123456789
```

## Terminal backends

By default, Hermes runs commands directly on your machine. For security, you can isolate command execution in a container or send it to a remote server.

```yaml
# In ~/.hermes/config.yaml
terminal:
  backend: local    # or: docker, ssh, singularity, modal, daytona
  timeout: 180
```

| Backend | What it does | Best for |
|---|---|---|
| `local` | Runs on your machine (default) | Development, trusted tasks |
| `docker` | Isolated containers | Security, reproducibility |
| `ssh` | Remote server | Keeping the agent away from its own code |
| `daytona` | Cloud sandbox workspace | Persistent remote dev environments |
| `singularity` | HPC containers | Cluster computing |
| `modal` | Serverless cloud | Scale-to-zero, pay-per-use |

### Docker isolation

```yaml
terminal:
  backend: docker
  docker_image: python:3.11-slim
  container_cpu: 1
  container_memory: 5120
  container_disk: 51200
  container_persistent: true
```

Containers run with a read-only root filesystem, all Linux capabilities dropped, no privilege escalation, PID limits, and namespace isolation.

### SSH backend

Run commands on a separate machine entirely:

```yaml
terminal:
  backend: ssh
```

```bash
# In ~/.hermes/.env
TERMINAL_SSH_HOST=my-server.example.com
TERMINAL_SSH_USER=myuser
TERMINAL_SSH_KEY=~/.ssh/id_rsa
```

## Voice mode

Hermes has voice support across CLI and messaging. You can talk to it with your mic in the terminal, get spoken replies in Telegram and Discord, and have live voice conversations in Discord voice channels.

### Prerequisites

```bash
# From the Hermes install tree (curl installer layout)
cd ~/.hermes/hermes-agent
uv pip install -e &quot;.[voice]&quot;   # includes faster-whisper for local STT

# System dependencies (Ubuntu/Debian)
sudo apt install portaudio19-dev ffmpeg libopus0
```

On native Windows, the install tree is under `%LOCALAPPDATA%\hermes\hermes-agent`.
### CLI voice mode

Start the CLI and enable voice:

```bash
hermes
/voice on
```

Press `Ctrl+B` to record. Speak, and when you stop, it auto-detects silence after 3 seconds and transcribes your audio. If TTS is enabled (`/voice tts`), the agent speaks its reply back.

### Messaging voice replies

In Telegram or Discord, send:

```
/voice tts
```

The agent now sends spoken audio alongside text for every response.

### Discord voice channels

The agent can join a Discord voice channel, listen to you speak, and reply with spoken audio:

```
/voice join
```

This requires additional Discord bot permissions (Connect, Speak, Use Voice Activity) and the Opus codec on your server.

### TTS providers

| Provider | Cost | Quality | Setup |
|---|---|---|---|
| Edge TTS | Free | Good | Works out of the box |
| NeuTTS | Free | Good | `uv pip install &quot;neutts[all]&quot;` in the Hermes venv |
| ElevenLabs | Paid | Premium | Set `ELEVENLABS_API_KEY` |
| OpenAI TTS | Paid | Good | Set `VOICE_TOOLS_OPENAI_KEY` |

Configure in `~/.hermes/config.yaml`:

```yaml
tts:
  provider: &quot;edge&quot;
  edge:
    voice: &quot;en-US-AriaNeural&quot;

stt:
  provider: &quot;local&quot;
  local:
    model: &quot;base&quot;
```

## Personality and SOUL.md

Hermes uses a file called `SOUL.md` as its identity. It goes into the system prompt first, before anything else, and shapes how the agent talks and thinks.

Edit it at `~/.hermes/SOUL.md`:

```markdown
# Personality
You are a pragmatic senior engineer with strong taste.
You optimize for truth, clarity, and usefulness over politeness theater.

## Style
- Be direct without being cold
- Prefer substance over filler
- Push back when something is a bad idea
- Keep explanations compact unless depth is useful

## What to avoid
- Sycophancy
- Hype language
- Overexplaining obvious things
```

### Built-in personalities

Switch personalities on the fly with `/personality`:

| Name | Description |
|---|---|
| `helpful` | Friendly, general-purpose assistant |
| `concise` | Brief, to-the-point responses |
| `technical` | Detailed technical expert |
| `creative` | Innovative thinking |
| `teacher` | Patient educator with examples |
| `pirate` | Tech-savvy buccaneer |
| `noir` | Hard-boiled detective narration |

```
/personality concise
/personality pirate
```

`SOUL.md` is your baseline. `/personality` is a session-level overlay.

## Scheduled tasks

Hermes has a built-in cron scheduler. Just tell it what you want in plain English:

```
❯ Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram.
❯ Every Friday at 5pm, back up the PostgreSQL database and report the size.
❯ Every hour, check if nginx is running and restart it if not.
```

The agent creates cron jobs that run through the gateway. Results land on whatever platform you configured.

## Migrating from OpenClaw

If you&apos;re coming from OpenClaw, Hermes can import your settings, memories, skills, and API keys.

**During first-time setup:** The setup wizard (`hermes setup`) auto-detects `~/.openclaw` and offers to migrate.

**Anytime after install:**

```bash
hermes claw migrate              # Interactive migration
hermes claw migrate --dry-run    # Preview what would be migrated
hermes claw migrate --preset user-data   # Migrate without secrets
hermes claw migrate --overwrite  # Overwrite existing conflicts
```

What gets imported:
- **SOUL.md** — persona file
- **Memories** — MEMORY.md and USER.md entries
- **Skills** — user-created skills go to `~/.hermes/skills/openclaw-imports/`
- **Command allowlist** — approval patterns
- **Messaging settings** — platform configs, allowed users, working directory
- **API keys** — Telegram, OpenRouter, OpenAI, Anthropic, ElevenLabs tokens
- **TTS assets** — workspace audio files
- **Workspace instructions** — AGENTS.md (with `--workspace-target`)

## Troubleshooting

When something feels off, run this sequence before adding more features:

```bash
hermes doctor           # Config / env / dependency checks
hermes model            # Re-select provider and model
hermes setup            # Re-run wizard if needed
hermes sessions list    # Confirm sessions and profile
hermes --continue       # Resume last chat
hermes gateway status   # Messaging gateway health
```

| Symptom | Likely cause | Fix |
|---|---|---|
| Empty or broken replies | Wrong provider auth or model | `hermes model` and re-auth |
| Custom endpoint returns garbage | Bad base URL / model name | Test the endpoint outside Hermes first |
| Gateway up but no messages | Token, allowlist, or platform setup | `hermes gateway setup` + `hermes gateway status` |
| `--continue` finds nothing | Different profile or unsaved session | `hermes sessions list` |
| Docker permission errors on `~/.hermes` | UID/GID mismatch | Set `HERMES_UID` / `HERMES_GID` to host owner |
| Dashboard fails on public bind | Auth required | Basic auth env vars, OAuth, or bind `127.0.0.1` |

## How Hermes Agent differs from OpenClaw

I&apos;ve used both extensively. Here are the differences that actually matter day to day:

**The learning loop changes how you work with it.** In OpenClaw, every complex task starts from scratch. In Hermes, once you&apos;ve walked the agent through deploying a Docker service, it creates a skill for that workflow. Next time you say &quot;deploy the staging API,&quot; it already knows the steps.

**Session search gives it long-term recall.** OpenClaw&apos;s memory is limited to what fits in its context files. Hermes stores every conversation in SQLite with full-text search. Ask &quot;what port did we configure for Redis last month?&quot; and it finds the answer in old sessions.

**The personality system is more structured.** OpenClaw uses a system prompt you edit by hand. Hermes has SOUL.md as a durable identity file, session-level `/personality` overlays, and 14 built-in presets.

**Voice mode actually works.** Mic input in the CLI, TTS replies on messaging, and live Discord voice channel conversations. Nothing else in the OpenClaw family does this.

**Terminal backend isolation.** OpenClaw runs commands on your local machine. Hermes can run them in Docker containers, on remote servers via SSH, or in serverless environments like Modal and Daytona. If the agent breaks something, it breaks the sandbox, not your server.

**Migration is painless.** `hermes claw migrate` imports everything from OpenClaw — memories, skills, API keys, platform configs. You don&apos;t start over.

**Where OpenClaw still wins:** bigger community, more third-party dashboards and skins, longer track record. If you want the more battle-tested option, OpenClaw is still it.

## CLI command reference

| Command | Description |
|---|---|
| `hermes` | Start chatting (classic CLI) |
| `hermes --tui` | Start the modern TUI |
| `hermes model` | Choose your LLM provider and model |
| `hermes tools` | Configure which tools are enabled |
| `hermes setup` | Full setup wizard |
| `hermes setup --portal` | Quick Nous Portal + Tool Gateway setup |
| `hermes config set KEY VAL` | Set a config value |
| `hermes config edit` | Open config.yaml in your editor |
| `hermes gateway setup` | Configure messaging platforms |
| `hermes gateway install` | Install as a system service |
| `hermes gateway start` | Start the messaging gateway |
| `hermes gateway status` | Check gateway status |
| `hermes dashboard` | Start the built-in web dashboard |
| `hermes skills browse` | Browse the Skills Hub |
| `hermes skills search QUERY` | Search for skills |
| `hermes skills install SKILL` | Install a skill |
| `hermes claw migrate` | Migrate from OpenClaw |
| `hermes sessions list` | List saved sessions |
| `hermes update` | Update to latest version |
| `hermes doctor` | Diagnose issues |
| `hermes --continue` | Resume last session |

## Configuration reference

The full directory structure:

```
~/.hermes/
├── config.yaml     # Settings (model, terminal, TTS, MCP, compression, etc.)
├── .env            # API keys and secrets
├── auth.json       # OAuth provider credentials
├── SOUL.md         # Agent identity
├── memories/       # MEMORY.md, USER.md
├── skills/         # Agent-created and hub skills
├── cron/           # Scheduled jobs
├── sessions/       # Conversation history
├── profiles/       # Multi-profile gateways (optional)
└── logs/           # Logs (secrets auto-redacted)
```

Key config options in `config.yaml`:

```yaml
model:
  provider: &quot;openrouter&quot;
  default: &quot;openrouter/free&quot;

terminal:
  backend: local
  timeout: 180

memory:
  memory_enabled: true
  user_profile_enabled: true

tts:
  provider: &quot;edge&quot;

stt:
  provider: &quot;local&quot;

display:
  tool_progress: all
```

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Do I need a GPU to run Hermes Agent?**

No. Hermes Agent is the client — it sends requests to an LLM provider like OpenRouter. The provider runs the model on their hardware. Your server just needs enough RAM for the Python process (around 200MB).

**Can I use Hermes Agent with Ollama for fully local inference?**

Yes. Set `OPENAI_BASE_URL=http://localhost:11434/v1` and `OPENAI_API_KEY=ollama` in `~/.hermes/.env`. Or use `hermes model` and select &quot;Custom endpoint.&quot; See our [Ollama Docker guide](/ollama-docker-install/) for setting up Ollama.

**How does the OpenRouter free tier work?**

OpenRouter offers several models at zero cost with rate limiting. You get a generous daily allowance. For heavier use, add credits to your OpenRouter account and switch to paid models. No changes needed on the Hermes side — same API key, different model name.

**Can I migrate from OpenClaw?**

Yes. Run `hermes claw migrate` and it imports your persona, memories, skills, API keys, and platform configs. Use `--dry-run` first to preview what gets migrated.

**Does Hermes work on Windows?**

Yes, natively. Run the PowerShell installer: `iex (irm https://hermes-agent.nousresearch.com/install.ps1)`. It bundles portable Git Bash when needed. WSL2 still works if you prefer a Linux environment. There is also a [Hermes Desktop](https://hermes-agent.nousresearch.com/) installer for macOS and Windows.

**Can I run Hermes with Docker Compose?**

Yes. Mount `~/.hermes` at `/opt/data`, run `nousresearch/hermes-agent setup` once, then `docker compose up -d` with `gateway run`. See the [Docker Compose section](#docker-compose-deployment) and the [official docker-compose.yml](https://github.com/NousResearch/hermes-agent/blob/main/docker-compose.yml).

**Can multiple people use one Hermes instance?**

Yes, through the messaging gateway. Each platform chat gets its own session. Multiple Telegram users or Discord channels can talk to the same instance with separate conversation histories. Use allowlists or DM pairing to control who has access.

**How does Hermes Agent compare to OpenFang?**

OpenFang focuses on autonomous agents (Hands) that work on schedules without prompting, and has 40 channel adapters with 16 security layers. Hermes focuses on the learning loop, voice mode, and a deeper memory system. Different strengths. See our [OpenFang setup guide](/openfang-setup-guide/) for the full comparison.

**How does it compare to CoPaw?**

CoPaw has a built-in web console and better support for Chinese messaging apps (DingTalk, Feishu, QQ). Hermes has the learning loop, voice mode, a built-in dashboard, and more terminal backend options. See our [CoPaw setup guide](/copaw-setup-guide/) for details.

**Is my data private?**

All data stays on your server. The only external calls go to your configured LLM provider. If you run local models via Ollama, nothing leaves your machine at all. Logs auto-redact secrets.

&lt;/Accordion&gt;

If you want an assistant that actually gets better the more you use it, Hermes Agent is the one to try. The skill creation loop, session search, Docker deployment path, and built-in dashboard make it a solid 24/7 option on a cheap VPS. And if you&apos;re already on OpenClaw, the migration command means you can try it without losing anything.

For other self-hosted assistant options, check out our [OpenClaw alternatives](/openclaw-alternatives/) roundup. If you want autonomous agents running on schedules, the [OpenFang setup guide](/openfang-setup-guide/) covers that. For a single Go binary on minimal hardware, see the [PicoClaw setup guide](/picoclaw-setup-guide/). And for the multi-channel web console approach, the [CoPaw setup guide](/copaw-setup-guide/) has the details. For the built-in UI and third-party web UIs, see the [Hermes dashboard guide](/hermes-dashboard-guide/) and [best Hermes dashboards](/best-hermes-dashboards/) roundup. For affordable model recommendations including MiniMax M3, MiMo V2.5 Pro, and GLM 5.2, see the [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide. If you are looking for a minimal coding agent to pair with Hermes, our [Pi coding agent setup guide](/pi-coding-agent-setup-guide/) covers installation, model configuration, and the best extensions. For structured task management with multi-agent workflows, the [Hermes Kanban setup guide](/hermes-kanban-setup-guide/) covers task boards, dependencies, and coordination patterns. Catalog of related GitHub projects (OpenClaw, Pi, OpenCode, memory, gateways): [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>How to Update All Node.js Dependencies to Latest Version</title><link>https://www.bitdoze.com/nodejs-update-dependencies/</link><guid isPermaLink="true">https://www.bitdoze.com/nodejs-update-dependencies/</guid><description>Learn how to update all Node.js dependencies with npm-check-updates, interactive mode, doctor mode, and supply chain security best practices.</description><pubDate>Tue, 14 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Node.js has a large ecosystem of packages you can pull into your projects. These packages are your **dependencies**, managed by **npm** (Node Package Manager) or alternative tools like pnpm, Yarn, and Bun.

Dependencies live in a file called **`package.json`** at the root of your project. This file tracks your project&apos;s name, version, scripts, and every dependency it needs. Here&apos;s a typical entry:

```json
&quot;dependencies&quot;: {
  &quot;express&quot;: &quot;^4.17.1&quot;
}
```

The version number uses semantic versioning prefixes. The `^` prefix means &quot;any version compatible with 4.x.x&quot; -- so `^4.17.1` accepts anything from 4.17.1 up to (but not including) 5.0.0. The `~` prefix is more restrictive, accepting only patch updates within the same minor version (e.g., `~4.17.1` means 4.17.x). An exact version like `4.17.1` locks to that specific release.

To install dependencies, run `npm install` in your terminal. This downloads packages from the npm registry into a `node_modules` folder.

If you don&apos;t have Node.js installed yet, check out how to [install Node.js using NVM](/install-nodejs-using-nvm-macos-ubuntu/) -- it&apos;s the recommended way to manage multiple Node.js versions. Fish shell users can also see [how to use NVM with Fish Shell](/nvm-fish-shell/).

Keeping those dependencies updated is important. Outdated packages accumulate security vulnerabilities, miss performance improvements, and eventually become incompatible with each other. This guide covers how to update Node.js dependencies safely in 2026.

## Why you should update Node.js dependencies regularly

Regular dependency updates give you:

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;Security patches for known vulnerabilities&lt;/li&gt;
  &lt;li&gt;New features and API improvements&lt;/li&gt;
  &lt;li&gt;Performance optimizations from upstream changes&lt;/li&gt;
  &lt;li&gt;Bug fixes that resolve issues in your codebase&lt;/li&gt;
  &lt;li&gt;Better compatibility with the latest Node.js versions&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

In 2025 alone, security researchers flagged nearly 455,000 malicious packages on npm. Supply chain attacks are a real and growing threat. Running outdated dependencies exposes your project to known exploits that have already been patched upstream.

But updating isn&apos;t risk-free.

&lt;Notice type=&quot;warning&quot; title=&quot;Breaking changes&quot;&gt;
Major version bumps (e.g., Express 4.x to 5.x) can include breaking API changes. Always check the changelog before upgrading across major versions. A function you rely on might have been renamed, removed, or its parameters changed. Use a staged update strategy (patch first, then minor, then major) to isolate problems.
&lt;/Notice&gt;

With the right tools and workflow, you can update without breaking production.

## Understanding semantic versioning (semver)

Before getting into update commands, understand how npm version numbers work. Every package follows the **MAJOR.MINOR.PATCH** convention:

| Version Part | What Changes | Example |
|---|---|---|
| **MAJOR** (1st) | Breaking changes, incompatible API changes | `4.17.1` → `5.0.0` |
| **MINOR** (2nd) | New features, backward-compatible | `4.17.1` → `4.18.0` |
| **PATCH** (3rd) | Bug fixes, security patches, backward-compatible | `4.17.1` → `4.17.2` |

&lt;Notice type=&quot;info&quot; title=&quot;Semver prefix quick reference&quot;&gt;
`^4.17.1` (caret) -- allows minor and patch updates within 4.x.x. This is the npm default.
`~4.17.1` (tilde) -- allows only patch updates within 4.17.x.
`4.17.1` (exact) -- locks to that exact version. No automatic updates.
`&gt;=4.17.1` -- any version at or above 4.17.1.
&lt;/Notice&gt;

Most `package.json` files use `^` prefixes. This means running `npm install` will pull the latest compatible patch or minor release -- but never a new major version. To jump across major versions, you need a tool like npm-check-updates.

## How to update all Node.js dependencies using npm-check-updates

[npm-check-updates](https://www.npmjs.com/package/npm-check-updates) (ncu) is the go-to tool for bumping dependencies to their latest versions. It rewrites the version numbers in your `package.json` without installing anything. You run `npm install` afterward. As of 2026, ncu is at v22+ and requires Node.js `^20.19.0 || ^22.12.0 || &gt;=24.0.0` with npm `&gt;=10.0.0`.

### 1. Install npm-check-updates

You have two options: global install or npx.

&lt;Tabs&gt;
&lt;Tab name=&quot;npx (Recommended)&quot;&gt;
```sh
npx npm-check-updates
```

Runs ncu without installing anything globally. This is the recommended approach. It always uses the latest version and doesn&apos;t pollute your global npm packages.
&lt;/Tab&gt;
&lt;Tab name=&quot;Global install&quot;&gt;
```sh
npm install -g npm-check-updates
```

Installs ncu globally so you can run `ncu` directly from any project. You&apos;ll need to run `npm update -g npm-check-updates` periodically to keep it current.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Important: npx ncu does not work&quot;&gt;
Use the full name `npx npm-check-updates`. The short form `npx ncu` resolves to a completely different (unrelated) package on npm. This is a common mistake that leads to confusing errors.
&lt;/Notice&gt;

### 2. Check which packages are outdated

Run `ncu` in your project directory to see which packages have newer versions available:

```sh
ncu
```

Output looks like this:

```
 express          ^4.21.0  →   ^5.1.0
 cors             ^2.8.5   →   ^3.0.0
 dotenv           ^16.4.5  →   ^16.4.7
 uuid             ^9.0.1   →   ^11.1.0
```

You can also use npm&apos;s built-in command as an alternative:

```sh
npm outdated
```

The difference: `npm outdated` shows what&apos;s available within your current semver ranges. `ncu` shows the absolute latest versions regardless of semver constraints -- including major version jumps.

### 3. Update package.json to latest versions

To rewrite `package.json` with the latest version numbers:

```sh
ncu -u
```

This updates the version strings in `package.json` but does **not** install anything yet. You&apos;ll see output like:

```
Upgrading /home/user/myproject/package.json
[====================] 5/5 100%

 express     ^4.21.0  →   ^5.1.0
 cors        ^2.8.5   →   ^3.0.0
 dotenv      ^16.4.5  →   ^16.4.7
 uuid        ^9.0.1   →   ^11.1.0
 morgan      ^1.10.0  →   ^1.10.1

Run npm install to install new versions.
```

### 4. Install the updated packages

Now install the new versions:

```sh
npm install
```

This downloads the updated packages into `node_modules`. Run your tests afterward to make sure nothing broke.

### Quick reference: npm-check-updates commands

| Command | What It Does |
|---|---|
| `ncu` | Show outdated packages (read-only) |
| `ncu -u` | Update `package.json` to latest versions |
| `ncu -i` | Interactive mode -- pick packages one by one |
| `ncu --doctor -u` | Iteratively test upgrades to find breaking changes |
| `ncu --target patch` | Only suggest patch-level updates |
| `ncu --target minor` | Only suggest minor + patch updates |
| `ncu --filter express` | Only check specific packages |
| `ncu --reject react` | Exclude specific packages from updates |
| `ncu --dep prod` | Only check production dependencies |
| `ncu --cooldown 7` | Skip packages published less than 7 days ago |

## Using interactive mode to update selectively

When you don&apos;t want to update everything at once, use interactive mode:

```sh
ncu -i
```

This displays a list of outdated packages with checkboxes. Use arrow keys to navigate and the spacebar to toggle individual packages on or off. Press Enter to apply your selections.

Interactive mode is useful when:
- You want to update a few safe packages first and handle risky ones later
- You&apos;re doing a triage pass on a project with many outdated dependencies
- You want to skip packages that you know have breaking changes

&lt;Notice type=&quot;info&quot; title=&quot;Organize output by update type&quot;&gt;
Add `--format group` to organize the list by major, minor, and patch updates:
```sh
ncu -i --format group
```
This makes it easy to quickly approve all patch updates and skip major ones.
&lt;/Notice&gt;

## Using doctor mode for safe major upgrades

Major version upgrades are the riskiest part of dependency updates. Doctor mode handles this automatically by iteratively upgrading packages and running your test suite to find exactly which upgrade breaks things.

```sh
ncu --doctor -u
```

Here&apos;s how it works:

1. Runs your test suite first to establish a baseline (tests must pass initially)
2. Upgrades all packages to their latest versions
3. Runs tests again
4. If tests fail, it uses a binary search approach. It reverts half the upgrades, tests, and narrows down until it finds the specific package causing the failure.
5. Keeps all passing upgrades and reverts only the breaking one
6. Repeats until all packages are tested

&lt;Notice type=&quot;success&quot; title=&quot;Recommended for major upgrades&quot;&gt;
Doctor mode is the safest way to handle large version jumps. Instead of guessing which package broke your app, the tool pinpoints it for you. The catch: you need a working test suite, and it requires the `-u` flag to actually apply changes.
&lt;/Notice&gt;

Available since ncu v8, doctor mode has become a standard part of dependency update workflows. It works with any test runner that exits with a non-zero code on failure.

## Targeting specific update levels with ncu

Sometimes you don&apos;t want the latest of everything. ncu&apos;s `--target` flag lets you control how aggressive updates are:

```sh
# Only patch updates (bug fixes, security patches)
ncu --target patch

# Minor + patch (new features, no breaking changes)
ncu --target minor

# Stay within current semver range (same as what npm install would do)
ncu --target semver

# Pre-release versions tagged @next
ncu --target @next

# Highest version number regardless of dist-tag
ncu --target greatest
```

A staged approach works well in practice:

1. `ncu --target patch -u &amp;&amp; npm install &amp;&amp; npm test` -- safe, apply immediately
2. `ncu --target minor -u &amp;&amp; npm install &amp;&amp; npm test` -- usually safe, test after
3. Handle major upgrades individually with doctor mode

You can also filter which packages to update:

```sh
# Only update specific packages
ncu --filter express,cors

# Update everything except specific packages
ncu --reject react,react-dom

# Use wildcards
ncu --filter &quot;@types/*&quot;

# Only production dependencies (skip devDependencies)
ncu --dep prod
```

## Updating dependencies with pnpm, Yarn, and Bun

npm-check-updates works with any package manager (it only modifies `package.json`). But each package manager also has its own built-in update commands:

&lt;Tabs&gt;
&lt;Tab name=&quot;npm&quot;&gt;
```sh
# Check outdated (within semver range)
npm outdated

# Update within semver ranges
npm update

# Update package.json to latest + install
npx npm-check-updates -u
npm install
```
&lt;/Tab&gt;
&lt;Tab name=&quot;pnpm&quot;&gt;
```sh
# Check outdated
pnpm outdated

# Update within semver ranges
pnpm update

# Update to latest versions (ignoring semver ranges)
pnpm update --latest

# Interactive update mode
pnpm update --interactive --latest
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Yarn&quot;&gt;
```sh
# Check outdated (Yarn 1.x / Classic)
yarn outdated

# Update within semver ranges
yarn upgrade

# Interactive upgrade (Yarn Berry / 2+)
yarn up -i

# Update to latest (Yarn Berry)
yarn up --interactive
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Bun&quot;&gt;
```sh
# Update dependencies within semver ranges
bun update

# Update to latest versions
bun update --latest

# Check for outdated packages
bun outdated
```

For a deeper dive, see [how to update packages in Bun](/bun-update-packages/). You can also read our comparison of [Bun vs npm, Yarn, pnpm, and others](/bun-package-manager/) to choose the right package manager for your project.
&lt;/Tab&gt;
&lt;/Tabs&gt;

All of these tools respect `package.json` version ranges by default. To jump across major versions, you typically need the `--latest` flag (pnpm, Bun) or a tool like ncu.

## Supply chain security: dependency cooldowns and release age

This section didn&apos;t exist when this article was first written. Supply chain security has since become a core part of dependency management.

Supply chain attacks on npm have exploded. In 2025, security researchers identified nearly 455,000 malicious packages on the npm registry. Attackers hijack popular package names, inject malware into legitimate updates, or typosquat common package names. The Axios compromise in March 2026 showed that even widely-trusted packages aren&apos;t immune.

The defense: **dependency cooldowns**, refusing to install packages that were published too recently.

### Setting a minimum release age in .npmrc

Starting with **npm 11.10.0** (February 2026), you can configure a `min-release-age` in your `.npmrc` file:

```
min-release-age=7d
```

This tells npm to reject any package published less than 7 days ago. The logic is simple: if a package update contains malware, it will likely be detected and removed by the community within a few days. Waiting a week gives security researchers time to flag malicious releases.

### Using cooldown with npm-check-updates

ncu v20+ auto-detects cooldown settings from your package manager config:

- npm: reads `min-release-age` from `.npmrc`
- pnpm: reads `minimumReleaseAge` from `.npmrc`
- Yarn Berry: reads `npmMinimalAgeGate` from `.yarnrc.yml`

You can also set it directly with ncu:

```sh
ncu --cooldown 7
```

This skips any package published less than 7 days ago when checking for updates.

&lt;Notice type=&quot;error&quot; title=&quot;npm v12 coming July 2026&quot;&gt;
Major security changes are landing in npm v12. By default, install scripts from dependencies will **not run** without explicit approval. Other defaults include `--allow-git: none` and `--allow-remote: none`. To prepare:

- Run `npm approve-scripts` to whitelist trusted packages now
- Run `npm deny-scripts` to explicitly block suspicious ones
- Test your CI/CD pipelines with these restrictions before v12 drops

These changes stop a common attack vector where malicious packages execute arbitrary code during `npm install`.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Protect your projects now&quot;&gt;
Even before npm v12, you can take these steps today:
1. Add `min-release-age=7d` to your `.npmrc`
2. Use `ncu --cooldown 7` when checking for updates
3. Run `npm audit` regularly to catch known vulnerabilities
4. Set up automated dependency updates with Dependabot or Renovate (covered below)

If you&apos;re deploying Node.js apps with Docker, see our guide on [top Docker commands you need to know](/docker-commands/) for container security best practices.
&lt;/Notice&gt;

## Running security audits with npm audit

After updating dependencies, always run a security audit:

```sh
npm audit
```

This checks your installed packages against the npm security advisory database and reports known vulnerabilities. Output includes the severity level (low, moderate, high, critical), the affected package, and the path through your dependency tree.

To automatically fix vulnerabilities:

```sh
# Fix within semver ranges (safe)
npm audit fix

# Fix with major version bumps (may break things)
npm audit fix --force
```

&lt;Notice type=&quot;info&quot; title=&quot;npm audit caveats&quot;&gt;
npm audit has a signal-to-noise problem. It often flags vulnerabilities in transitive dependencies (packages your packages depend on) that don&apos;t actually affect your runtime. A &quot;high severity&quot; audit finding in a dev-only package that only runs during testing is different from one in your production dependency path.

Use `npm audit --omit=dev` to focus on production dependencies. Don&apos;t panic over every audit finding. Evaluate whether the vulnerable code path is actually used in your project.
&lt;/Notice&gt;

## Automating dependency updates with Dependabot and Renovate

Manual updates work for small projects. For anything with CI/CD, automate it.

### Dependabot

GitHub&apos;s built-in dependency update bot. Configure it in `.github/dependabot.yml`:

```yaml
version: 2
updates:
  - package-ecosystem: &quot;npm&quot;
    directory: &quot;/&quot;
    schedule:
      interval: &quot;weekly&quot;
    open-pull-requests-limit: 10
    reviewers:
      - &quot;your-github-username&quot;
```

Dependabot creates pull requests automatically when new versions are available. It&apos;s free for all GitHub repositories and requires zero infrastructure.

### Renovate

Open source, self-hostable, and far more configurable. Advantages over Dependabot:

- **Grouping**: Combine multiple related updates into a single PR (e.g., all `@types/*` packages)
- **Automerge**: Auto-merge patch updates that pass CI
- **Schedules**: Run updates only during business hours
- **Custom rules**: Match packages by pattern, version type, or source

Configure via `renovate.json` in your repo root:

```json
{
  &quot;$schema&quot;: &quot;https://docs.renovatebot.com/renovate-schema.json&quot;,
  &quot;extends&quot;: [&quot;config:base&quot;],
  &quot;packageRules&quot;: [
    {
      &quot;matchUpdateTypes&quot;: [&quot;patch&quot;],
      &quot;automerge&quot;: true
    },
    {
      &quot;matchPackagePatterns&quot;: [&quot;@types/*&quot;],
      &quot;groupName&quot;: &quot;type definitions&quot;
    }
  ]
}
```

Use Dependabot if you&apos;re on GitHub and want something that works with zero config. Use Renovate if you need grouping, automerge, or finer control over update behavior.

## Best practices for updating Node.js dependencies safely

### Use a staged update strategy

Don&apos;t update everything at once. Work in stages:

1. Create a branch: `git checkout -b deps-update`
2. Update patch versions first: `ncu --target patch -u &amp;&amp; npm install &amp;&amp; npm test`
3. Update minor versions: `ncu --target minor -u &amp;&amp; npm install &amp;&amp; npm test`
4. Handle major versions individually: `ncu --doctor -u`
5. Commit and merge

This isolates problems. If something breaks, you know it&apos;s in the most recent stage.

### Create a .ncurc configuration file

Save project-specific ncu settings in `.ncurc.json`:

```json
{
  &quot;reject&quot;: [&quot;mongoose&quot;, &quot;sequelize&quot;],
  &quot;target&quot;: &quot;minor&quot;,
  &quot;upgrade&quot;: true
}
```

This keeps certain packages pinned (maybe you know they have breaking changes) and sets a default update level. Run `ncu` without flags and it reads this config automatically.

### Handle peer dependency conflicts

When packages require specific versions of shared dependencies, conflicts arise:

```sh
# Check for peer dependency issues
ncu --peer
```

If you hit peer dependency errors after updating, try:

```sh
npm install --legacy-peer-deps
```

This tells npm to use the older, more lenient dependency resolution. It&apos;s a workaround, not a fix, but it unblocks you while you sort out the conflict.

For a more permanent solution, use the `overrides` field in `package.json` to force specific versions:

```json
{
  &quot;overrides&quot;: {
    &quot;some-transitive-dependency&quot;: &quot;^2.0.0&quot;
  }
}
```

### General workflow tips

&lt;ListCheck&gt;
&lt;ul&gt;
  &lt;li&gt;Always work in a git branch when updating dependencies&lt;/li&gt;
  &lt;li&gt;Update frequently, weekly or monthly, to avoid accumulating debt&lt;/li&gt;
  &lt;li&gt;Read changelogs for major version updates before applying them&lt;/li&gt;
  &lt;li&gt;Pin exact versions in production, use ranges for dev tools&lt;/li&gt;
  &lt;li&gt;Set up CI to run tests on dependency update PRs&lt;/li&gt;
  &lt;li&gt;Use &lt;code&gt;min-release-age&lt;/code&gt; in .npmrc for supply chain protection&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Make sure your git workflow is solid. [Linking GitHub with SSH](/link-github-with-ssh-maco-linux/) makes pushing branches and reviewing PRs frictionless.

## Conclusion

Updating Node.js dependencies doesn&apos;t have to be scary. The workflow is straightforward:

1. **Check** what&apos;s outdated with `ncu`
2. **Update** in stages (patch → minor → major)
3. **Test** after each stage
4. **Commit** and merge

What&apos;s changed since this article was first published is the security environment. Supply chain attacks are real and growing. Configuring dependency cooldowns, running `npm audit`, and setting up automated updates with Dependabot or Renovate are no longer optional best practices. They&apos;re baseline hygiene.

Start by adding `min-release-age=7d` to your `.npmrc` today. Set up a weekly Dependabot schedule. Use doctor mode when you need to tackle major version jumps.

&lt;Button text=&quot;Install Node.js with NVM&quot; link=&quot;/install-nodejs-using-nvm-macos-ubuntu/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Host Node.js Apps with CloudPanel&quot; link=&quot;/install-cloudpanel-host-nodejs/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>tools</category><category>node</category><category>npm</category><category>javascript</category></item><item><title>Best Self-Hosted Server Panels: 2026 Comparison Guide</title><link>https://www.bitdoze.com/best-self-hosted-panels/</link><guid isPermaLink="true">https://www.bitdoze.com/best-self-hosted-panels/</guid><description>Compare the best self-hosted server panels for 2026 including Coolify, Dokploy, CloudPanel, and more. Find the right panel for PHP apps, Docker, and CI/CD.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/11/cloudpanel-interface.png&quot;;
import imag2 from &quot;../../assets/images/23/11/cyberpanel-features-hero.png&quot;;
import imag3 from &quot;../../assets/images/23/11/easypanel-interface.png&quot;;
import imag4 from &quot;../../assets/images/23/11/coolify-v4-ui.png&quot;;
import imag5 from &quot;../../assets/images/23/11/caprover-ui.png&quot;;

The self-hosted PaaS landscape has changed dramatically since 2023. Coolify now has 58K+ GitHub stars and powers over 481,000 self-hosted instances. New contenders like Dokploy have exploded onto the scene, gaining 35K+ stars in just two years. The best self-hosted server panels in 2026 are more capable, more polished, and more widely adopted than ever before.

This guide compares 8 panels across two categories: PHP/cPanel alternatives and Docker/CI-CD platforms. Whether you&apos;re hosting WordPress on a budget VPS or deploying microservices with push-to-deploy, there&apos;s a panel that fits.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Which panels are best for PHP and WordPress hosting&lt;/li&gt;
&lt;li&gt;Which panels handle Docker and CI/CD deployments best&lt;/li&gt;
&lt;li&gt;What&apos;s new in the self-hosted panel ecosystem in 2026&lt;/li&gt;
&lt;li&gt;How to choose the right panel for your stack and team size&lt;/li&gt;
&lt;li&gt;Security considerations you need to know about&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

You&apos;ll need a VPS to run these panels. Providers like [Hetzner](https://go.bitdoze.com/hetzner), [DigitalOcean](https://go.bitdoze.com/do), or [Vultr](https://go.bitdoze.com/vultr) work well and start at a few dollars per month.

&gt; If you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Self-hosted panels and how they can help you

A self-hosted server panel is a web dashboard that gives you full control over your server without touching the command line. You get a GUI for managing databases, domains, SSL certificates, deployments, backups, and more, all from a single interface.

These panels replace the need for managed hosting or expensive control panels like cPanel and Plesk. They run on your own VPS, which means:

- **Cost savings**: A $5/month VPS with a free panel replaces hosting that costs $50-200/month.
- **Full control**: You own the infrastructure. No vendor lock-in, no surprise price hikes.
- **Security isolation**: No shared hosting neighbors. Your data stays on your server.
- **Flexibility**: Install what you want, configure how you want, deploy how you want.

&lt;Notice type=&quot;info&quot; title=&quot;Why self-host?&quot;&gt;
Self-hosted panels let you run a full hosting platform on a $5/mo VPS, giving you the same capabilities as managed hosting costing 10-50x more.
&lt;/Notice&gt;

The ecosystem has matured significantly. Panels like Coolify and Dokploy now offer one-click service templates, automated SSL, preview deployments, and team collaboration features that rival commercial platforms.

## What features should be considered when choosing a self-hosted server panel?

&lt;Accordion label=&quot;User interface and ease of use&quot; group=&quot;features&quot; expanded=&quot;true&quot;&gt;
A clean, intuitive UI saves hours of frustration. Look for panels with drag-and-drop deployment, clear navigation, and good documentation. CloudPanel and EasyPanel stand out for their polished interfaces. Coolify and Dokploy have also improved significantly in recent versions.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Security&quot; group=&quot;features&quot;&gt;
Security features should include two-factor authentication (2FA), SSL/TLS certificate management (Let&apos;s Encrypt integration), firewall configuration, and regular security updates. The CyberPanel CVE-2024-51567 incident is a reminder that any panel can have vulnerabilities. Choose panels with active maintainers and a track record of fast patches.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Scalability&quot; group=&quot;features&quot;&gt;
If you plan to grow, look for multi-server support. EasyPanel offers Docker Swarm-based clustering (requires a business license). Coolify supports multi-server deployments out of the box. For single-server use, all panels work fine on a single VPS.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Docker and container support&quot; group=&quot;features&quot;&gt;
Docker support is table stakes for modern panels. Coolify, Dokploy, and EasyPanel are Docker-native. CloudPanel and CyberPanel focus on PHP/Node.js but can integrate Docker alongside. 1Panel has built-in Docker management with a visual container editor.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Backup capabilities&quot; group=&quot;features&quot;&gt;
Automated backups to S3-compatible storage are a must. Coolify, Dokploy, and EasyPanel all support scheduled backups to external storage. CloudPanel uses Rclone for remote backups. CyberPanel supports automatic backups to remote destinations.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Community and maintenance&quot; group=&quot;features&quot;&gt;
GitHub stars and commit frequency indicate project health. Coolify (58K+ stars), 1Panel (36K+), and Dokploy (35K+) have the largest communities. Check the release cadence. Panels that ship updates monthly are more likely to fix security issues quickly.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pricing and licensing&quot; group=&quot;features&quot;&gt;
Most panels offer a free self-hosted tier. CloudPanel is 100% free with no paid tiers. Coolify and Dokploy are free to self-host with optional cloud offerings. EasyPanel&apos;s multi-server and team features require a business license. CyberPanel has Enterprise tiers with LiteSpeed Enterprise licenses.
&lt;/Accordion&gt;

## Best self-hosted panels for PHP applications (cPanel alternatives)

If you&apos;re running PHP applications, WordPress, or Node.js sites and want a self-hosted cPanel alternative, these three panels are your best options. They&apos;re optimized for traditional web hosting with built-in web server management, database tools, and SSL automation.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/MijpWPVRgqA&quot;
  label=&quot;Self-Hosted Server Panels&quot;
/&gt;

### CloudPanel

&lt;Picture
  src={imag1}
  alt=&quot;CloudPanel Interface&quot;
/&gt;

[CloudPanel](https://www.cloudpanel.io/) is a lightweight, free server panel built around NGINX. It&apos;s my go-to recommendation for PHP hosting because it&apos;s fast, clean, and has zero cost. No paid tiers, no feature gating, no catch.

The current version is **v2.5.3** with a solid modern stack:

- **NGINX 1.25+** with HTTP/3 and QUIC support
- **PHP 7.1 through 8.5**
- **Node.js 18, 20, 22 LTS**
- **MySQL 8.0/8.4** and **MariaDB 10.6 through 11.4**
- **Redis** for caching
- **Varnish Cache** as an HTTP accelerator

CloudPanel now supports **Python applications** and can act as a **reverse proxy** for non-PHP workloads. OS support includes **Debian 11/12** and **Ubuntu 22.04/24.04**, running on both x86 and ARM64 architectures.

&lt;Notice type=&quot;success&quot; title=&quot;Still 100% free&quot;&gt;
Unlike many competitors, CloudPanel remains completely free with no paid tiers or feature gating.
&lt;/Notice&gt;

Key features:

- **One-click installations** for popular PHP applications
- **Free SSL certificates** via Let&apos;s Encrypt
- **PHP version management** per site to adjust memory limits, upload sizes, and execution times on the fly
- **File manager** for uploading, editing, and managing files through the browser
- **Advanced security** including site isolation, firewall, IP/bot blocking, basic auth, and 2FA
- **Backups** to external locations via Rclone

CloudPanel runs efficiently on affordable VPS like the [Hetzner CX22](https://go.bitdoze.com/hetzner). The minimal resource footprint means you don&apos;t need a powerful server.

Tutorials to get you started:

- [How To Install CloudPanel and Host Node.js Apps](https://www.bitdoze.com/install-cloudpanel-host-nodejs/)
- [Setup CloudPanel with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/)
- [CloudPanel Install on Hetzner With WordPress Setup](https://www.wpdoze.com/cloudpanel-install-on-hetzner/)
- [How To Secure CloudPanel And Have a Better Sleep](https://www.wpdoze.com/secure-cloudpanel/)
- [How to Safely Update CloudPanel to The Last Version](https://www.wpdoze.com/safely-update-cloudpanel/)

&gt; I have also created a course that can get you started, this is how much I like CloudPanel: [Master CloudPanel Course](https://webdoze.net/courses/cloudpanel-setup/)

CloudPanel does not come with an email system. This is intentional. It keeps the panel lightweight and focused. If you need email hosting, use a dedicated service or consider CyberPanel.

### CyberPanel

&lt;Picture
  src={imag2}
  alt=&quot;CyberPanel Interface&quot;
/&gt;

[CyberPanel](https://cyberpanel.net/) stands out for its LiteSpeed Web Server integration. If you need raw PHP performance plus built-in email hosting, CyberPanel delivers both. The current version is **v2.4.7** (May 2026), which includes a complete UI redesign that landed in v2.4.2.

Tech stack under the hood:

- **LiteSpeed/OpenLiteSpeed** as the web server
- **MariaDB** as the relational database
- **Redis** for caching
- **Built-in email server**
- **Docker Manager**
- **DNS manager**

&lt;Notice type=&quot;warning&quot; title=&quot;Security note&quot;&gt;
CyberPanel had a critical vulnerability (CVE-2024-51567) in late 2024 that was added to CISA&apos;s Known Exploited Vulnerabilities catalog. This was fixed in v2.3.8+. Always keep your panel updated to the latest version.
&lt;/Notice&gt;

What&apos;s new in CyberPanel v2.4.x:

- **Complete UI overhaul**: the interface has been redesigned from scratch
- **AI Scanner** for WordPress malware detection
- **Dark mode**
- **Web-based terminal** for direct server access from the browser
- **Real-time usage graphs** for monitoring CPU, memory, and disk
- **Per-website resource limits** using cgroups
- **One-click backup migration** between servers
- **n8n Docker app** for workflow automation
- **AlmaLinux 10** and **PHP 8.4/8.5** support

Note: CSF Firewall support was removed in v2.4.4 because CSF was discontinued in August 2025. CyberPanel has its own firewall management now.

Key features:

- **LiteSpeed Server** for high-performance PHP applications
- **Auto backups** to remote destinations
- **Built-in firewall** for security
- **File Manager** for browser-based file management
- **Docker Manager** for containerized applications
- **Email server** for full email hosting
- **DNS manager** for domain management
- **User management** with advanced package controls similar to cPanel

CyberPanel with LiteSpeed performs well on [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) with NVMe storage. The Enterprise tier offers LiteSpeed Enterprise licenses for production workloads at higher performance.

### 1Panel

[1Panel](https://1panel.dev/) is a modern open-source VPS control panel written in Go, with over 36,000 GitHub stars. It&apos;s especially popular in the Asia-Pacific region and offers a comprehensive approach to server management that covers both PHP hosting and Docker containers.

1Panel uses **OpenResty (NGINX)** as its web server and includes:

- **Docker management** with a visual container editor
- **WordPress one-click installation** with automatic SSL
- **AI agent support**: deploy Ollama and OpenClaw directly from the panel
- **App store** with dozens of pre-configured applications
- **Built-in firewall** and security hardening
- **Scheduled backups** to S3-compatible storage
- **File manager** with terminal access

&lt;Notice type=&quot;info&quot;&gt;
1Panel is especially popular in the Asia-Pacific region and offers a modern Go-based alternative to traditional PHP panels. It&apos;s a good all-rounder if you want both PHP hosting and Docker management in one panel.
&lt;/Notice&gt;

1Panel is **free** with Pro plans starting at $80/year for advanced features like multi-user management and priority support. The Go backend makes it fast and resource-efficient compared to PHP-based panels.

## Best Heroku &amp; Netlify alternatives for CI/CD and Docker apps

If you&apos;re deploying containerized applications, microservices, or want push-to-deploy CI/CD workflows, these panels are purpose-built for that. The self-hosted PaaS space has grown massively since 2023, with four strong contenders competing for your deployment stack.

### EasyPanel

&lt;Picture
  src={imag3}
  alt=&quot;EasyPanel Interface&quot;
/&gt;

[EasyPanel](https://easypanel.io/) is a Docker-native server control panel that makes application deployment straightforward. It uses Docker and Cloud Native Buildpacks under the hood, so you can deploy from Git, use templates, or run any Docker container.

&lt;Notice type=&quot;info&quot; title=&quot;Licensing change&quot;&gt;
EasyPanel&apos;s multi-server support now requires a business license. Single-server usage remains free.
&lt;/Notice&gt;

Key features:

- **Effortless setup**: quick installation with minimal configuration
- **User-friendly interface** with a clean dashboard for managing deployments
- **CI/CD automation**: push to GitHub and EasyPanel builds and deploys automatically
- **Docker integration** with native support for containers and compose files
- **120+ templates** for one-click deployment of popular apps (n8n, Plausible, SuiteCRM, and more)
- **Database management** for MySQL, MariaDB, PostgreSQL, MongoDB, and Redis
- **Multi-user access control** with role-based permissions
- **2FA** for account security
- **Database backups** to S3-compatible storage
- **Resource monitoring** for tracking CPU, memory, and disk usage
- **Multi-server support** via Docker Swarm (business license required)
- **Logs** and web consoles built into the interface

EasyPanel runs well on [DigitalOcean droplets](https://go.bitdoze.com/do) for simple Docker deployments.

Articles:

- [Easypanel.io: A Modern Hosting Panel for Applications and Databases](https://www.bitdoze.com/easypanel-modern-server-control-panel/)
- [How to Deploy Astro on Your VPS with EasyPanel](https://www.bitdoze.com/deploy-astro-easypanel/)

### Coolify

&lt;Picture
  src={imag4}
  alt=&quot;Coolify Interface&quot;
/&gt;

[Coolify](https://coolify.io/) is the most popular self-hosted PaaS in 2026. With 58,419 GitHub stars, 5,019 forks, and over 481,000 self-hosted instances, it has become the go-to platform for developers who want Heroku-like convenience without the price tag or vendor lock-in.

&lt;Notice type=&quot;success&quot; title=&quot;Most popular self-hosted PaaS&quot;&gt;
With 58K+ GitHub stars and 481K+ self-hosted instances, Coolify is the most widely adopted self-hosted deployment platform.
&lt;/Notice&gt;

Coolify v4.1.2 (June 2026) is a mature, stable release with a rich feature set:

- **280+ one-click services**: deploy databases, apps, and tools with a single click
- **Push-to-deploy** from Git repositories (GitHub, GitLab, and more)
- **Real-time terminal** for direct container and server access
- **Sentinel monitoring** for tracking server and application health
- **Audit logging** for tracking changes across your infrastructure
- **MCP (Model Context Protocol) server** for AI agent integration
- **Railpack buildpack** (beta) as an alternative to Nixpacks
- **Automatic SSL** via Let&apos;s Encrypt
- **Database provisioning** with automatic backups to S3-compatible storage
- **Multi-server support** for scaling across multiple VPS instances
- **Docker and Nixpacks** build systems
- **GitLab integration** and Arch Linux support

Coolify is free to self-host. There&apos;s also [Coolify Cloud](https://coolify.io/) (a managed option with 3,641+ customers) if you&apos;d rather not manage the infrastructure yourself.

Coolify&apos;s documentation recommends [Hetzner](https://go.bitdoze.com/hetzner) as an affordable VPS provider for self-hosting.

Articles:

- [Coolify Install A Free Heroku and Netlify Self-Hosted Alternative](https://www.bitdoze.com/coolify-install-heroku-alternative/)
- [Coolify v5 Self-Hosted PaaS Review](https://www.bitdoze.com/coolify-v5-self-hosted-paas-review/), our full Coolify review
- [Coolify vs Dokploy vs Kamal 2](https://www.bitdoze.com/coolify-vs-dokploy-vs-kamal-2/), deep-dive comparison

### Dokploy

[Dokploy](https://dokploy.com/) is the fastest-growing self-hosted PaaS, gaining over 35,000 GitHub stars since its April 2024 launch. It&apos;s an open-source alternative to Vercel, Netlify, and Heroku built with TypeScript, Docker, and Traefik.

&lt;Notice type=&quot;info&quot; title=&quot;Fastest growing panel&quot;&gt;
Dokploy gained 35K+ GitHub stars in just 2 years, making it the fastest-growing self-hosted PaaS option.
&lt;/Notice&gt;

Key features:

- **350+ service templates** for one-click deployments
- **Push-to-deploy** from GitHub, GitLab, and Bitbucket
- **Preview deployments**: automatically spin up preview environments for pull requests
- **SSL automation** via Let&apos;s Encrypt and Traefik
- **Database provisioning** for PostgreSQL, MySQL, MongoDB, Redis, and more
- **Automated backups** to S3-compatible storage
- **Traefik reverse proxy** with automatic service discovery
- **Team collaboration** with role-based access control
- **Custom domain management** per application
- **Webhook support** for CI/CD integration

Dokploy works great on [Vultr](https://go.bitdoze.com/vultr) instances for global deployments. Its TypeScript foundation and modern developer experience make it a strong choice if you&apos;re coming from Vercel or Netlify and want full control.

Articles:

- [Dokploy Install Guide](https://www.bitdoze.com/dokploy-install/)
- [Coolify vs Dokploy vs Kamal 2](https://www.bitdoze.com/coolify-vs-dokploy-vs-kamal-2/)

### CapRover

&lt;Picture
  src={imag5}
  alt=&quot;CapRover Interface&quot;
/&gt;

[CapRover](https://caprover.com/) is a mature, stable PaaS that uses Docker Swarm under the hood. It&apos;s been around longer than most alternatives and has earned a loyal community with 15,090 GitHub stars. The current version is **v1.14.2** (May 2026).

CapRover uses Docker, NGINX, Let&apos;s Encrypt, and NetData under the hood. You can deploy apps in any language that can be containerized, including Node.js, Python, PHP, Ruby, Go, and more.

Features:

- **One-click apps** for WordPress, MongoDB, MySQL, PostgreSQL, and more
- **Multiple deployment methods**: dashboard upload, CLI, webhooks, or git push
- **Free SSL certificates** via Let&apos;s Encrypt with automatic HTTP-to-HTTPS redirect
- **Docker Swarm** for scaling across multiple nodes
- **NGINX configuration** customization
- **Cloudflare integration**
- **Server performance monitoring** via NetData
- **File manager** for browser-based file management

What&apos;s new in recent versions:

- **Project structure** for organizing multiple applications
- **Themes and multi-language translations**
- **GoAccess built-in stats** for traffic analysis
- **Automated disk cleanup** to free up space
- **Custom Certbot commands** for DNS challenge support
- **UDP port mapping** for HTTP/3 support
- **Multiple app deletion** at once

## Quick comparison table

&lt;Tabs&gt;
&lt;Tab name=&quot;PHP/cPanel alternatives&quot;&gt;

| Feature | CloudPanel | CyberPanel | 1Panel |
|---------|-----------|------------|--------|
| **License** | Free | Free (Enterprise available) | Free + Pro ($80/yr) |
| **Web server** | NGINX (HTTP/3) | LiteSpeed/OpenLiteSpeed | OpenResty/NGINX |
| **PHP support** | 7.1 - 8.5 | Up to 8.5 | Yes |
| **Docker support** | Via integration | Built-in Docker Manager | Built-in visual editor |
| **Email hosting** | No | Yes | No |
| **Best for** | Lightweight PHP hosting | LiteSpeed performance + email | All-rounder (PHP + Docker) |
| **Latest version** | v2.5.3 | v2.4.7 | Active development |
| **OS support** | Debian 11/12, Ubuntu 22.04/24.04 | Ubuntu 20/22/24, AlmaLinux 8/9/10 | Debian, Ubuntu, CentOS, RHEL |

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker/CI-CD alternatives&quot;&gt;

| Feature | EasyPanel | Coolify | Dokploy | CapRover |
|---------|----------|---------|---------|----------|
| **License** | Free + Business | Free (Cloud available) | Free | Free |
| **Pricing** | Business for multi-server | Free self-hosted + Cloud | Free | Free |
| **Templates** | 120+ | 280+ one-click services | 350+ | One-click apps |
| **Docker engine** | Docker + Buildpacks | Docker + Nixpacks + Railpack | Docker + Traefik | Docker Swarm |
| **Email hosting** | No | No | No | No |
| **Multi-server** | Business license | Yes | Yes | Via Docker Swarm |
| **GitHub stars** | - | 58K+ | 35K+ | 15K+ |
| **Best for** | Teams, simple deploys | Most popular, feature-rich | Modern DX, Vercel alternative | Mature, stable PaaS |

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Button text=&quot;Try Coolify&quot; link=&quot;https://coolify.io&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt; &lt;Button text=&quot;Try Dokploy&quot; link=&quot;https://dokploy.com&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

## Complementary tools: Dockge and server monitoring

Not everything needs to be a full panel. Sometimes you just want a clean UI for managing Docker Compose stacks.

[Dockge](https://dockge.kuma.pet/) is a Docker Compose stack manager created by Louis Lam (the developer behind Uptime Kuma). With 23,786 GitHub stars, it&apos;s a popular choice for developers who want a simple, beautiful interface for managing `docker-compose.yaml` files without a full-blown server panel.

&lt;Notice type=&quot;info&quot;&gt;
Dockge is a complementary tool, not a replacement for a full server panel. It&apos;s perfect if you want a clean UI for managing Docker Compose stacks alongside your existing panel.
&lt;/Notice&gt;

Features include a real-time log viewer, interactive compose editor, backup management, and one-click start/stop/restart for stacks. Dockge can work alongside panels like CloudPanel. You run CloudPanel for your PHP sites and Dockge for your Docker Compose projects.

- [Dockge Install Guide](https://www.bitdoze.com/dockge-install/)

For monitoring your server resources (CPU, memory, disk, network), check out: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Conclusions

The self-hosted panel ecosystem in 2026 is mature and competitive. You have real choices across every category:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Best for PHP/WordPress&lt;/strong&gt;: CloudPanel (free, lightweight) or CyberPanel (LiteSpeed performance + email hosting)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Best for Docker/CI-CD&lt;/strong&gt;: Coolify (most popular, feature-rich) or Dokploy (fastest-growing, modern DX)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Best all-rounder&lt;/strong&gt;: 1Panel (covers both PHP and Docker with a modern Go backend)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Best for teams&lt;/strong&gt;: EasyPanel (multi-user access control, business features)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Best for stability&lt;/strong&gt;: CapRover (mature, proven, Docker Swarm-based)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keep your panels updated&lt;/strong&gt;, the CyberPanel CVE incident shows why regular updates matter&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

All of these panels are free to start with. The self-hosted PaaS space has never been more accessible. Pick the one that matches your stack, deploy it on a reliable VPS, and you&apos;ll have a production-grade hosting platform running in under an hour.

All these panels need a reliable VPS. [Hetzner](https://go.bitdoze.com/hetzner) offers excellent price-to-performance for European users, while [Vultr](https://go.bitdoze.com/vultr) provides global coverage with SSD-backed instances.</content:encoded><category>self-hosting</category><category>self-hosted</category><category>server-panels</category><category>cpanel-alternative</category></item><item><title>How To Add Pricing Table to Carrd.co</title><link>https://www.bitdoze.com/carrd-add-pricing-table/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-add-pricing-table/</guid><description>Add a responsive pricing table with monthly/yearly toggle to your Carrd site. Covers paid and free plugin options with step-by-step setup.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Carrd doesn&apos;t include a pricing table element out of the box. If you want to display subscription plans with a monthly/yearly toggle, you need to embed custom code or use a third-party widget.

This tutorial covers the CarrdMe pricing table plugin ($5), which gives you a responsive three-column pricing table with a billing cycle toggle. I&apos;ll also mention two free alternatives at the end if you want to compare options.

You&apos;ll need a **Pro Standard plan** ($19/year) or higher. That&apos;s the lowest Carrd plan that supports the Embed element for custom code.

## What is Carrd.co?

[Carrd.co](https://try.carrd.co/bitdoze) is a platform for building single-page, responsive websites. It&apos;s popular for personal profiles, landing pages, and simple business sites because of its low cost and ease of use. Plans start free; the Pro Standard plan at $19/year unlocks custom domains, forms, and code embeds.

**More Carrd tutorials:**

- [Add a sticky header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add Carrd cookie notice](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [Carrd.co review](https://www.bitdoze.com/carrd-review/)
- [How to add accordion FAQs to Carrd.co](https://www.bitdoze.com/add-accordion-carrd/)
- [How to add a custom domain to Carrd.co](https://www.bitdoze.com/carrd-add-domain/)
- [Carrd.co mobile responsive navbar](https://www.bitdoze.com/carrd-mobile-navbar/)
- [Back to top button on Carrd](https://www.bitdoze.com/carrd-back-to-top-button/)

&gt; The complete list of Carrd plugins, themes, and tutorials is on [carrdme.com](https://carrdme.com/).

## How to add the pricing table to Carrd

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Q3UpuDDs0AY&quot;
  label=&quot;How To Add Pricing Table to Carrd.co&quot;
/&gt;

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;

### 1. Download the pricing table code

Go to [CarrdMe pricing table](https://carrdme.com/) and download the plugin. It costs $5 and includes a single HTML/CSS/JS code block. You can use it on unlimited Carrd sites.

### 2. Customize colors

The code uses CSS custom properties for the main accent color. Look for this block near the top of the `&lt;style&gt;` section:

```css
:root {
  --priceb-col-gr: linear-gradient(
    135deg,
    rgba(163, 168, 240, 1) 0%,
    rgba(105, 111, 221, 1) 100%
  );
}
```

This gradient applies to the buttons, the active toggle state, and the featured card highlight. Replace the color values to match your brand. You can use a tool like [CSS Gradient](https://cssgradient.io/) to generate a new gradient.

### 3. Customize the font

The default font inherits from the page. To use a specific font, change this line in the `&lt;style&gt;` section:

```css
font-family: inherit;
```

Replace `inherit` with your preferred font stack, e.g. `&apos;Inter&apos;, sans-serif`.

### 4. Edit plan names and prices

You need to update two places: the HTML display and the JavaScript toggle logic.

**HTML, the visible price values:**

```html
&lt;li id=&quot;basic&quot; class=&quot;price bottom-bar&quot;&gt;&amp;dollar;19.99&lt;/li&gt;
```

This shows the default (monthly) price. The JavaScript below swaps these values when the visitor toggles between monthly and yearly.

**JavaScript, the price data object:**

```js
const prices = {
  basic: { monthly: &quot;$19.99&quot;, annual: &quot;$199.99&quot; },
  professional: { monthly: &quot;$24.99&quot;, annual: &quot;$249.99&quot; },
  master: { monthly: &quot;$39.99&quot;, annual: &quot;$399.99&quot; },
};
```

Change the plan names (if you rename them) and set your actual prices. The keys (`basic`, `professional`, `master`) must match the `id` attributes on the corresponding `&lt;li&gt;` elements in the HTML.

### 5. Edit feature lists

Each plan card has a list of features. Replace the `&lt;li&gt;` items with your own:

```html
&lt;li class=&quot;bottom-bar&quot;&gt;1 TB Storage&lt;/li&gt;
&lt;li class=&quot;bottom-bar&quot;&gt;5 Users Allowed&lt;/li&gt;
&lt;li class=&quot;bottom-bar&quot;&gt;Send up to 10 GB&lt;/li&gt;
&lt;li class=&quot;bottom-bar&quot;&gt;Unlimited&lt;/li&gt;
```

Add or remove items as needed. The `bottom-bar` class adds a border between rows, so keep it on each item for consistent styling.

### 6. Update the call-to-action buttons

Each card has a button linking to your checkout or signup page. Replace the URL and label text:

```html
&lt;li&gt;
  &lt;a href=&quot;https://your-checkout-url.com&quot;&gt;&lt;div class=&quot;btn&quot;&gt;Get Started&lt;/div&gt;&lt;/a&gt;
&lt;/li&gt;
```

### 7. Add the embed to Carrd

In the Carrd editor:

1. Add a **Container** element in the section where you want the pricing table.
2. Inside that container, add an **Embed** element.
3. Set **Type** to **Code**.
4. Paste the full code (HTML, CSS, and JavaScript) into the Code field.
5. Set the container alignment and spacing as needed.

### 8. Publish and test

Save your Carrd site and publish. The pricing table only renders on the live site. Carrd&apos;s editor doesn&apos;t execute embedded JavaScript.

Check these things:

- Does the monthly/yearly toggle switch prices correctly?
- Do all three columns display at the right width?
- On mobile, does the table stack vertically and remain readable?
- Do the buttons link to the right URLs?

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Carrd.co&quot; /&gt;

## Troubleshooting

**Pricing table doesn&apos;t appear:** Make sure you&apos;re on Pro Standard or higher. The free and Pro Lite plans don&apos;t support the Embed element. Also confirm the Embed&apos;s Style is set to Hidden. If it&apos;s set to something else, the container might block the table from rendering.

**Toggle doesn&apos;t switch prices:** Check that the JavaScript object keys (`basic`, `professional`, `master`) exactly match the `id` attributes on the HTML `&lt;li&gt;` elements. A typo in either place will break the toggle.

**Table looks broken on mobile:** The default code is responsive, but if your Carrd container is too narrow, the columns may overlap. Try widening the container or adding a media query override for smaller screens.

**Colors don&apos;t update:** Make sure you&apos;re editing the CSS variables in `:root`, not somewhere else in the style block. Browser DevTools (F12) can help you verify which styles are actually being applied.

## Free alternatives

If the $5 CarrdMe plugin isn&apos;t what you need, there are two free options:

**Jason&apos;s Plugins for Carrd:** [plugins.carrd.co](https://plugins.carrd.co/) offers a free pricing table with hover effects. Download it as a template, open it in Carrd, and copy the Embed element&apos;s code into your own site. Requires Pro Standard.

**Common Ninja:** [commoninja.com/widgets/pricing-tables/carrd](https://www.commoninja.com/widgets/pricing-tables/carrd) gives you a visual editor to build a pricing table, then generates an embed code you paste into Carrd. The free tier includes Common Ninja branding; paid plans start at a few dollars per month to remove it. This option works well if you want a drag-and-drop editor instead of editing code.

The CarrdMe plugin used in this tutorial is a good middle ground: it&apos;s cheap ($5, one-time), has no branding, gives you full code access, and the monthly/yearly toggle is built in.

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category></item><item><title>Launch a Self-Hosted Newsletter with Keila and Docker</title><link>https://www.bitdoze.com/keila-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/keila-setup/</guid><description>Deploy Keila, a self-hosted newsletter platform, with Docker and EasyPanel. Step-by-step guide covering SMTP setup, double opt-in, contacts, and campaigns.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/11/keila-project.png&quot;;
import imag2 from &quot;../../assets/images/23/11/creae-keila.png&quot;;
import imag3 from &quot;../../assets/images/23/11/keila-interface.png&quot;;
import imag4 from &quot;../../assets/images/23/11/keila-stats.png&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;

A self-hosted newsletter platform gives you full control of your subscriber data, email templates, and sending infrastructure, without paying per-contact fees to Mailchimp or Brevo. [Keila](https://www.keila.io/) is an open-source option built in Elixir. It runs on your server via Docker, uses PostgreSQL for storage, and connects to any SMTP provider (or AWS SES, Sendgrid, Mailgun, Postmark) to deliver campaigns.

Since we first published this guide in late 2023, Keila has matured a lot. Version 0.30 added transactional emails via API and reusable templates with content slots. Double opt-in, MJML support, welcome emails, and interaction-based segmentation are all built in now. The project has around 2,200 GitHub stars and is licensed under AGPL-3.0.

This guide walks through deploying Keila on a VPS using EasyPanel, the quickest way to get a working newsletter setup. You&apos;ll end up with a platform you can start sending from in under 30 minutes.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Self-hosted newsletter platform with full data ownership&lt;/li&gt;
&lt;li&gt;Double opt-in with captcha protection and customizable confirmation emails&lt;/li&gt;
&lt;li&gt;Visual block editor with MJML template support&lt;/li&gt;
&lt;li&gt;Transactional email API for order confirmations and password resets&lt;/li&gt;
&lt;li&gt;Campaign statistics with open and click tracking&lt;/li&gt;
&lt;li&gt;Multi-language interface (8 languages supported)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you want to explore more self-hosted Docker applications, check out [Best Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). And once your server is running, set up [Server and Docker monitoring](https://www.bitdoze.com/sever-monitoring/) to keep an eye on resource usage.

## What is Keila?

Keila is an open-source newsletter platform built with Elixir and Phoenix. It&apos;s licensed under AGPL-3.0 and designed for self-hosting. You run it on your own server, you own your data. No third party has access to your subscriber list or campaign content.

The architecture is straightforward. Keila runs as a Docker container, connects to PostgreSQL for data storage, and uses your configured email provider (SMTP, AWS SES, Sendgrid, Mailgun, or Postmark) to deliver messages. Subscribers interact with signup forms, confirmation pages, and unsubscribe links that Keila serves directly from your server.

&lt;Notice type=&quot;info&quot; title=&quot;AGPL-3.0 license note&quot;&gt;
If you modify Keila&apos;s source code and run it as a network service, you must make the modified source available to all users who interact with it, including subscribers who use signup forms, unsubscribe pages, or tracking endpoints. For most self-hosters who run Keila unmodified, this is not an issue.
&lt;/Notice&gt;

Keila prioritizes privacy by default. Campaign tracking (opens, clicks) is opt-in per campaign, so you can send newsletters without any tracking pixels or link redirects if you prefer. The platform supports 8 languages: English, German, French, Spanish, Bulgarian, Hungarian, Brazilian Portuguese, and Italian.

## Key features of Keila

Keila has evolved from a basic newsletter tool into a full-featured open source email marketing platform. Here&apos;s what it offers as of v0.30.2:

### Contact management

Import contacts via CSV, add custom fields, and use tags to organize your list. Smart segments let you filter contacts based on criteria you define, including interaction-based segmentation (introduced in v0.18) that targets subscribers by opens, clicks, or campaign engagement. Contact search and sorting are built in for quick lookups.

### Email editor and templates

Keila includes a visual block editor for building campaigns without code. You can also write in Markdown, use MJML for responsive templates (added in v0.15), or fall back to plain text. Mobile and desktop previews let you check how campaigns render before sending. The social media icons block (v0.18) adds share links to your emails. As of v0.30, reusable templates with content slots let you maintain consistent branding across campaigns.

### Double opt-in and compliance

Double opt-in was added in v0.13 and is now a mature feature. You get configurable confirmation email subjects and body text, custom failure messages and redirect URLs, and protected opt-in links (v0.19) that prevent accidental activation by mail scanners. Captcha protection supports both hCaptcha and Friendly Captcha to block bot signups. Keila is GDPR-friendly by design. No user tracking unless you explicitly enable it per campaign.

### Sending and deliverability

Keila natively supports SMTP, AWS SES, Sendgrid, Mailgun, and Postmark as sender types. Welcome emails (v0.19) automatically greet new subscribers. You can send campaign preview/test emails before launching to your full list. The new email scheduler introduced in v0.20 is much faster for large campaigns.

### Transactional emails and API

Starting with v0.30, Keila offers a transactional emails API for sending one-off messages like order confirmations, password resets, and notifications. The REST API also covers contacts, campaigns, and forms, so you can integrate Keila into your existing applications.

&lt;Notice type=&quot;success&quot; title=&quot;Keila has grown a lot&quot;&gt;
As of v0.30.2 (June 2026), Keila supports transactional emails, reusable templates, MJML, welcome emails, and interaction-based segmentation, features that were missing when this article was first published in November 2023.
&lt;/Notice&gt;

## How to install Keila with Docker and EasyPanel

The easiest way to deploy Keila on a VPS is through EasyPanel. It handles Docker container management, domain configuration, SSL certificates, and database provisioning with a few clicks. Docker Compose is also available if you prefer manual setup. The [Docker Commands You Must Know](https://www.bitdoze.com/docker-commands/) guide covers the basics.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Get Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

### Deploy a Hetzner VPS


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/lpvAGQwO0RA&quot;
  label=&quot;Keila Setup&quot;
/&gt;

You need a server with at least 2 vCPUs and 2 GB of RAM for EasyPanel (Keila itself can run on 1 GB). The Hetzner CX22 plan (2 vCPU, 4 GB RAM, ~€5/month) is a good fit. Ubuntu 22.04 or 24.04 works well as the base OS.

If you&apos;re not familiar with Hetzner, read the [Hetzner Cloud Review](https://www.bitdoze.com/hetzner-cloud-review/) for details on their infrastructure and pricing. You can also [compare VPS providers](https://www.bitdoze.com/digitalocean-vs-vultr-vs-hetzner/) if you want alternatives, or browse the [Best Self-Hosted Server Panels](https://www.bitdoze.com/best-self-hosted-panels/) for other control panel options.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

### Install EasyPanel

SSH into your server and run the EasyPanel installer:

```sh
curl -sSL https://get.easypanel.io | sh
```

This installs Docker, configures the EasyPanel dashboard, and sets up HTTPS access. Follow the complete [EasyPanel setup tutorial](https://www.bitdoze.com/easypanel-modern-server-control-panel/) for proper configuration including firewall rules and user management.

### Create an EasyPanel project

After installing EasyPanel, open the dashboard and create a new project. Select Keila from the template library. EasyPanel will pre-fill the service configuration.

&lt;Picture
  src={imag1}
  alt=&quot;EasyPanel template selection showing Keila newsletter platform option&quot;
/&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Template version is pinned&quot;&gt;
EasyPanel&apos;s Keila template pins v0.18. To access features like transactional emails, reusable templates, and the new email scheduler (v0.20+), change the image tag to `pentacent/keila:0.30` after creation.
&lt;/Notice&gt;

### Create Keila in EasyPanel

Fill in the creation form with these fields:

- **App Service Name:** A name for the Keila container (e.g., `keila-newsletter`)
- **App Service Image:** Use `pentacent/keila:0.30` for the latest stable release instead of `:latest`
- **Database Service Name:** A name for the PostgreSQL container (e.g., `keila-db`)
- **SMTP settings:** System From Email, SMTP Host, SMTP Port, SMTP Username, SMTP Password

&lt;Picture
  src={imag2}
  alt=&quot;EasyPanel form for creating a new Keila newsletter service with SMTP settings&quot;
/&gt;

If you don&apos;t have an SMTP provider yet, see the [Mail.Baby Review](https://www.bitdoze.com/mail-baby-review/) for a budget option, or check the SMTP providers section below for more choices.

&lt;Notice type=&quot;info&quot; title=&quot;Pin your image version&quot;&gt;
Use `pentacent/keila:0.30` instead of `:latest` for production stability. You can always update the tag later when new versions are released.
&lt;/Notice&gt;

Hit `create` to deploy. EasyPanel will pull the Docker images, set up the database, and start the containers. After deployment, you&apos;ll see the default credentials displayed: `changeme@easypanel.io:password123`. Change these immediately after first login.

### Add your domain to the installation

EasyPanel assigns a default subdomain, but you&apos;ll want your own domain for credibility and deliverability. Point an A record for your domain (or subdomain like `news.yourdomain.com`) to the VPS IP address. Then go to the project&apos;s `domains` section in EasyPanel and add your domain. HTTPS certificates are auto-configured by EasyPanel.

### Configure Keila

Log in with the default credentials and change the password immediately.

&lt;Notice type=&quot;warning&quot; title=&quot;Change default credentials now&quot;&gt;
The default EasyPanel login is `changeme@easypanel.io:password123`. Change both the email and password from the administration panel before doing anything else.
&lt;/Notice&gt;

From the Keila dashboard, create your first project and configure a sender. Keila supports multiple sender types, so you can add different senders for different projects. The video walkthrough covers the initial configuration in detail.

&lt;Picture
  src={imag3}
  alt=&quot;Keila newsletter dashboard showing projects and sender configuration&quot;
/&gt;

Once configured, you can import contacts, create signup forms, and start sending campaigns. Keila provides statistics for each campaign after sending:

&lt;Picture
  src={imag4}
  alt=&quot;Keila campaign statistics showing open rates and click metrics after sending a newsletter&quot;
/&gt;

## SMTP providers for sending newsletters

Keila supports five sender types natively: SMTP, AWS SES, Sendgrid, Mailgun, and Postmark. Each has different pricing and free tier limits. Here&apos;s a quick comparison:

| Provider | Free Tier | Paid Pricing | Notes |
|----------|-----------|-------------|-------|
| AWS SES | 62,000 emails/month (from EC2) | $0.10/1,000 emails | Cheapest at scale, requires AWS setup |
| Sendgrid | 100 emails/day (~3,000/month) | From $19.95/month | Easy setup, good deliverability |
| Mailgun | 5,000 emails/month (first 3 months) | From $35/month | Developer-friendly API |
| Postmark | 100 emails/month | $10/10,000 emails | Excellent for transactional |
| SMTP2GO | 1,000 emails/month | From $15/month | Simple SMTP relay |
| Mail.Baby | None | $1/month + $0.20/1,000 | Budget-friendly for low volume |

For a budget-focused setup, [Mail.Baby](https://www.bitdoze.com/mail-baby-review/) at $1/month plus $0.20 per 1,000 emails is hard to beat for small lists. If you&apos;re running on an EC2 instance, AWS SES gives you 62,000 free emails monthly.

&lt;Notice type=&quot;info&quot; title=&quot;Deliverability tip&quot;&gt;
Regardless of provider, configure SPF, DKIM, and DMARC records for your sending domain. These DNS records prove to receiving mail servers that you&apos;re authorized to send email from your domain. Without them, your newsletters will land in spam folders. See [How to Set Up SMTP Relay on a VPS](https://www.bitdoze.com/how-to-setup-smtp-relay-email-on-zeptomail/) for a detailed walkthrough.
&lt;/Notice&gt;

## Keila vs ListMonk: which newsletter platform to choose?

ListMonk is the other popular self-hosted newsletter option. Here&apos;s how they compare:

| Feature | Keila | ListMonk |
|---------|-------|----------|
| Language | Elixir (Phoenix) | Go |
| License | AGPL-3.0 | GPL-3.0 |
| GitHub Stars | ~2,200 | ~21,000 |
| Visual Editor | Block editor + MJML | Plain text / HTML |
| Double Opt-In | Yes (v0.13+) | Yes |
| Transactional Emails | Yes (v0.30+) | Yes |
| Welcome Emails | Yes (v0.19+) | No (manual) |
| Public Campaign Archives | Yes (v0.18+) | No |
| Cloud Option | Keila Cloud (EU-hosted) | None |
| Multi-Language | 8 languages | Limited |
| Database | PostgreSQL | PostgreSQL |

**Choose Keila if** you want a visual block editor with MJML template support, a managed cloud option, or features like welcome emails and public campaign archives.

**Choose ListMonk if** you prefer a lightweight Go binary, want a larger community, or don&apos;t need a visual editor.

If you&apos;re exploring other options, [Notifuse is another self-hosted newsletter](https://www.bitdoze.com/notifuse-self-host-newsletter/) worth looking at.

## Keila Cloud pricing (if you don&apos;t want to self-host)

If managing your own server isn&apos;t appealing, Keila offers a managed cloud option hosted in the EU. All plans include unlimited contacts and unlimited projects. You can use Keila&apos;s managed sending (&quot;Send with Keila&quot;) or bring your own SMTP/SES provider. There&apos;s no free tier. Keila is a bootstrapped project that doesn&apos;t monetize user data.

| Plan | Price | Emails/Month |
|------|-------|-------------|
| Keila XS | €8/mo | 2,000 |
| Keila S | €16/mo | 5,000 |
| Keila M | €32/mo | 15,000 |
| Keila L | €64/mo | 50,000 |
| Keila XL | €128/mo | 100,000 |
| Keila XXL | €256/mo | 250,000 |

&lt;Button link=&quot;https://www.keila.io/pricing/&quot; text=&quot;View Keila Cloud Plans&quot; variant=&quot;outline&quot; /&gt;

If you want a simpler managed newsletter service, [EmailOctopus](https://go.bitdoze.com/emailoctopus) offers a generous free tier with a drag-and-drop builder and analytics. [MailerLite](https://go.bitdoze.com/mailerlite) is another solid managed option with automation features and landing pages.

## What&apos;s new in Keila (v0.13-v0.30)

Keila has released 17 versions since this article was first published. Here are the highlights for returning readers:

&lt;Accordion label=&quot;v0.13 - v0.15: Double opt-in and MJML&quot; group=&quot;changelog&quot;&gt;

**v0.13.0 (December 2023):** Added double opt-in for signup forms with configurable confirmation emails, custom failure text, and redirect URLs. Also added captcha protection (hCaptcha and Friendly Captcha).

**v0.14.0 (January 2024):** Custom signup fields (text, checkbox, dropdown, tags, numbers), contact search and sorting improvements.

**v0.15.0 (August 2024):** MJML support for building responsive email templates. Users can now create mobile-friendly newsletters using MJML markup alongside the existing block editor and Markdown options.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;v0.17 – v0.19: Previews, archives and welcome emails&quot; group=&quot;changelog&quot;&gt;

**v0.17.0 (March 2025):** Preview emails: send test emails before launching a campaign. Mobile and desktop preview modes for checking email rendering. External IDs for contacts. French translation.

**v0.18.0 (January 2026):** Public campaign archives: share sent campaigns on a public page. Interaction-based segmentation: target subscribers who opened or clicked specific campaigns. Social media icons block in the editor. Spanish translation.

**v0.19.0 (January-February 2026):** Welcome emails: automatically send a greeting to new subscribers. Protected unsubscribe and opt-in links to prevent accidental activation by mail scanners. Bulgarian and Hungarian translations. Upgraded to Phoenix 1.7 and LiveView 1.x.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;v0.20 – v0.30: New scheduler, transactional emails and templates&quot; group=&quot;changelog&quot;&gt;

**v0.20.0 (May 2026):** Completely rewritten email scheduler, much faster for large campaigns. Brazilian Portuguese translation. Pre-filled form fields via URL query parameters.

**v0.30.0 (June 2026):** Major release. Templates and content slots: create reusable MJML/HTML/plain-text templates with dynamic content areas. Transactional emails API: send one-off messages (order confirmations, password resets) via a REST endpoint.

**v0.30.2 (June 2026):** Line-height setting in the editor, manual contact status changes. Current stable version.

&lt;/Accordion&gt;

## Conclusion

Keila has grown from a basic newsletter tool into a solid, actively maintained self-hosted newsletter platform. With double opt-in, MJML templates, transactional emails, welcome emails, and a full REST API, it covers most use cases that used to need a paid SaaS service. The data ownership story is straightforward: you run it on your server, you control everything.

Deployment through [EasyPanel](https://www.bitdoze.com/easypanel-modern-server-control-panel/) takes under 30 minutes. You&apos;ll need a VPS with at least 2 GB RAM and an SMTP provider to start sending. From there, Keila handles contact management, campaign building, scheduling, and statistics.

If Keila isn&apos;t quite right for your needs, [Notifuse is another self-hosted newsletter option](https://www.bitdoze.com/notifuse-self-host-newsletter/) to consider. And if you want to explore more self-hosted applications for your server, check out the [Best Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/) roundup.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Deploy Keila on Hetzner&quot; /&gt;</content:encoded><category>self-hosting</category><category>easypanel</category><category>email</category><category>docker</category></item><item><title>Mail.Baby Review 2026: Cheap SMTP Service Pros &amp; Cons</title><link>https://www.bitdoze.com/mail-baby-review/</link><guid isPermaLink="true">https://www.bitdoze.com/mail-baby-review/</guid><description>Mail.Baby review 2026: Is this $1/month SMTP service worth it? We cover pricing, SPF setup, the new API, WordPress plugin, deliverability, and pros vs cons.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/23/11/mail-baby-order.png&quot;;

If you&apos;re running a self-hosted application on a VPS (whether that&apos;s a [Hetzner](https://go.bitdoze.com/hetzner) box, a [Hostinger](https://go.bitdoze.com/hostinger-vps) VPS, or your own [Docker containers home server](/docker-containers-home-server/)) and need a cheap SMTP service for outbound email, this Mail.Baby review covers what you need to know. Mail.Baby is a budget transactional email service by Interserver that costs $1/month plus $0.20 per 1,000 emails. Since my original 2023 review, the service has added several useful features.

The biggest change: Mail.Baby added a new SPF verification method in December 2025 that eliminates the need to expose your server&apos;s IP address in DNS. That was the main security concern with this service, and it&apos;s now resolved.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/MkbcnC8TIow&quot;
  label=&quot;Mail.Baby Review&quot;
/&gt;

I&apos;ve been using Mail.Baby across multiple servers for transactional email and bulk sends. Here&apos;s what I look for in an email-sending service:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Not expensive**, bulk email without breaking the bank&lt;/li&gt;
&lt;li&gt;**Reliable delivery**, high inbox placement rate&lt;/li&gt;
&lt;li&gt;**Transactional and marketing**, one service for both use cases&lt;/li&gt;
&lt;li&gt;**Multiple domain support**, send from any of my 10+ domains&lt;/li&gt;
&lt;li&gt;**Secure**, encrypted connections to the SMTP server&lt;/li&gt;
&lt;li&gt;**Pay as you go**, only charged when I actually send emails&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Updated January 2026&quot;&gt;
This Mail.Baby review has been substantially rewritten to reflect the new SPF verification method, in-house email relay, REST API, WordPress plugin, and other improvements since the original 2023 article.
&lt;/Notice&gt;

If you&apos;re running applications on a VPS and want to [set up outbound email with Postfix](/postfix-external-smtp/), Mail.Baby works well as an external SMTP relay. Whether you&apos;re [choosing between VPS providers](/digitalocean-vs-vultr-vs-hetzner/) or already have a server running, Mail.Baby connects the same way regardless of hosting.

## What is Mail.Baby?

Mail.Baby is a budget outbound SMTP relay service operated by Interserver. It sits between your server or application and the recipient&apos;s mail server, handling deliverability, IP reputation, and spam filtering on your behalf.

You can send both transactional emails (password resets, order confirmations, monitoring alerts) and marketing emails (newsletters, campaigns) through the same service. Not all SMTP providers allow both. Many restrict or charge extra for marketing sends.

Since May 2024, Mail.Baby runs all email infrastructure in-house. Previously they used MailChannels as a backend, but moving everything in-house gives them full control over their IP reputation and delivery pipeline. This changed how consistently emails get delivered.

## Mail.Baby pricing

Mail.Baby charges a $1/month base fee plus $0.20 per 1,000 emails sent. The $1 monthly fee covers up to 5,000 emails, so if you send fewer than 5,000 emails per month, your total cost is just $1.

**Worked examples:**

| Monthly volume | Cost |
|---|---|
| 1,000 emails | $1.00 |
| 5,000 emails | $1.00 |
| 10,000 emails | $2.00 |
| 50,000 emails | $10.00 |
| 100,000 emails | $20.00 |

&lt;Notice type=&quot;success&quot; title=&quot;Pricing highlight&quot;&gt;
At $0.20 per 1,000 emails, Mail.Baby is one of the cheapest transactional email services available, comparable only to Amazon SES. Most competitors charge 5-10x more at similar volumes.
&lt;/Notice&gt;

There&apos;s no free tier, unlike Amazon SES (which offers 3,000 free message charges per month for the first 12 months). But the base cost is low enough that it barely matters for small senders.

## SPF configuration: two setup options

This is the section that changed the most since the original review. Previously, Mail.Baby required you to add your server&apos;s IP address to the SPF record, which exposed it publicly in DNS. That was a real security concern, especially if you used Cloudflare to proxy your server.

As of December 15, 2025, Mail.Baby offers an alternative: a TXT verification record that eliminates the IP exposure entirely.

### What is SPF?

SPF (Sender Policy Framework) is a DNS record that tells receiving mail servers which servers are authorized to send email on behalf of your domain. Mail.Baby checks your SPF record to verify your server is authorized before relaying your emails.

Here are both configuration methods:

&lt;Tabs&gt;
&lt;Tab name=&quot;Option A: Traditional SPF&quot;&gt;
**With server IP exposed in DNS.**

Add your server IP and Mail.Baby&apos;s SPF include to your domain&apos;s DNS:

```
v=spf1 ip4:YOUR.SERVER.IP include:spf-c.mailbaby.net -all
```

This authorizes both your server and Mail.Baby to send email for your domain. The downside: anyone who looks up your SPF record can see your server&apos;s real IP address. If you&apos;re behind Cloudflare or a similar proxy, this defeats the purpose of hiding your origin server.

This was the only option before December 2025, and it&apos;s why the original review flagged IP exposure as a major concern.
&lt;/Tab&gt;
&lt;Tab name=&quot;Option B: TXT Record (Recommended)&quot;&gt;
**No IP exposure.**

Instead of including your server IP in the SPF record, add a TXT verification record for Mail.Baby:

**Step 1:** Add a TXT record at `_mailbaby.yourdomain.com`:

```
_mailbaby.yourdomain.com  IN  TXT  &quot;v=1 user=mbXXXXX&quot;
```

Replace `mbXXXXX` with your Mail.Baby username (found in your control panel).

**Step 2:** Update your SPF record to include only Mail.Baby:

```
v=spf1 include:spf-c.mailbaby.net -all
```

Your server IP stays hidden. Mail.Baby verifies your authorization through the TXT record instead. This is the recommended method for anyone using Cloudflare or concerned about IP exposure.
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;SPF domain update&quot;&gt;
The old SPF include domain `include:relay.mailbaby.net` still works, but the current recommended domain is `include:spf-c.mailbaby.net`. Update your records when convenient.
&lt;/Notice&gt;

The new TXT record method removed the single biggest objection to using Mail.Baby. If you&apos;re running a server behind Cloudflare and care about [securing your VPS](/crowdsec-secure-server/), Option B is the way to go.

## Sending limits and deliverability

### Hourly sending cap

Mail.Baby enforces a limit of **6,000 emails per hour per email address**, not per account. If you have multiple sender addresses configured, each one gets its own 6,000/hour quota.

The critical detail: emails that exceed the 6,000/hour limit are **discarded, not queued**. Unlike some competitors (Mailchimp Transactional Email, for example, queues excess sends), Mail.Baby simply drops them. You&apos;ll get a rejection, but the email is gone.

&lt;Notice type=&quot;warning&quot; title=&quot;Important&quot;&gt;
Emails exceeding 6,000/hour per address are discarded, not queued. If you send high volumes, configure your application to throttle outbound sends. Stack excess emails on your side and release them in batches.
&lt;/Notice&gt;

This matters if you&apos;re doing bulk sends. If you need to send 20,000 emails, you&apos;ll need to spread them across multiple hours and potentially multiple sender addresses. Tools like [monitoring scripts that send email alerts](/monitor-cpu-usage-and-send-email-alerts-in-linux/) typically won&apos;t hit this limit, but newsletter tools or batch processors might.

### New user filter / warming period

New Mail.Baby accounts start with a &quot;New User Filter&quot; that applies stricter outbound filtering. This means your first batch of emails may get flagged or blocked more aggressively than established accounts.

Gradual ramp-up is the way to go. Start with small volumes of legitimate transactional email, build your sending reputation, and then scale up. Don&apos;t sign up and immediately blast 5,000 marketing emails.

### Deliverability performance

In my experience and based on community feedback across LowEndTalk and Reddit, deliverability is solid. The move to an in-house relay in May 2024 gave Mail.Baby full control over IP reputation management.

Recent security improvements include:
- **AI-powered phishing detection** (January 2026), scans outbound emails for phishing patterns
- **&quot;Rattle Trap&quot; reverse spam traps** (December 2025), identifies spam sources proactively
- **Batched logging speed boosts** (April 2026), infrastructure upgrade for faster processing

These additions show the team is actively working on the platform.

## Security and email authentication

### Connection security (ports and TLS)

Mail.Baby supports the following connection options:

| Port | Encryption | Use case |
|---|---|---|
| 25 | STARTTLS | Standard SMTP relay |
| 587 | STARTTLS | Submission (preferred for most setups) |
| 2500 | STARTTLS | Alternative submission port |
| 465 | SSL/TLS | Implicit TLS |

STARTTLS is used by default. If your server doesn&apos;t support TLS, you can connect insecurely, but that&apos;s not recommended. All SMTP details and credentials are available in the Mail.Baby control panel after signup.

### DKIM and DMARC alignment

For best deliverability to Gmail, Yahoo, and other major providers, you need proper email alignment. This means your &quot;Header From&quot; address (what the recipient sees) should match your &quot;Envelope From&quot; address (what SPF/DKIM checks use).

Mail.Baby&apos;s getting-started guide covers DKIM signing and DMARC policy setup. If you&apos;re sending from multiple domains, each domain needs its own DKIM key and DMARC record. This is standard for any SMTP relay, not specific to Mail.Baby.

### Spam filtering and bounce handling

Mail.Baby scans outbound email for spam content. If your server gets compromised and starts sending spam, Mail.Baby will block the outgoing messages. This protects both your reputation and theirs.

Bounce codes are now documented, which helps interpret delivery failures. Common codes cover SPF failures, compromised account detection, and bot form submissions. Check [Mail.Baby&apos;s bounce message guide](https://www.mail.baby/understanding-common-email-bounce-messages-and-errors/) for details.

## REST API and WordPress integration

Two major additions since the original review: a REST API for developers and a WordPress plugin for non-technical users.

### Using the Mail.Baby REST API

Mail.Baby offers a full REST API (v1.5.0) documented at [api.mailbaby.net](https://api.mailbaby.net). The API supports:

- **Simple send**, basic email with to, from, subject, and body
- **Advanced send**, CC, BCC, attachments, custom headers
- **Raw send**, pre-built RFC 822 message format
- **Delivery logs**, query send history and status
- **Block management**, manage blocked recipients
- **Deny rules**, configure outbound filtering

The API has an OpenAPI spec available, and sample clients exist in multiple languages on [GitHub](https://github.com/interserver/mailbaby-mail-api). If you&apos;re building a custom application that sends email, the API is cleaner than configuring SMTP credentials.

### WordPress setup with Mail Baby SMTP plugin

The official &quot;Mail Baby SMTP&quot; WordPress plugin has 600+ active installations and is currently at version 3.2.13 (tested up to WordPress 6.9.4).

The plugin supports multiple mailers: Mail.Baby, Sendinblue, Mailgun, SendGrid, Gmail, and SMTP.com. Install it from the WordPress plugin directory, enter your Mail.Baby credentials, and it handles SMTP configuration automatically.

&lt;Notice type=&quot;info&quot; title=&quot;Security note&quot;&gt;
If you&apos;re using the WordPress plugin, make sure you&apos;re on version 3.2.12 or later. CVE-2025-57992 (a CSRF vulnerability) was patched in that release. Update immediately if you&apos;re running an older version.
&lt;/Notice&gt;

If you&apos;re running WordPress and want to explore other SMTP options, see our guide on [sending emails in WordPress using Zoho SMTP with FluentSMTP](/send-emails-in-wordpress-zoho-smtp-fluentsmtp/). And if you&apos;re managing WordPress sites, having [reliable backup plugins](/best-free-wordpress-backup-plugins/) is just as important as reliable email delivery.

## Setup and registration

### How to sign up

1. Go to [Interserver](https://my.interserver.net/) and create an account
2. Navigate to **Mail → Order** in the control panel
3. Select the Mail.Baby email package

&lt;Picture
  src={imag1}
  alt=&quot;Mail.Baby email package order screen in Interserver control panel&quot;
/&gt;

After ordering, your SMTP credentials and connection details appear in the control panel. The setup is straightforward, no complex onboarding process.

If you need to [configure Postfix to use Mail.Baby as an external SMTP server](/postfix-external-smtp/), we have a detailed guide covering that setup. For managing your server alongside Mail.Baby, check our comparison of [self-hosted server panels](/best-self-hosted-panels/).

### Control panel setup guides

Mail.Baby provides official setup guides for popular control panels:
- **cPanel**, [Setup guide](https://www.mail.baby/tips/cpanel/)
- **DirectAdmin**, [Setup guide](https://www.mail.baby/tips/direct-admin/)
- **Postfix / Plesk**, [Setup guide](https://www.mail.baby/tips/mailbaby-for-postfix/)
- **Mailcow**, [Setup guide](https://www.mail.baby/tips/mailbaby-set-up-for-mailcow-systems/)

A separate Mail.Baby control panel (independent from Interserver&apos;s main dashboard) is in development. The current interface works but is cluttered with unrelated Interserver services.

&lt;Button text=&quot;Sign Up for Mail.Baby&quot; link=&quot;https://my.interserver.net/buy_mail&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Mail.Baby pros

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Extremely affordable pricing**, $1/month + $0.20/1,000 emails. Among the cheapest SMTP services available.&lt;/li&gt;
&lt;li&gt;**Supports transactional and marketing emails**, use one service for both, unlike providers that restrict marketing sends.&lt;/li&gt;
&lt;li&gt;**Multiple domain support**, send from any number of domains without per-domain fees.&lt;/li&gt;
&lt;li&gt;**New SPF TXT record method**, the Dec 2025 update eliminates IP exposure, making it safe behind Cloudflare.&lt;/li&gt;
&lt;li&gt;**In-house email relay**, full control over IP reputation since May 2024 (no more MailChannels dependency).&lt;/li&gt;
&lt;li&gt;**REST API**, programmatic email sending, logs, and management for developers.&lt;/li&gt;
&lt;li&gt;**WordPress plugin**, official plugin with 600+ active installations for easy integration.&lt;/li&gt;
&lt;li&gt;**AI-powered spam and phishing detection**, recent security improvements to protect your sending reputation.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Mail.Baby cons

- **No queuing for excess emails**, emails beyond 6,000/hour per address are lost, not queued for the next hour.
- **Interserver-branded dashboard**, the control panel is cluttered with unrelated Interserver hosting services. A separate Mail.Baby panel is coming but not yet available.
- **New user filter**, stricter outbound filtering on new accounts means you can&apos;t blast large volumes on day one.
- **False positives in spam filtering**, community reports occasional legitimate emails getting flagged. Rejected emails are still billed.
- **No free tier**, unlike Amazon SES which offers 3,000 free message charges per month for the first 12 months.

&lt;Notice type=&quot;warning&quot; title=&quot;Billing note&quot;&gt;
Rejected emails due to spam filtering are still counted and billed. Monitor your bounce rates to catch false positives early.
&lt;/Notice&gt;

## Mail.Baby vs alternatives

Here&apos;s how Mail.Baby compares to other popular SMTP services:

| Service | Price | Free Tier | Marketing Emails | Key Difference |
|---|---|---|---|---|
| **Mail.Baby** | $1/mo + $0.20/1K | No | Yes | Cheapest option with marketing support |
| **Amazon SES** | $0.10/1K | 3K/mo (12 months) | Yes | Cheapest per email but requires AWS setup |
| **MXroute** | $5/mo flat | No | Yes | Flat pricing, no per-email charges |
| **Mailgun** | $35/mo (50K emails) | 5K/mo (30 days) | Yes | Better dashboard, higher cost |
| **SendGrid** | $20/mo (50K emails) | 100/day | Yes | Well-known, API-first, expensive at scale |
| **Mailchimp Transactional** | $20/mo (25K emails) | No | Yes | Requires Mailchimp account, premium pricing |

**Amazon SES** is the closest competitor on price. New AWS accounts get 3,000 free message charges per month for the first 12 months. After that, or if you&apos;re not on EC2, you pay $0.10/1,000 emails. Still cheap, but SES requires more setup and AWS knowledge.

**MXroute** offers flat pricing with no per-email charges, which makes it attractive for high-volume senders. But the base price starts at $5/month.

**Mailgun and SendGrid** provide polished dashboards, better analytics, and more developer tooling. You&apos;re paying for the experience. They make sense when email is a core part of your product, not just a utility.

**Mailchimp Transactional Email** (formerly Mandrill) requires a Mailchimp account and is the most expensive option. It&apos;s designed for Mailchimp users who need transactional sends alongside their marketing platform.

If you&apos;re looking for another SMTP relay option, also consider [setting up SMTP relay with ZeptoMail](/how-to-setup-smtp-relay-email-on-zeptomail/) as an alternative.

## Should you use Mail.Baby?

Mail.Baby works well for budget-conscious self-hosters, developers running VPS instances, and small businesses that need reliable outbound email without paying premium prices.

The December 2025 SPF update resolved the biggest security concern. The May 2024 move to in-house infrastructure improved reliability. The REST API and WordPress plugin added integration options that didn&apos;t exist in 2023.

&lt;Notice type=&quot;success&quot; title=&quot;Verdict&quot;&gt;
Mail.Baby is worth it if you want cheap, reliable outbound email and don&apos;t need a polished dashboard. At $1/month, the risk to try it is minimal. The service has improved significantly since 2023 and remains one of the best budget SMTP options available.
&lt;/Notice&gt;

Where it falls short: the 6,000/hour hard cap with no queuing, the cluttered Interserver dashboard, and occasional spam false positives. If you need enterprise-grade deliverability guarantees or a sleek management interface, pay more for Mailgun or SendGrid.

For most self-hosted use cases, like sending transactional email from your applications, [monitoring alerts from your VPS](/monitor-cpu-usage-and-send-email-alerts-in-linux/), or running email for a small WordPress site, Mail.Baby does the job at a fraction of the cost.

&lt;Button text=&quot;Try Mail.Baby for $1/month&quot; link=&quot;https://my.interserver.net/buy_mail&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is Mail.Baby free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. Mail.Baby charges $1/month as a base fee (which covers up to 5,000 emails) plus $0.20 per 1,000 emails after that. There is no free tier. For comparison, Amazon SES offers 62,000 free emails/month when sent from EC2, but requires AWS setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Mail.Baby with Cloudflare?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Yes. Use the new TXT verification record method (added December 2025) instead of including your server IP in the SPF record. Add `_mailbaby.yourdomain.com` as a TXT record with your Mail.Baby username, then set your SPF to `v=spf1 include:spf-c.mailbaby.net -all`. Your origin server IP stays hidden.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happened to MailChannels?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Mail.Baby moved all operations in-house on May 1, 2024. They no longer use MailChannels as a backend. This gave Mail.Baby full control over their IP reputation and delivery infrastructure.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Mail.Baby support marketing emails?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Yes. Mail.Baby supports both transactional and marketing emails through the same service. Many competitors charge extra or restrict marketing sends. Mail.Baby doesn&apos;t.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a WordPress plugin?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Yes. The &quot;Mail Baby SMTP&quot; plugin is available on the WordPress plugin directory with 600+ active installations. It&apos;s currently at version 3.2.13 and supports multiple mailers. Make sure you&apos;re on version 3.2.12 or later to patch CVE-2025-57992.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the Mail.Baby REST API?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Mail.Baby offers a REST API (v1.5.0) for programmatic email sending. It supports simple sends, advanced sends with CC/BCC/attachments, raw RFC 822 messages, delivery logs, block management, and deny rules. Documentation is at [api.mailbaby.net](https://api.mailbaby.net) with sample clients on GitHub.
&lt;/Accordion&gt;</content:encoded><category>hosting</category><category>mail</category><category>smtp</category><category>email</category></item><item><title>TinyFish Review: Free Web Search and Fetch API for AI Coding Agents</title><link>https://www.bitdoze.com/tinyfish-ai-agents-web-search/</link><guid isPermaLink="true">https://www.bitdoze.com/tinyfish-ai-agents-web-search/</guid><description>TinyFish gives your AI agents structured web search and clean page fetching for free. 30 search/min, 150 fetch/min. Set it up with Pi, Hermes, OpenClaw, Claude Code, or any coding agent.</description><pubDate>Mon, 13 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Every AI coding agent hits the same wall eventually. You ask it something about a library&apos;s latest API, a Docker image that changed its tagging scheme, or a config format that got updated last week. The model&apos;s training data stops in 2025 (or earlier), and it either hallucinates or tells you to check the docs yourself. Not helpful.

The fix is giving your agent access to the live web. And that is where [TinyFish](https://tinyfish.ai/) comes in. TinyFish provides structured web search and clean page fetching through a single API key. Search and Fetch are free — no credit card, no trial period, just sign up and go.

I have been using TinyFish through the [pi-tinyfish](https://github.com/x1any/pi-tinyfish) package in [Pi coding agent](/pi-coding-agent-setup-guide/) and in [Mastra](/build-ai-agent-mastra/) for months now. It works well enough that I want to lay out what it does, how to set it up, and why it matters for agents like [Hermes](/hermes-agent-setup-guide/), [OpenClaw](/clawdbot-setup-guide/), and [OpenCode](/opencode-setup-guide/) too.

&lt;Button text=&quot;Get a free TinyFish API key&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;What TinyFish is and how Search + Fetch work&lt;/li&gt;
&lt;li&gt;Setting up TinyFish with Pi coding agent via pi-tinyfish&lt;/li&gt;
&lt;li&gt;Using TinyFish with Hermes Agent, OpenClaw, and other coding agents&lt;/li&gt;
&lt;li&gt;The TinyFish Cookbook: ready-made recipes for common automation tasks&lt;/li&gt;
&lt;li&gt;Pricing tiers and rate limits&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## What TinyFish actually does


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Hu_OGbBEW3M&quot;
  label=&quot;FREE TinyFish Makes AI Agents Actually Useful&quot;
/&gt;

TinyFish has four endpoints, but you really only need two of them to start:

**Search** takes a query and returns structured JSON results. Not a list of blue links meant for human eyes — rank-stable, clean data that an LLM can parse without guessing. Response times sit under 500ms. You can pass location and language hints for geo-targeted results.

**Fetch** takes one or more URLs and returns clean content. The page gets rendered in a real Chromium browser (JavaScript, SPAs, the works), then all the navigation bars, cookie banners, ads, and scripts get stripped out. You get markdown, HTML, or JSON back. Your model stops paying tokens for junk HTML.

The two heavier endpoints — Agent (natural-language browser automation) and Browser (raw CDP sessions) — are metered and cost credits. For coding agent use, Search and Fetch cover 95% of what you need.

### Real usage stats

Here&apos;s what the TinyFish dashboard looks like in practice. These are actual stats from coding agents using the free tier:

**Search API** — 40.6K total requests with 360ms average response time. Top users include Codex CLI, OpenCode, Claude Code, and Gemini CLI:

![TinyFish Search API stats](../../assets/images/26/07/tinyfish-search.webp)

**Fetch API** — 65.6K total requests. The Skills integration is the biggest user, followed by Gemini CLI, OpenCode, and Claude Code:

![TinyFish Fetch API stats](../../assets/images/26/07/tiny-fishfetch.webp)

### Why this matters for coding agents

Most coding agents rely on the model&apos;s training data for anything outside your codebase. That works for stable patterns and well-documented APIs. It falls apart for:

- **Recent releases** — a library that shipped breaking changes last week
- **Version-specific quirks** — &quot;does this Docker image still support ARM64 in v3?&quot;
- **Community solutions** — the GitHub issue where someone already solved your exact problem
- **Documentation lookups** — reading the actual docs instead of guessing from memory

Giving your agent a search+fetch pipeline turns it from &quot;I think this is how it works&quot; to &quot;here is the current documentation, and here are three GitHub issues confirming this behavior.&quot;

## Setting up TinyFish with Pi coding agent

Pi already has TinyFish support through the [pi-tinyfish](https://github.com/x1any/pi-tinyfish) package. It adds two tools to your agent: `tinyfish_search` and `tinyfish_fetch`. Install takes one command.

### Step 1: Get your API key

Sign up at [agent.tinyfish.ai](https://agent.tinyfish.ai/). No credit card. Copy the API key from the dashboard.

### Step 2: Install pi-tinyfish

&lt;Tabs&gt;
&lt;Tab name=&quot;npm&quot;&gt;
```bash
pi install npm:pi-tinyfish
```
&lt;/Tab&gt;
&lt;Tab name=&quot;git&quot;&gt;
```bash
pi install git:github.com/x1any/pi-tinyfish
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Step 3: Set the API key

```bash
export TINYFISH_API_KEY=&quot;your_api_key_here&quot;
```

Add that to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish`) so it persists across sessions.

### Step 4: Use it

That is it. Next time you start Pi in a project, the agent can call `tinyfish_search` and `tinyfish_fetch` as tools. When it needs to look up something about a library, check docs, or verify a config format, it will search the web and fetch the relevant pages automatically.

The search latency is typically 1-3 seconds. Fetch can take a few seconds longer depending on the page. Both tools default to sensible timeouts (10s for search, 150s for fetch).

&lt;Notice type=&quot;success&quot; title=&quot;Token savings&quot;&gt;
TinyFish Fetch strips navigation, scripts, and boilerplate from pages before returning content. Your model processes the actual article content, not three kilobytes of cookie consent banners and footer links. This cuts token usage per fetch significantly.
&lt;/Notice&gt;

## Using TinyFish with Hermes Agent

[Hermes Agent](/hermes-agent-setup-guide/) from Nous Research has a built-in web search tool, but TinyFish gives you more control over the search results and adds clean page fetching that Hermes does not have out of the box.

There are three ways to wire TinyFish into Hermes:

### Option 1: MCP Server

TinyFish runs an MCP server at `https://mcp.tinyfish.ai`. Add it to your Hermes MCP config:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

This gives Hermes access to both Search and Fetch through the standard MCP protocol. The agent sees them as native tools.

### Option 2: Agent Skill

Install the TinyFish skill from the cookbook:

```bash
npx skills add github.com/tinyfish-io/tinyfish-cookbook --skill use-tinyfish
```

This teaches Hermes when to reach for Search vs Fetch vs Agent, and how to call them correctly. The skill includes decision logic — use search for finding URLs, fetch for reading known pages, and escalate to agent only when interactive browser automation is needed.

### Option 3: CLI wrapper

If you prefer shell-based integration, install the CLI:

```bash
npm install -g @tiny-fish/cli
tinyfish auth login
```

Then Hermes can call `tinyfish search query &quot;...&quot;` and `tinyfish fetch content get &lt;urls&gt;` through its terminal access. The CLI writes results to the filesystem instead of piping through the model&apos;s context, which keeps token usage low.

### Which approach to pick

MCP is the cleanest if your Hermes version supports it. The skill approach works well if you want the agent to understand the tool hierarchy (search first, fetch second, agent last). CLI is the fallback that works everywhere.

## Using TinyFish with Claude Code

Claude Code has a one-shot helper to wire TinyFish as the web search/fetch backend:

```bash
tinyfish config-claude            # install
tinyfish config-claude --remove   # uninstall
```

This installs the MCP server configuration automatically. No manual config editing needed.

## Using TinyFish with OpenCode, OpenClaw, and others

The same patterns apply to [OpenClaw](/clawdbot-setup-guide/), [OpenCode](/opencode-setup-guide/), [Mastra](/build-ai-agent-mastra/), Cursor, Codex, and any other coding agent that supports MCP, skills, or shell access.

### MCP for any agent

The MCP server URL is the same regardless of which agent you use:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

This works with Claude Code, Cursor, Codex, ChatGPT desktop, and any MCP-aware client. Drop it in your config and restart.

### CLI setup (works everywhere)

The CLI is the most portable option. It works with any agent that can run shell commands:

```bash
npm install -g @tiny-fish/cli@latest
tinyfish auth login
```

For CI/CD or non-interactive setups:

```bash
echo $TINYFISH_API_KEY | tinyfish auth set
```

Verify it works:

```bash
tinyfish --version
tinyfish auth status --pretty
```

### Install the skill

The TinyFish skill teaches your agent when to reach for search vs fetch vs agent:

```bash
npx skills add github.com/tinyfish-io/tinyfish-cookbook --skill use-tinyfish
```

The skill covers the escalation ladder: search for finding URLs, fetch for reading pages, agent for interactive browser tasks, and browser for raw CDP control.

### REST API for custom integrations

If you are building something custom or your agent does not support MCP, use the REST endpoints directly:

```bash
# Search
curl &quot;https://api.search.tinyfish.ai?query=docker+compose+healthcheck&quot; \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot;

# Fetch
curl -X POST https://api.fetch.tinyfish.ai \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;urls&quot;: [&quot;https://docs.docker.com/compose/how-tos/startup-order/&quot;]}&apos;
```

Both endpoints return JSON. Search gives you ranked results with titles, snippets, and URLs. Fetch gives you the cleaned page content plus metadata (title, language, author, published date).

### SDKs for programmatic use

&lt;Tabs&gt;
&lt;Tab name=&quot;Python&quot;&gt;
```bash
pip install tinyfish
```
```python
from tinyfish import TinyFish

client = TinyFish()  # reads TINYFISH_API_KEY from env

# Search
results = client.search.query(&quot;best React state management 2026&quot;)

# Fetch
content = client.fetch.content.get(
    urls=[&quot;https://tanstack.com/query/latest&quot;],
    format=&quot;markdown&quot;
)
```
&lt;/Tab&gt;
&lt;Tab name=&quot;TypeScript&quot;&gt;
```bash
npm install @tiny-fish/sdk
```
```typescript
import { TinyFish } from &quot;@tiny-fish/sdk&quot;;

const client = new TinyFish(); // reads TINYFISH_API_KEY from env

// Search
const results = await client.search.query(&quot;best React state management 2026&quot;);

// Fetch
const content = await client.fetch.content.get({
  urls: [&quot;https://tanstack.com/query/latest&quot;],
  format: &quot;markdown&quot;,
});
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## The TinyFish Cookbook

The [TinyFish Cookbook](https://github.com/tinyfish-io/tinyfish-cookbook) is a collection of ready-made projects built on top of TinyFish. Some of them are genuinely useful:

| Project | What it does |
|---------|-------------|
| [viet-bike-scout](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/viet-bike-scout) | Motorbike rental price comparison across Vietnamese cities |
| [openbox-deals](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/openbox-deals) | Open-box and refurbished deal aggregator across 8 retailers |
| [competitor-scout-cli](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/competitor-scout-cli) | Natural-language CLI for researching competitor pricing |
| [silicon-signal](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/silicon-signal) | Semiconductor supply chain tracker |
| [code-reference-finder](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/code-reference-finder) | Find real-world usage examples for code snippets on GitHub and Stack Overflow |
| [tinyskills](https://github.com/tinyfish-io/tinyfish-cookbook/tree/main/tinyskills) | Generates SKILL.md guides from docs, GitHub, and developer blogs |

The code-reference-finder is the one I keep coming back to. Paste a function signature, and it searches GitHub and Stack Overflow for real usage examples. Saves a lot of &quot;how does anyone actually use this API&quot; time.

The cookbook also includes the [use-tinyfish skill](https://github.com/tinyfish-io/tinyfish-cookbook/blob/main/skills/use-tinyfish/SKILL.md) that teaches any coding agent the right tool to reach for. It covers the escalation ladder: search for finding URLs, fetch for reading pages, agent for interactive browser tasks, and browser for raw CDP control.

&lt;Button text=&quot;Browse the TinyFish Cookbook&quot; link=&quot;https://github.com/tinyfish-io/tinyfish-cookbook&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

## Pricing and rate limits

The free tier covers Search and Fetch with generous limits:

| Endpoint | Cost | Free tier rate limit |
|----------|------|---------------------|
| **Search** | Free | 30 requests/min |
| **Fetch** | Free | 150 URLs/min |
| **Agent** | 1 credit/step | 2 concurrent runs |
| **Browser** | 1 credit/4 min | 5 concurrent sessions |

The Agent and Browser endpoints consume credits. You get 500 free credits on signup, and paid plans start at $13/month for 1,650 credits. For coding agent use, Search and Fetch are all you need, and those are free.

Failed fetches do not count against your quota. If a URL returns an error, you do not pay for it.

&lt;Accordion label=&quot;Comparison with alternatives&quot; group=&quot;alternatives&quot;&gt;

TinyFish is not the only option for giving agents web access. Here is how it stacks up:

| Feature | TinyFish Fetch | Firecrawl | Native LLM fetch | Hand-rolled Playwright |
|---------|---------------|-----------|-------------------|----------------------|
| **JavaScript rendering** | Yes (real Chromium) | Yes | No (static HTML only) | Yes |
| **Clean content extraction** | Yes | Yes | Raw HTML | Manual |
| **Stealth/anti-bot** | Built-in | Varies | No | Manual setup |
| **Free tier** | Yes (Search + Fetch) | Limited free | Depends on provider | You pay for compute |
| **Token optimization** | Strips boilerplate | Strips boilerplate | Full HTML | Manual |
| **Multi-URL batching** | Up to 10 URLs/call | Varies | No | Manual |

The main advantage of TinyFish over hand-rolling Playwright is that you do not manage browser instances, proxy rotation, or anti-bot detection. The main advantage over native LLM fetch is JavaScript rendering — most modern docs sites are SPAs that return empty shells without it.

&lt;/Accordion&gt;

## What I like and what I do not

I have been using TinyFish through Pi and Mastra for months. Here is the honest take.

**What works well:** The search results are clean and fast. Fetch renders SPAs properly, which matters for React-based docs sites. The free tier covers daily coding agent use without issues. Token savings from clean content are noticeable. The CLI + skill combo works across every agent I have tried.

**What could be better:** Fetch can be slow on heavy pages (5-10 seconds for JavaScript-heavy sites). Search occasionally returns stale results for very recent events. And the Agent/Browser endpoints get expensive fast if you need interactive automation.

**Bottom line:** For giving your coding agent the ability to read current documentation and search the web, the free Search + Fetch tier works well. I have it wired into Pi through pi-tinyfish, into Mastra through the SDK, and use the CLI for everything else. It has become a standard part of my agent setup.

## Related articles

- [Free Web Search for AI Coding Agents: TinyFish Setup Guide](/tinyfish-free-search-coding-agents/) — focused setup guide for coding agents
- [Build Your Own AI Agent with Mastra](/build-ai-agent-mastra/) — full guide using TinyFish with Mastra
- [Pi coding agent setup guide](/pi-coding-agent-setup-guide/) — install and configure Pi
- [Hermes Agent setup guide](/hermes-agent-setup-guide/) — install and configure Hermes
- [OpenCode Go: 12 AI Coding Models for $10/Month](/opencode-go-plan/) — cheap models for your agent

## Next steps

- [Get a free TinyFish API key](https://go.bitdoze.com/tinyfish) — no credit card required
- [Install the CLI](https://docs.tinyfish.ai/cli) — `npm install -g @tiny-fish/cli@latest`
- [Install pi-tinyfish](https://github.com/x1any/pi-tinyfish) for Pi coding agent
- [Read the TinyFish Cookbook](https://github.com/tinyfish-io/tinyfish-cookbook) for project ideas and the agent skill
- [TinyFish documentation](https://docs.tinyfish.ai/) for the full API reference</content:encoded><category>ai</category><category>ai-tools</category><category>llm</category></item><item><title>How to Enable Syntax Highlighting in Zsh</title><link>https://www.bitdoze.com/enable-syntax-highlighting-zsh/</link><guid isPermaLink="true">https://www.bitdoze.com/enable-syntax-highlighting-zsh/</guid><description>Learn how to enable syntax highlighting in ZSH to improve your coding experience. Covers installation, color customization, and optional highlighters.</description><pubDate>Sun, 12 Jul 2026 01:00:00 GMT</pubDate><content:encoded>Zsh doesn&apos;t colorize commands as you type by default. The `zsh-syntax-highlighting` plugin fixes that. It highlights commands, options, strings, paths, and errors in real time, similar to how Fish shell works out of the box.

With syntax highlighting, valid commands show up in one color, invalid commands in another, strings and paths get their own colors too. You catch typos before pressing Enter.

ZSH comes pre-installed on macOS (since Catalina). On Linux, you&apos;ll need to install it first.

**Related ZSH guides:**

- [How to Enable Command Autocomplete in ZSH](https://www.bitdoze.com/enable-command-autocomplete-in-zsh/)
- [Top 15 Oh My ZSH Plugins You Must Try](https://www.bitdoze.com/best-oh-my-zsh-plugins/)
- [Zoxide: The Smarter Way to Navigate Your Terminal](https://www.bitdoze.com/zoxide/)

## What zsh-syntax-highlighting does

The plugin highlights your command line as you type, before you press Enter. It colorizes:

- **Commands:** valid commands in one color, unknown/invalid commands in red (by default)
- **Options:** flags like `--verbose` or `-la` get distinct colors
- **Strings:** single-quoted, double-quoted, and unquoted strings are differentiated
- **Paths:** existing file paths are highlighted (underlined by default)
- **Reserved words:** `if`, `for`, `while`, `case`, etc.
- **Globs and expansions:** `*.txt`, `$(command)`, `$variable`

The default highlighter (called `main`) covers these. There are also optional highlighters for bracket matching, regex patterns, and root-user warnings (more on those below).

The plugin requires ZSH 4.3.11+. Every modern ZSH version meets this.

## Step 1: Install zsh-syntax-highlighting

There are four ways to install it. Pick the one that fits your setup.

### Option A: With Oh My Zsh

If you&apos;re using Oh My Zsh:

```bash
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git \
  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
```

Then edit `~/.zshrc` and add `zsh-syntax-highlighting` to your plugins list. **It must be the last plugin in the list:**

```zsh
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
```

If you don&apos;t have Oh My Zsh yet:

```bash
sh -c &quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&quot;
```

### Option B: Manual install (without Oh My Zsh)

Clone the repo and source the script:

```bash
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git \
  ~/.zsh/zsh-syntax-highlighting
```

Add this to the **end** of your `~/.zshrc`:

```zsh
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
```

### Option C: With Homebrew (macOS)

```bash
brew install zsh-syntax-highlighting
```

Add to the **end** of your `~/.zshrc`:

```zsh
source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
```

### Option D: System package manager (Linux)

The plugin is packaged for most Linux distributions:

| Distro | Package name | Source command |
|--------|-------------|----------------|
| Debian/Ubuntu | `zsh-syntax-highlighting` | `source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh` |
| Fedora | `zsh-syntax-highlighting` | `source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh` |
| Arch Linux | `zsh-syntax-highlighting` (AUR/community) | `source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh` |
| openSUSE | `zsh-syntax-highlighting` | `source /usr/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh` |

Install with your package manager, then add the appropriate `source` line to the **end** of your `~/.zshrc`.

## Step 2: Source it at the end of .zshrc

This is the most common mistake people make. `zsh-syntax-highlighting` must be sourced **after everything else** in your `.zshrc`, after Oh My Zsh, after `compinit`, after other plugins, after custom widgets.

The plugin works by hooking into ZSH&apos;s line editor (ZLE). If it&apos;s sourced before other things that modify the command line, those modifications won&apos;t trigger re-highlighting.

If you&apos;re using Oh My Zsh and `zsh-syntax-highlighting` is the last plugin in your `plugins=()` array, this is handled automatically. For manual installs, put the `source` line at the very bottom of `.zshrc`.

## Step 3: Reload and test

Apply the changes:

```bash
source ~/.zshrc
```

Or open a new terminal tab. Then test:

1. Type a valid command like `ls`. It should appear colored (green by default in many themes).
2. Type a nonexistent command like `asdfg`. It should appear in red.
3. Type `echo &quot;hello&quot;`. The string `&quot;hello&quot;` should have its own color.

If you see colors, it&apos;s working.

## Customizing syntax highlighting colors

The default colors are reasonable, but you can override any of them. Add these to `~/.zshrc` **before** the `source` line for the plugin.

### Common style overrides

```zsh
typeset -A ZSH_HIGHLIGHT_STYLES

# Valid commands, default is &apos;fg=green&apos; on many setups
ZSH_HIGHLIGHT_STYLES[command]=&apos;fg=blue,bold&apos;

# Unknown commands, default is &apos;fg=red&apos;
ZSH_HIGHLIGHT_STYLES[unknown-token]=&apos;fg=red,bold&apos;

# Aliases
ZSH_HIGHLIGHT_STYLES[alias]=&apos;fg=magenta,bold&apos;

# Shell builtins (cd, echo, export, etc.)
ZSH_HIGHLIGHT_STYLES[builtin]=&apos;fg=blue&apos;

# Shell reserved words (if, for, while, case)
ZSH_HIGHLIGHT_STYLES[reserved-word]=&apos;fg=yellow,bold&apos;

# Existing file paths
ZSH_HIGHLIGHT_STYLES[path]=&apos;fg=cyan,underline&apos;

# Strings in single quotes
ZSH_HIGHLIGHT_STYLES[single-quoted-argument]=&apos;fg=yellow&apos;

# Strings in double quotes
ZSH_HIGHLIGHT_STYLES[double-quoted-argument]=&apos;fg=yellow&apos;

# Command options/flags (--verbose, -la)
ZSH_HIGHLIGHT_STYLES[single-hyphen-option]=&apos;fg=cyan&apos;
ZSH_HIGHLIGHT_STYLES[double-hyphen-option]=&apos;fg=cyan&apos;

# Globbing expressions (*.txt, *.log)
ZSH_HIGHLIGHT_STYLES[globbing]=&apos;fg=green,bold&apos;

# Redirection operators (&gt;, &gt;&gt;, |)
ZSH_HIGHLIGHT_STYLES[redirection]=&apos;fg=magenta&apos;

# Command substitutions ($(echo foo))
ZSH_HIGHLIGHT_STYLES[command-substitution]=&apos;fg=cyan&apos;

# Assignments (FOO=bar)
ZSH_HIGHLIGHT_STYLES[assign]=&apos;fg=green&apos;
```

### Disable a specific highlight

To turn off highlighting for a particular style:

```zsh
ZSH_HIGHLIGHT_STYLES[globbing]=&apos;none&apos;
```

### Available style names

The `main` highlighter defines these style keys (this is not exhaustive, but covers the ones you&apos;d most likely want to customize):

| Style name | What it highlights |
|---|---|
| `command` | External command names |
| `alias` | Aliases |
| `builtin` | Shell builtins (`cd`, `pwd`, `shift`) |
| `function` | Function names |
| `reserved-word` | `if`, `for`, `while`, `case`, etc. |
| `unknown-token` | Invalid/unknown commands (errors) |
| `path` | Existing file paths |
| `path_prefix` | Prefixes of existing paths |
| `single-quoted-argument` | `&apos;single quoted&apos;` strings |
| `double-quoted-argument` | `&quot;double quoted&quot;` strings |
| `dollar-quoted-argument` | `$&apos;dollar quoted&apos;` strings |
| `single-hyphen-option` | `-o` style flags |
| `double-hyphen-option` | `--option` style flags |
| `globbing` | Glob patterns (`*.txt`) |
| `redirection` | `&lt;`, `&gt;`, `&gt;&gt;`, pipes |
| `comment` | Comments (when `INTERACTIVE_COMMENTS` is set) |
| `assign` | Variable assignments |
| `default` | Everything else |

The color syntax follows ZSH&apos;s `zle_highlight` format: `fg=color,bold`, `bg=color`, `underline`, `standout`, etc. You can combine attributes with commas.

## Optional highlighters

The `main` highlighter is active by default. The plugin ships with several others you can enable:

| Highlighter | What it does |
|---|---|
| `main` | Command, option, string, path highlighting (default) |
| `brackets` | Matches brackets and parentheses with colors |
| `pattern` | Highlights text matching user-defined patterns |
| `regexp` | Highlights text matching user-defined regexps |
| `cursor` | Highlights the cursor position |
| `root` | Highlights the entire line when running as root (red background) |
| `line` | Applies a style to the entire command line |

To enable additional highlighters, add them to the `ZSH_HIGHLIGHT_HIGHLIGHTERS` array:

```zsh
ZSH_HIGHLIGHT_HIGHLIGHTERS=(main brackets cursor)
```

### Bracket matching example

Enable the `brackets` highlighter and customize the colors:

```zsh
ZSH_HIGHLIGHT_HIGHLIGHTERS+=(brackets)

# Matched bracket pair
ZSH_HIGHLIGHT_STYLES[bracket-level-1]=&apos;fg=cyan,bold&apos;
ZSH_HIGHLIGHT_STYLES[bracket-level-2]=&apos;fg=green,bold&apos;
ZSH_HIGHLIGHT_STYLES[bracket-level-3]=&apos;fg=magenta,bold&apos;

# Unmatched bracket (error)
ZSH_HIGHLIGHT_STYLES[cursor-matchingbracket]=&apos;standout&apos;
```

### Root user warning

The `root` highlighter makes the entire command line visually distinct when you&apos;re logged in as root. A good safety net:

```zsh
ZSH_HIGHLIGHT_HIGHLIGHTERS+=(root)
ZSH_HIGHLIGHT_STYLES[root]=&apos;bg=red&apos;
```

### Pattern highlighting

Highlight specific patterns you define. Useful for flagging risky commands:

```zsh
ZSH_HIGHLIGHT_HIGHLIGHTERS+=(pattern)
ZSH_HIGHLIGHT_PATTERNS+=(&apos;rm -rf *&apos; &apos;fg=white,bold,bg=red&apos;)
```

This makes `rm -rf` commands stand out with a red background.

## Putting it all together

A minimal `~/.zshrc` with syntax highlighting configured:

```zsh
# Oh My Zsh
export ZSH=&quot;$HOME/.oh-my-zsh&quot;
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
source $ZSH/oh-my-zsh.sh

# Completion styles
zstyle &apos;:completion:*&apos; matcher-list &apos;m:{a-zA-Z}={A-Za-z}&apos;
zstyle &apos;:completion:*&apos; menu select

# Syntax highlighting customization (before the plugin sources)
ZSH_HIGHLIGHT_STYLES[unknown-token]=&apos;fg=red,bold&apos;
ZSH_HIGHLIGHT_STYLES[command]=&apos;fg=blue,bold&apos;
ZSH_HIGHLIGHT_STYLES[alias]=&apos;fg=magenta,bold&apos;
```

Without Oh My Zsh:

```zsh
# Completion system
autoload -Uz compinit &amp;&amp; compinit

# Plugins
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh

# Syntax highlighting, must be LAST
ZSH_HIGHLIGHT_STYLES[unknown-token]=&apos;fg=red,bold&apos;
ZSH_HIGHLIGHT_STYLES[command]=&apos;fg=blue,bold&apos;
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
```

Note: with manual installs, the `ZSH_HIGHLIGHT_STYLES` overrides go before the `source` line.

## Limiting highlight length

If you edit very long command lines and notice lag, you can limit the maximum line length that gets highlighted:

```zsh
ZSH_HIGHLIGHT_MAXLENGTH=512
```

Lines longer than 512 characters won&apos;t be highlighted. The default is no limit.

## Troubleshooting

**No colors appearing:** Make sure the `source` line is at the very end of `.zshrc`. This is the single most common fix. Run `source ~/.zshrc` or open a new terminal to test.

**Plugin not found (Oh My Zsh):** Verify the plugin directory exists:

```bash
ls ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting
```

If it&apos;s empty, re-clone the plugin.

**Colors look wrong or invisible:** Your terminal color scheme might conflict. For example, if your background is dark green and &quot;valid command&quot; is also green, you won&apos;t see anything. Adjust the styles as shown in the customization section, or change your terminal&apos;s color scheme.

**Highlighting breaks after adding other plugins:** Since `zsh-syntax-highlighting` must be sourced last, adding a new plugin after it can cause issues. In Oh My Zsh, make sure `zsh-syntax-highlighting` stays at the end of your `plugins=()` array. For manual installs, move its `source` line to the bottom.

**Slow typing on long commands:** Set `ZSH_HIGHLIGHT_MAXLENGTH` to a reasonable value (like 512) to skip highlighting on very long inputs.

**Want syntax highlighting without plugins?** [Fish Shell](/install-fish-shell-ubuntu/) includes syntax highlighting, autosuggestions, and rich completions out of the box. See the [Fish Shell vs Zsh](/fish-shell-vs-zsh/) comparison if you want to explore that option.</content:encoded><category>linux</category><category>zsh</category></item><item><title>How to Build a One Page Website on a Budget</title><link>https://www.bitdoze.com/build-one-page-website-budget/</link><guid isPermaLink="true">https://www.bitdoze.com/build-one-page-website-budget/</guid><description>Build a one page website on a budget using Astro, static WordPress, or Carrd. Compare free hosting options, costs, and ease of use to find the best fit.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

One page websites work well for portfolios, landing pages, resumes, and small business sites. They&apos;re fast to load, easy to navigate on mobile, and simple to maintain.

The problem: building and hosting a website sounds expensive. Web developers charge hundreds or thousands of dollars. Managed hosting plans add recurring costs. But you don&apos;t actually need any of that for a single-page site.

This guide covers three affordable ways to build a one page website on a budget, ranging from completely free to under $20/year. Each option has different tradeoffs around customization, technical skill required, and cost.

## How to Build a One Page Website on a Budget

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/HpGWpBefkOE&quot;
  label=&quot;How to Build a One Page Website on a Budget&quot;
/&gt;

## Option 1: Astro

[Astro](https://astro.build/) is a static site framework built for speed. It generates plain HTML, CSS, and JavaScript with no runtime overhead. Astro 7.0 (released June 2026) uses Vite 8 and includes a Rust-based compiler for faster builds.

Astro supports React, Svelte, Vue, and its own component syntax. You can mix frameworks in the same project. For a one page website, you probably won&apos;t need any framework. Astro&apos;s built-in templating is enough.

**What it costs:** Free. Astro itself is open source. You can host the built output on Cloudflare Pages, Vercel, or Netlify for free.

**Technical skill needed:** Moderate. You need to know HTML and CSS. Some JavaScript helps. You&apos;ll use the command line to create the project and push to Git.

### Why Astro works for one page sites

- Outputs static HTML, no server needed at runtime
- Fast build times with the Rust compiler in Astro 7
- Hundreds of free templates on the [Astro themes directory](https://astro.build/themes/)
- Deploy to Cloudflare Pages for free unlimited bandwidth

### Free hosting for Astro

| Platform | Free bandwidth | Free builds | Custom domains |
|----------|---------------|-------------|----------------|
| Cloudflare Pages | Unlimited | 500/month | Unlimited |
| Vercel (Hobby) | 100 GB | 6,000 minutes/month | Unlimited |
| Netlify | 100 GB | 300 minutes/month | Unlimited |

Cloudflare Pages is the best free option for static sites. Unlimited bandwidth means no surprise bills, and the 300+ edge locations serve your site fast worldwide.

### Getting started with Astro

1. Install Node.js (v22.12.0+ required)
2. Run `npm create astro@latest` and pick a template
3. Customize the template with your content
4. Push to GitHub
5. Connect the repo to Cloudflare Pages, Vercel, or Netlify
6. Your site is live

**Useful resources:**

- [Best Astro.js Online Courses/Tutorials](https://www.bitdoze.com/best-astrojs-online-courses/)
- [How To Deploy An Astro.JS Blog On Cloudflare](https://www.bitdoze.com/deploy-astrojs-cloudflare/)
- [Link GitHub with A SSH Key to MacOS or Linux](https://www.bitdoze.com/link-github-with-ssh-maco-linux/)

## Option 2: Static WordPress

WordPress runs millions of websites, but it normally needs a PHP server and a database. For a one page site, that&apos;s overkill, and it costs more than necessary.

Static WordPress solves this. You build the site in WordPress (using the block editor or a page builder), then convert it to plain HTML files. The static files get hosted on a free platform with no server to manage.

**What it costs:** Free if you host locally during development and deploy to Kinsta Static, Cloudflare Pages, or Netlify. Kinsta Static offers 100 free sites with 100 GB bandwidth per month.

**Technical skill needed:** Low. If you can use WordPress, you can do this. The conversion step uses a plugin called Simply Static.

### How it works

1. Set up WordPress locally (LocalWP or similar) or on cheap hosting
2. Build your one page site using the block editor or a theme
3. Install the [Simply Static](https://wordpress.org/plugins/simply-static/) plugin
4. Generate static HTML files
5. Deploy to Kinsta Static, Cloudflare Pages, or Netlify

### Kinsta Static hosting (free)

Kinsta&apos;s free static hosting is generous:

- 100 static sites per account
- 100 GB bandwidth per month
- 600 build minutes per month
- Free SSL certificates
- Custom domain support
- Sites hosted on Cloudflare&apos;s 300+ edge locations

You can connect a GitHub, GitLab, or Bitbucket repo. Kinsta auto-detects the build settings for popular static site generators.

**Useful resources:**

- [Free WordPress Static Site on Kinsta](https://www.wpdoze.com/deploy-wp-static-website-kinsta-static/)
- [Breakdance + Kinsta Static Site](https://www.wpdoze.com/breakdance-kinsta-static-site/)

## Option 3: Carrd

[Carrd](https://try.carrd.co/bitdoze) is a drag-and-drop builder made specifically for one page websites. No coding required. You pick a template, customize it with the visual editor, and publish.

**What it costs:** Free for basic use. Paid plans start at $19/year for 10 sites with custom domains.

**Technical skill needed:** None. If you can use a mouse, you can build a Carrd site.

### Carrd pricing

| Plan | Cost/year | Sites | Custom domains | Remove branding |
|------|-----------|-------|----------------|-----------------|
| Free | $0 | 3 | No | No |
| Pro Lite | $9 | 3 | No | Yes |
| Pro Standard | $19 | 10 | Yes | Yes |
| Pro Plus | $49 | 25 | Yes | Yes |

Pro Standard at $19/year is the sweet spot. You get 10 sites with custom domains, forms, and the ability to embed custom code. That&apos;s roughly $1.58/month per site.

### What Carrd is good at

- Landing pages and portfolios
- Simple business sites with contact forms
- Link-in-bio pages
- Resume or CV sites

### What Carrd can&apos;t do

- Blog posts or dynamic content
- E-commerce (limited to Stripe/PayPal buttons)
- Multi-page navigation
- Complex layouts with many sections (free plan has a 50-element limit)

**Useful resources:**

- [Carrd.co Review: The Best Budget Landing Page Builder](https://www.bitdoze.com/carrd-review/)
- [How To Add a Sticky Header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Upgrade Your Carrd.co Website With A Cookie Notice in Minutes](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [How To Add Accordion FAQs Drop-Down to Carrd.co](https://www.bitdoze.com/add-accordion-carrd/)

On [carrdme.com](https://carrdme.com/) you can find free Carrd resources and templates.

## Which option should you pick?

It depends on what matters most to you:

**Pick Astro if** you know HTML and CSS and want full control over the design. The learning curve is steeper, but the result is a fast site hosted for free with no recurring costs. Best for developers or anyone willing to learn.

**Pick Static WordPress if** you&apos;re comfortable with WordPress but want free hosting. The workflow adds a conversion step, but you get the familiar WordPress editor. Good for non-developers who want more flexibility than Carrd.

**Pick Carrd if** you want something live in 10 minutes with zero technical skill. The free plan works for basic sites, and Pro Standard at $19/year is cheap for what you get. Best for non-technical users who need a simple online presence fast.

## Conclusions

Building a one page website on a budget is easier than ever. You have three solid options depending on your skill level and what you need:

- **Astro + Cloudflare Pages** = $0, fast, developer-friendly
- **Static WordPress + Kinsta Static** = $0, familiar WordPress workflow
- **Carrd** = $0 to $19/year, no code needed, live in minutes

All three options produce fast, mobile-friendly sites. The main difference is how much control you want over the design and how much technical work you&apos;re willing to do.</content:encoded><category>web-development</category><category>astro</category><category>carrd</category></item><item><title>How to Check Remote Ports Using the nc Command in Linux</title><link>https://www.bitdoze.com/check-remote-port-in-linux-nc/</link><guid isPermaLink="true">https://www.bitdoze.com/check-remote-port-in-linux-nc/</guid><description>Learn how to check if remote ports are open using the nc (netcat) command in Linux. Covers TCP/UDP testing, port ranges, timeouts, and scripting patterns.</description><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>Need to know if a port is open on a remote server? The `nc` (netcat) command is the fastest way to find out. One command, instant answer. No browser, no GUI, no heavy tools required.

This guide covers how to check remote ports with `nc` on Linux, including TCP and UDP testing, port ranges, timeouts for scripts, and common gotchas across different netcat implementations.

**Related guides:**

- [Top 100+ Linux Commands You MUST Know](https://www.bitdoze.com/linux-commands/)
- [How to Secure an SSH Server in Linux](https://www.bitdoze.com/secure-ssh-server-linux/)
- [How To Do SSH Port Forwarding in Linux](https://www.bitdoze.com/ssh-tunneling-linux/)
- [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## What is nc (netcat)?

`nc` is a command-line utility that reads and writes data across network connections using TCP or UDP. People call it the &quot;Swiss army knife&quot; of networking because it can do a lot: port scanning, file transfers, banner grabbing, and acting as a simple client or server.

The original netcat was written by Hobbit in 1995. Since then, several implementations have appeared, and they behave differently:

| Implementation | Default on | Package name | Notes |
|---|---|---|---|
| **netcat-openbsd** | Debian, Ubuntu | `netcat-openbsd` | Supports `-z`, IPv6, proxies |
| **ncat** (from Nmap) | CentOS, RHEL, Fedora | `nmap-ncat` | Supports `-z` (since nmap 7.25), SSL |
| **netcat-traditional** | (legacy) | `netcat-traditional` | Older, fewer features |

Most examples in this guide work with all three, but if something behaves unexpectedly, check which version you&apos;re running:

```bash
nc -h 2&gt;&amp;1 | head -1
# or
which nc &amp;&amp; ls -l $(which nc)
```

## Install nc on Linux

`nc` might already be installed. If not:

**Debian / Ubuntu:**

```bash
sudo apt install netcat-openbsd
```

Note: On Ubuntu 24.04+, `netcat` is a virtual package. Installing `netcat-openbsd` explicitly is the cleanest approach.

**CentOS / RHEL / Fedora:**

```bash
sudo dnf install nmap-ncat
```

**Arch Linux:**

```bash
sudo pacman -S openbsd-netcat
```

**macOS:**

`nc` comes preinstalled (BSD netcat). It works for basic port checking.

## Check if a single TCP port is open

The basic syntax for checking a remote port:

```bash
nc -zv &lt;host&gt; &lt;port&gt;
```

Flags explained:
- `-z`: scan only, don&apos;t send any data after connecting
- `-v`: verbose output (shows success/failure messages)

**Example: check if SMTP is reachable on Gmail:**

```bash
nc -zv smtp.gmail.com 587
```

```
Connection to smtp.gmail.com port 587 [tcp/submission] succeeded!
```

**Example: check a port that&apos;s closed:**

```bash
nc -zv smtp.gmail.com 5555
```

```
nc: connectx to smtp.gmail.com port 5555 (tcp) failed: Connection refused
```

**Example: check SSH on your VPS:**

```bash
nc -zv 192.168.1.100 22
```

```
Connection to 192.168.1.100 port 22 [tcp/ssh] succeeded!
```

## Reading the output

`nc` gives you three possible outcomes:

| Output | Meaning |
|---|---|
| `Connection to &lt;host&gt; &lt;port&gt; port [tcp/*] succeeded!` | Port is open, service is listening |
| `Connection refused` | Port is closed or nothing is listening |
| `Operation timed out` | Firewall is dropping packets, or host is unreachable |

&quot;Connection refused&quot; and &quot;timed out&quot; are different problems. Refused means the server actively rejected the connection (port closed). Timed out means you never got a response at all (firewall blocking, wrong IP, or network issue). This distinction matters when troubleshooting.

## Check a range of ports

You can scan multiple ports in one command using a hyphen:

```bash
nc -zv &lt;host&gt; &lt;start&gt;-&lt;end&gt;
```

**Example: scan ports 585 through 590:**

```bash
nc -zv smtp.gmail.com 585-590
```

```
nc: connectx to smtp.gmail.com port 585 (tcp) failed: Connection refused
nc: connectx to smtp.gmail.com port 586 (tcp) failed: Connection refused
Connection to smtp.gmail.com port 587 [tcp/submission] succeeded!
nc: connectx to smtp.gmail.com port 588 (tcp) failed: Connection refused
nc: connectx to smtp.gmail.com port 589 (tcp) failed: Connection refused
nc: connectx to smtp.gmail.com port 590 (tcp) failed: Connection refused
```

**Filter for only open ports:**

```bash
nc -zv smtp.gmail.com 585-590 2&gt;&amp;1 | grep succeeded
```

```
Connection to smtp.gmail.com port 587 [tcp/submission] succeeded!
```

This is useful when scanning a bunch of ports and you only care about the ones that are open.

## Set a connection timeout with -w

By default, `nc` waits a long time before giving up on a connection attempt. For scripts and automation, you almost always want to set a timeout:

```bash
nc -zv -w 3 &lt;host&gt; &lt;port&gt;
```

The `-w 3` flag tells nc to give up after 3 seconds if the connection hasn&apos;t been established. Without it, a blocked port can hang for 30+ seconds (the OS TCP timeout).

**Skip DNS resolution with -n:**

If you&apos;re passing an IP address, add `-n` to skip DNS lookups. This speeds up scans significantly:

```bash
nc -zvn -w 3 192.168.1.100 22
```

## Check UDP ports

Use the `-u` flag to test UDP ports:

```bash
nc -zuv &lt;host&gt; &lt;port&gt;
```

**Example: check DNS (UDP port 53):**

```bash
nc -zuv 8.8.8.8 53
```

**Important caveat:** UDP is connectionless. Unlike TCP, there&apos;s no handshake, so `nc` sends a packet and has no way to confirm it arrived. You might see &quot;succeeded&quot; even if nothing is listening, or you might see no output at all.

For reliable UDP testing, combine `nc` with `tcpdump` on the target machine, or use protocol-specific tools like `dig` for DNS or `iperf3` for throughput.

## Use nc in scripts

A common pattern is waiting for a service to become available before running something else. This comes up in Docker Compose setups, CI/CD pipelines, and deployment scripts:

```bash
#!/bin/bash
HOST=db.example.com
PORT=5432

echo &quot;Waiting for $HOST:$PORT...&quot;
until nc -z -w 2 &quot;$HOST&quot; &quot;$PORT&quot; 2&gt;/dev/null; do
  sleep 1
done
echo &quot;Port $PORT is open. Continuing...&quot;
```

This loop checks the port every second until it&apos;s reachable, then proceeds. The `2&gt;/dev/null` suppresses the verbose output so your logs stay clean.

**Check multiple ports in a loop:**

```bash
for port in 80 443 8080; do
  if nc -z -w 2 example.com &quot;$port&quot; 2&gt;/dev/null; then
    echo &quot;Port $port: OPEN&quot;
  else
    echo &quot;Port $port: CLOSED&quot;
  fi
done
```

## Banner grabbing with nc

Beyond port checking, `nc` can grab service banners, the text a server sends when you first connect. This tells you what software and version is running:

```bash
nc -v smtp.gmail.com 25
```

```
220 smtp.gmail.com ESMTP ...
```

Similarly for HTTP:

```bash
printf &quot;HEAD / HTTP/1.1\r\nHost: example.com\r\n\r\n&quot; | nc example.com 80
```

This sends a minimal HTTP request and shows you the raw response headers. Useful for debugging web servers, reverse proxies, or load balancers without curl&apos;s overhead.

## Troubleshooting

| Problem | Likely cause | What to try |
|---|---|---|
| `Connection refused` | Port closed, no service listening | Verify service is running on the target (`ss -tlnp` on the target host) |
| `Operation timed out` | Firewall dropping packets, wrong IP | `ping` the host first, check firewall rules (`iptables -L`, `ufw status`) |
| `No route to host` | Network routing issue | Check subnet, gateway, VPN connectivity |
| `nc -z` says &quot;invalid option&quot; | Old ncat version without `-z` support | Update nmap-ncat or use `nc --send-only &lt;/dev/null &lt;host&gt; &lt;port&gt;` as a workaround |
| Output is silent | Connection succeeded but no banner | The service doesn&apos;t send a greeting. Use `-v` to confirm connection status |
| UDP shows &quot;succeeded&quot; but service isn&apos;t responding | UDP is connectionless | Use `tcpdump` or protocol-specific tools to verify |

## nc vs other port-checking tools

`nc` isn&apos;t the only way to check ports. Here&apos;s how it compares:

| Tool | Best for | Installed by default? | Notes |
|---|---|---|---|
| `nc` | Quick single-port checks, scripts | Often yes | Lightweight, fast, no dependencies |
| `nmap` | Scanning many ports, service detection | No | Overkill for a single port check |
| `curl` | Testing HTTP/HTTPS endpoints | Usually yes | Only works for web protocols |
| `telnet` | Interactive TCP testing | Being phased out | No UDP support, no `-z` equivalent |
| `ss` / `netstat` | Checking local listening ports | Yes | Only works for ports on the machine you&apos;re on |

For checking a remote port from the command line, `nc` hits the sweet spot: it&apos;s fast, works for both TCP and UDP, and is available on almost every Linux system.

## Conclusion

The `nc` command is the quickest way to check if a remote port is reachable from a Linux terminal. The core command is `nc -zv &lt;host&gt; &lt;port&gt;`. Add `-w` for timeouts in scripts and `-u` for UDP testing.

Just remember: different Linux distros ship different netcat implementations, and they don&apos;t all support the same flags. If `nc -zv` behaves strangely, check which version you&apos;re running. On modern systems (Ubuntu 22.04+, RHEL 8+, Fedora), the `-z` flag works out of the box with the default netcat package.</content:encoded><category>linux</category><category>networking</category><category>devops</category></item><item><title>Chatto Self-Hosted: The Privacy Answer to EU Chat Control</title><link>https://www.bitdoze.com/chatto-self-hosted/</link><guid isPermaLink="true">https://www.bitdoze.com/chatto-self-hosted/</guid><description>Self-host Chatto on Docker or a single binary to keep your team&apos;s messages private. Complete guide covering setup, costs, and why it beats EU Chat Control.</description><pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;


On July 9, 2026, the EU Parliament voted to greenlight Chat Control 1.0. The vote was 276 in favor, 314 against, with 17 abstentions. A majority of MEPs actually opposed it, but the motion to reject needed an absolute majority of 361 votes. It didn&apos;t get them.

What this means in practice: US tech companies are once again allowed to scan private messages without a warrant. This affects direct messages on Instagram, Discord, Snapchat, Skype, Xbox, and emails through Gmail and iCloud. The exemption lasts until April 3, 2028, unless a permanent deal is reached first.

Patrick Breyer, former MEP and civil rights activist, put it bluntly: *&quot;Our children are the real losers in this undemocratic process.&quot;* He&apos;s right. The EU Commission&apos;s own data shows mass scanning accounted for only 36% of abuse reports in 2024. The German Federal Criminal Police (BKA) reports that 48% of alerts aren&apos;t even criminally relevant. And 99% of Meta&apos;s reports are previously known material. ([Source](https://www.patrick-breyer.de/en/eu-parliament-greenlights-chat-control-1-0-breyer-our-children-lose-out/))

You can read the full legislative analysis on [Patrick Breyer&apos;s site](https://www.patrick-breyer.de/en/eu-parliament-greenlights-chat-control-1-0-breyer-our-children-lose-out/). The political fight over Chat Control 2.0 — the permanent CSAM regulation — resumes in September 2026. But you don&apos;t have to wait for politicians to fix this.

&lt;Notice type=&quot;warning&quot; title=&quot;EU Chat Control 1.0 Is Now Law&quot;&gt;
  As of July 9, 2026, US tech companies can scan your unencrypted private messages on Instagram, Discord, Snapchat, Skype, Xbox, Gmail, and iCloud — without a warrant. This temporary law runs until April 2028. End-to-end encrypted services like WhatsApp and Signal are exempt *for now*, but Chat Control 2.0 is still being negotiated. Self-hosting your own chat on infrastructure you control is the single most practical step you can take right now.
&lt;/Notice&gt;

The fastest way to opt out of mass surveillance is to stop using platforms that participate in it. If your team runs on Slack or Discord, your messages sit on US servers subject to scanning. A self-hosted chat server on a VPS you control fixes that in an afternoon.

Enter [Chatto](https://github.com/chattocorp/chatto).

## Why EU Chat Control Changes Everything

Let&apos;s be precise about what just happened.

The EU Parliament didn&apos;t pass a new surveillance law. It extended a temporary derogation that had been in place before, allowing US tech companies to voluntarily scan private messages for child sexual abuse material (CSAM). The key word is *voluntarily* — these companies chose to implement scanning, and the EU just gave them legal cover to keep doing it.

The numbers tell a different story than the one politicians sell:

- **36%** — the share of abuse reports that came from mass scanning of private chats in 2024. The majority came from public posts and cloud storage, which were never affected by this law.
- **48%** — alerts from the German BKA that turned out to not be criminally relevant at all.
- **40%** — investigations triggered by chat control that ended up targeting minors themselves, not adult predators.
- **99%** — Meta&apos;s reports that consisted of previously known material, doing little to stop active abuse.
- **Zero** — evidence from the EU Commission that suspicionless scanning increased convictions or rescued children.

![EU Parliament vote results for Chat Control 1.0 on July 9 2026](../../assets/images/26/07/chatto-vs-incumbents.svg)

A symbolic exemption was added for end-to-end encrypted communications. WhatsApp and Signal users aren&apos;t directly affected — *by this vote*. But here&apos;s what matters for teams: most team chat tools (Slack, Discord, Microsoft Teams) don&apos;t use end-to-end encryption for messages. Your team&apos;s conversations on those platforms are exactly what Chat Control targets.

And Chat Control 2.0 — the permanent regulation — is still in trilogue negotiations. If it passes with scanning mandates, the scope could expand. The precedent is set.

The practical response isn&apos;t to wait and see. It&apos;s to move your team&apos;s communication to infrastructure you own.

## What Is Chatto

Chatto is a self-hosted team chat application, open-sourced on July 8, 2026 by developer Hendrik Mans. It&apos;s written in Go (41.7%), TypeScript (40.2%), and Svelte (11.1%), and licensed under AGPL-3.0.

The pitch is simple: a single ~50 MB binary that serves its own web frontend, requires no database, and uses [NATS](https://nats.io) for persistence. It supports voice and video calls through LiveKit, encrypts data at rest with per-user keys, and includes zero third-party tracking or analytics.

Current release is v0.4.4 with ~1,400 GitHub stars. The developer targets 1.0 within 6–12 months. Each server is fully isolated — no federation by design, which is actually a privacy feature. No data leaks between servers because there&apos;s no inter-server communication at all.

&lt;Button text=&quot;View Chatto on GitHub&quot; link=&quot;https://github.com/chattocorp/chatto&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

Key features:

- **No database required** — NATS handles all persistence
- **Single binary** — works on Linux (x86_64 &amp; ARM64), macOS, Windows, FreeBSD
- **Voice/video calls** — powered by LiveKit, end-to-end encrypted, includes screen sharing
- **Encryption at rest** — per-user keys with crypto-shredding on account deletion
- **SSO support** — OIDC, Google, GitHub, Discord
- **Roles and permissions** — fine-grained, room groups as permission boundaries
- **Multiple servers in one client** — connect to several Chatto servers at once
- **PWA** — install on mobile via browser; native apps planned but not available yet
- **ConnectRPC + GraphQL APIs** — for bots and integrations

Official resources: [chatto.run](https://chatto.run) | [docs.chatto.run](https://docs.chatto.run) | [Community server](https://chat.chatto.run/)

![Chatto self-hosted team chat interface](../../assets/images/26/07/chatto-ui.webp)

## Chatto vs Slack and Mattermost

If you&apos;re evaluating Chatto as a self-hosted Slack alternative, here&apos;s how it stacks up against the main competitors.

### Feature comparison table

&lt;Tabs&gt;
&lt;Tab name=&quot;Chatto vs Mattermost&quot;&gt;
| Feature | Chatto | Mattermost |
|---------|--------|------------|
| Self-hosted | Yes (AGPL) | Yes (MIT/Apache, restricted free tier) |
| Price | $0 forever | Free (limited) / $10/user/mo Pro |
| Database | None (NATS) | PostgreSQL required |
| Voice/video calls | Yes (LiveKit, E2EE) | Free tier: no / Pro: yes |
| SSO | OIDC, Google, GitHub, Discord | Free tier: no / Pro: yes |
| Deployment | Single binary or Docker | Docker with PostgreSQL + Elasticsearch |
| RAM usage (idle) | Tens of MB | 1–2 GB minimum |
| Mobile | PWA | Native iOS + Android |
| Federation | No (by design) | No |
| License | AGPL-3.0 | MIT + Apache 2.0 (open core) |

Mattermost v10 and v11 significantly restricted the free self-hosted tier — SSO and voice calls moved to the paid plan. If you want feature parity with Chatto on Mattermost, you&apos;re paying $10/user/month.

&lt;/Tab&gt;
&lt;Tab name=&quot;Chatto vs Slack&quot;&gt;
| Feature | Chatto | Slack |
|---------|--------|-------|
| Self-hosted | Yes | No |
| Price | $0 | $7.25–$15/user/mo |
| Database | None (NATS) | N/A (SaaS) |
| Voice/video calls | Yes (LiveKit, E2EE) | Yes (not E2EE) |
| SSO | OIDC, Google, GitHub, Discord | Pro plans only |
| Data location | Your server, your country | US servers |
| Chat Control affected | No | Yes — US platform, unencrypted messages |
| Mobile | PWA | Native iOS + Android |
| License | AGPL-3.0 | Proprietary |

Slack is directly affected by Chat Control 1.0. Your team&apos;s messages sit on US servers, unencrypted at rest, and are now subject to suspicionless scanning. There&apos;s no self-hosted option. If privacy matters, Slack is not a choice — it&apos;s a liability.

&lt;/Tab&gt;
&lt;Tab name=&quot;Chatto vs Matrix&quot;&gt;
| Feature | Chatto | Matrix/Element |
|---------|--------|----------------|
| Self-hosted | Yes | Yes |
| Price | $0 | Free (Apache 2.0) |
| Database | None (NATS) | PostgreSQL required |
| Voice/video calls | Yes (LiveKit, E2EE) | Yes (Jitsi/Element Call) |
| E2EE messages | No (at rest only) | Yes (Olm/Megolm) |
| SSO | OIDC, Google, GitHub, Discord | Yes |
| Deployment | Single binary | Synapse + PostgreSQL + reverse proxy |
| Federation | No (by design) | Yes (complex) |
| RAM usage (idle) | Tens of MB | 512 MB–2 GB for Synapse |
| License | AGPL-3.0 | Apache 2.0 |

Matrix is the strongest open-source competitor on paper — it has E2EE for messages and federation. But Synapse is resource-hungry, federation adds operational complexity, and the UX is polarizing. If you want something that &quot;just works&quot; on a cheap VPS without a database, Chatto is simpler. If you need true E2EE between clients or federation across servers, Matrix is the better fit.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Pricing comparison

Let&apos;s talk money. Here&apos;s what a 50-person team pays annually:

| Solution | Annual cost (50 users) |
|----------|----------------------|
| **Chatto on Hetzner CX22** | ~€48/year (VPS only) |
| **Mattermost Free** | $0 (but no SSO, no voice, restricted) |
| **Mattermost Pro** | $6,000/year |
| **Slack Pro** | $4,350/year |
| **Slack Business+** | $9,000/year |

Mattermost&apos;s free tier used to be competitive. In v10/v11 they stripped out SSO and voice calls. If your team needs those features — and most teams do — you&apos;re looking at $10/user/month.

&lt;ListCheck&gt;
**What you get for $0 with Chatto:**
- Unlimited users
- Voice and video calls via LiveKit (end-to-end encrypted)
- SSO (OIDC, Google, GitHub, Discord)
- Encryption at rest with per-user keys
- No database to manage
- Single binary deployment
- Screen sharing
- Fine-grained roles and permissions
&lt;/ListCheck&gt;

## Self-Hosting Chatto With a Standalone Binary

The standalone binary is the fastest way to try Chatto. Five minutes from download to running server. This path uses an embedded NATS instance — no external dependencies.

**Step 1: Download the binary**

Grab the latest release from GitHub. For Linux x86_64:

```bash
# Download latest release (check https://github.com/chattocorp/chatto/releases for the current version)
wget https://github.com/chattocorp/chatto/releases/download/v0.4.4/chatto-linux-amd64.tar.gz
tar xzf chatto-linux-amd64.tar.gz
chmod +x chatto-linux-amd64
sudo mv chatto-linux-amd64 /usr/local/bin/chatto
```

For ARM64 servers (like Hetzner CAX instances):

```bash
wget https://github.com/chattocorp/chatto/releases/download/v0.4.4/chatto-linux-arm64.tar.gz
tar xzf chatto-linux-arm64.tar.gz
chmod +x chatto-linux-arm64
sudo mv chatto-linux-arm64 /usr/local/bin/chatto
```

**Step 2: Initialize the configuration**

```bash
chatto init
```

This generates a `chatto.toml` configuration file in the current directory. Edit it to set your domain, SMTP credentials, and admin email.

**Step 3: Start the server**

```bash
chatto run
```

Chatto serves its web frontend on the configured port. For production use with TLS, you can enable the built-in Let&apos;s Encrypt support in `chatto.toml` or put it behind a reverse proxy.

**Step 4: Run as a systemd service (optional)**

For persistent operation, create a systemd unit:

```ini
[Unit]
Description=Chatto Chat Server
After=network.target

[Service]
Type=simple
User=chatto
WorkingDirectory=/opt/chatto
ExecStart=/usr/local/bin/chatto run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable --now chatto
```

&lt;Notice type=&quot;info&quot; title=&quot;When to use the binary vs Docker&quot;&gt;
  The standalone binary is ideal for testing, personal use, or small teams with fewer than 10 users. It uses embedded NATS, which means everything runs in one process — simple but no zero-downtime updates. For production deployments with multiple users, voice/video calls through LiveKit, and automatic TLS via Caddy, use the Docker Compose setup below.
&lt;/Notice&gt;

If you haven&apos;t set up Docker yet, see our guide on [installing Docker on Ubuntu ARM](/install-docker-ubuntu-arm/).

## Self-Hosting Chatto With Docker Compose

This is the recommended production setup. You get separate NATS for data persistence (survives container restarts), LiveKit for voice/video calls, and Caddy for automatic TLS.

### Prerequisites

&lt;ListCheck&gt;
**What you need before starting:**
- A VPS with Docker and Compose v2 installed ([install Docker on Ubuntu ARM](/install-docker-ubuntu-arm/))
- A domain name with a DNS A record pointing to your VPS IP (e.g., `chat.example.com`)
- A `livekit.*` subdomain pointing to the same VPS (e.g., `livekit.chat.example.com`)
- SMTP credentials for email verification (Gmail, SendGrid, Mailgun, etc.)
- (Optional) S3-compatible storage for file uploads
&lt;/ListCheck&gt;

For VPS providers, check our [DigitalOcean vs Vultr vs Hetzner](/digitalocean-vs-vultr-vs-hetzner/) comparison. The [Hetzner Cloud review](/hetzner-cloud-review/) covers why CX22 at €3.99/month is the sweet spot for this kind of workload.

Required ports:
- **TCP 80, 443** — web traffic and TLS
- **UDP 3478, 50000-50200** — for LiveKit voice/video calls (only needed if you enable calls)

You also need a `livekit.*` subdomain pointing to the same VPS (e.g., `livekit.chat.example.com`). Caddy uses this for the LiveKit WebSocket endpoint that browsers connect to for calls.

### docker-compose.yml walkthrough

Clone the Docker Compose example from the main Chatto repository:

```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/chattocorp/chatto.git chatto-source
git -C chatto-source sparse-checkout set examples/dockercompose
cp -R chatto-source/examples/dockercompose chatto
rm -rf chatto-source
cd chatto
```

Run the initialization script to generate your `.env` file and LiveKit secrets:

```bash
chmod +x init-env.sh
./init-env.sh chat.example.com admin@example.com
```

Replace `chat.example.com` with your domain and `admin@example.com` with the email for the first owner account. The script generates `.env` and `livekit.generated.yaml` with matching NATS, Chatto, and LiveKit secrets.

Open `.env` and configure your SMTP credentials:

```env
# Domain (must have DNS A record pointing to this server)
CHATTO_DOMAIN=chat.example.com

# Admin email (for Let&apos;s Encrypt TLS and first owner account)
ADMIN_EMAIL=admin@example.com

# SMTP settings (required for user registration)
CHATTO_SMTP_ENABLED=true
CHATTO_SMTP_HOST=smtp.sendgrid.net
CHATTO_SMTP_PORT=587
CHATTO_SMTP_USER=apikey
CHATTO_SMTP_PASSWORD=your-api-key-here
CHATTO_SMTP_FROM=chat@example.com

# LiveKit settings (auto-generated by init-env.sh)
CHATTO_LIVEKIT_API_KEY=...
CHATTO_LIVEKIT_API_SECRET=...
```

The `docker-compose.yml` defines four services:

```yaml
services:
  chatto:
    image: ghcr.io/chattocorp/chatto:latest
    restart: unless-stopped
    volumes:
      - chatto_data:/data
    env_file: .env
    depends_on:
      - nats

  nats:
    image: nats:latest
    restart: unless-stopped
    command: &quot;--jetstream --store_dir /data&quot;
    volumes:
      - nats_data:/data

  livekit:
    image: livekit/livekit-server:latest
    restart: unless-stopped
    command: &quot;--config /etc/livekit.yaml&quot;
    volumes:
      - ./livekit.yaml:/etc/livekit.yaml:ro
    ports:
      - &quot;3478:3478/udp&quot;
      - &quot;50000-50200:50000-50200/udp&quot;

  caddy:
    image: caddy:latest
    restart: unless-stopped
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config

volumes:
  chatto_data:
  nats_data:
  caddy_data:
  caddy_config:
```

The architecture flows like this:

![Chatto Docker Compose architecture: Internet to Caddy to Chatto to NATS plus LiveKit](../../assets/images/26/07/chatto-docker-architecture.svg)

&lt;Notice type=&quot;warning&quot; title=&quot;Edit your .env file before starting&quot;&gt;
  Chatto requires SMTP for email verification. If you skip this, users cannot register. Fill in your `CHATTO_SMTP_*` credentials in the `.env` file before running `docker compose up`. Also double-check that your domain&apos;s DNS A record points to the VPS IP — Caddy needs this for automatic TLS certificate provisioning.
&lt;/Notice&gt;

For more on configuring environment variables in Docker, see our guide on [environment variables in Docker](/docker-env-vars/).

### Starting and updating

Start the stack:

```bash
docker compose up -d
```

Verify everything is running:

```bash
docker compose ps
```

All four services should show as `running`. Check logs if something isn&apos;t right:

```bash
docker compose logs -f chatto
```

&lt;Accordion label=&quot;How do I update Chatto when a new version is released?&quot; group=&quot;faq&quot;&gt;

Updating is straightforward because NATS data lives in a persistent volume. The container can be replaced without losing messages.

```bash
# Pull the latest images
docker compose pull

# Recreate containers with the new images
docker compose up -d
```

Your chat history, user accounts, and settings all survive the update because they&apos;re stored in the `nats_data` and `chatto_data` volumes.

For a complete guide on container updates, see [how to update a container with Docker Compose](/updating-container-docker-compose/).

After updating, clean up old images to free disk space: [Clean All Docker Images](/cleanup-all-docker-things/).

To keep an eye on your logs, you can [redirect Docker logs to a single file](/redirect-docker-logs-to-a-single-file/).

&lt;/Accordion&gt;

## What Does It Cost to Run Chatto

Chatto itself is free. The only cost is the VPS it runs on.

Here&apos;s what real pricing looks like as of July 2026:

| VPS Provider | Plan | RAM | vCPU | Monthly Cost |
|-------------|------|-----|------|-------------|
| Hetzner CX22 | x86 | 4 GB | 2 | €3.99 |
| Contabo Cloud VPS S | x86 | 8 GB | 4 | €5.99 |
| Hetzner CAX11 | ARM64 | 4 GB | 2 | €3.99 |
| DigitalOcean Basic | x86 | 2 GB | 1 | $14.00 |

The developer&apos;s own benchmarks: Chatto uses tens of MB at idle and roughly 10 MB per additional connected user. A 1 GB VPS handles a small team. A 4 GB VPS (like Hetzner CX22) is comfortable for 20–30 concurrent users with LiveKit calls running.

Now compare annual costs for a 50-person team:

| Solution | Annual Cost |
|----------|------------|
| Chatto + Hetzner CX22 | ~€48/year |
| Mattermost Pro | $6,000/year |
| Slack Pro | $4,350/year |
| Slack Business+ | $9,000/year |

&lt;Notice type=&quot;success&quot; title=&quot;Bottom line&quot;&gt;
  A 50-person team saves $5,950+/year compared to Mattermost Pro. Chatto on Hetzner costs less than a Netflix subscription. Even if you add $10/month for a SendGrid SMTP plan and a few dollars for S3 file storage, you&apos;re still under $200/year.
&lt;/Notice&gt;

![Chatto self-hosted chat cost comparison breakdown](../../assets/images/26/07/chatto-cost-breakdown.svg)

For more VPS pricing details, check the [Hetzner Cloud review](/hetzner-cloud-review/) and our [DigitalOcean vs Vultr vs Hetzner](/digitalocean-vs-vultr-vs-hetzner/) benchmarks.

## Privacy and Encryption Deep-Dive

&quot;Privacy&quot; is a marketing word until you explain the mechanism. Here&apos;s how Chatto actually protects your data.

**Encryption at rest.** Every user gets a unique encryption key. Message text and personally identifiable information (PII) are encrypted before being written to NATS. If someone gains raw access to the NATS storage, they can&apos;t read the messages without the per-user keys.

**Crypto-shredding.** When a user deletes their account, their encryption key is destroyed. The encrypted data remains on disk but is mathematically irrecoverable. This is a GDPR-friendly approach — you can prove data is gone without relying on &quot;we promise we deleted it.&quot;

**No third-party tracking.** No Google Analytics, no Sentry breadcrumbs, no Mixpanel events. Chatto doesn&apos;t phone home. The only external connections are the ones you configure (SMTP, S3, OIDC providers).

**Server isolation.** Each Chatto server is fully independent. No federation means no data leaks between organizations. Your server knows nothing about other Chatto servers. This is a deliberate design choice, not a missing feature.

**Calls are E2EE.** Voice and video calls through LiveKit use end-to-end encryption. Even your own server can&apos;t intercept call content.

**GDPR compliance.** Host Chatto on a European VPS (Hetzner is in Germany and Finland), and your data never leaves the EU. You are the data processor. You decide retention policies, access controls, and deletion schedules. Compare this to Slack or Discord, where data sits on US servers subject to FISA warrants, National Security Letters, and now Chat Control scanning.

![Chatto encryption at rest and crypto-shredding flow](../../assets/images/26/07/chatto-encryption-diagram.svg)

&lt;Notice type=&quot;info&quot; title=&quot;Important: messages are not end-to-end encrypted&quot;&gt;
  Encryption at rest protects your data on disk, but Chatto messages are **not** end-to-end encrypted between clients the way Signal or WhatsApp messages are. The server can read message content in memory while processing it. This is the same model as Mattermost and Slack — but it&apos;s important to be honest about it. If your threat model requires true E2EE for text messages (where even the server operator can&apos;t read them), Matrix with Olm/Megolm is a better fit. For most teams, encryption at rest plus server isolation is sufficient.
&lt;/Notice&gt;

For another privacy-first self-hosted tool, see our guide to [install Plausible Analytics](/install-plausible-analytics/) — cookie-free analytics you own.

## Honest Caveats and Limitations

Chatto is impressive for a project that went open-source three days ago. But it&apos;s not finished, and you should know what you&apos;re signing up for.

**Pre-1.0 software.** Current version is v0.4.4. Breaking changes are possible until 1.0, which the developer targets in 6–12 months. Configuration formats, APIs, and data structures may change.

**Single developer.** Hendrik Mans built Chatto himself and is not accepting outside contributions (per the CONTRIBUTING.md). The bus factor is real. AGPL means the code is open forever and can be forked, but the primary development depends on one person.

**No native mobile apps.** Chatto works as a PWA — you install it from the browser on your phone. It&apos;s decent but not the same as a native app with push notifications. Native iOS and Android apps are planned but not available.

**No E2EE for text messages.** Calls are end-to-end encrypted via LiveKit. Messages are encrypted at rest but not between clients. This is the same model as Slack and Mattermost, but it&apos;s not Signal-level privacy.

**AGPL license.** If you modify the Chatto server and offer it as a service to others, you must release your modifications under AGPL. For internal team use, this is a non-issue. For SaaS businesses planning to white-label it, talk to a lawyer.

**No content moderation tools yet.** Version 0.5 is adding reporting features. If you&apos;re running a public community, moderation is limited for now.

**No Slack import.** Migration from Slack is planned but not available today. You&apos;d be starting fresh.

**Chat Control 1.0 targets unencrypted messages on US platforms.** WhatsApp and Signal users are technically unaffected by this specific vote. But the vote sets precedent, and Chat Control 2.0 could change the rules.

&lt;Accordion label=&quot;Is Chatto production-ready?&quot; group=&quot;faq&quot;&gt;
It depends on your risk tolerance. For small teams willing to accept pre-1.0 instability and the occasional breaking change, yes — it&apos;s functional and the core chat experience is solid. For mission-critical enterprise deployments where uptime and stability are non-negotiable, wait for 1.0 or stick with Mattermost. The developer is transparent about the current state.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What if the single developer stops maintaining Chatto?&quot; group=&quot;faq&quot;&gt;
The AGPL license means the code is open forever. If development stalls, the community can fork the project. NATS is a mature, independently maintained project — your data layer won&apos;t disappear. That said, the bus factor is a real concern for long-term planning. If you need vendor-backed SLAs and guaranteed multi-year support, a commercial product like Mattermost is safer. If you&apos;re comfortable with open-source risk (and most bitdoze readers are), this is manageable.
&lt;/Accordion&gt;

## Final Verdict: Should You Self-Host Chatto?

The EU just legalized mass scanning of private messages on major US-owned platforms. The stats show this approach doesn&apos;t work — 99% of Meta&apos;s reports are known material, 48% of alerts aren&apos;t criminally relevant, and there&apos;s zero evidence it rescued children. But the law is the law, and it runs until 2028.

You have three options:

1. **Do nothing.** Keep using Slack/Discord and accept that your team&apos;s messages are subject to scanning.
2. **Switch to an E2EE messenger.** Signal and WhatsApp protect 1:1 chats, but they&apos;re not team chat tools. No channels, no roles, no file history.
3. **Self-host Chatto.** Run a lightweight, privacy-first chat server on a VPS for €4/month. Full control, no scanning, no tracking.

Chatto isn&apos;t perfect. It&apos;s pre-1.0, has no native mobile apps, no E2EE for text messages, and depends on a single developer. Those are real limitations.

But the math is hard to argue with: a single ~50 MB binary, no database, tens of MB of RAM, voice/video calls, SSO, encryption at rest — all for $0 in software costs and €4/month in hosting. For a team of 50, that&apos;s €48/year vs. $6,000/year for Mattermost Pro.

If you already self-host other tools on your VPS, Chatto is a natural addition to the stack. You own the server, you own the data, and no parliament vote changes that.

&lt;Button text=&quot;Get Started with Chatto →&quot; link=&quot;https://github.com/chattocorp/chatto&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Read the Official Docs&quot; link=&quot;https://docs.chatto.run&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

Once Chatto is running, monitor it with [Uptime Kuma](/deploy-uptime-kuma/) so you know if it goes down. If you want a simpler deployment experience with a web UI, [Coolify as a self-hosted PaaS](/coolify-install-heroku-alternative/) can manage the Docker stack for you.</content:encoded><category>self-hosting</category><category>chatto</category><category>self-hosted</category><category>docker</category></item><item><title>How to Enable Command Autocomplete in ZSH</title><link>https://www.bitdoze.com/enable-command-autocomplete-in-zsh/</link><guid isPermaLink="true">https://www.bitdoze.com/enable-command-autocomplete-in-zsh/</guid><description>Learn how to enable tab completion, zsh-autosuggestions, and zsh-autocomplete plugins in ZSH for faster terminal workflows.</description><pubDate>Sat, 11 Jul 2026 00:00:00 GMT</pubDate><content:encoded>ZSH has a powerful completion system, but it doesn&apos;t do everything out of the box. You need to enable the built-in tab completion and optionally add plugins for inline suggestions. This guide covers all three layers: ZSH&apos;s native completion system, the `zsh-autosuggestions` plugin, and the `zsh-autocomplete` plugin.

ZSH comes pre-installed on macOS (since Catalina). On Linux, you&apos;ll need to install it first.

**Related ZSH guides:**

- [How to Enable Syntax Highlighting in Zsh](https://www.bitdoze.com/enable-syntax-highlighting-zsh/)
- [Top 15 Oh My ZSH Plugins You Must Try](https://www.bitdoze.com/best-oh-my-zsh.plugins/)
- [Zoxide: The Smarter Way to Navigate Your Terminal](https://www.bitdoze.com/zoxide/)

## Understanding ZSH completion vs autosuggestions

These terms get mixed up constantly. Here&apos;s what each one actually does:

**Built-in completion (compinit)** — ZSH&apos;s native completion system. When you press `Tab`, it shows possible completions for commands, options, filenames, variables, and more. It&apos;s context-aware: it knows that `git ch&lt;Tab&gt;` should show `checkout` and `cherry-pick`, not random files. This is the foundation — everything else builds on it.

**zsh-autosuggestions** — A plugin that shows a grayed-out suggestion as you type, based on your command history. Think of it as fish shell&apos;s inline suggestions ported to ZSH. You type `dock` and it suggests `docker ps` from your history in muted text. Press `→` to accept the suggestion. This is the most popular ZSH autocomplete plugin with over 35,000 GitHub stars.

**zsh-autocomplete** — A different plugin (by Marlon Richert) that shows a real-time dropdown menu of completions as you type, without pressing Tab. More like an IDE&apos;s autocomplete. You type `git` and a list of subcommands appears below the prompt immediately. Fewer stars (~6,600) but a different UX approach.

Most people want built-in completion + zsh-autosuggestions. That&apos;s the standard setup.

## Step 1: Enable ZSH&apos;s built-in completion system

ZSH&apos;s completion system (`compinit`) needs to be initialized. If you&apos;re using Oh My Zsh, this is already done for you. If you&apos;re running plain ZSH, add this to your `~/.zshrc`:

```zsh
autoload -Uz compinit &amp;&amp; compinit
```

This loads the completion system and initializes it. Without this line, basic Tab completion won&apos;t work properly — you&apos;ll only get filename completion, not context-aware command completions.

To speed up `compinit` (it can be slow because it checks many files on startup), you can cache the dump file:

```zsh
# Rebuild the dump file only once a day
autoload -Uz compinit
if [[ -n ${ZDOTDIR:-$HOME}/.zcompdump(#qN.mh+24) ]]; then
  compinit
else
  compinit -C
fi
```

The `-C` flag skips the security check and uses the cached dump file. The conditional rebuilds it only if the dump is older than 24 hours.

### Useful completion styles

Add these to your `~/.zshrc` after `compinit` to improve the default completion behavior:

```zsh
# Case-insensitive completion
zstyle &apos;:completion:*&apos; matcher-list &apos;m:{a-zA-Z}={A-Za-z}&apos;

# Menu selection — use arrow keys to navigate completions
zstyle &apos;:completion:*&apos; menu select

# Group completions by type (commands, files, options)
zstyle &apos;:completion:*&apos; group-name &apos;&apos;

# Show descriptions for completions
zstyle &apos;:completion:*&apos; format &apos;%F{yellow}-- %d --%f&apos;

# Colorize file completions
zstyle &apos;:completion:*&apos; list-colors &quot;${(s.:.)LS_COLORS}&quot;
```

With `menu select`, Tab shows completions in a navigable menu. Arrow keys move through it, and Enter selects.

## Step 2: Install zsh-autosuggestions

This plugin gives you fish-like inline suggestions from your history. There are two ways to install it.

### Option A: With Oh My Zsh

If you&apos;re already using Oh My Zsh:

```bash
# Clone the plugin into Oh My Zsh&apos;s custom plugins directory
git clone https://github.com/zsh-users/zsh-autosuggestions \
  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions
```

Then edit `~/.zshrc` and add `zsh-autosuggestions` to your plugins list:

```zsh
plugins=(git zsh-autosuggestions)
```

If you don&apos;t have Oh My Zsh installed yet:

```bash
sh -c &quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&quot;
```

### Option B: Manual install (without Oh My Zsh)

```bash
git clone https://github.com/zsh-users/zsh-autosuggestions ~/.zsh/zsh-autosuggestions
```

Add this to your `~/.zshrc`:

```zsh
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
```

### Option C: With Homebrew (macOS)

```bash
brew install zsh-autosuggestions
```

Add to `~/.zshrc`:

```zsh
source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh
```

### Reload your shell

```bash
source ~/.zshrc
```

Or just open a new terminal tab.

## Step 3: Test it

Start typing a command you&apos;ve used before. You should see a grayed-out suggestion appear after your cursor. For example, type `ec` and it might suggest `echo &quot;hello&quot;` from your history.

Key bindings for zsh-autosuggestions:

| Action | Key |
|--------|-----|
| Accept full suggestion | `→` (right arrow) or `End` |
| Accept one word of suggestion | `Alt+F` or `Esc then F` |
| Clear suggestion | `Ctrl+Space` |
| Toggle suggestions on/off | Bind with `autosuggest-toggle` |

You can also accept with `Ctrl+E` (end-of-line) if your keybindings support it.

## Customizing zsh-autosuggestions

Add these to `~/.zshrc` after the `source` line (or in `$ZSH_CUSTOM` if using Oh My Zsh).

### Change the suggestion color

The default is `fg=8` (dark gray). Adjust if it&apos;s hard to read in your terminal:

```zsh
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=&quot;fg=240&quot;
```

Or use a hex color:

```zsh
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=&quot;fg=#666666&quot;
```

### Change the suggestion strategy

By default, suggestions come from your command history. You can also use tab-completion results or a combination:

```zsh
# Try history first, fall back to completion system
ZSH_AUTOSUGGEST_STRATEGY=(history completion)

# Only use history (default, fastest)
ZSH_AUTOSUGGEST_STRATEGY=(history)

# Like history, but prioritizes matches from after the same previous command
ZSH_AUTOSUGGEST_STRATEGY=(match_prev_cmd)
```

Using `(history completion)` is slower because it runs the completion engine for every keystroke. Stick with `(history)` unless you want completions from commands you haven&apos;t run before.

### Disable suggestions for large pasted buffers

When you paste a large block of text, autosuggestions can lag. Set a buffer size limit:

```zsh
ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE=20
```

Suggestions are disabled for any input longer than 20 characters.

### Bind Ctrl+Space to accept suggestions

```zsh
bindkey &apos;^ &apos; autosuggest-accept
```

## Optional: Install zsh-autocomplete for real-time menus

If you want IDE-style completions that appear as you type (without pressing Tab), install `zsh-autocomplete` instead of or alongside `zsh-autosuggestions`:

```bash
git clone --depth 1 -- https://github.com/marlonrichert/zsh-autocomplete.git \
  ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autocomplete
```

Add to your plugins list in `~/.zshrc`:

```zsh
plugins=(git zsh-autosuggestions zsh-autocomplete)
```

Or without Oh My Zsh:

```bash
git clone --depth 1 -- https://github.com/marlonrichert/zsh-autocomplete.git ~/.zsh/zsh-autocomplete
```

```zsh
source ~/.zsh/zsh-autocomplete/zsh-autocomplete.plugin.zsh
```

**Note:** Remove any `compinit` calls from your `.zshrc` — zsh-autocomplete handles initialization itself.

**Important caveat:** zsh-autocomplete and zsh-autosuggestions do different things and can conflict visually. Many users run both, but if the UI feels cluttered, pick one. zsh-autosuggestions is more popular and lightweight. zsh-autocomplete is heavier but shows more completions.

## Putting it all together

Here&apos;s a minimal `~/.zshrc` with all the essentials:

```zsh
# Oh My Zsh installation (if using)
export ZSH=&quot;$HOME/.oh-my-zsh&quot;
plugins=(git zsh-autosuggestions zsh-syntax-highlighting)
source $ZSH/oh-my-zsh.sh

# Completion styles
zstyle &apos;:completion:*&apos; matcher-list &apos;m:{a-zA-Z}={A-Za-z}&apos;
zstyle &apos;:completion:*&apos; menu select

# Autosuggestions config
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=&quot;fg=240&quot;
ZSH_AUTOSUGGEST_STRATEGY=(history)
ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE=20
```

Without Oh My Zsh:

```zsh
# Completion system
autoload -Uz compinit
if [[ -n ${ZDOTDIR:-$HOME}/.zcompdump(#qN.mh+24) ]]; then
  compinit
else
  compinit -C
fi

# Completion styles
zstyle &apos;:completion:*&apos; matcher-list &apos;m:{a-zA-Z}={A-Za-z}&apos;
zstyle &apos;:completion:*&apos; menu select

# Plugins
source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh
source ~/.zsh/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

# Autosuggestions config
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=&quot;fg=240&quot;
ZSH_AUTOSUGGEST_STRATEGY=(history)
```

The `zsh-syntax-highlighting` plugin is worth adding too — it colorizes commands as you type, so you can spot typos before hitting Enter.

## Troubleshooting

**Suggestions not showing up:** Make sure `compinit` is loaded. Check with `which _complete` — if it returns nothing, the completion system isn&apos;t initialized.

**Gray text invisible in iTerm2:** Go to iTerm2 Settings → Profiles → Colors. Make sure &quot;Bright Black&quot; (ANSI color 8) is different from your background color. They&apos;re sometimes the same, making suggestions invisible.

**Slow shell startup:** If `compinit` is taking too long, use the cached version shown in Step 1. Also check if you&apos;re loading too many plugins — each one adds startup time.

**Completions not working after installing a new CLI tool:** Some tools (like `kubectl`, `docker`, `gh`) require you to generate and source their completion scripts. For example:

```bash
# kubectl
kubectl completion zsh &gt; ~/.zsh/completions/_kubectl

# docker
docker completion zsh &gt; ~/.zsh/completions/_docker

# GitHub CLI
gh completion -s zsh &gt; ~/.zsh/completions/_gh
```

Make sure `~/.zsh/completions` is in your `fpath` before `compinit`:

```zsh
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit &amp;&amp; compinit
```

**Want a batteries-included alternative?** [Fish Shell](https://www.bitdoze.com/install-fish-shell-ubuntu/) ships with autosuggestions, syntax highlighting, and rich completions out of the box. No plugins needed. See the [Fish Shell vs Zsh](https://www.bitdoze.com/fish-shell-vs-zsh/) comparison if you want to explore that option.

## Which setup should you use?

| Setup | Best for | Complexity |
|-------|----------|------------|
| Built-in completion only | Minimalists who want Tab-based completion | Low |
| Built-in + zsh-autosuggestions | Most users — history suggestions + Tab completion | Low |
| Built-in + zsh-autosuggestions + zsh-syntax-highlighting | Recommended trio — the &quot;standard&quot; ZSH power setup | Low |
| Built-in + zsh-autocomplete | IDE fans who want real-time dropdown menus | Medium |
| All of the above | Terminal maximalists | Medium |

For most people: Oh My Zsh (or plain ZSH) + `zsh-autosuggestions` + `zsh-syntax-highlighting` is the sweet spot. It&apos;s fast, proven, and covers 90% of what you need.</content:encoded><category>linux</category><category>zsh</category><category>terminal</category><category>shell</category></item><item><title>Astro vs Next.js vs TanStack Start: Which Wins in 2026?</title><link>https://www.bitdoze.com/astro-vs-nextjs-vs-tanstack-start-which-wins-2026/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-vs-nextjs-vs-tanstack-start-which-wins-2026/</guid><description>Astro vs Next.js vs TanStack Start compared on performance, cost, and developer experience. Real benchmarks, hosting costs, and a decision framework for 2026.</description><pubDate>Fri, 10 Jul 2026 01:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Stop asking &quot;which framework is best.&quot; Start asking &quot;what am I building.&quot;

The meta-framework space in 2026 has three clear contenders: **Astro** — content-first, zero-JS-by-default, now backed by Cloudflare. **Next.js** — the React full-stack workhorse, RSC-first, Vercel-optimized. **TanStack Start** — the new challenger, developer-control-focused, type-safe everything, Vite-powered.

Every existing comparison covers two of these. This is the first article to put all three side by side with real benchmarks, cost data, and a decision framework. No fanboy advocacy. No false winner.

&lt;ListCheck&gt;
**What you&apos;ll get from this article:**
- Real benchmark data across all three frameworks
- Hosting cost breakdowns at 10K, 50K, and 100K monthly visitors
- A decision framework based on project type, not hype
- Honest tradeoffs — each framework loses somewhere
&lt;/ListCheck&gt;

## The state of meta-frameworks in 2026

The State of JS 2025 survey painted a clear picture. Astro leads meta-framework satisfaction by a 39-point margin over Next.js. Next.js still dominates usage at 60–70% adoption, but satisfaction is declining. TanStack Start appeared as a write-in option at roughly 4% — not bad for a framework that was still in beta during the survey — and won &quot;Breakthrough of the Year&quot; at the 2026 Open Source Awards.

Three things define the current moment:

### What changed since 2025

Cloudflare acquired The Astro Technology Company on January 16, 2026. Astro remains MIT-licensed and open-source, but now has corporate backing with deep pockets and edge infrastructure. This is the same dynamic Vercel has with Next.js, but on the other side of the aisle.

Next.js 16 shipped in October 2025 with Turbopack as the default bundler, Cache Components (`&quot;use cache&quot;`), React Compiler support, and a new `proxy.ts` replacing `middleware.ts`. Build speeds improved 2–5x over the previous Webpack-based toolchain.

TanStack Start hit v1 RC in September 2025. It&apos;s Vite-powered, type-safe by default, and deploys to anything via Nitro. The Inngest team reported an 83% reduction in local development times after migrating from Next.js.

&lt;Notice type=&quot;info&quot; title=&quot;The 2026 meta-framework scene at a glance&quot;&gt;
Three frameworks backed by three different models: Astro → Cloudflare, Next.js → Vercel, TanStack Start → community. Each bet is valid. The question is which bet matches your project.
&lt;/Notice&gt;

## Three frameworks, three philosophies

The choice isn&apos;t about features. It&apos;s about what each framework believes the web is. Astro bets the web is content — optimize for static delivery. Next.js bets the web is applications — optimize for React server rendering. TanStack Start bets the web is data — optimize for type-safe client-server communication.

These are fundamentally different bets, and the tradeoffs flow from them.

### Astro — content first, zero JS by default

Astro uses an islands architecture: pages ship as static HTML by default, and interactive components hydrate only where you explicitly mark them. A typical Astro blog ships 0KB of JavaScript to the browser. Zero.

The framework is also framework-agnostic. You can use React, Vue, Svelte, Solid, Preact, or Lit — even mix them on the same page. Content Collections provide structured, type-safe content management with Zod schemas (Zod 4 in Astro 6).

Astro 6 beta (January 2026) brings significant upgrades: the Vite Environment API for dev/prod parity, Live Content Collections for dynamic data, native CSP support, and build speeds 5x faster than Astro 5.

Notable users include Microsoft, Cloudflare, Digital Ocean, Adobe, Porsche, IKEA, OpenAI, and Google Chrome. You can [deploy an Astro blog on Cloudflare](/deploy-astrojs-cloudflare/) in minutes, and Astro&apos;s database layer is surprisingly capable — see how [Astro DB works with Bunny Database](/astro-db-bunny-database/).

&lt;Tabs&gt;
&lt;Tab name=&quot;Quick start&quot;&gt;
```bash
# Create a new Astro project
npm create astro@latest

# Build
npm run build
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Island architecture&quot;&gt;
```astro
---
import LikeButton from &apos;../components/LikeButton.tsx&apos;;
import Comments from &apos;../components/Comments.vue&apos;;
---
&lt;article&gt;
  &lt;h1&gt;My Blog Post&lt;/h1&gt;
  &lt;p&gt;Static content here — zero JS shipped for this part.&lt;/p&gt;
  &lt;LikeButton client:load initialLikes={42} /&gt;
  &lt;Comments client:visible postId=&quot;123&quot; /&gt;
&lt;/article&gt;
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Astro 6 CSP config&quot;&gt;
```js
// astro.config.mjs
import { defineConfig } from &apos;astro/config&apos;;
import cloudflare from &apos;@astrojs/cloudflare&apos;;
import react from &apos;@astrojs/react&apos;;
import tailwind from &apos;@astrojs/tailwind&apos;;

export default defineConfig({
  output: &apos;static&apos;,
  adapter: cloudflare(),
  integrations: [react(), tailwind()],
  security: {
    csp: true,
  },
});
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Next.js — the React full-stack default

Next.js is the 800-pound gorilla. At 140K+ GitHub stars and 60–70% meta-framework adoption, it&apos;s the default choice for React teams. The App Router, React Server Components, and streaming SSR are all designed to make full-stack React development feel cohesive.

Next.js 16 landed in October 2025 with meaningful improvements. Turbopack is now the default bundler (2–5x faster builds). Cache Components let you opt into caching with `&quot;use cache&quot;` directives instead of relying on the old implicit caching layers. The React Compiler support means automatic memoization in many cases. A new `proxy.ts` replaces the confusing `middleware.ts` pattern.

The pain points are real though. RSC boundaries remain the #1 source of confusion — understanding where code runs (server? client? both?) takes weeks to internalize. The App Router&apos;s patterns (layouts, `loading.tsx`, `error.tsx`, parallel routes, intercepting routes) are powerful but dense. And Vercel optimization means self-hosting is always second-class.

```tsx
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return (
    &lt;article&gt;
      &lt;h1&gt;{post.title}&lt;/h1&gt;
      &lt;div&gt;{post.content}&lt;/div&gt;
    &lt;/article&gt;
  );
}
```

Next.js 16 Cache Components config:

```ts
// next.config.ts
const nextConfig = {
  cacheComponents: true,
};
export default nextConfig;
```

### TanStack Start — developer control over convention

TanStack Start takes a different approach: client-first with explicit server capabilities. Instead of defaulting to server rendering and letting you opt into client behavior, it does the opposite. You start with a client-rendered React app and add server functions where you need them.

The key features are genuine differentiators. Type-safe file-based routing catches param errors at compile time. `createServerFn` makes server boundaries explicit — no guessing where code runs. Isomorphic loaders work with TanStack Query for efficient data fetching. Search param validation via Zod prevents runtime errors from malformed URLs.

Deployment is framework-agnostic: Vercel, Netlify, Railway, bare Node, Docker, Cloudflare Workers via Nitro presets. No vendor lock-in. You can even build a [TanStack Start todo app with Drizzle](/tanstack-start-bunny-database-drizzle/) to see it in action.

```tsx
import { createServerFn } from &apos;@tanstack/react-start&apos;;
import { z } from &apos;zod&apos;;

export const createPost = createServerFn({ method: &apos;POST&apos; })
  .validator(z.object({ title: z.string().min(1) }))
  .middleware([authMiddleware])
  .handler(async ({ data, context }) =&gt; {
    return db.posts.create({ title: data.title });
  });
```

Type-safe route with loader:

```tsx
export const Route = createFileRoute(&apos;/dashboard/&apos;)({
  loader: async () =&gt; {
    const data = await fetchDashboardData();
    return data;
  },
  component: DashboardPage,
});
```

## Performance and bundle size

How much JavaScript each framework ships by default has real consequences. Every kilobyte affects Time to Interactive, Core Web Vitals, and ultimately your SEO rankings and user retention.

### Default bundle sizes compared

Astro ships 0KB by default. Zero. Unless you explicitly add interactive islands, the browser receives pure HTML and CSS. For a typical blog page, that&apos;s it.

Next.js ships its React runtime plus page JavaScript. Even with React Server Components reducing what goes to the client, a simple blog page ships roughly 85–95KB gzipped. That&apos;s the baseline — it goes up from there as you add client components.

TanStack Start lands somewhere in between. The initial server-rendered response is fast (SSR), then the client takes over as a single-page app. The bundle size depends on your app&apos;s complexity, but the core runtime is leaner than Next.js because there&apos;s no RSC machinery to ship.

![Bar chart comparing default bundle sizes: Astro 0KB, Next.js 95KB, TanStack Start intermediate](../../assets/images/26/07/bundle-size-comparison.svg)

### Build speed benchmarks

Astro 6 processes 100 markdown posts in about 200ms — a 5x improvement over Astro 5&apos;s 1000ms. For large content sites, this transforms CI/CD pipelines. The [Astro 7 benchmark on a 743-page site](/astro-7-faster-builds/) shows even more dramatic gains with the Rust compiler.

Next.js 16 with Turbopack is 2–5x faster than the old Webpack builds. It&apos;s a massive improvement, though Turbopack still lags behind Vite for HMR speed.

TanStack Start uses Vite natively, which means near-instant HMR during development. Build times for production are competitive with Turbopack.

### Core Web Vitals in practice

For a content/blog site, the numbers are stark:

| Metric | Astro | Next.js | TanStack Start |
|--------|-------|---------|----------------|
| JS shipped | 0KB | ~95KB gzipped | App-dependent |
| Time to Interactive | &amp;lt;100ms | ~1.4s | SSR fast, then SPA |
| Lighthouse score | 100/100 | ~94/100 | ~95-98/100 |

These are for simple content sites. As interactive island count grows in Astro, or as client components increase in the others, the gap narrows. But the baseline advantage is real.

&lt;Notice type=&quot;warning&quot; title=&quot;Benchmarks are context-dependent&quot;&gt;
These numbers are for content/blog sites. Complex app benchmarks look very different. Astro&apos;s advantage shrinks as interactive island count grows — a dashboard with 20 React islands ships a lot more JS than a blog with zero.
&lt;/Notice&gt;

You can [deploy Astro on a VPS](/deploy-astro-on-vps/) and see these scores immediately in production.

## Developer experience and learning curve

Performance numbers are measurable. DX compounds over months of daily development. The best framework on paper isn&apos;t best if your team fights it every sprint.

### Astro&apos;s mental model

Astro has the simplest mental model of the three: HTML + CSS + islands. The `.astro` file format uses frontmatter for logic and HTML-like template syntax for markup. If you know HTML, you can read an Astro file.

Framework agnosticism means your team isn&apos;t locked into React. A Vue developer can contribute Svelte components to the same Astro project. Content Collections give you type-safe content with schema validation — the best native DX for content management in any framework.

The caveat: once you need heavy interactivity, the island model adds coordination overhead. Islands can&apos;t easily share state. You end up managing inter-island communication yourself, which is where frameworks like Next.js and TanStack Start have natural advantages.

If you&apos;re learning Astro, check out the [best Astro.js courses](/best-astrojs-online-courses/) for structured learning paths.

### Next.js complexity tax

Next.js pays for its power with complexity. The RSC mental model — understanding the server/client boundary — is the #1 source of confusion for new and experienced developers alike. App Router patterns (layouts, `loading.tsx`, `error.tsx`, parallel routes, intercepting routes) are powerful but dense.

Caching has historically been the worst pain point. Multiple implicit caches with unintuitive invalidation rules. Next.js 16 improves this significantly with Cache Components and clearer APIs like `updateTag` and `revalidateTag`, but the learning curve is still steep.

The tradeoff: once you learn it, the ecosystem depth is hard to beat. More tutorials, more Stack Overflow answers, more third-party integrations, and the largest hiring pool of any meta-framework.

### TanStack Start type safety

TanStack Start feels closest to &quot;plain React&quot; with server capabilities bolted on. There&apos;s no new mental model to learn — it&apos;s React components, React hooks, and TanStack Query.

The real differentiator is type-safe routing. Compile-time param validation catches URL bugs before they hit production. `createServerFn` makes server boundaries explicit: you define a function, mark it as a server function, and the types tell you exactly what&apos;s available. No implicit boundaries, no guessing.

The tradeoff: smaller ecosystem, fewer tutorials, steeper initial setup if you&apos;re new to TanStack Query. Best fit for senior teams comfortable with thinner but well-designed primitives.

&lt;Tabs&gt;
&lt;Tab name=&quot;Astro&quot;&gt;
```astro
---
// Astro: data fetching in frontmatter
const posts = await fetch(&apos;https://api.example.com/posts&apos;)
  .then(r =&gt; r.json());
---
&lt;ul&gt;
  {posts.map(post =&gt; &lt;li&gt;{post.title}&lt;/li&gt;)}
&lt;/ul&gt;
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Next.js&quot;&gt;
```tsx
// Next.js: Server Component — async by default
export default async function PostsPage() {
  const posts = await fetch(&apos;https://api.example.com/posts&apos;)
    .then(r =&gt; r.json());
  return (
    &lt;ul&gt;
      {posts.map(post =&gt; &lt;li&gt;{post.title}&lt;/li&gt;)}
    &lt;/ul&gt;
  );
}
```
&lt;/Tab&gt;
&lt;Tab name=&quot;TanStack Start&quot;&gt;
```tsx
// TanStack Start: route loader
export const Route = createFileRoute(&apos;/posts/&apos;)({
  loader: async () =&gt; {
    const posts = await fetch(&apos;https://api.example.com/posts&apos;)
      .then(r =&gt; r.json());
    return posts;
  },
  component: PostsPage,
});

function PostsPage() {
  const posts = Route.useLoaderData();
  return (
    &lt;ul&gt;
      {posts.map(post =&gt; &lt;li key={post.id}&gt;{post.title}&lt;/li&gt;)}
    &lt;/ul&gt;
  );
}
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Ecosystem and community health

Ecosystem size matters for longevity. Satisfaction matters for daily happiness. Here&apos;s how the three stack up:

| Dimension | Astro | Next.js | TanStack Start |
|-----------|-------|---------|----------------|
| GitHub Stars | ~60.9K | ~140.6K | ~14.8K (Router) |
| State of JS Usage | ~25-30% | ~60-70% | ~4% write-in |
| Satisfaction (SoJS) | #1 (39pt lead) | Declining | N/A (too new) |
| Integrations | Growing (Tailwind, MDX, Strapi) | Massive (everything React) | Small but TanStack ecosystem |
| Hiring Pool | Small-medium | Very large | Small |
| Corporate Backing | Cloudflare | Vercel | Community + partners |

&lt;Notice type=&quot;info&quot; title=&quot;Ecosystem ≠ just star count&quot;&gt;
Next.js has the largest ecosystem but Astro&apos;s satisfaction lead and Cloudflare backing suggest shifting momentum. TanStack&apos;s community is small but passionate — it mirrors where Next.js was in 2017 before it became the default. The &quot;Breakthrough of the Year&quot; award validates real community confidence.
&lt;/Notice&gt;

## Cost analysis: hosting at scale

This is the section most framework comparisons skip. Hosting costs are the hidden decision factor — you pick a framework, build your app, and then discover your hosting bill at 50K visitors is $500/month.

### Static content sites (10K–100K visitors)

Astro static deploys are free at any scale. Cloudflare Pages, Netlify, GitHub Pages — all offer free tiers that handle 100K+ monthly visitors without blinking. Pre-rendered HTML is the cheapest thing to serve on the internet.

Next.js on Vercel Hobby ($0) works for small sites, but bandwidth and serverless function limits hit fast. Once you exceed the free tier, Pro starts at $20/month per user plus $20 usage credit. For a simple blog, that&apos;s overkill.

TanStack Start can be as cheap as Astro if your site is static-heavy. If you need SSR, standard Node hosting applies — $4–20/month on any VPS.

### Full-stack apps at scale

Here&apos;s where it gets expensive. Real-world Vercel pricing data from multiple sources:

| Monthly Active Users | Vercel Cost | Self-Hosted VPS |
|---------------------|-------------|-----------------|
| 10K | $0–50/month | $4–10/month |
| 50K | $230–1,180/month | $10–20/month |
| 100K | $560–2,250/month | $20–40/month |

&lt;Notice type=&quot;warning&quot; title=&quot;Vercel costs scale fast&quot;&gt;
At 50K MAU you could be paying $1,180/month on Vercel Pro. A $20/month Hetzner VPS handles the same traffic. The convenience tax is real — factor this into your framework decision before you build.
&lt;/Notice&gt;

Check [Hetzner Cloud pricing](/hetzner-cloud-cost-optimized-plans/) for concrete VPS cost data.

### Self-hosting and Docker

All three frameworks support Docker. The difference is how much the framework fights you.

**Astro**: Static files go anywhere — a CDN, a $4 VPS with Nginx, S3. SSR requires a Node adapter but is straightforward.

**Next.js**: Requires a Node server. Dockerfile from `next start`. Works on any VPS, but some features (image optimization, ISR, middleware) behave differently outside Vercel.

**TanStack Start**: Nitro outputs standard Node. Docker-friendly by design. No platform-specific behavior to worry about.

```dockerfile
# Generic Node Dockerfile — works for all three
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
EXPOSE 3000
CMD [&quot;node&quot;, &quot;dist/server.js&quot;]
```

The PaaS middle ground: tools like Coolify, Railway, and Render offer Vercel-like convenience without the Vercel price tag. If you want one-click deploys on your own infrastructure, check [Coolify as a self-hosted alternative](/coolify-install-heroku-alternative/).

## The Cloudflare factor: what Astro&apos;s acquisition means

Cloudflare acquired The Astro Technology Company on January 16, 2026. Astro remains MIT-licensed and open-source — that&apos;s confirmed in the announcement. But the implications are worth examining.

Cloudflare&apos;s incentive is clear: make Astro the best framework on Cloudflare Pages and Workers. This mirrors Vercel&apos;s relationship with Next.js. We&apos;re heading toward a duopoly: Vercel-optimized vs Cloudflare-optimized.

The risk: will Astro become Cloudflare-first the way Next.js is Vercel-first? Will platform-specific features create soft lock-in?

The reassurance: Astro&apos;s island architecture and framework-agnostic philosophy are inherently platform-neutral. Unlike Next.js, which is deeply tied to Vercel&apos;s infrastructure (edge functions, image optimization, ISR), Astro&apos;s static-first approach means the output is just files. Files go anywhere.

Cloudflare&apos;s investment likely means faster development, better edge deployment, more resources, and tighter integration with Workers. For the Astro community, this is a net positive — as long as you keep deploying to non-Cloudflare platforms too.

&lt;Notice type=&quot;info&quot; title=&quot;The corporate backing dynamic&quot;&gt;
Vercel → Next.js: deep integration, but vendor lock-in risk. Cloudflare → Astro: same dynamic, different platform. Community → TanStack Start: no corporate sugar daddy, but maximum independence. Each model has tradeoffs.
&lt;/Notice&gt;

You can [deploy an Astro blog on Cloudflare](/deploy-astrojs-cloudflare/) today to see the integration in practice.

## Is TanStack Start production-ready?

The elephant in the room. TanStack Start has been in v1 RC since September 2025 — still not &quot;stable&quot; by semver standards. So can you ship real products on it?

Yes, with caveats. Multiple production SaaS apps run on TanStack Start today. MakerKit ships their SaaS starter on it. Appwrite documented their migration from Next.js. The &quot;Breakthrough of the Year&quot; award at the 2026 Open Source Awards validates community confidence beyond hype.

Key considerations:

&lt;ListCheck&gt;
**TanStack Start production readiness:**
- Type-safe routing — production-ready
- Server functions (`createServerFn`) — production-ready
- Streaming SSR — production-ready
- Docker deployment — production-ready
- TanStack Query integration — battle-tested library
- RSC support — coming as non-breaking v1.x addition (Composite Components model)
- Large plugin ecosystem — not yet
- Enterprise hiring pool — not yet
&lt;/ListCheck&gt;

Honest take: if you&apos;re a startup shipping fast with a senior team comfortable with TanStack primitives, TanStack Start is viable today. If you need enterprise hiring guarantees, a massive plugin ecosystem, or your team is mostly junior developers, wait 6–12 months for the ecosystem to mature.

## When to choose which framework

There is no universal winner. The right choice depends on what you&apos;re building, who&apos;s building it, and where it runs.

![Flowchart to choose between Astro, Next.js, and TanStack Start based on project type](../../assets/images/26/07/decision-flowchart.svg)

### Choose Astro when

You&apos;re building content sites: blogs, documentation, marketing pages, portfolios. Performance and Core Web Vitals are non-negotiable. You want minimal JavaScript. Your team uses multiple frameworks (React + Vue). Budget is tight — static hosting is free at any scale. You want the simplest mental model with the lowest learning curve.

### Choose Next.js when

You&apos;re building complex web applications: SaaS products, e-commerce platforms, dashboards. You need React Server Components, streaming, ISR. Your team is React-only and values ecosystem depth. Vercel deployment is acceptable or preferred. You need the largest hiring pool for scaling your engineering team.

### Choose TanStack Start when

You&apos;re building applications with complex client-side state. The RSC mental model creates friction for your team. Type-safe routing is a priority. You want deploy-anywhere flexibility without vendor lock-in. Your team has senior engineers comfortable with thinner but well-designed primitives. You&apos;re already invested in the TanStack Query/Router ecosystem.

## Migration considerations

What if you pick wrong? Switching costs are real but manageable.

Astro to Next.js: moderate effort. Rewrite `.astro` templates as React components, adopt the RSC model, move static content logic into Server Components.

Next.js to Astro: moderate effort. Strip server logic, move interactivity into islands, convert App Router pages to Astro pages. Content-heavy sites migrate easier than app-heavy ones.

Next.js to TanStack Start: lower effort. Both are React, but routing patterns and server function approaches differ. You&apos;ll rewrite route definitions and replace RSC patterns with loaders + server functions.

TanStack Start to Next.js: moderate effort. Lose type-safe routing, adopt RSC patterns, restructure data fetching around Server Components.

&lt;Notice type=&quot;success&quot; title=&quot;The best migration is the one you don&apos;t need&quot;&gt;
Choosing the right framework upfront saves weeks of refactoring. If you&apos;re 80% content, start with Astro — you can always add an SPA subdomain later. If you&apos;re 80% app, start with Next.js or TanStack Start. Use the decision framework above.
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Which framework is fastest for a typical content site?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Astro, hands down. A typical Astro blog ships 0KB of JavaScript, achieves Time to Interactive under 100ms, and scores 100/100 on Lighthouse out of the box. Next.js ships ~95KB gzipped for the same content, with TTI around 1.4 seconds. The difference is visible to users and to Google&apos;s Core Web Vitals.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can TanStack Start realistically replace Next.js for a SaaS product?&quot; group=&quot;faq&quot;&gt;
Yes, if your team is comfortable with TanStack primitives and you don&apos;t need the Next.js ecosystem depth. Multiple SaaS products ship on TanStack Start today (MakerKit, Appwrite). The type-safe routing and explicit server functions are genuinely better DX for many teams. The gap is in ecosystem: fewer third-party integrations, fewer tutorials, smaller hiring pool.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Astro enough for a project that might grow into an app?&quot; group=&quot;faq&quot;&gt;
Yes, up to a point. Astro&apos;s islands let you add interactivity incrementally — drop in a React widget here, a Svelte component there. But if your project becomes 80%+ interactive, you&apos;ll feel the friction of island coordination overhead. At that point, a framework designed for apps (Next.js or TanStack Start) is the better foundation.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do the three compare with a headless CMS?&quot; group=&quot;faq&quot;&gt;
All three work well with Strapi, Sanity, Contentful, and other headless CMSs. Astro&apos;s Content Collections have the best native DX for content fetching — type-safe schemas with Zod validation. Next.js integrates via Server Components and `fetch()`. TanStack Start uses route loaders with TanStack Query caching. All viable; Astro edges ahead on content-specific workflows.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the cheapest way to host a Next.js app?&quot; group=&quot;faq&quot;&gt;
Self-host on a Hetzner VPS ($4/month) with Docker. Build with `next build`, run with `next start` in a container. Skip Vercel Pro unless you specifically need their edge features or preview deployments. At 50K+ MAU, the cost difference between Vercel and a VPS is hundreds of dollars per month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is the Cloudflare acquisition good or bad for Astro?&quot; group=&quot;faq&quot;&gt;
Good for now. More resources, faster development, MIT license stays. The risk is soft lock-in to Cloudflare-specific features down the road. Mitigation: Astro&apos;s static-first output is inherently portable. Keep deploying to non-Cloudflare platforms to keep them honest.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;When will TanStack Start be stable?&quot; group=&quot;faq&quot;&gt;
v1 RC has been out since September 2025. A stable release is expected in 2026 but no confirmed date. Production apps already run on the RC without issues. The TanStack Query and Router libraries underneath are battle-tested — it&apos;s the framework layer that&apos;s still maturing.
&lt;/Accordion&gt;

## Verdict

There is no single winner. There are three strong frameworks for three different bets on the web.

**Astro** wins for content sites. Zero JS by default, best Core Web Vitals, free hosting at any scale, and now Cloudflare backing. If you&apos;re building a blog, docs site, or marketing page, this is the obvious choice.

**Next.js** wins for complex React applications. The ecosystem depth, hiring pool, and RSC model are unmatched for teams building SaaS products, e-commerce platforms, or enterprise dashboards. Accept the Vercel optimization tax and the learning curve.

**TanStack Start** wins for developer control. Type-safe routing, explicit server boundaries, deploy-anywhere flexibility. Best for senior teams who want thin, well-designed primitives without vendor lock-in. Wait for ecosystem maturity if your team needs guardrails.

The meta-framework space is healthier than it&apos;s been in years. Real competition is driving real improvement across all three. Developers now have genuine choices.

&lt;Button text=&quot;More dev tools articles&quot; link=&quot;/categories/dev-tools/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category><category>next-js</category><category>tanstack-start</category></item><item><title>How To Add A Contact Form To Astro (Free, No Backend)</title><link>https://www.bitdoze.com/add-contact-form-astro/</link><guid isPermaLink="true">https://www.bitdoze.com/add-contact-form-astro/</guid><description>Learn how to add a working contact form to your Astro website that sends email notifications. Three options: Web3Forms, formsubmit.co, and OpnForm.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/08/opnform-settings.jpeg&quot;;

Static sites can&apos;t process form submissions on their own. There&apos;s no server to handle the POST request, validate the data, or send an email. You need an external service to act as the backend.

This guide covers three ways to add a working contact form to Astro that sends you email notifications when someone submits it. The first option (Web3Forms) is the one I recommend for most people.

**Other Astro tutorials:**

- [How To Add A Contact Form To Any Static Website](https://www.bitdoze.com/add-contact-form-static-websites/)
- [How to Host Astro on a VPS](https://www.bitdoze.com/deploy-astro-on-vps/)

## Option 1: Web3Forms (recommended)

[Web3Forms](https://web3forms.com/) is a form-to-email API. You create a form in HTML, point it at their endpoint, and submissions land in your inbox. It works without a backend server or any server-side code.

**Free plan limits:** 250 submissions/month, honeypot spam protection, email notifications included, 30-day submission storage. No credit card required. File uploads are available on the paid Starter plan ($12/mo).

### Step 1: Get an access key

Go to [web3forms.com](https://web3forms.com/) and enter your email address. You&apos;ll receive an access key. This key is not a secret — it&apos;s safe to put in client-side code. It acts as an alias for your email address.

### Step 2: Create the form component

Create a new Astro component or add the form to an existing page. Here&apos;s a minimal example:

```astro
---
// src/pages/contact.astro
import Layout from &quot;../layouts/Layout.astro&quot;;
---

&lt;Layout title=&quot;Contact&quot;&gt;
  &lt;form
    action=&quot;https://api.web3forms.com/submit&quot;
    method=&quot;POST&quot;
    id=&quot;contact-form&quot;
    data-astro-reload
    novalidate
  &gt;
    &lt;input type=&quot;hidden&quot; name=&quot;access_key&quot; value=&quot;YOUR_ACCESS_KEY_HERE&quot; /&gt;

    &lt;!-- Honeypot spam protection --&gt;
    &lt;input type=&quot;checkbox&quot; name=&quot;botcheck&quot; style=&quot;display: none;&quot; /&gt;

    &lt;div&gt;
      &lt;label for=&quot;name&quot;&gt;Name&lt;/label&gt;
      &lt;input type=&quot;text&quot; name=&quot;name&quot; id=&quot;name&quot; required /&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;label for=&quot;email&quot;&gt;Email&lt;/label&gt;
      &lt;input type=&quot;email&quot; name=&quot;email&quot; id=&quot;email&quot; required /&gt;
    &lt;/div&gt;

    &lt;div&gt;
      &lt;label for=&quot;message&quot;&gt;Message&lt;/label&gt;
      &lt;textarea name=&quot;message&quot; id=&quot;message&quot; rows=&quot;5&quot; required&gt;&lt;/textarea&gt;
    &lt;/div&gt;

    &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;

    &lt;p id=&quot;result&quot;&gt;&lt;/p&gt;
  &lt;/form&gt;
&lt;/Layout&gt;
```

Replace `YOUR_ACCESS_KEY_HERE` with the key from step 1.

The `data-astro-reload` attribute is important if you&apos;re using Astro&apos;s View Transitions. Without it, the form submission may fail because Astro intercepts the navigation.

### Step 3: Add AJAX submission (optional but better UX)

The plain HTML form works, but it redirects the user to a Web3Forms success page. If you want to show a &quot;sent successfully&quot; message without leaving the page, add this script:

```astro
&lt;script is:inline&gt;
  document.addEventListener(&quot;DOMContentLoaded&quot;, () =&gt; {
    const form = document.getElementById(&quot;contact-form&quot;);
    const result = document.getElementById(&quot;result&quot;);

    form.addEventListener(&quot;submit&quot;, function (e) {
      e.preventDefault();
      const formData = new FormData(form);
      const object = Object.fromEntries(formData);
      const json = JSON.stringify(object);

      result.textContent = &quot;Sending...&quot;;

      fetch(&quot;https://api.web3forms.com/submit&quot;, {
        method: &quot;POST&quot;,
        headers: {
          &quot;Content-Type&quot;: &quot;application/json&quot;,
          Accept: &quot;application/json&quot;,
        },
        body: json,
      })
        .then(async (response) =&gt; {
          const data = await response.json();
          if (response.status === 200) {
            result.textContent = data.message;
          } else {
            result.textContent = data.message;
          }
        })
        .catch(() =&gt; {
          result.textContent = &quot;Something went wrong.&quot;;
        })
        .then(() =&gt; {
          form.reset();
        });
    });
  });
&lt;/script&gt;
```

If you use View Transitions in Astro, replace the `DOMContentLoaded` listener with `astro:page-load`. Here&apos;s the complete version:

```astro
&lt;script is:inline&gt;
  document.addEventListener(&quot;astro:page-load&quot;, () =&gt; {
    const form = document.getElementById(&quot;contact-form&quot;);
    const result = document.getElementById(&quot;result&quot;);

    if (!form) return;

    form.addEventListener(&quot;submit&quot;, function (e) {
      e.preventDefault();
      const formData = new FormData(form);
      const object = Object.fromEntries(formData);
      const json = JSON.stringify(object);

      result.textContent = &quot;Sending...&quot;;

      fetch(&quot;https://api.web3forms.com/submit&quot;, {
        method: &quot;POST&quot;,
        headers: {
          &quot;Content-Type&quot;: &quot;application/json&quot;,
          Accept: &quot;application/json&quot;,
        },
        body: json,
      })
        .then(async (response) =&gt; {
          const data = await response.json();
          if (response.status === 200) {
            result.textContent = data.message;
          } else {
            result.textContent = data.message;
          }
        })
        .catch(() =&gt; {
          result.textContent = &quot;Something went wrong.&quot;;
        })
        .then(() =&gt; {
          form.reset();
        });
    });
  });
&lt;/script&gt;
```

The `if (!form) return;` guard prevents errors on pages that don&apos;t have the contact form. With View Transitions, Astro re-runs the `astro:page-load` event on every navigation, so the check matters.

### Step 4: Add a redirect after submission (optional)

Instead of AJAX, you can redirect to a thank-you page after submission. Add this hidden input to your form:

```html
&lt;input type=&quot;hidden&quot; name=&quot;redirect&quot; value=&quot;https://yourdomain.com/thanks&quot; /&gt;
```

Web3Forms will redirect the user there after processing the submission.

### Step 5: Style the form

The form markup above is bare HTML. Style it with your preferred approach — Tailwind, vanilla CSS, or a component library. Web3Forms is just an API endpoint. It doesn&apos;t inject any UI or styles.

The [Web3Forms Astro docs](https://docs.web3forms.com/how-to-guides/static-site-generators/astro) have a complete Tailwind-styled example if you want a starting point.

## Option 2: formsubmit.co

[Formsubmit.co](https://formsubmit.co/) is another free form-to-email service. No signup required. You point your form&apos;s `action` attribute at their endpoint with your email address, submit the form once, click a confirmation link, and you&apos;re done.

### Setup

```astro
&lt;form
  action=&quot;https://formsubmit.co/your@email.com&quot;
  method=&quot;POST&quot;
  data-astro-reload
&gt;
  &lt;input type=&quot;text&quot; name=&quot;name&quot; required /&gt;
  &lt;input type=&quot;email&quot; name=&quot;email&quot; required /&gt;
  &lt;textarea name=&quot;message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

Submit the form once. Check your inbox for a confirmation email from Formsubmit.co. Click the link. From that point on, all submissions go to your email.

### Useful hidden fields

```html
&lt;!-- Redirect after submission --&gt;
&lt;input type=&quot;hidden&quot; name=&quot;_next&quot; value=&quot;https://yourdomain.com/thanks&quot; /&gt;

&lt;!-- CC another email --&gt;
&lt;input type=&quot;hidden&quot; name=&quot;_cc&quot; value=&quot;team@email.com&quot; /&gt;

&lt;!-- Set subject line --&gt;
&lt;input type=&quot;hidden&quot; name=&quot;_subject&quot; value=&quot;New contact form submission&quot; /&gt;

&lt;!-- Enable reCAPTCHA --&gt;
&lt;input type=&quot;hidden&quot; name=&quot;_captcha&quot; value=&quot;true&quot; /&gt;
```

### formsubmit.co vs Web3Forms

| Feature | formsubmit.co | Web3Forms |
|---------|--------------|-----------|
| Free submissions | Unlimited | 250/month |
| Signup required | No (email confirmation) | Yes (email only) |
| Spam protection | reCAPTCHA (free) | Honeypot (free), reCAPTCHA (paid) |
| AJAX support | Yes | Yes |
| Custom redirect | Yes | Yes |
| File uploads | Yes (free) | Paid only (Starter $12/mo) |
| Submission storage | No | 30 days (free) |

Both work well. formsubmit.co has no monthly cap and supports file uploads on the free plan, but Web3Forms has a cleaner API and stores submissions for 30 days, which is useful if an email gets lost.

## Option 3: OpnForm (embedded form builder)

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EhE6H9nCm8U&quot;
  label=&quot;OpnForm - Add a contact form to Astro&quot;
/&gt;

[OpnForm](https://opnform.com/) is an open-source form builder with a drag-and-drop editor. You design the form visually, then embed it as an iframe.

**What&apos;s free vs. paid:** OpnForm&apos;s free plan includes basic email notifications through their built-in email integration. However, using your own SMTP server (custom SMTP), Slack/Discord/Telegram notifications, and Captcha are all Pro plan features ($25/mo). If you&apos;re fine with OpnForm sending the emails through their default service, the free plan works for basic contact forms. If you need custom SMTP or anti-spam Captcha, you&apos;ll need to upgrade.

### How to use OpnForm in Astro

1. Create an account at [opnform.com](https://opnform.com/) and build your form.
2. Go to the form&apos;s **Share** page and copy the embed code.
3. Paste it into your Astro page:

```astro
---
// src/pages/contact.astro
import Layout from &quot;../layouts/Layout.astro&quot;;
---

&lt;Layout title=&quot;Contact&quot;&gt;
  &lt;iframe
    style=&quot;border:none;width:100%;&quot;
    height=&quot;500px&quot;
    src=&quot;https://opnform.com/forms/your-form-slug&quot;
  &gt;&lt;/iframe&gt;
&lt;/Layout&gt;
```

Adjust the `height` value to fit your form. The iframe approach means you don&apos;t have control over the form&apos;s styling — OpnForm handles that in the embed.

&lt;Picture
  src={img1}
  alt=&quot;OpnForm form builder settings&quot;
/&gt;

## Which option should you pick?

**Just want emails in your inbox for free?** Use Web3Forms. It takes 5 minutes, the free plan handles 250 submissions/month (more than enough for a contact form), and the honeypot spam protection works out of the box.

**Need unlimited submissions and file uploads without creating an account?** Use formsubmit.co. The one-time email confirmation is the only setup step.

**Want a visual form builder with advanced logic and conditional fields?** Use OpnForm. The free plan includes basic email notifications, but you&apos;ll need the Pro plan for custom SMTP and Captcha.

For a simple contact form that sends you an email, Web3Forms is the best balance of simplicity and features. Here&apos;s the minimal code again:

```astro
&lt;form action=&quot;https://api.web3forms.com/submit&quot; method=&quot;POST&quot; data-astro-reload&gt;
  &lt;input type=&quot;hidden&quot; name=&quot;access_key&quot; value=&quot;YOUR_KEY&quot; /&gt;
  &lt;input type=&quot;checkbox&quot; name=&quot;botcheck&quot; style=&quot;display: none;&quot; /&gt;
  &lt;input type=&quot;text&quot; name=&quot;name&quot; placeholder=&quot;Name&quot; required /&gt;
  &lt;input type=&quot;email&quot; name=&quot;email&quot; placeholder=&quot;Email&quot; required /&gt;
  &lt;textarea name=&quot;message&quot; placeholder=&quot;Message&quot; required&gt;&lt;/textarea&gt;
  &lt;button type=&quot;submit&quot;&gt;Send&lt;/button&gt;
&lt;/form&gt;
```

Get your free access key at [web3forms.com](https://web3forms.com/) and you&apos;re done.</content:encoded><category>web-development</category><category>astro</category></item><item><title>How to Deploy Astro on Your VPS with EasyPanel</title><link>https://www.bitdoze.com/deploy-astro-easypanel/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-astro-easypanel/</guid><description>Learn how to deploy an Astro static site on your VPS using EasyPanel. Covers project setup, GitHub integration, Nixpacks build, and custom domain configuration.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/11/easypanel-general.png&quot;;
import imag2 from &quot;../../assets/images/23/11/ep-domains.png&quot;;

Astro is a modern web framework built for content-driven sites. It renders pages to static HTML by default, which means fast load times and good SEO out of the box. You can use React, Svelte, Vue, or just Astro&apos;s own template syntax — and mix them in the same project.

This tutorial walks through deploying an Astro static site on your own VPS using [EasyPanel](https://easypanel.io/). EasyPanel is a self-hosted PaaS that sits on top of Docker and gives you a web UI to deploy apps, manage databases, and handle SSL. If you haven&apos;t installed it yet, start here: [Easypanel.io: A Modern Hosting Panel for Applications and Databases](https://www.bitdoze.com/easypanel-modern-server-control-panel/).

**Related articles:**

- [How To Deploy Static Website Astro.JS on VPS Servers](https://www.bitdoze.com/deploy-astro-on-vps/)
- [Coolify Install A Free Heroku and Netlify Self-Hosted Alternative](https://www.bitdoze.com/coolify-install-heroku-alternative/)
- [How To Deploy An Astro.JS Blog On Cloudflare](https://www.bitdoze.com/deploy-astrojs-cloudflare/)
- [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Prerequisites

Before you start, you need:

- A VPS with EasyPanel installed (2 GB RAM minimum, Ubuntu 20.04+)
- A GitHub account
- Node.js v22.12.0+ on your local machine (required by Astro 5+)
- A domain pointed to your VPS IP (for production deployment)

If you don&apos;t have a VPS yet, Hetzner is a solid, affordable option:

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 free credit&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/l-yohC7xD38&quot;
  label=&quot;Deploy Astro on Your VPS with EasyPanel&quot;
/&gt;

## Step 1: Create a GitHub repository

Create a new repo on GitHub. You can use a public or private repo — if private, you&apos;ll need to add a GitHub token in EasyPanel under **Settings &gt; GitHub**.

If you need help setting up SSH keys for GitHub, see [Link GitHub with A SSH Key to MacOS or Linux](https://www.bitdoze.com/link-github-with-ssh-maco-linux/).

Clone the empty repo locally:

```sh
git clone git@github.com:yourusername/your-astro-site.git
cd your-astro-site
```

## Step 2: Initialize Astro

Run the Astro CLI wizard inside your repo directory:

```sh
npm create astro@latest
```

When prompted:
- **Project name:** use `.` to scaffold in the current directory
- **Template:** choose a starter template or &quot;Empty&quot; for a blank project
- **Install dependencies:** yes
- **Initialize git repo:** no (you already have one)

This creates the standard Astro project structure with `src/`, `public/`, and `astro.config.mjs`.

## Step 3: Configure for static hosting

Astro outputs static HTML by default (`output: &apos;static&apos;`), which is what we want. But EasyPanel needs a way to serve the built files. There are two approaches.

### Option A: Use `serve` (recommended for Nixpacks)

Install the `serve` package — a lightweight static file server:

```sh
npm install serve
```

Update your `package.json` scripts so EasyPanel knows how to start the app:

```json
{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;astro dev&quot;,
    &quot;start&quot;: &quot;serve dist/&quot;,
    &quot;build&quot;: &quot;astro build&quot;,
    &quot;preview&quot;: &quot;astro preview&quot;
  }
}
```

The `start` script tells EasyPanel to serve the `dist/` directory after the build completes.

### Option B: Use a custom Dockerfile

If you prefer more control, create a `Dockerfile` in your project root:

```dockerfile
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM joseluisq/static-web-server:2-alpine
COPY --from=build /app/dist /public
ENV SERVER_PORT=80
```

When deploying in EasyPanel, choose &quot;Dockerfile&quot; as the build method instead of Nixpacks.

## Step 4: Verify the build locally

Run these commands before pushing to make sure everything works:

```sh
npm run build
npm run start
```

The build should complete without errors, and `serve` should start on port 3000. Open `http://localhost:3000` to verify.

If you see issues, fix them now — debugging build failures is much easier locally than in EasyPanel&apos;s deployment logs.

## Step 5: Push to GitHub

```sh
git add .
git commit -m &quot;initial astro site&quot;
git push
```

## Step 6: Deploy in EasyPanel

### Create a project and service

1. In EasyPanel, click **Create Project**
2. Inside the project, click **Service** and select **App**
3. Connect your GitHub repository

### Configure the build

In the **Source** section, set the repository owner and name:

&lt;Picture
  src={imag1}
  alt=&quot;EasyPanel GitHub source configuration&quot;
/&gt;

In the **Build** section:
- Choose **Nixpacks** as the builder
- Nixpacks will auto-detect Astro and run `npm install` then `npm run build`
- The start command will use your `package.json` `start` script (`serve dist/`)

Click **Save**, then **Deploy**. Watch the deployment logs to confirm the build succeeds.

### Enable auto-deploy

Auto-deploy triggers a new build whenever you push to your repo. Set it up in one of two ways:

- **Auto Deploy button:** If you&apos;re using a GitHub token, click the **Auto Deploy** button in your project settings. This creates a webhook on your repo automatically.
- **Manual webhook:** Copy the webhook URL from **General** in your project settings and add it to your GitHub repo under **Settings &gt; Webhooks**.

### Add your domain

&lt;Picture
  src={imag2}
  alt=&quot;EasyPanel domain configuration&quot;
/&gt;

1. Create an **A record** in your DNS provider pointing your domain (or subdomain) to your VPS IP
2. In EasyPanel, go to **Domains** in your project
3. Add your domain — EasyPanel will automatically provision a Let&apos;s Encrypt SSL certificate

## Project structure tips

For a typical Astro blog or documentation site, your structure will look like this:

```
your-astro-site/
├── src/
│   ├── pages/          # Route-based pages
│   ├── content/        # Markdown/MDX content
│   ├── components/     # Reusable UI components
│   └── layouts/        # Page layouts
├── public/             # Static assets (images, fonts)
├── astro.config.mjs    # Astro configuration
├── package.json
└── tsconfig.json
```

Add any integrations you need with:

```sh
npx astro add mdx sitemap
```

This installs and configures the MDX and sitemap integrations automatically.

## Troubleshooting

**Build fails in EasyPanel but works locally.** Check the Node.js version. EasyPanel&apos;s Nixpacks may use a different version than your local machine. You can specify the version in your `package.json`:

```json
{
  &quot;engines&quot;: {
    &quot;node&quot;: &quot;&gt;=22.12.0&quot;
  }
}
```

**Site shows &quot;Cannot GET /&quot;.** The `start` script is missing or wrong. Make sure `&quot;start&quot;: &quot;serve dist/&quot;` is in your `package.json` scripts.

**Deployments don&apos;t trigger on push.** Verify the webhook is set up correctly. In your GitHub repo, go to **Settings &gt; Webhooks** and check that the payload URL matches your EasyPanel project&apos;s webhook URL. The content type should be `application/json`.

**Large Docker images.** Nixpacks can produce large images. If disk space is a concern, use the Dockerfile approach (Option B above) with a multi-stage build for smaller images.

**External content not rebuilding.** If your Astro site pulls content from a CMS (Sanity, Contentful, etc.), the webhook won&apos;t detect changes in external data. You&apos;ll need to manually trigger a **Force Rebuild** in EasyPanel, or set up a `CACHEBUST` environment variable that you increment before each rebuild.

## Conclusions

Deploying Astro on EasyPanel is straightforward once you have the `serve` package configured. The free EasyPanel plan supports up to 3 projects, which is enough for most personal sites. Every push to your repo triggers a rebuild, and EasyPanel handles SSL and reverse proxy automatically.

For developers who want full control over their hosting without paying for managed platforms like Netlify or Vercel, this setup gives you that — you just pay for the VPS.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 free credit&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>web-development</category><category>easypanel</category><category>astro</category><category>self-hosted</category></item><item><title>Easypanel.io: A Modern Hosting Panel for Applications and Databases</title><link>https://www.bitdoze.com/easypanel-modern-server-control-panel/</link><guid isPermaLink="true">https://www.bitdoze.com/easypanel-modern-server-control-panel/</guid><description>Deploy apps, databases, and SSL certificates with Easypanel.io. This review covers features, pricing, installation, and how it compares to alternatives like Coolify and Portainer.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/11/add-domain.jpeg&quot;;
import img2 from &quot;../../assets/images/23/11/Easypanel-app-deploy.jpeg&quot;;

Easypanel.io is a self-hosted server control panel that turns your VPS into a Platform-as-a-Service (PaaS). It uses Docker under the hood but gives you a clean web UI to deploy applications, manage databases, and handle SSL certificates without touching the terminal.

If you&apos;ve used Heroku, Netlify, or Vercel, the workflow feels familiar: push your code, and Easypanel builds and deploys it. The difference is you own the infrastructure and pay only for the VPS.

**Other guides you might find useful:**

- [How to Deploy Astro on Your VPS with EasyPanel](https://www.bitdoze.com/deploy-astro-easypanel/)
- [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## What Easypanel does

Easypanel sits on top of Docker Swarm and Traefik. It handles:

- **Application deployment** from GitHub repos, Docker images, or Dockerfiles
- **Database management** for MySQL, MariaDB, PostgreSQL, MongoDB, and Redis
- **Automatic SSL** via Let&apos;s Encrypt with auto-renewal
- **Zero-downtime deployments** using Docker Swarm&apos;s rolling update strategy
- **Web terminal** for running commands inside containers without SSH
- **Template library** with 200+ one-click installers for popular apps

The panel uses Cloud Native Buildpacks (the same technology behind Heroku) to auto-detect your app&apos;s language and build a Docker image. This means you don&apos;t need a Dockerfile for Node.js, Python, Ruby, PHP, Go, or Java apps.

## Pricing

Easypanel has four plans, licensed per server:

| Plan | Price | Projects | Key features |
|------|-------|----------|--------------|
| **Free** | $0/mo | Up to 3 | Unlimited services, unlimited deployments, basic monitoring |
| **Hobby** | $10.90/mo | Unlimited | Advanced monitoring, notifications, database backups, custom service domains |
| **Growth** | $16.90/mo | Unlimited | Multiple users, access control |
| **Business** | $29.90/mo | Unlimited | Cluster support (under development), white-labeling, priority support |

The free plan is genuinely usable for personal projects. Three projects with unlimited services per project covers most homelab and side-project setups. You only need to pay when you want backups, monitoring, or team features.

All paid plans come with a 30-day money-back guarantee. Payments are processed through LemonSqueezy.

## What you can deploy

### Databases

Easypanel treats databases as first-class citizens. You can spin up any of these with a few clicks:

- **MySQL / MariaDB** — the standard choice for WordPress, Laravel, and most PHP apps
- **PostgreSQL** — better for apps that need JSON columns, full-text search, or GIS
- **MongoDB** — document store for flexible schemas
- **Redis** — in-memory cache and message broker

Each database gets its own web console, so you can run queries without leaving the browser. You can keep databases private (only accessible from within the server) or expose them publicly.

### Templates

The [template library](https://easypanel.io/templates) has grown significantly. Some popular options:

- **WordPress** — full CMS with themes and plugins
- **Plausible** — privacy-focused web analytics
- **n8n** — workflow automation (Zapier alternative)
- **Listmonk** — self-hosted newsletter manager
- **Grafana** — metrics visualization and dashboards
- **Uptime Kuma** — uptime monitoring with notifications
- **Gitea** — lightweight Git hosting
- **Ghost** — publishing platform for newsletters and blogs

Templates are just pre-configured Docker Compose stacks. You can customize environment variables, volumes, and ports after deployment.

### Applications

For custom code, you connect a GitHub repository and Easypanel handles the rest. It auto-detects the language, builds the image, and deploys it. Every push to your repo triggers a new build.

Static sites, APIs, full-stack apps — they all work the same way. You can also deploy from a Docker image or a custom Dockerfile if you need more control.

## Installation

Here&apos;s how to get Easypanel running on a fresh VPS.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/RxgKE8TkQyQ&quot;
  label=&quot;Easypanel.io installation walkthrough&quot;
/&gt;

### Requirements

- A Linux server (Ubuntu 20.04+ recommended)
- At least 2 GB RAM and 2 CPU cores
- A fresh server with nothing else running on it

Hetzner is a solid choice for the VPS — good performance, low price, and reliable networking.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 free credit&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

### Step 1: Update the server

```sh
apt update &amp;&amp; apt -y upgrade
reboot
```

### Step 2: Add swap (if needed)

Some providers like Hetzner don&apos;t add swap by default. Adjust the size based on your server&apos;s RAM:

```sh
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | sudo tee -a /etc/fstab
```

### Step 3: Install Easypanel

One command:

```sh
curl -sSL https://get.easypanel.io | sh
```

This script installs Docker (if not present), initializes Docker Swarm, sets up Traefik as a reverse proxy, and deploys the Easypanel container. The whole process takes 2-5 minutes depending on your server&apos;s speed and network.

When it finishes, you&apos;ll see the access URL:

```
Easypanel was installed successfully on your server!

    http://YOUR_SERVER_IP:3000
```

The running containers after installation:

```sh
docker ps
# CONTAINER ID   IMAGE                          NAMES
# c7295671c7c5   easypanel/error-pages:latest   error-pages
# 1e5a1e16da83   easypanel/easypanel:latest     easypanel
# 71a49438ab9a   traefik:2.8                    traefik
```

### Step 4: Configure the panel

**Point a domain to your server.** Create an A record in your DNS provider (Cloudflare, Namecheap, etc.) pointing to your server&apos;s IP. A subdomain like `panel.yourdomain.com` works well.

**Set the domain in Easypanel.** Open the panel at `http://YOUR_SERVER_IP:3000`, create your admin account, then go to **Settings &gt; General** and enter your domain.

&lt;Picture
  src={img1}
  alt=&quot;Easypanel domain configuration&quot;
/&gt;

**Enable 2FA.** Go to **Settings &gt; Authentication** and set up two-factor authentication with an authenticator app. This is important since the panel has full control over your server.

**Connect GitHub.** If you&apos;re deploying private repos, go to **Settings &gt; GitHub** and add a [personal access token](https://github.com/settings/tokens).

### Step 5: Deploy your first project

Click **Create Project**, then add a service. For a GitHub app:

&lt;Picture
  src={img2}
  alt=&quot;Easypanel app deployment&quot;
/&gt;

1. Select **App** as the service type
2. Connect your GitHub repository
3. Choose a build method (Nixpacks for auto-detection, or Dockerfile for custom builds)
4. Add your domain under the **Domains** tab
5. Deploy

The free plan allows up to 3 projects, but each project can contain multiple services (app + database + cache, for example).

For more details on deploying specific frameworks, see [How to Deploy Astro on Your VPS with EasyPanel](https://www.bitdoze.com/deploy-astro-easypanel/).

## Easypanel vs alternatives

| Feature | Easypanel | Coolify | Portainer | Dokku |
|---------|-----------|---------|-----------|-------|
| **Type** | PaaS panel | PaaS panel | Container manager | PaaS (CLI) |
| **UI** | Web-based | Web-based | Web-based | CLI only |
| **GitHub auto-deploy** | Yes | Yes | No (manual) | Via plugins |
| **Database management** | Built-in | Built-in | No | Via plugins |
| **SSL certificates** | Automatic | Automatic | Manual | Via plugins |
| **Templates** | 200+ | 100+ | Community | Limited |
| **Multi-server** | Coming soon | Yes | Yes | No |
| **Free plan** | 3 projects | Unlimited | 5 nodes | Fully free |
| **Docker knowledge needed** | Minimal | Minimal | Moderate | Moderate |

Easypanel&apos;s strength is simplicity. If you want a Heroku-like experience on your own VPS without learning Docker internals, it&apos;s a good fit. Coolify is similar but has more features (like multi-server support). Portainer is better if you want direct Docker control. Dokku is great if you prefer the command line.

## Troubleshooting

**Panel not accessible after install.** Check that port 3000 is open in your server&apos;s firewall. If you&apos;re using Cloudflare, make sure the DNS record is set to &quot;DNS only&quot; (grey cloud) during setup.

**App builds failing.** Check the build logs in the Easypanel UI. Common issues: missing environment variables, wrong Node.js version, or insufficient memory. Adding more swap can help with memory-constrained builds.

**SSL certificate not provisioning.** Make sure your DNS A record is propagated before setting the domain in Easypanel. Let&apos;s Encrypt needs to reach your server to verify ownership.

**Container using too much disk.** Easypanel doesn&apos;t set Docker log rotation by default. You may want to configure log limits in your server&apos;s Docker daemon settings.

## Conclusions

Easypanel is a solid choice if you want to deploy apps and databases on a VPS without becoming a Docker expert. The free plan handles most personal projects, and the paid plans are reasonably priced for what they add.

It&apos;s not the most feature-rich option — Coolify has caught up and offers more for free — but Easypanel&apos;s UI is clean, the setup is straightforward, and the template library covers most common use cases.

For developers who want a self-hosted PaaS that just works, Easypanel is worth trying. Spin up a cheap Hetzner VPS, run the install command, and you&apos;ll have a working deployment platform in under 10 minutes.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 free credit&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>hosting</category><category>easypanel</category><category>self-hosted</category><category>docker</category></item><item><title>Herdr Review: Open-Source Agent Multiplexer for AI Coding</title><link>https://www.bitdoze.com/herdr-agent-multiplexer/</link><guid isPermaLink="true">https://www.bitdoze.com/herdr-agent-multiplexer/</guid><description>Herdr review: a free open-source agent multiplexer that runs AI coding agents side by side in one terminal. Features, setup, and how it compares to tmux.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;


If you&apos;re running three, five, maybe ten AI coding agents at the same time — Claude Code in one terminal, [Codex](/codex-app-any-model/) in another, Copilot CLI in a third — you already know the pain. You have terminal sessions scattered everywhere and zero visibility into which agent needs your attention right now.

Herdr is a free, open-source agent multiplexer built in Rust that solves exactly this problem. It runs inside your existing terminal (iTerm2, Kitty, Alacritty, WezTerm, Ghostty — whatever you already use) and adds a sidebar that shows real-time agent state: blocked, working, done, idle. Think of it as tmux that actually understands AI coding agents.

The project is about 105 days old, has nearly 15,000 GitHub stars, hit #1 on GitHub Trending on June 30, 2026, and is built by a single full-time developer. That kind of traction usually means the tool solves a real problem.

&lt;Notice type=&quot;info&quot; title=&quot;What You&apos;ll Learn&quot;&gt;
This review covers what Herdr is, how it compares to tmux and Zellij, how to install and configure it, and whether it actually delivers on its promise of terminal-native agent multiplexing. If you&apos;re exploring [AI coding tools and assistants](/ai-coding-tools/), this will help you decide if Herdr fits your workflow.
&lt;/Notice&gt;

---

## What is Herdr?

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NKppWN1atkQ&quot;
  label=&quot;Herdr: The Terminal Multiplexer That Actually Works&quot;
/&gt;


Herdr positions itself as &quot;one terminal for the whole herd.&quot; At its core, it&apos;s a terminal-native agent runtime and multiplexer that combines tmux-style session persistence with first-class awareness of AI coding agents.

It runs inside your existing terminal — it doesn&apos;t replace it. You launch Herdr and get a mouse-friendly TUI where you can split panes, manage named sessions, and see which agents are active. The key difference from tmux: Herdr detects AI agents running in its panes and displays their state in a sidebar.

Here&apos;s what that looks like in practice:

![Herdr architecture overview showing client-server model with session server, PTY panes, agent state sidebar, and socket API](../../assets/images/26/07/herdr-architecture.svg)

The architecture is client-server: a background session server manages PTY sessions (they survive laptop sleep, WiFi drops, SSH disconnects), and a thin client renders the TUI. Communication happens over a local Unix socket, or through an SSH tunnel for remote access.

Herdr is a single Rust binary — about 10–11 MB. No Electron, no accounts, no telemetry, no cloud dependency.

&lt;ListCheck&gt;

**What Herdr gives you:**
- tmux-style persistent sessions with detach/reattach
- Real-time agent state sidebar (blocked/working/done/idle)
- Mouse-native TUI with click, drag, split, and right-click menus
- CLI and Socket API for agent-driven orchestration
- Remote SSH attach for headless servers
- Plugin system with community marketplace
- 14+ AI coding agent support out of the box
- Zero telemetry, no account required

&lt;/ListCheck&gt;

If you&apos;ve looked at tools like [FreeBuff](/freebuff-free-ai-coding-agent/) or [Zcode](/zcode-ai-review/) as AI coding environments, Herdr sits in a different category. Those are agent wrappers. Herdr is the terminal layer underneath — it manages the panes and sessions where agents run, and adds orchestration capabilities on top.

---

## Why terminal-native agent multiplexing matters

The multi-agent workflow is no longer niche. In 2026, a typical developer might run Claude Code for architecture decisions, Codex for implementation, Copilot CLI for quick edits, and Gemini CLI for research — all in parallel. Each agent runs in its own terminal session, and you&apos;re constantly context-switching between them.

The pain points are real and consistent across the community:

1. **No visibility.** You have 10+ agent sessions open and no idea which one is blocked waiting for your input, which is still working, and which finished an hour ago.
2. **GUI wrappers are limiting.** Most are Mac-only, Electron-based, or replace your terminal entirely. If you work on a Linux VPS or want to access your agents from your phone over SSH, you&apos;re out of luck.
3. **Remote access is an afterthought.** Running agents on a headless server and managing them from your laptop shouldn&apos;t require a full desktop environment.
4. **Agents can&apos;t orchestrate.** In tmux, agents are isolated in their panes. There&apos;s no way for one agent to spin up another, wait for results, or read output from a sibling pane.

Terminal-native matters for the self-hosting and VPS crowd because it means zero overhead. No Electron, no desktop dependency, no GPU requirements. You can run Herdr on a $5 Hetzner VPS and access it from your phone via SSH. If you&apos;re interested in [SSH port forwarding](/ssh-tunneling-linux/) workflows, Herdr&apos;s remote attach mode fits right in.

---

## Herdr key features

### Agent state tracking

This is the feature that makes Herdr more than just another terminal multiplexer.

Herdr detects agents using process-name matching plus terminal output heuristics. For most of the 14+ supported agents, detection is zero-config — launch the agent in a pane and Herdr picks it up automatically. It tracks four semantic states:

- **Working** — the agent is actively generating or processing
- **Blocked** — the agent is waiting for user input (a prompt, permission, etc.)
- **Done** — the agent finished its task
- **Idle** — a pane exists but no agent activity detected

For richer state reporting, you can install agent-specific integrations:

```bash
herdr integration install claude
herdr integration install codex
herdr integration install copilot
```

Integrations add hooks that report state more accurately than heuristics alone. For example, the Claude Code integration uses its `SessionStart` hook to report initialization, task progress, and completion states.

&lt;Notice type=&quot;success&quot; title=&quot;14+ Agents Supported&quot;&gt;
Herdr supports Claude Code, Codex, Copilot CLI, Cursor Agent, Pi, Droid, Amp, OpenCode, Grok CLI, Gemini CLI, Antigravity CLI, Kimi Code CLI, [Hermes Agent](/hermes-agent-setup-guide/), QoderCLI, Kiro CLI, and more. If your agent isn&apos;t listed, the generic process detection usually works — it just won&apos;t report as much detail.
&lt;/Notice&gt;

### Persistent sessions and detach/reattach

Like tmux, Herdr runs a background session server that manages PTY sessions. Your agent sessions survive laptop sleep, WiFi drops, and SSH disconnects.

Named sessions make it easy to organize:

```bash
# Start or attach to a named session
herdr session attach work
herdr session attach side-project

# List active sessions
herdr session list

# Kill a session
herdr session kill side-project
```

Detach from a session (default prefix is `Ctrl+B`, then `D`), and reattach later with `herdr session attach`. Your agents keep running in the background the whole time.

### Mouse-native TUI

If you&apos;ve bounced off tmux because everything requires keyboard shortcuts, Herdr might change your mind. The TUI is fully mouse-aware:

- Click to select panes
- Drag pane borders to resize
- Right-click for context menus (split, close, rename)
- Touch support works over SSH — you can manage agents from your phone

The layout adapts to narrow terminal widths, so it works reasonably well even on small screens.

### Remote SSH attach

This is where Herdr gets interesting for the VPS and self-hosting crowd. You can run Herdr on a remote server and attach to it from your local machine:

![Herdr remote SSH attach workflow showing laptop connecting through SSH tunnel to VPS with session server](../../assets/images/26/07/remote-ssh-workflow.svg)

&lt;Tabs&gt;
&lt;Tab name=&quot;Direct SSH&quot;&gt;
```bash
# SSH into the server and launch Herdr directly
ssh user@your-server
herdr
```
This works but renders the full TUI over SSH — fine on good connections, laggy on slow ones.
&lt;/Tab&gt;
&lt;Tab name=&quot;Thin client (--remote)&quot;&gt;
```bash
# Stream the remote TUI to your local terminal
herdr --remote ssh://user@your-server:2222

# With SSH config aliases
herdr --remote ssh://myserver
```
The thin client mode streams only the rendering data, which is more responsive than raw SSH on slow connections.
&lt;/Tab&gt;
&lt;Tab name=&quot;SSH config aliases&quot;&gt;
Add to your `~/.ssh/config`:
```
Host myserver
    HostName your-server
    User user
    Port 2222
    IdentityFile ~/.ssh/id_ed25519
```
Then use: `herdr --remote ssh://myserver`
&lt;/Tab&gt;
&lt;/Tabs&gt;

The Moshi iOS terminal app has native Herdr support, which means you can manage your agent fleet from an iPhone or iPad over SSH.

---

## How to install Herdr

Herdr is a single Rust binary with no runtime dependencies. Installation takes under a minute.

&lt;Tabs&gt;
&lt;Tab name=&quot;curl (Linux/macOS)&quot;&gt;
```bash
curl -fsSL https://herdr.dev/install.sh | sh
```
This downloads the latest binary and places it in `/usr/local/bin/`.
&lt;/Tab&gt;
&lt;Tab name=&quot;Homebrew&quot;&gt;
```bash
brew install herdr
```
Available in Homebrew core (v0.7.3 at time of writing). Installs on both Intel and Apple Silicon Macs.
&lt;/Tab&gt;
&lt;Tab name=&quot;Nix&quot;&gt;
```bash
nix profile install github:ogulcancelik/herdr
```
Works with Nix flakes. Check the repo for the latest flake.nix if you prefer pinning.
&lt;/Tab&gt;
&lt;Tab name=&quot;Cargo (from source)&quot;&gt;
```bash
git clone https://github.com/ogulcancelik/herdr
cd herdr
cargo build --release
```
Requires Rust toolchain. Build produces a single binary at `target/release/herdr`.
&lt;/Tab&gt;
&lt;Tab name=&quot;Windows (beta)&quot;&gt;
```powershell
# PowerShell one-liner
iwr -useb https://herdr.dev/install.ps1 | iex
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Windows Beta&quot;&gt;
Windows support is functional but still in beta. Linux and macOS are the primary platforms. If you&apos;re on Windows, expect occasional rough edges.
&lt;/Notice&gt;

The binary is about 10–11 MB. After installation, verify it works:

```bash
herdr --version
```

---

## Getting started with Herdr

&lt;Notice type=&quot;info&quot; title=&quot;Before You Start&quot;&gt;
You need a terminal emulator (iTerm2, Kitty, Alacritty, WezTerm, Ghostty, or any modern terminal), Herdr installed, and at least one AI coding agent installed (Claude Code, Codex, Copilot CLI, etc.).
&lt;/Notice&gt;

### Your first workspace

Launch Herdr by running:

```bash
herdr
```

You&apos;ll see the TUI with a single pane. Key keybindings use a prefix model (default `Ctrl+B`), similar to tmux:

- `Ctrl+B`, then `V` — split vertically
- `Ctrl+B`, then `-` — split horizontally
- `Ctrl+B`, then arrow keys — navigate between panes
- `Ctrl+B`, then `D` — detach from session
- `Ctrl+B`, then `X` — close current pane

You can also do all of this with the mouse — click to select panes, drag borders to resize, right-click for the context menu.

Create a named session from the start:

```bash
herdr session attach my-workspace
```

### Running multiple coding agents

Here&apos;s where Herdr earns its keep. Open multiple panes and launch different agents in each:

```bash
# Pane 1: Claude Code for architecture
claude

# Pane 2: Codex for implementation
codex

# Pane 3: Copilot CLI for quick edits
copilot
```

The agent state sidebar on the right shows what each agent is doing in real time. When Claude Code is waiting for your input, its state flips to &quot;blocked&quot; with an indicator. When Codex is generating code, it shows &quot;working.&quot; You can glance at the sidebar and immediately know which pane needs your attention.

This is especially useful when you&apos;re running a lead agent plus helper agents. The lead handles the main task while helpers run in parallel on subtasks — and you see all their states at once.

### Installing agent integrations

For better state detection, install integrations for your agents:

```bash
herdr integration install claude
herdr integration install codex
herdr integration install copilot
herdr integration install gemini
herdr integration install grok
```

Integrations add hooks that report richer state information. Without integrations, Herdr uses process-name matching and terminal output heuristics — which work, but integrations give you more granular status (e.g., &quot;generating code&quot; vs &quot;waiting for tool approval&quot; vs &quot;idle&quot;).

---

## Herdr socket API and agent orchestration

This is the feature that differentiates Herdr from every other terminal multiplexer. Agents can drive the terminal programmatically through a socket API.

![Agent orchestration flowchart showing lead agent using socket API to create helper agents, monitor state, and read output](../../assets/images/26/07/orchestration-flowchart.svg)

Instead of just running inside panes, agents can:

- **Create new panes** — spin up helper agents on demand
- **Run commands** — execute shell commands in any pane
- **Read output** — capture stdout from other panes
- **Wait on state** — block until another agent reaches a specific state (done, idle, etc.)
- **Split and attach** — manage workspace layout programmatically

Here&apos;s what orchestration looks like from the CLI:

```bash
# Create a new pane in the current workspace
herdr pane split --vertical

# Run a command in the active pane
herdr pane run &quot;claude --prompt &apos;Fix the authentication bug&apos;&quot;

# Read the last 50 lines of output from pane 2
herdr pane read --pane 2 --lines 50

# Wait until pane 2&apos;s agent reaches &quot;done&quot; state
herdr wait --pane 2 --state done

# Create a named workspace for a specific task
herdr workspace create feature-branch
```

&lt;Notice type=&quot;info&quot; title=&quot;Agent-Driven Orchestration&quot;&gt;
No other terminal multiplexer offers this. tmux has scripting capabilities, and Zellij has a plugin API, but neither understands agent states. Herdr&apos;s socket API lets agents coordinate with each other — a lead agent can spin up helpers, wait for results, and proceed. If you&apos;re interested in [building your own AI agent](/build-ai-agent-mastra/) that could integrate with this API, it opens up real orchestration workflows.
&lt;/Notice&gt;

Practical use cases:

- **Lead agent + helpers:** One agent runs the main task, spawns helper agents for subtasks (linting, testing, documentation), and waits for them to finish.
- **Automated test-and-fix loops:** An agent runs tests, reads the output, fixes failures, and repeats — all through the socket API.
- **Multi-repo workflows:** Different agents work on different repositories in parallel, coordinated by a lead agent.

---

## Herdr vs tmux vs Zellij

This is the comparison most people want to see. Here&apos;s an honest breakdown:

| Feature | Herdr | tmux | Zellij |
|---------|-------|------|--------|
| Agent state awareness | Yes (blocked/working/done/idle) | No | No |
| Mouse-native UI | Yes | Partial (needs config) | Yes |
| Agent-shaped API | Yes (read, send, wait, split) | Terminal scripting | Plugin scripting |
| SSH attach | Yes | Yes | Yes |
| Plugin ecosystem maturity | Young (~105 days) | Very mature (18 years) | Growing |
| Session resurrection | Not yet | Yes (via plugins like tmux-resurrect) | Built-in |
| Status bar widgets | Agent-focused | Battery, CPU, weather, etc. | Built-in tabs |
| Built-in sandboxing | No | No | Yes |
| Configuration complexity | Low | High | Medium |

&lt;Tabs&gt;
&lt;Tab name=&quot;Herdr strengths&quot;&gt;
- Agent state sidebar — the killer feature tmux and Zellij can&apos;t match
- Socket API for agent orchestration — genuinely novel
- Mouse-first design — no config needed
- Single binary, zero dependencies
- Remote thin client mode
- Works with your existing terminal (doesn&apos;t replace it)
&lt;/Tab&gt;
&lt;Tab name=&quot;tmux strengths&quot;&gt;
- 18 years of battle-tested stability
- Massive plugin ecosystem (tmux-resurrect, tmux-continuum, tmuxinator)
- Status bar widgets for everything (battery, CPU, network, weather)
- Session resurrection and auto-save
- Available on every Unix system by default or package manager
- Deep customization with `.tmux.conf`
&lt;/Tab&gt;
&lt;Tab name=&quot;Zellij strengths&quot;&gt;
- Built-in floating panes and session management
- Layout system with YAML files
- Built-in sandboxing for plugins
- Session resurrection out of the box
- Growing plugin ecosystem with web technologies
- More intuitive default keybindings (no prefix mode)
&lt;/Tab&gt;
&lt;/Tabs&gt;

**When tmux is still the better choice:** If you have a deeply customized `.tmux.conf`, rely on specific tmux plugins, or have 18 years of muscle memory around tmux keybindings, switching to Herdr has real costs. tmux is also the safer bet for production server management where stability matters more than features.

**When Herdr wins:** If you run multiple AI coding agents and want real-time visibility into what they&apos;re doing, there&apos;s no alternative. The agent state sidebar alone justifies the switch for multi-agent workflows. The socket API is a bonus that opens up orchestration patterns tmux simply can&apos;t support.

---

## Herdr plugins and community ecosystem

The plugin ecosystem is young but growing fast. There are already 83+ public repositories tagged &quot;herdr&quot; on GitHub.

Notable community plugins:

- **herdr-plus** — Adds Quick Actions (predefined command templates) and a Projects view for organizing workspaces
- **herdr-file-viewer** — A git-aware file viewer TUI (64 GitHub stars). Browse files, see diffs, and navigate your project without leaving Herdr
- **herdr-reviewr** — Code review sidebar (44 stars). Review pull requests directly in Herdr
- **ccgram** — Telegram bridge for Herdr (202 stars). Get notifications on Telegram when agents finish, block, or error
- **vim-herdr-navigation** — Vim/Neovim integration for pane navigation
- **pi-bellwether** — Package manager integration for Herdr management

Third-party tools are also emerging:

- **Moshi** — An iOS terminal app with native Herdr support. Manage your agent panes from an iPhone.
- **Agentchute** — Intra-agent communication layer that works with Herdr&apos;s socket API

The ecosystem is roughly where tmux was in its first year. The plugin count is growing fast (from zero to 83 repos in ~105 days), and the quality of the top plugins is already decent. Expect this to mature quickly given the project&apos;s growth trajectory.

---

## Who should (and shouldn&apos;t) use Herdr

&lt;Accordion label=&quot;Herdr is great if...&quot; group=&quot;fit&quot; expanded=&quot;true&quot;&gt;

- You run 3+ AI coding agents simultaneously and lose track of which ones need attention
- You work on remote servers via SSH and want a terminal-native tool (not Electron, not a web UI)
- You&apos;re on Linux or macOS and prefer tools that run inside your existing terminal
- You want zero telemetry, no accounts, and a single binary with no dependencies
- You&apos;re interested in agent orchestration — having agents coordinate through a socket API
- You manage VPS infrastructure and want SSH-first tooling — if you&apos;re running [self-hosted server panels](/best-self-hosted-panels/), Herdr fits that philosophy

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Consider alternatives if...&quot; group=&quot;fit&quot;&gt;

- You&apos;re a tmux power user with a deeply customized config and 18 years of plugin dependencies. The migration cost is real.
- You&apos;re on Windows only. Support is beta and still has rough edges.
- You need production-grade stability for critical workflows. Herdr is 105 days old and pre-1.0.
- You need built-in sandboxing or process isolation. Herdr doesn&apos;t sandbox agents.
- You prefer a GUI that replaces your terminal (Warp, cmux, Conductor). Herdr is terminal-native by design.
- You&apos;re cost-conscious and want to pair Herdr with [affordable AI models](/best-cheap-models-hermes-agent/) — this works, but the cost savings come from the models, not from Herdr itself.

&lt;/Accordion&gt;

---

## Licensing and pricing

Herdr is licensed under **AGPL-3.0-or-later** — free for personal and commercial use. You can download, run, and modify it without paying anything.

&lt;Notice type=&quot;info&quot; title=&quot;Licensing Note&quot;&gt;
AGPL means that if you modify Herdr and distribute it (or run it as a network service), you must share your modifications under the same license. For typical use — running it locally to manage your agents — this has no practical impact. It&apos;s free, it works, you don&apos;t owe anyone anything.

If your organization can&apos;t comply with AGPL (some enterprise policies prohibit it), a commercial license is available. Contact hey@herdr.dev for pricing. No public pricing page exists.
&lt;/Notice&gt;

Key licensing details:

- **Zero telemetry** — no phone-home, no analytics, no usage tracking
- **No account required** — download and run. No cloud dependency.
- **No vendor lock-in** — it&apos;s a standard Rust binary. If you stop using it, your terminal and agents are unaffected.

For context on the broader open-source AI ecosystem, see our guide to [open source LLMs for coding](/best-open-source-llms-claude-alternative/) — running open-source models with an open-source terminal multiplexer is about as vendor-neutral as you can get.

---

## Risks and things to know

&lt;Notice type=&quot;warning&quot; title=&quot;Pre-1.0 Software&quot;&gt;
Herdr is approximately 105 days old (as of July 2026). It&apos;s moving fast, which means features ship quickly but breaking changes are possible. Pin an update date and revisit.
&lt;/Notice&gt;

Honest caveats:

1. **Young project.** Pre-1.0, ~105 days old. APIs and features may change. If you build workflows around the socket API, expect some adaptation as the project evolves.
2. **Solo developer.** Bus factor is 1. Ogulcan Celik is full-time on this, but there&apos;s no team backing it. The community (nearly 15,000 stars) helps, but core development depends on one person.
3. **Missing tmux features.** No battery/CPU status bar widgets. No session resurrection equivalent to tmux-resurrect. No tmuxinator-style project templates (yet).
4. **Performance on large setups.** Some users on Hacker News reported text rendering delay with many panes open. Your mileage may vary with 10+ panes.
5. **No built-in sandboxing.** Agents run with your user permissions. If an agent does something destructive, Herdr won&apos;t stop it.
6. **No public commercial pricing.** Organizations need to email for a quote — no self-serve option.
7. **Keybinding learning curve.** The prefix-based model is familiar to tmux users, but the specific bindings differ. Expect a few days of adjustment.

The rapid community growth (15,000+ stars, 83+ repos, active Discord) mitigates some of these risks. Tools that solve real problems tend to attract contributors and survive.

---

## Verdict

Herdr is the first terminal multiplexer that actually understands AI coding agents. If you&apos;re running multiple agents and losing track of which ones need you, the agent state sidebar alone is worth the switch.

It&apos;s not a tmux replacement yet — not for power users with deep customization needs. tmux has 18 years of ecosystem maturity, and Herdr has 105 days. But for the specific use case of multi-agent terminal workflows, nothing else comes close.

The socket API is novel. No other terminal multiplexer lets agents coordinate programmatically — creating panes, reading output, waiting on state. This opens up orchestration workflows that aren&apos;t possible with tmux or Zellij.

The honest recommendation: try it for your next multi-agent session. Run Herdr for a day with three or four agents, watch the state sidebar, and see if it changes how you work. If it doesn&apos;t, tmux is still there. If it does, you&apos;ll probably stick with it.

&lt;Button text=&quot;Try Herdr on GitHub&quot; link=&quot;https://github.com/ogulcancelik/herdr&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Visit herdr.dev&quot; link=&quot;https://herdr.dev/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

If you&apos;re also exploring [affordable AI models](/best-cheap-models-hermes-agent/) to pair with Herdr, or looking at [AI coding tools and assistants](/ai-coding-tools/) more broadly, we have guides for both.

---

## FAQ

&lt;Accordion label=&quot;Is Herdr free to use?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Herdr is licensed under AGPL-3.0-or-later — free for personal and commercial use. No account, no subscription, no cloud dependency. A commercial license is available for organizations that can&apos;t comply with AGPL (contact hey@herdr.dev).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Herdr replace tmux?&quot; group=&quot;faq&quot;&gt;
Not yet for power users. Herdr adds agent awareness and orchestration that tmux lacks, but tmux has 18 years of plugins, configuration options, and battle-tested stability. Herdr is better for multi-agent workflows; tmux is better for established, heavily customized setups. Many developers run both — tmux for general server management, Herdr for agent sessions.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What AI coding agents does Herdr support?&quot; group=&quot;faq&quot;&gt;
14+ agents out of the box, including Claude Code, Codex, Copilot CLI, Gemini CLI, Grok CLI, Cursor Agent, Pi, Droid, Amp, OpenCode, Antigravity CLI, Kimi Code CLI, [Hermes Agent](/hermes-agent-setup-guide/), QoderCLI, Kiro CLI, and more. If your agent isn&apos;t listed, generic process detection usually works — you just won&apos;t get granular state reporting without an integration.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Herdr over SSH on a remote server?&quot; group=&quot;faq&quot;&gt;
Yes. Two options: (1) Install Herdr on the server and SSH in normally — the TUI renders over SSH. (2) Use `herdr --remote ssh://user@server` for thin client mode, which streams rendering data and is more responsive on slow connections. The Moshi iOS terminal app also has native Herdr support for mobile access.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Herdr work on Windows?&quot; group=&quot;faq&quot;&gt;
Windows support is currently in beta. Linux and macOS are the primary platforms and fully supported. If you&apos;re Windows-only, expect occasional rough edges. The beta is functional enough for basic use but not yet at feature parity with the Unix builds.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Herdr detect agent states?&quot; group=&quot;faq&quot;&gt;
Two layers. First, process-name matching — Herdr recognizes the binary names of supported agents (claude, codex, copilot, etc.) and infers basic state from terminal output patterns. Second, agent integrations (`herdr integration install &lt;agent&gt;`) install hooks that report richer state information directly. Integrations give you more granular status (e.g., &quot;waiting for tool approval&quot; vs &quot;generating code&quot;) compared to heuristics alone.
&lt;/Accordion&gt;

Herdr pairs with coding agents covered in [top AI GitHub repos](/top-ai-github-repos/) (OpenCode, Pi, Cline, OpenHands).</content:encoded><category>tools</category><category>herdr</category><category>agent-multiplexer</category><category>ai-coding</category></item><item><title>Pi Coding Agent Setup Guide: Install, Configure Models, and Best Extensions</title><link>https://www.bitdoze.com/pi-coding-agent-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/pi-coding-agent-setup-guide/</guid><description>Complete guide to installing Pi coding agent, connecting cheap models via OpenCode Go or OpenRouter, and setting up the best extensions for memory, web access, and sub-agents.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I have been running [OpenCode](/opencode-setup-guide/) and [Hermes Agent](/hermes-agent-setup-guide/) for a while now, but Pi kept coming up in conversations. People on Reddit and Hacker News kept calling it &quot;the minimal one that does not get in your way.&quot; After two weeks of daily use, I see why. Pi is a terminal coding agent built by Mario Zechner that stays small at the core. You install it, point it at a project, and start working. Everything else — memory, MCP support, sub-agents, themes, skills — gets added through extensions you actually choose.

That minimalism is the point. Where OpenCode ships with a TUI, plan mode, and image support out of the box, Pi gives you a clean slate and a TypeScript extension system. You build the agent you want instead of disabling the features you do not need.

Below you will find installation steps, model configuration with cheap providers, the extensions I actually use, and how to skip the research phase with LazyPi.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing Pi on Linux, macOS, and via npm&lt;/li&gt;
&lt;li&gt;Connecting to cheap models via OpenCode Go or OpenRouter&lt;/li&gt;
&lt;li&gt;Adding TinyFish for free web search and page fetching&lt;/li&gt;
&lt;li&gt;The extensions that actually matter&lt;/li&gt;
&lt;li&gt;LazyPi: one-command setup with 60+ skills and 76 themes&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/gfw3jBeLeqs&quot;
  label=&quot;Pi Agent Setup Guide: Top Extensions That Make It Unstoppable&quot;
/&gt;


If you are still deciding between coding agents, our [OpenCode setup guide](/opencode-setup-guide/) covers the open-source Claude Code alternative, and the [GitHub Copilot alternatives](/github-copilot-alternatives-2026/) article breaks down what to do after the June 1 pricing change.

## What Pi actually is

Pi is a terminal coding agent that reads your codebase, plans changes, edits files, runs shell commands, and iterates on failures. The default installation gives you four tools: read, write, edit, and bash. You add everything else through extensions.

The extension system is TypeScript. You drop a `.ts` file into `~/.pi/agent/extensions/` and Pi loads it. Extensions can register tools, intercept commands, add slash commands, and modify the system prompt. Hot-reload with `/reload` without restarting.

## Installing Pi

&lt;Tabs&gt;
&lt;Tab name=&quot;One-line installer&quot;&gt;
```bash
curl -fsSL https://pi.dev/install.sh | sh
```
&lt;/Tab&gt;
&lt;Tab name=&quot;npm&quot;&gt;
```bash
npm install -g @mariozechner/pi-coding-agent
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Then run it in a project directory:

```bash
cd /path/to/project
pi
```

Pi needs Node.js 18+. If you do not have Node, install it first:

```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
source ~/.bashrc
nvm install 22
```

### Authenticate

Use `/login` in Pi to set up your provider:

```bash
pi
/login
# Select your provider
```

Or set API keys directly:

```bash
export OPENCODE_API_KEY=your-key
pi
```

You can also store keys in `~/.pi/agent/auth.json`.

## Configuring models

Pi supports 20+ built-in providers. Set the key and Pi auto-discovers all models:

```bash
export OPENROUTER_API_KEY=sk-or-...
pi
# /model shows all 200+ OpenRouter models
```

Use `/model` or `Ctrl+L` to pick a model. Use `Shift+Tab` to cycle thinking level.

### Best cheap models for Pi

| Model | Why | Cost |
|-------|-----|------|
| MiniMax M2.7 | Cheapest, good for everyday edits | $0.30/M input |
| Qwen 3.6 Plus | Best front-end and &quot;vibe coding&quot; | $0.33/M input |
| DeepSeek V4 Pro | 1M context, lowest hallucination | $0.435/M input |
| GLM 5.2 | Strongest coding accuracy | $1.40/M input |
| Kimi K2.6 | Agent swarm for complex tasks | $0.75/M input |

For a full breakdown, see the [best cheap models for coding agents](/best-cheap-models-hermes-agent/) guide.

### Custom models with models.json

For Ollama or any OpenAI-compatible API, create `~/.pi/agent/models.json`:

```json
{
  &quot;providers&quot;: {
    &quot;ollama&quot;: {
      &quot;baseUrl&quot;: &quot;http://localhost:11434/v1&quot;,
      &quot;api&quot;: &quot;openai-completions&quot;,
      &quot;apiKey&quot;: &quot;ollama&quot;,
      &quot;models&quot;: [
        { &quot;id&quot;: &quot;llama3.1:8b&quot; },
        { &quot;id&quot;: &quot;qwen2.5-coder:7b&quot; }
      ]
    }
  }
}
```

### Using OpenCode Go with Pi

[OpenCode Go](https://go.bitdoze.com/opencode-go) is a $10/month subscription that bundles 16 models (Grok 4.5, Kimi K3, GLM-5.2, and more). Pi supports it as a built-in provider — full details in the [OpenCode Go review](/opencode-go-plan/):

```bash
export OPENCODE_API_KEY=your-opencode-go-key
pi
# /model, select opencode-go provider
```

For a detailed look at limits and benchmarks, see the [OpenCode Go guide](/opencode-go-plan/).

&lt;Button text=&quot;Get $5 Free Credits for OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Project instructions with AGENTS.md

Create an `AGENTS.md` file in your project root:

```markdown
# Project Instructions

- Run `npm run check` after code changes.
- Do not run production migrations locally.
- Use the existing error handling pattern in src/errors/.
```

Pi loads `~/.pi/agent/AGENTS.md` for global instructions and `AGENTS.md` from the current directory. Run `/reload` after changes.

### APPEND_SYSTEM.md for global rules

For rules that apply across every project, use `~/.pi/agent/APPEND_SYSTEM.md`:

```markdown
- Read local files first before searching online.
- Research via TinyFish when the codebase does not have the answer.
- Explain risky edits before executing.
- Write simply. No AI-slop language.
```

## Extensions worth installing

Extensions are where Pi becomes more than a basic agent.

### Must-have extensions

**pi-tinyfish** — Free web search and page fetching. The agent looks up docs, checks Stack Overflow, or fetches API references without leaving the terminal. [Get your free API key here](https://go.bitdoze.com/tinyfish).

**pi-hermes-memory** — Persistent memory across sessions. The agent remembers project conventions, your preferences, and past decisions. Without this, Pi starts fresh every time.

**pi-mcp-adapter** — Connects Pi to any MCP-compatible tool server. GitHub, Playwright, Brave Search, Postgres — any MCP server works.

**pi-subagents** — Run isolated sub-agents for parallel work. When a task has independent parts, sub-agents tackle them simultaneously.

### Nice-to-have extensions

**pi-powerbar** — Status line showing model name, token usage, and context status.

**pi-vision-proxy** — Fixes vision for models without image support (like DeepSeek). Proxies images to Kimi K2.6 or another vision model.

**pi-plan** — Read-only planning mode with approval-based execution.

**pi-simplify** — Reviews recently changed code for clarity and consistency.

### Installing extensions

```bash
# Single-file extension
cp extension.ts ~/.pi/agent/extensions/

# Directory extension
cp -r pi-memory-md ~/.pi/agent/extensions/
cd ~/.pi/agent/extensions/pi-memory-md
npm install
```

Then restart Pi or run `/reload`.

## My Pi setup

Here is the setup I run on every machine:

```bash
pi install npm:pi-tinyfish
pi install npm:pi-hermes-memory
pi install npm:@juanibiapina/pi-powerbar
pi install npm:pi-mcp-adapter
pi install npm:pi-subagents
```

**pi-tinyfish** — Free web search and page fetching. [Get your free API key](https://go.bitdoze.com/tinyfish).

**pi-hermes-memory** — Persistent memory across sessions.

**@juanibiapina/pi-powerbar** — Status line with model name and token usage.

**pi-mcp-adapter** — Connect to any MCP tool server.

**pi-subagents** — Parallel sub-agents for complex tasks.

### Using OpenCode Go

Instead of managing separate API keys, I use [OpenCode Go](/opencode-go-plan/). $10/month, 16 models, one key:

```bash
export OPENCODE_API_KEY=your-opencode-go-key
pi
# /model, select opencode-go provider
```

&lt;Button text=&quot;Get $5 Free Credits for OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## LazyPi: one command, everything configured

If you do not want to pick individual extensions, LazyPi does it for you:

```bash
npx @robzolkos/lazypi
```

This installs Pi if you do not have it, then adds 60+ skills, 76 themes, MCP support, sub-agents, persistent memory, and more. You can install everything at once or use the interactive picker.

&lt;Notice type=&quot;info&quot; title=&quot;LazyPi tip&quot;&gt;
LazyPi is a quick start, not a permanent dependency. Everything it installs lives in your `~/.pi/agent/` directory and works independently.
&lt;/Notice&gt;

## Community config: pi-config

The [pi-config](https://github.com/amosblomqvist/pi-config) repository has curated extensions and skills. Browse and copy what you need:

```bash
# Copy an extension
cp extensions/bash-guard.ts ~/.pi/agent/extensions/

# Copy a skill
cp -r skills/reddit ~/.pi/agent/skills/
```

Notable extensions: `bash-guard` (blocks dangerous commands), `stop-slop` (prevents low-quality filler), `web-fetch` (fetch web pages).

## pi_agent_rust: the Rust port

If you want a single binary with faster startup, there is [pi_agent_rust](https://github.com/Dicklesworthstone/pi_agent_rust) — 823 GitHub stars, 12ms startup, under 8MB.

```bash
curl -fsSL https://raw.githubusercontent.com/Dicklesworthstone/pi_agent_rust/main/install.sh | bash
```

Both versions read the same config files, so you can switch between them.

## Pi vs OpenCode vs Claude Code

| Feature | Pi | OpenCode | Claude Code |
|---------|-----|----------|-------------|
| **Default tools** | 4 | Full set | Full set |
| **Extension system** | TypeScript | Config + rules | MCP + hooks |
| **Model choice** | 20+ built-in + custom | 75+ providers | Anthropic only |
| **Plan mode** | Via extension | Built-in | No |
| **Memory** | Via extension | No | No |
| **MCP support** | Via extension | Built-in | Built-in |
| **Sub-agents** | Via extension | No | No |
| **Pricing** | Free (pay API) | Free (pay API) | $20/month + API |

Pi starts smaller but grows through extensions. If you want to build your own agent workflow from parts, Pi is the better foundation. If you want everything working out of the box, OpenCode gets you there faster.

## Daily workflow tips

**Switching models:** `/model` or `Ctrl+L`. Cycle thinking level with `Shift+Tab`.

**Referencing files:** Type `@` to fuzzy-search, or pass on command line: `pi @README.md &quot;Summarize this&quot;`

**Running commands:** Prefix with `!` to send output to the model. Use `!!` to run without adding to context.

**Sessions:** `pi -c` continues last session. `pi -r` browses previous sessions. Inside Pi: `/resume`, `/new`, `/tree`, `/fork`.

**Steering:** While Pi works, press Enter to interrupt with a new message. Alt+Enter queues a follow-up.

**Context:** `/compact` summarizes old messages. `/tree` shows conversation history.

**Non-interactive:** `pi -p &quot;Summarize this codebase&quot;` for one-shot prompts.

## Running Pi on a VPS

```bash
tmux new -s pi
cd /path/to/project
pi
# Detach: Ctrl+B, D
# Reattach: tmux attach -t pi
```

## FAQ

&lt;Accordion label=&quot;Is Pi free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
The agent is free and open source. You pay for LLM API usage. With cheap models like MiniMax M2.7 at $0.30/M input tokens, a month of coding costs $3-10. The [OpenCode Go subscription](/opencode-go-plan/) bundles 16 models for $10/month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Pi with Ollama?&quot; group=&quot;faq&quot;&gt;
Yes. Add your Ollama instance to `~/.pi/agent/models.json`. See our [Ollama Docker guide](/ollama-docker-install/) for setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is LazyPi?&quot; group=&quot;faq&quot;&gt;
LazyPi (`npx @robzolkos/lazypi`) adds 60+ skills, 76 themes, MCP support, sub-agents, and persistent memory in one command. Everything it installs works independently.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Pi alongside other agents?&quot; group=&quot;faq&quot;&gt;
Yes. Pi is for coding tasks. [Hermes Agent](/hermes-agent-setup-guide/) handles broader tasks like web searches and server management. They do not conflict.
&lt;/Accordion&gt;

## Related articles

- [Top AI GitHub repos](/top-ai-github-repos/) — curated catalog: Pi, OpenCode, OpenClaw, Hermes, skills, gateways
- [OpenCode vs Pi Agent](/opencode-vs-pi-agent/) — side-by-side comparison
- [TinyFish: Free Web Search for AI Coding Agents](/tinyfish-ai-agents-web-search/) — detailed TinyFish setup guide
- [Free Web Search for Coding Agents](/tinyfish-free-search-coding-agents/) — focused setup guide
- [OpenCode Go: 12 AI Models for $10/Month](/opencode-go-plan/) — cheap models for your agent
- [Hermes Agent setup guide](/hermes-agent-setup-guide/) — install and configure Hermes
- [OpenCode setup guide](/opencode-setup-guide/) — install and configure OpenCode
- [Best cheap models for coding agents](/best-cheap-models-hermes-agent/) — model pricing and benchmarks</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>llm</category></item><item><title>Free Web Search for AI Coding Agents: TinyFish Search + Fetch Setup Guide</title><link>https://www.bitdoze.com/tinyfish-free-search-coding-agents/</link><guid isPermaLink="true">https://www.bitdoze.com/tinyfish-free-search-coding-agents/</guid><description>Give your AI coding agent free web search and page fetching. TinyFish offers 30 search/min and 150 fetch/min at no cost. Setup guide for Pi, Hermes, OpenClaw, Claude Code, and more.</description><pubDate>Fri, 10 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Your AI coding agent knows Python, JavaScript, Docker, Kubernetes, and plenty more. But ask it about a library update that shipped last week, or a breaking change in a new release, and it either hallucinates or tells you to check the docs yourself.

The fix is giving your agent access to the live web. Actual web search and page fetching that works in real time.

[TinyFish](https://go.bitdoze.com/tinyfish) made that free. No credit card, no trial period. Free search and fetch for every developer and every agent.

&lt;Button text=&quot;Get Your Free TinyFish API Key&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## What TinyFish gives you for free

Two endpoints, both free:

**Search** takes a query and returns structured JSON results. Not blue links designed for human eyes, but clean, rank-stable data that your agent can parse directly. Response times are under 500ms. You can pass location and language hints for geo-targeted results.

**Fetch** takes one or more URLs and returns clean content. The page renders in a real Chromium browser (JavaScript, SPAs, the works), then navigation bars, cookie banners, ads, and scripts get stripped. You get markdown, HTML, or JSON back. Your model stops paying tokens for junk HTML.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Hu_OGbBEW3M&quot;
  label=&quot;FREE TinyFish Makes AI Agents Actually Useful&quot;
/&gt;

### Free tier limits

| Endpoint | Rate Limit | Cost |
|----------|-----------|------|
| **Search** | 30 requests/min | Free |
| **Fetch** | 150 URLs/min | Free |
| **Agent** | 2 concurrent runs | 1 credit/step |
| **Browser** | 5 concurrent sessions | 1 credit/4 min |

For coding agent use, Search and Fetch cover most of what you need. The Agent and Browser endpoints cost credits (you get 500 free on signup), but most coding workflows never touch them.

### Real usage stats

These are actual TinyFish dashboard stats showing how coding agents are using the free tier:

**Search API** — 40.6K total requests with 360ms average response time:

![TinyFish Search API stats showing 40.6K requests](../../assets/images/26/07/tinyfish-search.webp)

**Fetch API** — 65.6K total requests. Notice how Codex CLI, OpenCode, Claude Code, and other agents are all using it:

![TinyFish Fetch API stats showing 65.6K requests](../../assets/images/26/07/tiny-fishfetch.webp)

&lt;Notice type=&quot;success&quot; title=&quot;Failed fetches are free&quot;&gt;
If a URL returns an error or times out, it does not count against your quota. You only pay for successful fetches.
&lt;/Notice&gt;

## Why your coding agent needs web access

Most coding agents rely on the model&apos;s training data for anything outside your codebase. That works for stable patterns and well-documented APIs. It breaks down for recent releases, version-specific quirks, community solutions on GitHub, and current documentation.

Giving your agent a search+fetch pipeline changes the answer from &quot;I think this is how it works&quot; to &quot;here is the current documentation.&quot;

### Token savings

TinyFish Fetch strips navigation, scripts, and boilerplate from pages before returning content. Your model processes the actual article content, not three kilobytes of cookie consent banners and footer links. This cuts token usage per fetch compared to raw HTML fetching.

## Setting up TinyFish with your coding agent

There are four ways to wire TinyFish into your agent. Pick the one that matches your setup.

&lt;Button text=&quot;Get a Free TinyFish API Key&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### Option 1: CLI + Skill (Recommended)

The CLI is the most portable option. It works with any agent that can run shell commands, and the skill teaches your agent when to reach for search vs fetch.

**Install the CLI:**

```bash
npm install -g @tiny-fish/cli@latest
```

**Authenticate:**

```bash
tinyfish auth login
```

This opens the API keys page in your browser. Paste your key when prompted. The key saves to `~/.tinyfish/config.json`.

For CI/CD or non-interactive setups:

```bash
echo $TINYFISH_API_KEY | tinyfish auth set
```

**Verify it works:**

```bash
tinyfish --version
tinyfish auth status --pretty
```

**Install the skill** (teaches your agent the tool hierarchy):

```bash
npx skills add github.com/tinyfish-io/tinyfish-cookbook --skill use-tinyfish
```

The skill covers the escalation ladder: search for finding URLs, fetch for reading pages, agent for interactive browser tasks, and browser for raw CDP control.

**Test it:**

```bash
# Search
tinyfish search query &quot;best Docker monitoring tools 2026&quot; --pretty

# Fetch
tinyfish fetch content get --format markdown &quot;https://docs.docker.com&quot;
```

### Option 2: MCP Server

If your agent supports MCP (Model Context Protocol), this is the cleanest integration:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

Drop this into your agent&apos;s MCP config and restart. The agent sees Search and Fetch as native tools.

Works with: Claude Code, Cursor, Codex, ChatGPT desktop, and any MCP-aware client.

### Option 3: REST API

For custom integrations or agents that don&apos;t support MCP:

```bash
# Search
curl &quot;https://api.search.tinyfish.ai?query=docker+compose+healthcheck&quot; \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot;

# Fetch
curl -X POST https://api.fetch.tinyfish.ai \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;urls&quot;: [&quot;https://docs.docker.com/compose/how-tos/startup-order/&quot;]}&apos;
```

Both endpoints return JSON. Search gives you ranked results with titles, snippets, and URLs. Fetch gives you the cleaned page content plus metadata (title, language, author, published date).

### Option 4: SDKs

For programmatic use in your own tools:

&lt;Tabs&gt;
&lt;Tab name=&quot;TypeScript&quot;&gt;
```bash
npm install @tiny-fish/sdk
```
```typescript
import { TinyFish } from &quot;@tiny-fish/sdk&quot;;

const client = new TinyFish(); // reads TINYFISH_API_KEY from env

// Search
const results = await client.search.query(&quot;best React state management 2026&quot;);

// Fetch
const content = await client.fetch.content.get({
  urls: [&quot;https://tanstack.com/query/latest&quot;],
  format: &quot;markdown&quot;,
});
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Python&quot;&gt;
```bash
pip install tinyfish
```
```python
from tinyfish import TinyFish

client = TinyFish()  # reads TINYFISH_API_KEY from env

# Search
results = client.search.query(&quot;best React state management 2026&quot;)

# Fetch
content = client.fetch.content.get(
    urls=[&quot;https://tanstack.com/query/latest&quot;],
    format=&quot;markdown&quot;
)
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Agent-specific setup guides

### Pi coding agent

Pi has native TinyFish support through the [pi-tinyfish](https://github.com/x1any/pi-tinyfish) package:

&lt;Tabs&gt;
&lt;Tab name=&quot;npm&quot;&gt;
```bash
pi install npm:pi-tinyfish
```
&lt;/Tab&gt;
&lt;Tab name=&quot;git&quot;&gt;
```bash
pi install git:github.com/x1any/pi-tinyfish
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Set your API key:

```bash
export TINYFISH_API_KEY=&quot;your_api_key_here&quot;
```

Add that to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.config/fish/config.fish`) so it persists across sessions.

That&apos;s it. Next time you start Pi, the agent can call `tinyfish_search` and `tinyfish_fetch` as tools. When it needs to look up something about a library, check docs, or verify a config format, it will search the web and fetch the relevant pages automatically.

See the [Pi setup guide](/pi-coding-agent-setup-guide/) for full Pi installation instructions.

### Hermes Agent

[Hermes Agent](/hermes-agent-setup-guide/) from Nous Research has a built-in web search tool, but TinyFish gives you more control and adds clean page fetching.

**MCP approach** (cleanest if your Hermes version supports it):

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

**CLI approach** (works everywhere):

```bash
npm install -g @tiny-fish/cli@latest
tinyfish auth login
```

Hermes can then call `tinyfish search query &quot;...&quot;` and `tinyfish fetch content get &lt;urls&gt;` through its terminal access.

### OpenClaw

[OpenClaw](/clawdbot-setup-guide/) supports MCP servers. Add the TinyFish MCP server to your OpenClaw config:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

Or install the skill:

```bash
npx skills add github.com/tinyfish-io/tinyfish-cookbook --skill use-tinyfish
```

### Claude Code

Claude Code has a one-shot helper to wire TinyFish as the web search/fetch backend:

```bash
tinyfish config-claude
```

This installs the MCP server configuration automatically. To remove it:

```bash
tinyfish config-claude --remove
```

### OpenCode

[OpenCode](/opencode-setup-guide/) supports MCP servers. Add to your OpenCode config:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

### Cursor, Codex, and others

Any agent that supports MCP can use the same configuration:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://mcp.tinyfish.ai&quot;
    }
  }
}
```

For agents without MCP support, use the CLI or REST API options.

## Practical examples

Here&apos;s what your agent can do with free web access:

### Look up current documentation

```bash
tinyfish fetch content get --format markdown &quot;https://docs.docker.com/compose/how-tos/startup-order/&quot;
```

Your agent reads the actual docs instead of guessing from training data.

### Search for solutions to errors

```bash
tinyfish search query &quot;TypeError: Cannot read property of undefined React 19&quot; --pretty
```

Find GitHub issues, Stack Overflow answers, and blog posts about the exact error.

### Check library versions and changelogs

```bash
tinyfish search query &quot;Next.js 15 breaking changes&quot; --pretty
tinyfish fetch content get --format markdown &quot;https://nextjs.org/blog/next-15&quot;
```

Verify what changed before your agent suggests code that works with the old API.

### Research best practices

```bash
tinyfish search query &quot;Docker healthcheck best practices 2026&quot; --pretty
```

Get current recommendations instead of outdated patterns from 2023.

## The tool escalation ladder

The TinyFish skill teaches your agent to pick the right tool for the job:

| Tool | When to use | Speed | Cost |
|------|-------------|-------|------|
| **search** | Find URLs, current facts, docs, pricing | Fastest | Free |
| **fetch** | Read known URLs, get clean content | Fast | Free |
| **agent** | Interact with pages, click, fill forms | Slower | Credits |
| **browser** | Raw CDP control for complex automation | Slowest | Credits |

Start with the lightest tool that can answer the question. Only escalate when needed.

## Comparison with alternatives

| Feature | TinyFish Fetch | Firecrawl | Native LLM fetch | Hand-rolled Playwright |
|---------|---------------|-----------|-------------------|----------------------|
| **JavaScript rendering** | Yes (real Chromium) | Yes | No (static HTML only) | Yes |
| **Clean content extraction** | Yes | Yes | Raw HTML | Manual |
| **Stealth/anti-bot** | Built-in | Varies | No | Manual setup |
| **Free tier** | Yes (Search + Fetch) | Limited free | Depends on provider | You pay for compute |
| **Token optimization** | Strips boilerplate | Strips boilerplate | Full HTML | Manual |
| **Multi-URL batching** | Up to 10 URLs/call | Varies | No | Manual |

The main advantage of TinyFish over hand-rolling Playwright is that you don&apos;t manage browser instances, proxy rotation, or anti-bot detection. The main advantage over native LLM fetch is JavaScript rendering — most modern docs sites are SPAs that return empty shells without it.

&lt;Accordion label=&quot;How does TinyFish compare to Brave Search API?&quot; group=&quot;alternatives&quot;&gt;
Brave Search API offers a free tier (2,000 queries/month) but returns traditional search results without page fetching. You get titles, URLs, and snippets, but not the full page content. TinyFish gives you both search and clean page fetching for free, with higher rate limits (30 search/min = ~43,200/month).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use TinyFish with my own API key from OpenCode Go?&quot; group=&quot;alternatives&quot;&gt;
TinyFish is a separate service from OpenCode Go. You need a TinyFish API key from [agent.tinyfish.ai](https://go.bitdoze.com/tinyfish). The good news is it&apos;s free, so you can use both OpenCode Go for your LLM models and TinyFish for web access without any conflicts.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if I hit the rate limits?&quot; group=&quot;alternatives&quot;&gt;
Requests get throttled or rejected until the rate limit window resets. For Search, the limit is 30 requests per minute. For Fetch, it&apos;s 150 URLs per minute. If you need higher limits, paid plans are available at [tinyfish.ai/pricing](https://tinyfish.ai/pricing).
&lt;/Accordion&gt;

## What I like and what I don&apos;t

I&apos;ve been using TinyFish through Pi and Mastra for weeks now. Here&apos;s the honest take.

**What works well:** The search results are clean and fast. Fetch renders SPAs properly, which matters for React-based docs sites. The free tier covers daily coding agent use without issues. Token savings from clean content are noticeable.

**What could be better:** Fetch can be slow on heavy pages (5-10 seconds for JavaScript-heavy sites). Search occasionally returns stale results for very recent events. And the Agent/Browser endpoints get expensive fast if you need interactive automation.

**Bottom line:** For giving your coding agent web access, the free Search + Fetch tier works well. I have it wired into Pi through pi-tinyfish and into Mastra through the SDK. It&apos;s become a standard part of my agent setup.

&lt;Button text=&quot;Get Started with TinyFish (Free)&quot; link=&quot;https://go.bitdoze.com/tinyfish&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Related articles

- [TinyFish: Free Web Search and Fetch API for Your AI Coding Agents](/tinyfish-ai-agents-web-search/) — detailed TinyFish overview
- [Build Your Own AI Agent with Mastra](/build-ai-agent-mastra/) — full guide using TinyFish with Mastra
- [Pi coding agent setup guide](/pi-coding-agent-setup-guide/) — install and configure Pi
- [Hermes Agent setup guide](/hermes-agent-setup-guide/) — install and configure Hermes
- [OpenCode Go: 12 AI Coding Models for $10/Month](/opencode-go-plan/) — cheap models for your agent</content:encoded><category>ai</category><category>ai-tools</category><category>tinyfish</category><category>coding-agents</category></item><item><title>How To Add Accordion FAQs Drop-Down to Carrd.co</title><link>https://www.bitdoze.com/add-accordion-carrd/</link><guid isPermaLink="true">https://www.bitdoze.com/add-accordion-carrd/</guid><description>Add an accordion FAQ drop-down to your Carrd site with a free code embed. Step-by-step guide covering setup, styling, and mobile responsiveness.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/05/carrd-embed.png&quot;;

Carrd doesn&apos;t have a built-in accordion element. If you want collapsible FAQ sections on your site, you need to embed custom code. The good news: there&apos;s a free accordion plugin that handles this, and the setup takes about 5 minutes.

You&apos;ll need a **Pro Standard plan** ($19/year) or higher. That&apos;s the lowest Carrd plan that supports the Embed element for custom code. The accordion plugin is free to download — no paid add-on required.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/c7WfK-LviA4&quot;
  label=&quot;How To Add Accordion FAQs Drop-Down to Carrd.co&quot;
/&gt;

&lt;Button link=&quot;https://go.carrdme.com/accordion&quot; text=&quot;Carrd accordion plugin&quot; /&gt;

**More Carrd tutorials:**

- [Add a sticky header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add Carrd cookie notice](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [How to add a pricing table to Carrd.co](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Carrd.co review](https://www.bitdoze.com/carrd-review/)
- [How to add a custom domain to Carrd.co](https://www.bitdoze.com/carrd-add-domain/)
- [Carrd.co mobile responsive navbar](https://www.bitdoze.com/carrd-mobile-navbar/)

&gt; The complete list of Carrd plugins, themes, and tutorials is on [carrdme.com](https://carrdme.com/).

## What the accordion plugin gives you

- **Customizable colors** — change the background, text, and hover colors to match your site&apos;s design.
- **Unlimited tabs** — add as many FAQ items as you need by duplicating a simple HTML block.
- **Mobile responsive** — the accordion looks good on both desktop and mobile screens.
- **Animated toggle** — smooth open/close animation with a rotating chevron icon.

The plugin is built with Vue.js and wraps everything into a single embed block. You don&apos;t need to install anything separately — just paste the code into a Carrd Embed element.

## How to add the accordion FAQ to Carrd

### 1. Download the accordion code

Grab the free code from [carrdme.com](https://go.carrdme.com/accordion). It&apos;s a single block of HTML, CSS, and JavaScript that you&apos;ll paste into Carrd.

### 2. Add an Embed element in Carrd

Open your Carrd site in the editor. In the section where you want the FAQ, click **Add Element** and select **Embed**. Set the following:

- **Type:** Code
- **Style:** Hidden (the accordion renders its own visible elements, so the embed container itself should be hidden)

For more details on how Carrd&apos;s embed system works, see the [official Carrd embed documentation](https://carrd.co/docs/building/embedding-custom-code).

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Carrd.co&quot; /&gt;

### 3. Paste the code and customize your FAQ items

Paste the full code into the Embed element&apos;s Code field. Then edit the FAQ content. Each accordion item follows this pattern:

```html
&lt;button class=&quot;accordion&quot;&gt;Your question here?&lt;/button&gt;
&lt;div class=&quot;panel&quot;&gt;
  &lt;p&gt;Your answer goes here. You can include multiple paragraphs, lists, or inline HTML.&lt;/p&gt;
&lt;/div&gt;
```

Add more items by repeating the same block:

```html
&lt;button class=&quot;accordion&quot;&gt;What is your refund policy?&lt;/button&gt;
&lt;div class=&quot;panel&quot;&gt;
  &lt;p&gt;We offer a full refund within 30 days of purchase. Contact support@example.com to start the process.&lt;/p&gt;
&lt;/div&gt;

&lt;button class=&quot;accordion&quot;&gt;How do I contact support?&lt;/button&gt;
&lt;div class=&quot;panel&quot;&gt;
  &lt;p&gt;Email us at support@example.com or use the contact form on our website. We respond within 24 hours.&lt;/p&gt;
&lt;/div&gt;

&lt;button class=&quot;accordion&quot;&gt;Do you offer custom plans?&lt;/button&gt;
&lt;div class=&quot;panel&quot;&gt;
  &lt;p&gt;Yes. Reach out with your requirements and we&apos;ll put together a tailored package.&lt;/p&gt;
&lt;/div&gt;
```

Replace the Lorem ipsum placeholder text with your actual questions and answers. Duplicate or remove blocks to match your FAQ count.

### 4. Customize the colors

The code includes a `&lt;style&gt;` section at the top with commented color variables. Look for lines like these:

```css
/* Background color of accordion buttons */
.accordion {
  background-color: #f1f1f1;
}

/* Background color when hovering */
.accordion:hover {
  background-color: #ddd;
}

/* Text color */
.accordion {
  color: #444;
}

/* Panel background */
.panel {
  background-color: white;
}
```

Change the hex color values to match your site&apos;s palette. The comments in the code make it straightforward to find each property.

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;

### 5. Publish and test

Save your changes and publish the site. You can only preview the accordion after publishing — Carrd&apos;s editor doesn&apos;t render embedded JavaScript in the builder.

Check these things on the live site:

- Click each FAQ item. Does it expand and collapse smoothly?
- Open the site on your phone. Is the text readable and the tap targets large enough?
- Do the colors match your design?

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

## Embedding multiple accordion sections on the same page

If you need two or more separate FAQ groups on one Carrd site, you&apos;ll need to give each embed a unique ID. The default code uses `#app` as its container ID, so a second embed will conflict.

Here&apos;s how to fix it for each additional accordion:

1. In the CSS section, change `#app` to a unique name like `#app2`.
2. In the HTML section, change `&lt;div id=&quot;app&quot;&gt;` to `&lt;div id=&quot;app2&quot;&gt;`.
3. In the JavaScript section at the bottom, change `el: &quot;#app&quot;` to `el: &quot;#app2&quot;`, and rename the `const vm` to something unique like `const vm2`.

```js
// First accordion (unchanged)
const vm = new Vue({
  el: &quot;#app&quot;,
  // ...
});

// Second accordion (unique names)
const vm2 = new Vue({
  el: &quot;#app2&quot;,
  // ...
});
```

Repeat this pattern for each additional accordion embed.

## Troubleshooting

**Accordion doesn&apos;t appear after publishing:** Make sure the Embed element&apos;s Style is set to **Hidden**. If it&apos;s set to something else, the container may block the accordion from rendering. Also confirm your Carrd plan supports Embeds (Pro Standard or higher).

**Accordion looks broken or unstyled:** Carrd strips some CSS from embeds if the code is pasted into the wrong field. Make sure you&apos;re pasting into the **Code** field (not the Label or URL field).

**Animations don&apos;t work:** The accordion uses Vue.js for its toggle behavior. If you have another embed on the same page that also loads Vue, they may conflict. Check your browser console for JavaScript errors.

**Text is too small on mobile:** Add a font-size override in the style section of the code. For example: `.accordion { font-size: 16px; }` and `.panel p { font-size: 15px; }`.

## Alternatives to the code embed approach

If you&apos;d rather not manage raw code, there are two other options:

- **Common Ninja** — a third-party widget service that generates an embeddable accordion. Their free tier includes branding. Paid plans start at around $5/month to remove it.
- **Jason&apos;s Plugins for Carrd** ([plugins.carrd.co](https://plugins.carrd.co/)) — offers a free animated accordion plugin with a different visual style. Also a code embed, but with different default styling.

The CarrdMe accordion used in this tutorial is free, has no branding, and gives you full control over the code. For most use cases, it&apos;s the better choice.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How To Add a Sticky Header to Carrd (CSS Tutorial)</title><link>https://www.bitdoze.com/add-stickey-header-carrd/</link><guid isPermaLink="true">https://www.bitdoze.com/add-stickey-header-carrd/</guid><description>Step-by-step guide to adding a sticky header to your Carrd website using CSS. Works on Pro Standard and Pro Plus plans with embed code.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/05/carrd-page-settings.png&quot;;

A sticky header keeps your navbar pinned to the top of the screen as visitors scroll. It&apos;s a standard pattern on most websites, but Carrd doesn&apos;t have a built-in toggle for it. You need to add it yourself with a small CSS snippet.

This guide walks you through the full process. You&apos;ll need a **Pro Standard plan** ($19/year) or higher, since that&apos;s the lowest tier that supports custom code embeds.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/hClMHp1CSec&quot;
  label=&quot;How To Add a Sticky Header to Carrd.co&quot;
/&gt;

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;

**More Carrd tutorials:**

- [Add Carrd Cookie Notice](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [How To Add Pricing Table to Carrd.co](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)
- [How To Add Accordion FAQs Drop-Down to Carrd.co](https://www.bitdoze.com/add-accordion-carrd/)
- [How To Add Custom Domain to Carrd.co](https://www.bitdoze.com/carrd-add-domain/)
- [Carrd.co Mobile Responsive Navbar](https://www.bitdoze.com/carrd-mobile-navbar/)

&gt; The complete list of Carrd plugins, themes, and tutorials is on [carrdme.com](https://carrdme.com/).

## Prerequisites

| Feature | Pro Standard ($19/yr) | Pro Plus ($49/yr) |
|---------|----------------------|-------------------|
| Custom code embeds | Yes | Yes |
| Set custom element IDs in settings | No (use browser inspect) | Yes (Advanced Settings tab) |

Both plans work. The difference is how you find the container ID — more on that in step 2.

## How to add a sticky header to Carrd

### 1. Set page padding to zero

Open your Carrd site in the editor and click the **Page** element. In its appearance settings, set both **vertical and horizontal padding to 0**.

If you skip this, the fixed navbar will have incorrect sizing and won&apos;t line up with the edges of the page.

&lt;Picture
  src={imag1}
  alt=&quot;Carrd page settings showing padding options&quot;
/&gt;

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

### 2. Get the container ID

Your navbar elements should be wrapped in a Container element. You need that container&apos;s ID to target it with CSS.

**Pro Plus users:** Open the container&apos;s settings, go to the Advanced Settings tab, and set a custom ID (e.g., `navbar`). Use that ID in the CSS below.

**Pro Standard users:** You can&apos;t set custom IDs, but you can find the auto-generated one. Right-click your navbar container in the browser preview, choose **Inspect**, and look for an ID like `container01` or `container02` on the `&lt;div&gt;` element. The video above shows this process in detail.

### 3. Add the CSS code

Add a new **Embed** element to your Carrd site. Set its type to **Code** and style to **Hidden** and **Head**. Then paste one of the snippets below.

#### Sticky on all screen sizes

```html
&lt;style&gt;
  #container01 {
    position: fixed !important;
    z-index: 99;
    top: 0;
    left: 0;
    width: 100%;
  }
&lt;/style&gt;
```

#### Sticky only on screens wider than 600px

```html
&lt;style&gt;
  @media screen and (min-width: 600px) {
    #container01 {
      position: fixed !important;
      z-index: 99;
      top: 0;
      left: 0;
      width: 100%;
    }
  }
&lt;/style&gt;
```

Replace `#container01` with your actual container ID.

**What the properties do:**

- `position: fixed` — removes the element from normal flow and pins it to the viewport.
- `!important` — overrides Carrd&apos;s default positioning styles.
- `z-index: 99` — keeps the header above other content.
- `top: 0; left: 0` — anchors it to the top-left corner.
- `width: 100%` — ensures it spans the full viewport width. Without this, the header may not cover the entire page.

### 4. Add a spacer below the header

When you make an element `position: fixed`, it&apos;s removed from the normal document flow. This means the content below it slides up and hides behind the navbar.

To fix this, add a **Divider** element right after your header container. Set its top margin to roughly match the height of your navbar. You can adjust this separately for desktop and mobile in the Divider&apos;s appearance settings.

Alternatively, you can add top padding to the next container below the header, but using a Divider gives you more control over responsive spacing.

### 5. Save and test

Publish your changes and check the result:

- Does the header stick to the top when you scroll?
- Does it span the full width of the page?
- Is the content below visible (not hidden behind the header)?
- On mobile (if you used the responsive snippet), does the header scroll away normally?

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

## Troubleshooting

**Header doesn&apos;t span full width:** Make sure `left: 0` and `width: 100%` are in your CSS. Also check that page padding is set to 0 (step 1).

**Content is hidden behind the header:** You need a spacer. Add a Divider with appropriate top margin, or add padding-top to the next element.

**Header flickers or jumps on load:** This can happen if Carrd&apos;s page animations conflict with `position: fixed`. Try removing the page-level animation or adding the class `is-ready` to your header container.

**Header overlaps on mobile but looks too tall:** Use the `@media` version of the CSS to disable the sticky behavior on small screens, or reduce the container&apos;s padding in the mobile layout.

**z-index doesn&apos;t seem to work:** Make sure there&apos;s no parent element with `overflow: hidden` that could clip the fixed header. Also try increasing the z-index value (e.g., `9999`).

## When to use Carrd&apos;s built-in Header Marker instead

Carrd has a native **Header Marker** control that makes a header region visible on every section. If you&apos;re using Sections and just need the header to appear on all pages (not stick during scroll), the Header Marker is the simpler solution — no custom code needed.

Use the sticky CSS approach when you want the header to stay pinned at the top of the viewport as the visitor scrolls through a long single-page site.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Redirect Docker Logs to a Single File</title><link>https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/</link><guid isPermaLink="true">https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/</guid><description>Learn how to redirect Docker logs to a single file using docker logs commands, json-file driver configuration, and the local driver. Practical guide with code examples.</description><pubDate>Thu, 09 Jul 2026 00:00:00 GMT</pubDate><content:encoded>Docker stores each container&apos;s logs in separate files under `/var/lib/docker/containers/`. When you&apos;re running multiple containers, searching through scattered log files gets old fast. This guide shows three ways to redirect Docker logs to a single file — from a quick one-liner to daemon-wide configuration.

**Other Docker guides you might find useful:**

- [Add Users to a Docker Container](https://www.bitdoze.com/add-users-to-docker-container/)
- [Copy Multiple Files in One Layer Using a Dockerfile](https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/)
- [Install Docker &amp; Docker-compose for Ubuntu ARM](https://www.bitdoze.com/install-docker-ubuntu-arm/)
- [Environment Variables ARG and ENV in Docker](https://www.bitdoze.com/docker-env-vars/)

## Method 1: One-off redirect with docker logs

The simplest way to dump container logs to a file:

```bash
docker logs my-container &gt; container.log 2&gt;&amp;1
```

The `2&gt;&amp;1` part matters. Without it, you only capture stdout. stderr gets lost. This command merges both streams into one file.

To follow logs in real-time and write them continuously:

```bash
docker logs -f my-container &gt; container.log 2&gt;&amp;1 &amp;
```

The `-f` flag follows new output. The `&amp;` runs it in the background. This is useful for debugging but has limitations — the process dies when your shell session ends.

To capture logs from multiple containers into one file:

```bash
for container in $(docker ps --format &apos;{{.Names}}&apos;); do
  echo &quot;=== $container ===&quot; &gt;&gt; all-logs.log
  docker logs &quot;$container&quot; &gt;&gt; all-logs.log 2&gt;&amp;1
done
```

**When to use this method:** Quick debugging, one-off log exports, or when you need a snapshot of what&apos;s happening right now.

## Method 2: Configure json-file driver with rotation

Docker uses the `json-file` logging driver by default. It writes logs as JSON to `/var/lib/docker/containers/&lt;id&gt;/&lt;id&gt;-json.log`. The catch: no rotation is enabled by default. A chatty container will fill your disk.

### Per-container configuration

Set the logging driver when starting a container:

```bash
docker run -d \
  --name my-app \
  --log-driver json-file \
  --log-opt max-size=10m \
  --log-opt max-file=3 \
  my-app:latest
```

- `max-size=10m` — caps each log file at 10 MB
- `max-file=3` — keeps 3 rotated files (oldest gets deleted)

With these settings, the container uses at most 30 MB for logs.

### Daemon-wide configuration

To apply rotation to every new container, edit `/etc/docker/daemon.json`:

```json
{
  &quot;log-driver&quot;: &quot;json-file&quot;,
  &quot;log-opts&quot;: {
    &quot;max-size&quot;: &quot;10m&quot;,
    &quot;max-file&quot;: &quot;3&quot;
  }
}
```

Restart Docker for the changes to take effect:

```bash
sudo systemctl restart docker
```

**Important:** Existing containers don&apos;t pick up the new defaults. You need to recreate them. Also, all values in `daemon.json` must be strings — `&quot;max-file&quot;: 3` (without quotes) will break the daemon.

### Verify the configuration

Check a container&apos;s current logging driver:

```bash
docker inspect --format=&apos;{{.HostConfig.LogConfig}}&apos; my-app
```

Find the log file path:

```bash
docker inspect --format=&apos;{{.LogPath}}&apos; my-app
```

## Method 3: Use the local driver (recommended for production)

The `local` driver is Docker&apos;s recommended replacement for `json-file` in most situations. It uses a more efficient binary format, compresses rotated files automatically, and has sensible defaults (20 MB per file, 5 files kept).

```json
{
  &quot;log-driver&quot;: &quot;local&quot;
}
```

Or per-container:

```bash
docker run -d \
  --name my-app \
  --log-driver local \
  my-app:latest
```

The `docker logs` command works the same way with both drivers.

**Why json-file is still the default:** Docker can&apos;t switch without breaking tools that depend on the json-file layout — Kubernetes being the main one. If you&apos;re not running Kubernetes, use `local`.

### Docker Compose configuration

For Compose stacks, use the `logging` key:

```yaml
services:
  api:
    image: my-app:latest
    logging:
      driver: local
  
  worker:
    image: my-worker:latest
    logging:
      driver: json-file
      options:
        max-size: &quot;20m&quot;
        max-file: &quot;5&quot;
```

To apply the same logging config across all services with a YAML anchor:

```yaml
x-logging: &amp;default-logging
  driver: json-file
  options:
    max-size: &quot;10m&quot;
    max-file: &quot;3&quot;

services:
  api:
    image: my-app:latest
    logging: *default-logging
  
  worker:
    image: my-worker:latest
    logging: *default-logging
```

## Where Docker stores logs by default

Before redirecting logs, it helps to understand the default behavior:

- Docker captures stdout and stderr from every container
- Each container gets its own log file under `/var/lib/docker/containers/`
- Logs are stored in JSON format with timestamps and stream type
- No rotation is enabled by default (this is the main problem)

Find the log file for a specific container:

```bash
docker inspect --format=&apos;{{.LogPath}}&apos; my-container
```

Output looks like:

```
/var/lib/docker/containers/a4f8c9e1.../a4f8c9e1...-json.log
```

Each line is a JSON object:

```json
{&quot;log&quot;:&quot;Listening on port 8080\n&quot;,&quot;stream&quot;:&quot;stdout&quot;,&quot;time&quot;:&quot;2023-07-03T10:14:02.123456789Z&quot;}
```

**Don&apos;t use external log rotation tools** (like `logrotate`) on Docker&apos;s internal log files. Docker assumes exclusive access. External truncation can corrupt log state or prevent containers from being removed.

## Forwarding logs to a central system

For production setups with multiple hosts, consider forwarding logs to a central system:

### Syslog driver

```bash
docker run -d \
  --log-driver syslog \
  --log-opt syslog-address=tcp://logs.example.com:514 \
  --log-opt tag=&quot;{{.Name}}&quot; \
  my-app:latest
```

### Fluentd driver

```bash
docker run -d \
  --log-driver fluentd \
  --log-opt fluentd-address=localhost:24224 \
  --log-opt tag=&quot;docker.{{.Name}}&quot; \
  my-app:latest
```

Since Docker 20.10, remote drivers (syslog, fluentd, splunk) automatically maintain a local cache alongside forwarding. `docker logs` still works. The cache uses the `local` driver internally with 5 files of 20 MB each.

Other available drivers: `journald`, `gelf` (Graylog), `awslogs` (CloudWatch), `splunk`.

## Common issues

**Container logs filling disk:** Enable rotation with `max-size` and `max-file`. This is the most common Docker disk issue. Without rotation, json-file has no upper bound.

**`docker logs` not showing output after changing driver:** Make sure the container was created after the daemon configuration change. Existing containers keep their original settings.

**Daemon won&apos;t start after editing daemon.json:** Validate the JSON first:

```bash
sudo dockerd --validate --config-file /etc/docker/daemon.json
```

If the daemon is already down, check `journalctl -u docker` for error details.

**Want to disable local caching for remote drivers:** Add `&quot;cache-disabled&quot;: &quot;true&quot;` to `log-opts`.

## Which method should you use?

| Method | Best for | Persistence |
|--------|----------|-------------|
| `docker logs &gt; file` | Quick debugging, one-off exports | No (runs in shell) |
| `json-file` with rotation | Single-host setups, Kubernetes | Yes (managed by Docker) |
| `local` driver | Single-host production setups | Yes (more efficient) |
| `syslog`/`fluentd` | Multi-host, centralized logging | Yes (external system) |

For most self-hosted setups, the `local` driver with default settings is the right choice. It handles rotation automatically and uses less disk than `json-file`.</content:encoded><category>self-hosting</category><category>docker</category><category>logging</category><category>devops</category></item><item><title>Upgrade Your Carrd.co Website With A Cookie Notice in Minutes</title><link>https://www.bitdoze.com/add-cookie-notice-carrd/</link><guid isPermaLink="true">https://www.bitdoze.com/add-cookie-notice-carrd/</guid><description>Enhance your website&apos;s functionality and legal compliance in just a few minutes with our easy-to-implement cookie notice code for Carrd.co.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/23/05/carrd-embed.png&quot;;

If your Carrd site uses Google Analytics, Facebook Pixel, embedded YouTube videos, or any other third-party scripts, you need a cookie notice. Privacy laws like GDPR (EU) and CCPA (California) require websites to inform visitors about cookie use and, in many cases, get consent before setting non-essential cookies.

This tutorial shows you how to add a simple, responsive cookie notice to your Carrd site using the Embed element. The code is free, lightweight, and mobile-friendly.

## When do you need a cookie notice on Carrd?

Carrd itself doesn&apos;t set tracking cookies. But if you add any of these to your site, you likely need a cookie notice:

- **Google Analytics** — sets `_ga`, `_gid` tracking cookies
- **Facebook Pixel** — tracks visitors across sites for ad targeting
- **Embedded YouTube videos** — YouTube sets cookies when videos load
- **Embedded social widgets** — Twitter, Instagram embeds can set tracking cookies
- **Third-party form tools** — Typeform, Stripe embeds may set cookies

If your Carrd site is purely static content with no third-party scripts, you probably don&apos;t need a cookie notice. But if you&apos;re unsure, adding one is the safer choice.

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;

## What you need

- **Carrd Pro Standard** plan ($19/year) or higher — the Embed element is not available on Free or Pro Lite plans
- A few minutes to paste code and customize

Carrd Pro Standard unlocks custom code embeds, custom domains, forms, and Google Analytics integration. At $19/year (about $1.58/month), it&apos;s the plan most Carrd users need.

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/u3F19tG0hnE&quot;
  label=&quot;How to Add a Cookie Notice to Carrd.co&quot;
/&gt;

## Cookie notice code for Carrd

Here&apos;s the complete code. Copy it as-is, then customize the colors, text, and cookie duration to match your site.

```html
&lt;div id=&quot;cookie-notice&quot;&gt;
  &lt;p&gt;This website uses cookies to ensure you get the best experience. By continuing to browse, you agree to our use of cookies.&lt;/p&gt;
  &lt;button id=&quot;cookie-accept&quot; onclick=&quot;acceptCookie()&quot;&gt;Got it!&lt;/button&gt;
  &lt;button id=&quot;cookie-reject&quot; onclick=&quot;rejectCookie()&quot;&gt;No thanks&lt;/button&gt;
&lt;/div&gt;

&lt;style&gt;
  #cookie-notice {
    font-family: inherit;
    color: #fff;
    background: #596cd5;
    padding: 20px;
    position: fixed;
    bottom: 10px;
    left: 10px;
    max-width: 320px;
    box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
    border-radius: 5px;
    margin: 0;
    display: none;
    z-index: 1000000;
    box-sizing: border-box;
  }
  #cookie-notice p {
    margin: 0 0 12px 0;
    font-size: 14px;
    line-height: 1.5;
  }
  #cookie-notice button {
    font-family: inherit;
    color: #fff;
    border: 0;
    padding: 10px 16px;
    margin-right: 8px;
    margin-top: 4px;
    cursor: pointer;
    border-radius: 3px;
    font-size: 14px;
  }
  #cookie-accept {
    background: #3842c7;
  }
  #cookie-reject {
    background: transparent;
    border: 1px solid rgba(255,255,255,0.4) !important;
  }
  @media only screen and (max-width: 600px) {
    #cookie-notice {
      max-width: 100%;
      bottom: 0;
      left: 0;
      border-radius: 0;
    }
  }
&lt;/style&gt;

&lt;script&gt;
  function setCookie(name, value, days) {
    var expires = &quot;&quot;;
    if (days) {
      var date = new Date();
      date.setTime(date.getTime() + days * 86400000);
      expires = &quot;; expires=&quot; + date.toUTCString();
    }
    document.cookie = name + &quot;=&quot; + (value || &quot;&quot;) + expires + &quot;; path=/&quot;;
  }
  function getCookie(name) {
    var nameEQ = name + &quot;=&quot;;
    var ca = document.cookie.split(&quot;;&quot;);
    for (var i = 0; i &lt; ca.length; i++) {
      var c = ca[i].trim();
      if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
    }
    return null;
  }
  function acceptCookie() {
    setCookie(&quot;cookie_consent&quot;, &quot;accepted&quot;, 365);
    document.getElementById(&quot;cookie-notice&quot;).style.display = &quot;none&quot;;
  }
  function rejectCookie() {
    setCookie(&quot;cookie_consent&quot;, &quot;rejected&quot;, 365);
    document.getElementById(&quot;cookie-notice&quot;).style.display = &quot;none&quot;;
  }
  if (!getCookie(&quot;cookie_consent&quot;)) {
    document.getElementById(&quot;cookie-notice&quot;).style.display = &quot;block&quot;;
  }
&lt;/script&gt;
```

### What this code does

The notice appears as a fixed banner in the bottom-left corner. On mobile (under 600px), it stretches to full width at the bottom of the screen. It has two buttons:

- **Got it!** — sets a cookie named `cookie_consent` with value `accepted` that lasts 365 days
- **No thanks** — sets the same cookie with value `rejected`

Once the user clicks either button, the banner disappears and won&apos;t show again for a year. The notice uses `display: none` by default, so it only appears for visitors who haven&apos;t made a choice yet.

### Customization options

- **Background color** — change `#596cd5` to your brand color
- **Button color** — change `#3842c7` for the accept button
- **Cookie duration** — change `365` in the `setCookie` calls (365 = 1 year, 30 = 1 month)
- **Text** — edit the `&lt;p&gt;` content to match your site&apos;s tone
- **Position** — change `bottom: 10px; left: 10px` to position elsewhere (e.g., `bottom: 0; left: 0; right: 0` for a full-width footer bar)

## How to add the cookie notice to Carrd

### Step 1: Add an Embed element

In the Carrd editor, click **Add Element** and select **Embed**. Set the **Type** to **Code**. Give it a label like &quot;Cookie Notice&quot; so you can identify it later.

&lt;Picture
  src={imag1}
  alt=&quot;Carrd Embed element setup with Type set to Code&quot;
/&gt;

The Embed element requires Carrd Pro Standard ($19/year) or higher. It&apos;s not available on Free or Pro Lite plans.

### Step 2: Paste the code

In the **Code** field, paste the entire code block from above. You can customize the colors, text, and cookie duration before pasting, or edit it after.

### Step 3: Publish and test

Carrd doesn&apos;t preview embed code in the editor. You need to publish the site to see the cookie notice. After publishing:

1. Open your site in an incognito/private browser window
2. The cookie notice should appear
3. Click &quot;Got it!&quot; or &quot;No thanks&quot; — the banner should disappear
4. Refresh the page — the banner should stay hidden
5. Clear your cookies and refresh — the banner should reappear

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Try Carrd Pro Free for 7 Days&quot; /&gt;

## Limitations of this approach

This is a simple cookie notice, not a full consent management platform (CMP). Here&apos;s what it does and doesn&apos;t do:

**What it does:**
- Informs visitors that your site uses cookies
- Records whether they accepted or rejected
- Hides the banner after they make a choice

**What it doesn&apos;t do:**
- Block third-party scripts before consent (Google Analytics still loads)
- Provide granular cookie category controls
- Log consent records for audit purposes
- Auto-scan your site for cookies

For most Carrd sites, a simple notice is enough. If you run advertising campaigns in the EU or process large amounts of user data, consider a dedicated CMP like Cookiebot, CookieYes, or CookieScript — some have free tiers.

### Strict GDPR compliance

Under strict GDPR interpretation, you should block non-essential cookies until the user accepts. This is difficult to do on Carrd because the Embed element loads scripts immediately. If strict compliance matters to your business, consider:

- Using a third-party CMP that handles script blocking
- Switching to cookieless analytics like [Plausible](https://plausible.io/) or Fathom (no consent needed)
- Consulting a privacy professional for your specific situation

## Related Carrd tutorials

- [Add a Sticky Header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add a Pricing Table to Carrd](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)
- [Add Accordion FAQs to Carrd](https://www.bitdoze.com/add-accordion-carrd/)
- [Add a Custom Domain to Carrd](https://www.bitdoze.com/carrd-add-domain/)
- [Carrd Mobile Responsive Navbar](https://www.bitdoze.com/carrd-mobile-navbar/)

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Get Carrd Pro Standard&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category></item><item><title>Convert Images to SVG Free With Vectorizer.ai</title><link>https://www.bitdoze.com/convert-images-to-svg/</link><guid isPermaLink="true">https://www.bitdoze.com/convert-images-to-svg/</guid><description>Convert PNG, JPEG images to SVG easily online with Vectorizer.ai. Free preview to test quality, plus alternatives for fully free SVG conversion.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

## Why use SVG

Scalable Vector Graphics (SVG) is an XML-based format for 2D graphics on the web. Unlike raster images made of pixels, SVG uses mathematical equations to define shapes and lines.

**Scalability.** SVG scales to any size without losing quality. A single logo file works at 16px in a navbar and at 1600px on a billboard. No need to maintain multiple resolution variants.

**Small file size.** For logos, icons, and illustrations, SVGs are typically smaller than equivalent PNGs or JPEGs of the same dimensions. This means faster page loads and less bandwidth.

**Resolution-independent.** SVG looks sharp on any display — standard, Retina, or 4K. No blurriness on high-DPI screens.

**Editable.** You can modify SVG with any text editor or manipulate it with CSS and JavaScript. Colors, shapes, and animations are all changeable without an image editor.

SVG is ideal for logos, icons, diagrams, and illustrations. It&apos;s not the right format for photographs — use WebP or AVIF for those.

## What is Vectorizer.ai

[Vectorizer.ai](https://vectorizer.ai/) converts raster images into vector graphics using AI. It traces edges, detects shapes, and reconstructs them as clean vector paths with smooth curves and precise geometry.

The team has over 15 years of experience in vectorization. Their &quot;Deep Vector Engine&quot; combines deep learning with classical algorithms, and their proprietary &quot;Vector Graph&quot; framework handles automated edits like corner cleanup, curve fairing, and shape fitting.

Key features:

- **Full shape fitting** — recognizes circles, ellipses, rounded rectangles, and stars instead of reducing everything to Bezier curves
- **Multiple curve types** — uses straight lines, circular arcs, elliptical arcs, and quadratic/cubic Bezier curves where each fits best
- **Sub-pixel precision** — extracts features smaller than one pixel using anti-aliasing data
- **Symmetry detection** — identifies and preserves mirror and rotational symmetries
- **Palette control** — auto-detects color count, with manual adjustment available
- **Pre-crop** — lets you crop to the area you care about, so only the cropped portion counts against the resolution limit

**Supported inputs:** PNG, JPG, GIF, BMP, WebP (max 3 megapixels, max 30MB)

**Supported outputs:** SVG, EPS, DXF, PDF, and a &quot;cleaned up&quot; PNG

## Vectorizer.ai pricing (updated)

Vectorizer.ai is **no longer free**. The original version of this article described it as free, but the pricing model changed.

| Plan | Price | What you get |
|------|-------|-------------|
| **Free preview** | $0 | Upload, vectorize, and inspect the interactive preview. You cannot download production results. |
| **Web App** | $9.99/month | Unlimited downloads of SVG, EPS, DXF, PDF. Full export options. Cancel anytime. |
| **API** | Usage-based credits | For automated workflows. Free to integrate and test; production downloads cost credits. Unused credits roll over (up to 5x). |

The free preview lets you see exactly how your image vectorizes before you pay. You can also download results from Vectorizer.ai&apos;s example images for free to test software compatibility.

## How to use Vectorizer.ai

The process is straightforward:

1. Go to [vectorizer.ai](https://vectorizer.ai/)
2. Drag and drop your image onto the page (or click the file picker, or paste with Cmd+V / Ctrl+V)
3. Wait a few seconds for the AI to process
4. Inspect the interactive preview — zoom in to check path quality and color accuracy
5. Adjust the palette if needed (you can change color count, merge colors, or edit individual swatches)
6. Download as SVG, EPS, DXF, or PDF (requires a subscription)

**Tips:**

- Crop your image to the area you want vectorized before uploading. Only the cropped portion counts against the 3 megapixel limit.
- Logos, icons, and line art produce the cleanest results. Complex photographs work but may need more cleanup.
- Check the &quot;Adobe Compatibility&quot; option in SVG export settings if you plan to open the file in Illustrator.

You can see how Vectorizer.ai handles a complex logo in the video below:

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/dbbEQssxC6A&quot;
  label=&quot;Vectorizer.ai - Convert Images to SVG&quot;
/&gt;

## Free alternatives to Vectorizer.ai

If you don&apos;t want to pay $9.99/month, there are several free options:

### Recraft AI

[Recraft](https://www.recraft.ai/ai-image-vectorizer) offers a free AI-powered image vectorizer in the browser. It converts PNG, JPG, and WebP to SVG. The free tier has some limitations, but for occasional conversions it&apos;s a strong option that doesn&apos;t require installation.

### Inkscape

[Inkscape](https://inkscape.org/) is a free, open-source vector editor with a built-in Trace Bitmap feature. It works offline, handles multiple tracing modes (brightness cutoff, edge detection, color quantization), and outputs editable SVG natively. The interface is dated and the learning curve is steeper, but it&apos;s the most capable free desktop option.

### Vectorizer.com

[Vectorizer.com](https://vectorizer.com/) is a completely free online tool with no registration required. Upload up to 20 files at once and get instant SVG results. Data is kept for a maximum of 1 hour then deleted. Quality is decent for simple images but won&apos;t match AI-powered tools on complex artwork.

### Photopea

[Photopea](https://www.photopea.com/) is a free, browser-based image editor that works like Photoshop. It has an Image Trace feature for vectorization. It&apos;s ad-supported but requires no account.

## Vectorizer.ai vs alternatives

| Tool | Price | AI-powered | Best for |
|------|-------|-----------|----------|
| Vectorizer.ai | $9.99/month (free preview) | Yes | Highest-quality AI vectorization |
| Recraft | Free tier | Yes | Free AI vectorization in the browser |
| Inkscape | Free | No | Offline, full-featured vector editor |
| Vectorizer.com | Free | No | Quick, no-signup conversions |
| Photopea | Free (ads) | No | Browser-based editing + vectorization |

## Limitations to know

- **3 megapixel max resolution** for the web app. Large photos need to be cropped or downscaled first.
- **No background removal.** You need to remove or make the background transparent before uploading.
- **24-hour data retention.** Uploaded images and results are deleted after 24 hours.
- **No ML training use.** Their terms prohibit using output for training machine learning models.
- **Subscription required to download.** The free preview is inspection-only; you pay to export.

## When Vectorizer.ai is worth paying for

Vectorize images regularly for work? The $9.99/month is easy to justify — the AI output is cleaner than free classical tracers, the shape detection saves manual cleanup, and unlimited downloads mean no per-image costs.

Need to convert one logo once? Use the free preview to test quality, then decide whether to subscribe for a month or use a free alternative instead.

Need an API for your app? The free integration testing makes it easy to evaluate before committing to a credit plan.

For most people, I&apos;d start with Recraft or Inkscape. If the quality isn&apos;t good enough for your use case, then try Vectorizer.ai&apos;s free preview.</content:encoded><category>tools</category><category>svg</category><category>image-tools</category><category>ai</category></item><item><title>Shottr: Fast Mac Screenshot Tool With Pixel-Perfect Precision</title><link>https://www.bitdoze.com/shottr-mac-screenshot-tool/</link><guid isPermaLink="true">https://www.bitdoze.com/shottr-mac-screenshot-tool/</guid><description>Shottr is a lightweight Mac screenshot tool with scrolling capture, OCR, annotations, and a pixel ruler. Review of features, pricing ($12 one-time), and how it compares to CleanShot X.</description><pubDate>Wed, 08 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

macOS has a built-in screenshot tool (`⌘ + Shift + 5`), but it&apos;s basic. No scrolling capture, weak markup, no OCR. If you need more than that, [Shottr](https://shottr.cc/) is worth a look.

Shottr is a Mac-only screenshot app built by a solo developer. It&apos;s around 2MB, launches fast, and captures screenshots in roughly 165ms. It runs natively on Apple Silicon (M1 through M4) with no Rosetta needed.

You can use Shottr for free indefinitely — after 30 days it starts showing prompts to buy a license, but all features keep working. The Basic license is $12 one-time, which is still cheap compared to alternatives.

## What Shottr does

Shottr captures screenshots and lets you annotate, measure, and extract text from them. The core workflow:

1. Capture (full screen, area, window, or scrolling)
2. Annotate with arrows, text, blur, counters, highlights
3. Copy, save, or upload

The standout features are the pixel ruler, OCR text recognition, and scrolling screenshots. These are things macOS doesn&apos;t offer and most free tools miss.

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/jQpw9NQ4mHM&quot;
  label=&quot;Shottr - Mac Screenshot Tool With Pixel-Level Precision&quot;
/&gt;

&gt; If you&apos;re interested in more free Mac apps, check [toolhunt.net mac apps section](https://toolhunt.net/mac/).

## Features

### Capture modes

- **Area capture** — select a region with crosshairs
- **Window capture** — grab any window with or without shadow/background
- **Full screen** — entire screen capture
- **Scrolling capture** — scrolls and captures long web pages, chat logs, documents in one shot. No manual stitching.
- **Repeat area** — retake a screenshot of the same region without reselecting
- **Delayed capture** — 3-second delay before capturing

Scrolling capture is the feature most people come to Shottr for. It handles long pages, email threads, and chat conversations well. The max height defaults to 20,000px but can go up to 200,000px.

### OCR and text recognition

Select any area of the screen and Shottr extracts text using Apple&apos;s Vision framework. It also reads QR codes — useful when you see one on-screen and don&apos;t want to grab your phone.

The OCR works well with printed text, UI labels, code blocks, and documents. Handwriting and fancy fonts may be less reliable. It preserves line breaks, so code and lists stay readable in your clipboard.

Since v1.9.1, you can configure line-break removal in settings if you prefer clean paragraph text.

### Annotation and markup tools

16 tools available in the toolbar:

- **Arrows** — standard, curved, and bendable (since v1.9). Multiple styles including slim and hand-drawn.
- **Text labels** — customizable colors, sizes. Pointy arrow by default.
- **Shapes** — rectangles, ovals with fill/opacity control
- **Blur and pixelate** — hide sensitive info. Press `B` to blur a selected area quickly.
- **Highlighter** — text highlight with configurable cap style
- **Spotlight** — dims the background around a selection. Adjust opacity with keys 1-9.
- **Counter tool** — numbered step annotations (1, 2, 3...)
- **Freehand drawing** — variable stroke width and smoothness
- **Magnifier** — zoomed-in callout for detail shots (new in v1.9)
- **Hand-drawn style** — makes shapes and arrows look sketched (new in v1.9)

Since v1.8, you can use custom annotation colors instead of the preset palette.

### Screen ruler and measurements

This is where Shottr stands out for developers and designers. Press arrow keys while measuring to get exact pixel distances between UI elements. Hold `Shift` to see outer dimensions. Click to stamp the measurement directly onto the screenshot.

The color inspector shows hex codes for any pixel. Since v1.8, it also supports OKLCH and APCA color formats — useful if you work with modern CSS color spaces or need contrast checking.

### Backdrop tool (v1.8+)

Added in the 2024 update, the Backdrop tool places screenshots on colored backgrounds with drop shadows and rounded corners. Pick a background color, adjust shadow intensity, set corner radius.

It&apos;s useful for documentation, social media posts, or making screenshots look less like raw grabs. The options are fewer than dedicated tools like Shots.so, but it works for quick jobs.

### S3 upload (v1.9+)

Since November 2025, Shottr supports uploading screenshots to any S3-compatible storage. This includes AWS S3, MinIO, Tencent COS, Yandex Object Storage, and other third-party services.

This isn&apos;t full cloud sharing with shareable links — you need your own S3 bucket set up. But for developers who already use S3, it&apos;s a direct path from screenshot to URL.

### Pinned screenshots

Pin a screenshot as a floating always-on-top window. Resize with scroll wheel. Useful for comparing designs, referencing specs, or overlaying screenshots side by side.

## What Shottr doesn&apos;t do

Be aware of these gaps before committing:

- **No screen recording** — Shottr is screenshot-only. No video, no GIF capture. If you need screen recording, CleanShot X or OBS are better picks.
- **No cloud sharing** — Cloud upload is listed as &quot;in testing&quot; but hasn&apos;t launched. You can&apos;t get a shareable link from Shottr. The S3 upload is the closest option, but requires your own infrastructure.
- **No video or GIF exports** — You can create simple two-frame before/after GIFs by overlaying images, but that&apos;s it.
- **Limited background templates** — The Backdrop tool is functional but has fewer options than dedicated screenshot beautifiers.

## Pricing

Shottr is free to use — it works indefinitely after the 30-day evaluation period, but shows prompts asking you to purchase. A license removes the nag prompts and supports the developer.

| Tier | Price | What you get |
|------|-------|-------------|
| **Free (unlicensed)** | $0 | All features, nag prompts after 30 days |
| **Basic** | $12 one-time | All core features, no prompts, standard updates |
| **Friends Club** | $30 one-time | Experimental features, priority support, early access |

One license covers one user and up to 5 Macs. This is a one-time purchase, not a subscription.

At $12, Shottr is cheaper than most alternatives:

| Tool | Price | Notes |
|------|-------|-------|
| **Shottr** | $12 one-time | Pixel ruler, OCR, no recording |
| **CleanShot X** | $29 one-time | Screen recording, cloud, GIFs |
| **Snagit** | $39/year subscription | Full-featured, enterprise-oriented |
| **macOS built-in** | Free | Basic capture only, no scrolling |

No credit card needed to try it. Download and use it — you&apos;ll only hit a nag screen after a month.

## Shottr vs CleanShot X

These two get compared constantly. Here&apos;s the honest breakdown:

| Feature | Shottr | CleanShot X |
|---------|--------|-------------|
| Price | $12 one-time | $29 one-time |
| Scrolling capture | Yes | Yes |
| OCR / text extraction | Yes | Yes |
| QR code reader | Yes | Yes |
| Pixel ruler | Yes | No |
| Screen recording | No | Yes |
| GIF recording | No | Yes |
| Cloud storage | No (S3 upload only) | Yes (built-in) |
| Background templates | Basic | 10+ templates |
| Quick Access Overlay | No | Yes |
| Apple Silicon native | Yes | Yes |

**Pick Shottr if:** you need pixel-level measurements, want a lightweight app, and don&apos;t need screen recording or cloud sharing.

**Pick CleanShot X if:** you want an all-in-one tool with recording, GIFs, and instant cloud links. The $29 price is worth it if you use those features regularly.

CleanShot X is also available through Setapp (starting at $8.99/month billed annually for Mac, with 250+ apps included), which is a good deal if you use multiple Mac utilities.

## How to use Shottr (quick start)

1. Download from [shottr.cc](https://shottr.cc/) — no account needed
2. Install and grant Screen Recording permission when prompted
3. Set up hotkeys in Preferences (or use defaults: `⌘ + Shift + 2` for area, `⌘ + Shift + 3` for full screen)
4. Take a screenshot — the editor window opens automatically
5. Use the toolbar to annotate: arrows, text, blur, counters
6. Press `⌘ + C` to copy or `⌘ + S` to save

The whole process from capture to export takes a few seconds. The interface is functional but not flashy — it prioritizes speed over visual polish.

## Who should use Shottr

**Good for:**

- Developers measuring UI elements and documenting bugs
- Designers checking pixel spacing and color values
- Anyone who needs scrolling screenshots of long pages
- Users who want OCR text extraction from screen content
- Budget-conscious users who don&apos;t need recording or cloud features

**Not ideal for:**

- Content creators who need GIF or video screen recordings
- Teams that share screenshots via cloud links in Slack or GitHub
- Users who want a polished, modern UI experience
- Anyone needing App Store screenshot sets or batch exports

## Conclusion

Shottr is a focused, fast screenshot tool for Mac. The pixel ruler, OCR, and scrolling capture are features you won&apos;t find in the built-in macOS tool. At $12 one-time, it&apos;s one of the cheapest paid options that actually does these things well.

The trade-offs are clear: no screen recording, no cloud sharing, and a utilitarian interface. If those matter to you, CleanShot X is the better investment. If you mainly take screenshots, annotate them, and save locally, Shottr gets the job done without overcharging you.

&lt;Button link=&quot;https://shottr.cc/&quot; text=&quot;Download Shottr — Free to Try&quot; /&gt;</content:encoded><category>tools</category><category>screenshot</category><category>mac</category></item><item><title>How To Install Plausible Analytics With One Click</title><link>https://www.bitdoze.com/install-plausible-analytics/</link><guid isPermaLink="true">https://www.bitdoze.com/install-plausible-analytics/</guid><description>Step-by-step guide to deploy Plausible Analytics on your own VPS using Coolify. Privacy-first, cookie-free analytics with ClickHouse backend, no recurring fees.</description><pubDate>Tue, 07 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/03/01createservice.png&quot;;
import img2 from &quot;../../assets/images/23/03/choose_plausible_analytics.jpeg&quot;;
import img3 from &quot;../../assets/images/23/03/plausible-configs.png&quot;;
import img4 from &quot;../../assets/images/23/03/plausible-secrets.jpeg&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Plausible Analytics](https://plausible.io/) is a privacy-first, open-source alternative to Google Analytics. It collects no personal data, sets no cookies, and the tracking script is about 2.5KB. Self-hosting Plausible means you own the data, pay nothing beyond your server costs, and don&apos;t need cookie consent banners.

In this guide, you&apos;ll deploy Plausible on your own VPS using [Coolify](https://coolify.io/), a self-hosted PaaS. Coolify handles the Docker Compose orchestration, ClickHouse database, PostgreSQL database, SSL certificates, and reverse proxy configuration automatically.

Plausible CE (Community Edition) v3.2.1 is the current release as of May 2026. The v3.x line introduced teams support, scroll depth metrics, segments, and a new configurable tracking script (v3.0.0 and v3.1.0). v3.2.1 is a security patch that fixes CVE-2026-8467.

## What you get with self-hosted Plausible

- **No pageview limits** — Plausible Cloud starts at $9/month for 10K pageviews and scales up with traffic. Self-hosted has no limits.
- **Full data ownership** — analytics data stays on your server. No third-party access.
- **GDPR compliance** — no cookies, no IP tracking, no personal data collection. No consent banners needed.
- **Lightweight script** — 2.5KB, roughly 54x smaller than GA4&apos;s 135KB.
- **Three containers** — Plausible app (Elixir/Phoenix), ClickHouse (analytics events), PostgreSQL (accounts and config).

## Prerequisites

- A VPS with at least 2 CPU cores and 2 GB RAM (4 GB recommended for comfortable operation)
- A domain or subdomain for your Plausible dashboard (e.g., `analytics.yourdomain.com`)
- Coolify installed on the server

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/RNnuXCUhHF4&quot;
  label=&quot;How To Install Plausible Analytics With One Click&quot;
/&gt;

## Step 1: Set up a VPS server

You need a VPS to host Plausible. [Hetzner](https://go.bitdoze.com/hetzner) offers the best price-to-performance for self-hosting. Other options: [DigitalOcean](https://go.bitdoze.com/do), [Vultr](https://go.bitdoze.com/vultr), [Hostinger](https://go.bitdoze.com/hostinger-vps). We have VPS benchmarks here: [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/).

For Plausible alongside Coolify and a few other services, a 4 GB RAM / 2-core VPS is a solid starting point.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Step 2: Install Coolify

SSH into your VPS as root and run:

```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash
```

The script installs Docker Engine 24+, dependencies, and Coolify itself. After installation, access the Coolify dashboard at `http://your-server-ip:8000` and create your admin account immediately.

For complete setup including SSL and domain configuration, see: [Coolify Install Guide](https://www.bitdoze.com/coolify-install-heroku-alternative/)

## Step 3: Point a domain to the server

You need a domain or subdomain for Plausible. Add an A record in your DNS pointing to the VPS IP. For example, `analytics.yourdomain.com` → `your-server-ip`.

If you use Cloudflare, leave the proxy disabled (gray cloud). Coolify&apos;s Traefik reverse proxy handles SSL via Let&apos;s Encrypt automatically.

## Step 4: Deploy the Plausible service

### 4.1 Create a new service

In Coolify, go to **Create New Resource** and select **Service**. Search for &quot;Plausible Analytics&quot; in the service catalog and select it.

&lt;Picture
  src={img1}
  alt=&quot;Coolify create new service&quot;
/&gt;
&lt;Picture
  src={img2}
  alt=&quot;Select Plausible Analytics from Coolify service catalog&quot;
/&gt;

Coolify&apos;s Plausible template deploys three containers:

- **Plausible** — the Elixir/Phoenix web application (image: `ghcr.io/plausible/community-edition:v3.2.1`)
- **plausible-db** — PostgreSQL 16 for user accounts and site configuration
- **plausible-events-db** — ClickHouse for analytics event storage and fast aggregation

### 4.2 Configure the service

In the service configuration, set these values:

- **Name** — a name for your deployment (e.g., &quot;plausible-analytics&quot;)
- **Version / Tag** — select the latest version (v3.2.1 as of May 2026)
- **URL (FQDN)** — your Plausible domain (e.g., `https://analytics.yourdomain.com`)

&lt;Picture
  src={img3}
  alt=&quot;Coolify Plausible service configuration&quot;
/&gt;

Save the configuration.

### 4.3 Configure environment variables

Plausible CE is configured via environment variables. In the Coolify service settings, you can add these as environment variables:

**Required (Coolify generates these automatically):**

- `BASE_URL` — set to your Plausible URL (e.g., `https://analytics.yourdomain.com`)
- `SECRET_KEY_BASE` — a 64+ character random string for session encryption
- `TOTP_VAULT_KEY` — encryption key for two-factor authentication secrets

**Registration:**

- `DISABLE_REGISTRATION` — defaults to `invite_only`. This means only users you invite can create accounts. After your admin account is set up, you don&apos;t need to change this. If you want to lock it down completely, set it to `true` instead.

**SMTP (optional, for email reports and password resets):**

```bash
MAILER_EMAIL=analytics@yourdomain.com
SMTP_HOST_ADDR=smtp.yourdomain.com
SMTP_HOST_PORT=587
SMTP_USER_NAME=analytics@yourdomain.com
SMTP_USER_PWD=your_smtp_password
SMTP_HOST_SSL_ENABLED=true
```

Without SMTP, Plausible works fine — you just won&apos;t receive email reports or be able to reset passwords via email.

**Google Search Console integration (optional):**

```bash
GOOGLE_CLIENT_ID=your_client_id
GOOGLE_CLIENT_SECRET=your_client_secret
```

This lets Plausible import search query data from Google Search Console. You configure it in Plausible&apos;s site settings after deployment.

&lt;Picture
  src={img4}
  widths={[200, 400, 900]}
  sizes=&quot;(max-width: 900px) 100vw, 900px&quot;
  alt=&quot;Plausible environment variables in Coolify&quot;
/&gt;

For the full list of configuration options, see the [Plausible CE Configuration wiki](https://github.com/plausible/community-edition/wiki/Configuration).

Hit **Deploy**. Coolify pulls the Docker images, creates persistent volumes for PostgreSQL and ClickHouse, and starts all three containers. The first deploy takes a few minutes.

### 4.4 Access Plausible

After deployment completes, visit your Plausible URL (e.g., `https://analytics.yourdomain.com`). You&apos;ll see a registration page to create your admin account.

## Step 5: Add your first site

In the Plausible dashboard, click **Add a website** and enter your site&apos;s domain. Plausible generates a tracking snippet:

```html
&lt;script defer data-domain=&quot;yourdomain.com&quot;
  src=&quot;https://analytics.yourdomain.com/js/script.tagged-events.js&quot;&gt;
&lt;/script&gt;
```

Add this snippet to the `&lt;head&gt;` of every page on your website. Because the script loads from your own domain rather than a third-party server, it&apos;s less likely to be blocked by ad blockers or privacy extensions.

### Tracking script variants

Plausible offers several script variants for additional tracking:

- `script.js` — basic pageview tracking
- `script.hash.js` — tracks hash-based routing (for SPAs)
- `script.outbound-links.js` — tracks clicks on external links
- `script.file-downloads.js` — tracks file download clicks
- `script.tagged-events.js` — enables custom event tracking via CSS classes

Combine them: `script.tagged-events.outbound-links.file-downloads.js`. Adding extensions increases the file size, but it remains far smaller than Google Analytics even with everything enabled.

## Data persistence and backups

Plausible stores data in two databases:

- **PostgreSQL** — user accounts, site configuration, goals
- **ClickHouse** — analytics events (the bulk of your data)

Both run as Docker containers with persistent volumes managed by Coolify. If you redeploy Plausible, your data survives.

For backups, use Coolify&apos;s built-in S3 backup for PostgreSQL. For ClickHouse, ensure your VPS backup strategy includes Docker volumes or use a provider with automatic snapshots.

## Updating Plausible

To update Plausible CE, change the image tag in the Coolify service settings (e.g., from `v3.2.0` to `v3.2.1`) and redeploy. Coolify pulls the new image and restarts the container. PostgreSQL and ClickHouse migrations run automatically on startup.

Watch the [Plausible CE releases](https://github.com/plausible/analytics/releases) for security patches and new features. The v3.2.1 release (May 2026) fixed a security vulnerability (CVE-2026-8467) — update if you&apos;re running an older version.

## More Coolify tutorials

- [Coolify Install Guide](https://www.bitdoze.com/coolify-install-heroku-alternative/)
- [Install Uptime Kuma with Coolify](https://www.bitdoze.com/deploy-uptime-kuma/)
- [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/)

## Conclusion

Self-hosted Plausible on Coolify gives you privacy-first analytics with no recurring fees and no pageview limits. The one-click service template handles the complex parts (ClickHouse, PostgreSQL, networking, SSL). You configure environment variables, deploy, and start tracking.

The script loads fast from your own domain and won&apos;t get blocked by ad blockers, so your analytics are more accurate than a typical cloud setup — and visitors never see a cookie consent banner.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>self-hosting</category><category>coolify</category><category>plausible</category></item><item><title>Shots.so: Free Mockup Generator for Screenshots and Devices</title><link>https://www.bitdoze.com/shots-so-mockups/</link><guid isPermaLink="true">https://www.bitdoze.com/shots-so-mockups/</guid><description>Shots.so is a free mockup generator that turns screenshots into polished device mockups. This review covers features, pricing, and how to use it for social media and blog images.</description><pubDate>Mon, 06 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

If you need polished device mockups for social media, blog posts, or product pages and don&apos;t want to open Figma or Photoshop, [Shots.so](https://shots.so/) does the job in your browser. Drop in a screenshot, pick a device frame and background, export.

It&apos;s been around since 2022 and has become a go-to tool for indie hackers and developers who want good-looking visuals without a designer. The free tier is generous enough for most casual use.

## What Shots.so does

Shots.so takes a screenshot or image and wraps it in a device frame (iPhone, MacBook, browser, iPad, Android phone, Apple Watch) on top of a customizable background. You can export the result as a static image or, on paid plans, as video.

The workflow is:

1. Upload your screenshot or paste an image
2. Choose a device frame and screenshot style
3. Pick a background (gradient, solid color, image, or AI-generated &quot;magic&quot; background)
4. Add effects if you want (shadows, VFX, 3D shapes)
5. Export as PNG (free) or WebP/video (paid)

No account needed to start. No watermark on free exports.

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/enJYtsPIxYY&quot;
  label=&quot;Shots.so - Create Beautiful Mockups&quot;
/&gt;

## Features

### Device frames

Shots.so supports a solid range of devices: iPhone (including iPhone 17), MacBook, iPad, iMac, Apple Watch, Android phones, and browser frames. You pick the device and the screenshot adapts to fit.

### Screenshot styles

Multiple ways to style the screenshot inside the frame:

- **Default** — clean, no effects
- **Glass Light / Glass Dark** — frosted glass overlay
- **Liquid Glass** — the trendy translucent glass effect
- **Inset Light / Inset Dark** — recessed screen look
- **Outline** — wireframe border
- **Border** — solid border around the screen
- **Shadow** — drop shadow on the device

### Backgrounds

This is where Shots.so stands out. Background options include:

- **Solid and gradient** colors with full customization
- **Glass** backgrounds with blur effects
- **Preset collections** — Cosmic, Mystic, Desktop, Abstract, Earth, Radiant, Texture, By Paper
- **Unsplash integration** — search and use stock photos directly
- **Magic backgrounds** — AI-generated backgrounds based on your uploaded media&apos;s colors and style
- **Transparent** — export with no background (useful for compositing)

### Effects and scenes

- **VFX overlays** — noise, VHS, glitch effects
- **3D shapes** — geometric elements you can add to compositions
- **Shadow scenes** — realistic shadows cast by the device
- **Scene presets** — none, shadow, shapes

### Frame presets and aspect ratios

Magic Preset auto-suggests layouts. You can also pick specific aspect ratios: 16:9, 4:3, 3:2, 1:1, 9:16. Social media presets for Instagram, Twitter/X, and Pinterest are built in, so you don&apos;t have to guess dimensions.

### Animated mockups

On paid plans, Shots.so supports animated mockups with video zoom effects and motion presets. This is useful for Product Hunt galleries, changelog posts, or landing page hero sections where a static image isn&apos;t enough.

## Pricing

| Plan | Price | What you get |
|------|-------|-------------|
| **Free** | $0 | PNG export, no watermark, all device frames, backgrounds, basic styles |
| **Basic** | ~$8/month | Higher resolution exports, more export options |
| **Pro** | ~$12/month | WebP export, transparent video (WebM), animated mockups, video zoom |

The free tier is genuinely usable. If you only need a few static mockups per month for tweets or blog posts, you won&apos;t need to pay. The paid plans make sense if you need video exports, WebP format, or transparent backgrounds regularly.

## When to use Shots.so

**Good for:**

- Quick hero images for blog posts and documentation
- Social media posts showing off a product UI or app screenshot
- Developer portfolios — frame your side projects nicely
- Changelog and announcement images
- One-off mockups where speed matters more than pixel-perfect control

**Not ideal for:**

- App Store / Google Play screenshot submissions (no batch export for all required sizes)
- Product Hunt gallery images at specific dimensions (no dedicated template)
- OG images at 1200×630 (no built-in template)
- Team workflows with shared brand assets

If you need App Store screenshot sets or launch kits, tools like Screenhance or AppLaunchFlow are built for that. Shots.so is a single-shot beautifier, and it&apos;s very good at that job.

## Shots.so vs alternatives

| Tool | Free tier | Strengths | Weaknesses |
|------|-----------|-----------|------------|
| **Shots.so** | Generous, no watermark | Fast UI, great backgrounds, wide device library | No App Store sets, limited templates |
| **Mokkit** | Preview free, export on paid | Full keyframe animation timeline, URL screenshot capture | Fewer devices (no Android/iPad yet) |
| **Screenhance** | 3 exports/month | App Store sets, animated GIF/WebM, 100+ templates | $6+ per launch or $12/month |
| **Device Shots** | Free | Simple, fast | Fewer customization options |
| **Mockupviews** | 100% free | No signup, no watermark | Newer, smaller feature set |

## How to use Shots.so (quick start)

1. Go to [shots.so](https://shots.so/) — no signup required
2. Click the media area or drag and drop your screenshot
3. Select a device frame from the **Device** section
4. Pick a screenshot style (Glass, Inset, Outline, etc.) from **Style**
5. Choose a background from the **Background** panel — try &quot;Magic&quot; for auto-generated gradients
6. Adjust the layout and zoom if needed
7. Click **Export** in the top right, choose PNG resolution, and download

The whole process takes under a minute once you know the interface.

## Conclusion

Shots.so is a solid, free mockup generator for developers and creators who want good-looking device frames without fussing with design tools. The free tier handles most use cases. The paid plans add video and advanced export formats.

It won&apos;t replace a full design workflow or handle App Store submissions, but for the &quot;I need a nice screenshot for this tweet or blog post&quot; use case, it&apos;s hard to beat for speed and quality.

&lt;Button link=&quot;https://shots.so/&quot; text=&quot;Try Shots.so Free&quot; /&gt;</content:encoded><category>tools</category><category>mockups</category><category>design</category></item><item><title>How To Deploy Uptime Kuma With One Click</title><link>https://www.bitdoze.com/deploy-uptime-kuma/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-uptime-kuma/</guid><description>Learn how to deploy Uptime Kuma with 1 click in Docker via Coolify. Covers v2.x features including new notification providers, Globalping DNS, OracleDB monitoring, and more.</description><pubDate>Sun, 05 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/03/01createservice.png&quot;;
import img2 from &quot;../../assets/images/23/03/02chooseuptimekuma.png&quot;;
import img3 from &quot;../../assets/images/23/03/03uptimekumaconfigs.jpeg&quot;;
import img4 from &quot;../../assets/images/23/03/04accessuptimekuma.png&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[Uptime Kuma](https://www.bitdoze.com/uptime-kuma-tool/) is an open source monitoring tool that you can install on your VPS via Docker. [Uptime Kuma](https://uptimekuma.org/) will help you monitor your website and send alerts when it is down. Now at version **2.4.0**, Uptime Kuma has seen massive improvements since its v1 days — including a revamped notification system, new monitor types, Globalping DNS integration, collapsible status page groups, and much more. In this tutorial, we will see how we can install it via Coolify with just 1 click.

If you don&apos;t know, Coolify is a self-hosted Heroku or Netlify alternative that also allows you to deploy various Docker apps. For more details you can check out: [Coolify Install A Free Heroku and Netlify Self-Hosted Alternative](https://www.bitdoze.com/coolify-install-heroku-alternative/) where we go into more detail.

## 1. Deploy A VPS server

To host Uptime Kume in a Docker container you need a VPS server, there are a lot of services that can help you with this, the most known are [DigitalOcean](https://go.bitdoze.com/do), [Vultr](https://go.bitdoze.com/vultr), [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), I also wrote an article and made a video with the benchmarks here: [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/) you can check it out.

In this tutorial, we are going to use Hetzner for this where we have the VPS created and we can ssh to it to have Coolify installed and then deploy Uptime Kuma.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## 2. Installing Coolify

Coolify can be installed with a simple command, you can also check my tutorial for complete steps including the SSL certificate: [Coolify Install](https://www.bitdoze.com/coolify-install-heroku-alternative/)

We will install Coolify on a Ubuntu server (22.04 or 24.04 LTS recommended). To do this, you just need to SSH to the VPS server and run the following command:

```bash
wget -q https://get.coollabs.io/coolify/install.sh \
-O install.sh; sudo bash ./install.sh
```


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

If you are interested in how to monitor your CPU and have an automatic email sent when the load is too high, you should have a look at [Monitor CPU Usage and Send Email Alerts in Linux](https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/)

## 🎥 Uptime Kuma Video with Deployment

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/GYbqyhs4suk&quot;
  label=&quot;How To Deploy Uptime Kuma With One Click&quot;
/&gt;

## 3. Deploy the Uptime Kuma service with Coolify

### 3.1 Point Domain or Subdomain to VPS

We need a domain or subdomain that we can use to access [Uptime Kuma](https://uptimekuma.org/), in this tutorial we are going to use CloudFlare and point a subdomain to the VPS server where we have Coolify installed. We will not be using the CloudFlare proxy and will leave it disabled. All you need to do is add an A record and point it to the server IP.

### 3.2 Create a service

You need to go to Create New Resource and select **Service**. From there select the **UptimeKuma** service as shown in the images below:

&lt;Picture
  src={img1}
  alt=&quot;Coolify service create&quot;
/&gt;
&lt;Picture
  src={img2}
  alt=&quot;Coolify choose UptimeKuma&quot;
/&gt;

### 3.3 Configure Coolify Uptime Kuma

The next thing on the list is to configure the Coolify service, in there you need to add the below:

- **Name** - here you add the name of your deployment
- **Version / Tag** - you choose the Uptime Kuma version you want to install
- **URL (FQDN)** - you add the domain or sudomain that you want to use

&lt;Picture
  src={img3}
  alt=&quot;Coolify UptimeKuma Configs&quot;
/&gt;

After what needs to be done is to hit Save and Deploy button.

### 3.4 Access Uptime Kuma

After deploy is finished you should go and access Uptime Kuma dashboard with the URL you have added in the configs. The first time you will be prompted to create a user and a password.

&lt;Picture
  src={img4}
  alt=&quot; UptimeKuma Access&quot;
/&gt;

After you create the account you can add your website that needs to be monitored and configure the notifications. The configs will be stored on the disks with docker volums and in case you redeploy it you will not lose any data.

## What&apos;s New in Uptime Kuma v2.x

Uptime Kuma has evolved significantly since v1. If you&apos;re upgrading or deploying fresh, here are the highlights from the v2.x series:

### v2.0 — Major Release (October 2024)

The big rewrite with breaking changes. Key improvements:
- **Better performance** — completely rewritten backend for faster monitoring
- **New notification providers**: Nextcloud Talk, Brevo, Evolution API
- **Proxy support** for notifications via environment variable
- **Improved mobile UI** — buttons no longer go off-screen on small devices
- **2FA autofocus** — smoother two-factor authentication login
- Migration guide available on the [Uptime Kuma Wiki](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2)

### v2.1 — Globalping &amp; Custom Templates (February 2025)

- **Globalping DNS support** — monitor DNS resolution from distributed probes worldwide
- **New providers**: Jira Service Management, Google Sheets, Teltonika SMS gateway
- **Custom message templates** for Discord and ntfy notifications
- **Tags in Teams notifications**
- **Bavarian German language support**

### v2.2 — WhatsApp &amp; Structured Logging (March 2025)

- **WhatsApp (360Messenger)** notification provider
- **Signal templating** — customize your Signal notification messages
- **SOCKS proxy for notifications** — route alerts through a SOCKS proxy
- **Fluxer notification provider**
- **Structured JSON logging** for better log aggregation

### v2.3 — WebSocket &amp; OracleDB (May 2025)

- **WebSocket monitor improvements** — now supports authentication
- **OracleDB monitor** — monitor Oracle database connectivity
- **New providers**: Telnyx messaging, VK notifications, MAX messenger
- **Collapsible groups on status pages** — organize monitors into expandable sections
- **SQLite busy_timeout fix** — resolves `SQLITE_BUSY` errors on low-power devices

### v2.4 — Latest (May 2026)

- **VKTeams bot** notification provider
- **Incidents in RSS** — subscribe to incident updates via RSS feed
- **EgoSMS** for Uganda SMS notifications
- **Bearer token support** for WebSocket upgrade monitors
- **LiquidJS security fix** for notification templates

&lt;Notice type=&quot;info&quot; title=&quot;Upgrading from v1?&quot;&gt;
If you&apos;re still running Uptime Kuma v1, follow the official [Migration Guide](https://github.com/louislam/uptime-kuma/wiki/Migration-From-v1-To-v2) before upgrading. Always back up your data first!
&lt;/Notice&gt;

## Conclusions

This is how you can easily deploy Uptime Kuma with the help of Coolify. The config has everything that it needs and you can access it securely with an SSL certificate. With v2.x, Uptime Kuma has grown from a simple uptime monitor into a full-featured monitoring platform with dozens of notification providers, database monitors, DNS probing via Globalping, and a polished status page system. Whether you&apos;re monitoring a single blog or an entire infrastructure, Uptime Kuma covers you.</content:encoded><category>self-hosting</category><category>coolify</category></item><item><title>Cognee vs Hindsight: Which Agent Memory System Should You Self-Host?</title><link>https://www.bitdoze.com/cognee-vs-hindsight/</link><guid isPermaLink="true">https://www.bitdoze.com/cognee-vs-hindsight/</guid><description>Compare Cognee and Hindsight for AI agent memory. Architecture, retrieval strategies, integrations, benchmark performance, and when to use each one.</description><pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;

I&apos;ve been running both Cognee and Hindsight for a few months now. They solve related problems but take very different approaches, and picking the wrong one for your use case will waste weeks of integration work.

Both are open source. Both go beyond basic RAG. Both give AI agents persistent, structured memory. That&apos;s where the similarities end.

This article breaks down how they differ architecturally, what each one is actually good at, which integrations they support, and when you should pick one over the other (or both).

## Quick comparison

| | Cognee | Hindsight |
|---|---|---|
| **Architecture** | Knowledge graph + vector search | Multi-strategy hybrid (semantic + BM25 + graph + temporal) |
| **Primary strength** | Data ingestion and knowledge extraction from documents | Agent interaction memory with multi-strategy retrieval |
| **Memory type** | Institutional (knowledge from your data) | Personalization + institutional |
| **Data sources** | 30+ connectors (PDFs, Slack, Notion, images, audio) | Agent conversations and structured input |
| **Benchmark (LongMemEval)** | Not ranked | 94.6% (state of the art) |
| **SDKs** | Python only | Python, TypeScript, Go |
| **Protocol** | REST API | MCP-first |
| **Storage** | SQLite + LanceDB + Kuzu (or PostgreSQL + pgvector) | PostgreSQL with pgvector |
| **License** | Open core | MIT |
| **Web UI** | CLI-only (`cognee-cli -ui`) | Built-in Control Plane |
| **GitHub stars** | ~12K | ~4K (growing fast) |

## How each one works

### Cognee: turn your data into a knowledge graph

Cognee is a pipeline. You feed it data from various sources, it extracts entities and relationships, builds a knowledge graph, and layers vector embeddings on top. When you query it, the graph traversal and vector search work together.

The core loop:

1. **Ingest** - Pull data from 30+ connectors (PDFs, Slack, Notion, databases, images, audio)
2. **Process** - Chunk text, extract entities, resolve relationships
3. **Store** - SQLite for metadata, LanceDB for vectors, Kuzu for the knowledge graph (or PostgreSQL + pgvector for all three)
4. **Query** - Hybrid graph traversal + vector similarity

The API is straightforward:

```python
import cognee

await cognee.remember(&quot;The production database runs PostgreSQL 17&quot;)
results = await cognee.recall(&quot;What database does production use?&quot;)
```

Cognee&apos;s new API uses `remember`, `recall`, `forget`, and `improve` operations. The `remember` call stores data in the knowledge graph, `recall` queries it, and `improve` lets the agent learn from feedback.

Where Cognee shines: taking a corpus of documents, meeting transcripts, or code repositories and turning them into structured, queryable knowledge. If your agent needs to answer questions grounded in company docs, Cognee&apos;s pipeline handles the extraction and graph construction.

### Hindsight: learn from agent interactions

Hindsight takes a different approach. Instead of ingesting external data sources, it captures and structures what agents actually learn during operation, conversations, decisions, corrections, and outcomes.

It organizes memory into three categories:

- **World facts** - Things that are true (&quot;The project uses PostgreSQL 17&quot;)
- **Experiences** - Things that happened (&quot;Last deployment broke because of a migration issue&quot;)
- **Mental models** - Patterns formed by reflecting on facts and experiences (&quot;This user prefers concise responses&quot;)

When you store a memory with `retain`, Hindsight runs an LLM to extract entities, relationships, and temporal data. When you search with `recall`, it runs four retrieval strategies in parallel:

1. Semantic search (vector similarity)
2. Keyword matching (BM25)
3. Graph traversal (entity and relationship links)
4. Temporal filtering (time ranges)

Results get merged with reciprocal rank fusion and reranked. This multi-strategy approach is why Hindsight scores 94.6% on the LongMemEval benchmark, the highest of any agent memory system tested.

```python
from hindsight_client import Hindsight

client = Hindsight(base_url=&quot;http://localhost:8888&quot;)

client.retain(bank_id=&quot;my-project&quot;, content=&quot;Alice prefers concise responses&quot;)
results = client.recall(bank_id=&quot;my-project&quot;, query=&quot;How should I talk to Alice?&quot;)
```

The `reflect` operation goes deeper. It pulls together related memories and generates new observations. An AI project manager could reflect on risks, a sales agent could reflect on outreach patterns, a support agent could reflect on gaps in documentation.

## Architecture differences

&lt;Tabs&gt;
  &lt;Tab name=&quot;Storage&quot;&gt;
    **Cognee** uses three storage layers by default: SQLite for metadata, LanceDB for vectors, and Kuzu for the knowledge graph. You can swap these for PostgreSQL + pgvector (handling both relational and vector data) plus Neo4j or FalkorDB for graphs. This flexibility is useful but means more moving parts.

    **Hindsight** uses a single PostgreSQL instance with pgvector. One database handles vectors, metadata, entity graphs, and temporal indexes. Simpler to deploy, simpler to back up, simpler to scale.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Retrieval&quot;&gt;
    **Cognee** combines graph traversal with vector similarity. The knowledge graph structure enables multi-hop queries, following entity relationships across connected data points. Good for &quot;What documents mention the same project as this meeting transcript?&quot;

    **Hindsight** runs four retrieval strategies in parallel and fuses results. Semantic search handles conceptual matches, BM25 catches exact terms, graph traversal follows entity links, and temporal filtering understands time expressions like &quot;last week&quot; or &quot;before the migration.&quot; No single strategy handles every query type, so the parallel approach covers blind spots.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Data ingestion&quot;&gt;
    **Cognee** has 30+ connectors: PDFs, Slack, Notion, Google Drive, SharePoint, databases, images (via vision models), audio (via transcription). If you need to ingest from external sources, Cognee is built for it.

    **Hindsight** doesn&apos;t connect to external data sources. It captures memory from agent interactions, conversations, decisions, corrections, and outcomes. You feed it information through the retain API, not through connectors.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Integrations

This is where the two diverge significantly.

### Cognee integrations

Cognee is Python-only for the SDK. If your agent is written in TypeScript or Go, you&apos;ll need to use the REST API or build a wrapper.

Available integrations:
- **MCP server** - Works with Cursor, Claude Code, and other MCP clients
- **Claude Code plugin** - Hooks into session lifecycle for automatic memory capture
- **OpenClaw plugin** - `cognee-openclaw` for OpenClaw agent workflows
- **REST API** - Full-featured API for any language

### Hindsight integrations

Hindsight ships SDKs for Python, TypeScript, and Go. The MCP-first design means any MCP-compatible agent works without an SDK.

Available integrations:
- **Claude Code** - Hooks for automatic conversation capture and context recall
- **OpenCode** - Community plugin with auto-retain and session-start recall
- **OpenClaw** - Direct integration with server-side access control
- **Hermes Agent** - Memory backend for the Hermes multi-agent framework
- **Agno** - Direct integration for structured long-term memory
- **Zed** - Long-term memory for the Zed editor&apos;s AI assistant
- **Cursor** - MCP server integration
- **n8n** - Community node for workflow automation
- **LangChain/LangGraph** - Memory Tools, Graph Nodes, BaseStore adapter
- **LlamaIndex** - BaseToolSpec and BaseMemory support
- **Vercel AI SDK** - Memory for AI SDK and AI Chatbot applications
- **Pipecat** - Memory for voice AI pipelines
- **Dify** - Plugin for chatflow and agent apps
- **LiteLLM** - Proxy callbacks for zero-code-change memory
- **CrewAI, Pydantic AI, AutoGen, AG2, Strands** - Framework-specific integrations

The breadth of Hindsight&apos;s integrations is a direct result of the multi-language SDK support. Cognee&apos;s Python-only approach limits its reach outside the Python ecosystem.

## Benchmark performance

Hindsight holds the top score on the LongMemEval benchmark at 94.6%. This benchmark tests memory system performance across conversational AI scenarios, including:

- Recalling facts from past conversations
- Understanding temporal relationships
- Handling multi-hop reasoning across memories
- Maintaining consistency over long interaction histories

Cognee doesn&apos;t have a published LongMemEval score. This doesn&apos;t mean it&apos;s worse at everything, it means the benchmark tests are focused on conversational memory, which is Hindsight&apos;s strength. Cognee&apos;s knowledge graph approach excels at different tasks, like extracting structured information from documents and reasoning across connected data points.

The benchmark matters if your use case is agent interaction memory. If your use case is document knowledge extraction, the benchmark is less relevant.

## Self-hosting both

Both tools work well with Docker Compose. I have guides for each:

- [How to Self-Host Cognee](/cognee-self-host/) - Full setup with Dokploy or Docker Compose, PostgreSQL, pgvector, and MCP integration
- [Deploy Hindsight on Docker](/hindsight-docker-deploy/) - Complete deployment with PostgreSQL, pgvector, and authentication

&lt;Notice type=&quot;info&quot; title=&quot;You can run both&quot;&gt;
Cognee and Hindsight aren&apos;t mutually exclusive. Use Cognee to build a knowledge layer from your document corpus and organizational data. Use Hindsight to give your agents memory of their runtime interactions and user-specific context. They complement each other.
&lt;/Notice&gt;

## When to use Cognee

Pick Cognee when:

- **Your primary need is knowledge extraction from existing data.** Thousands of PDFs, Slack threads, meeting transcripts, or code repositories that need to become structured, queryable knowledge.
- **You work with multimodal data.** Cognee processes images through vision models and audio through transcription, integrating extracted knowledge into the same graph.
- **Your stack is Python-only.** The Python SDK is clean and the &quot;6 lines of code&quot; claim holds for basic use cases.
- **You want a local-first knowledge graph.** SQLite + LanceDB + Kuzu runs entirely locally with no external dependencies.
- **Reducing hallucinations is your top priority.** The knowledge graph provides traceable paths from query to source.

## When to use Hindsight

Pick Hindsight when:

- **Your agent needs to learn from its own interactions.** User preferences, past decisions, corrections, and contextual history across sessions.
- **You need multi-strategy retrieval.** Some queries need semantic similarity, others need exact keywords, others need temporal reasoning. Running all four in parallel covers each method&apos;s blind spots.
- **Your stack is multi-language.** Python, TypeScript, and Go SDKs, plus MCP protocol support.
- **You want MCP-native integration.** Plug-and-play memory with any MCP-compatible agent.
- **Temporal reasoning matters.** Questions like &quot;What changed since last deployment?&quot; or &quot;What did the user say about pricing before the Q3 review?&quot;
- **You want a web UI.** Hindsight&apos;s Control Plane gives you a browser interface for managing memory banks and testing queries. Cognee&apos;s UI requires running a CLI locally.

## Can you use both together?

Yes. They solve different problems:

- **Cognee** builds knowledge from your existing data (documents, transcripts, code)
- **Hindsight** captures knowledge from agent runtime (conversations, decisions, outcomes)

A practical setup: Cognee ingests your company documentation and builds a knowledge graph. Hindsight remembers what your agent learns while helping users. The agent queries Cognee when it needs institutional knowledge and Hindsight when it needs to remember user preferences or past interactions.

Both run on PostgreSQL with pgvector, so you can use the same database server if you want to keep infrastructure simple.

## Cost comparison

Both are free to self-host. The main costs are:

- **Infrastructure** - A VPS with 4GB RAM handles both. PostgreSQL with pgvector is shared, so you&apos;re not doubling your database costs.
- **LLM API usage** - Both need an LLM for extraction and reasoning. Cognee uses it for entity extraction during ingestion. Hindsight uses it for fact extraction during retain operations and for reflect. Costs scale with how much data you process.
- **Embedding API usage** - Both generate vector embeddings. Similar cost profile since both use the same embedding models (OpenAI, Cohere, etc.).

Using local models with Ollama can reduce API costs to nearly zero for both tools, at the expense of slower processing and potentially lower extraction quality.

## The bottom line

Cognee is a knowledge extraction engine. Point it at your data, it builds a graph you can query. Hindsight is an interaction memory system. Let your agent use it, and it gets smarter over time.

If your agent forgets things users told it, pick Hindsight. If your agent can&apos;t answer questions about your documentation, pick Cognee. If both problems sound familiar, run both.

Memory tools sit next to assistants and frameworks in the [top AI GitHub repos](/top-ai-github-repos/) catalog.</content:encoded><category>ai</category><category>ai-agents</category><category>self-hosted</category><category>docker</category></item><item><title>Deploy Hindsight Agent Memory on Docker: Complete Setup Guide</title><link>https://www.bitdoze.com/hindsight-docker-deploy/</link><guid isPermaLink="true">https://www.bitdoze.com/hindsight-docker-deploy/</guid><description>Step-by-step guide to deploying Hindsight, an open-source agent memory system, using Docker Compose with PostgreSQL and pgvector for production use.</description><pubDate>Fri, 03 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import hindsightUi from &quot;../../assets/images/26/07/hindsight-ui.webp&quot;;

Most AI agents forget everything the moment a conversation ends. You tell them your preferences, correct their mistakes, feed them context, and the next session starts from scratch. Hindsight fixes that.

[Hindsight](https://github.com/vectorize-io/hindsight) is an open-source agent memory system built by Vectorize.io. It doesn&apos;t just store conversation history like a glorified chat log. Instead, it extracts facts, builds mental models, and learns from interactions over time. On the LongMemEval benchmark (the standard test for agent memory), it outperforms every other solution currently available.

The core idea: agents should get better the more you use them, the same way a human assistant learns your preferences over weeks and months.

This guide walks through deploying Hindsight on Docker with a proper PostgreSQL backend, configuring it for production use, and interacting with it through the API and client libraries.

## What Hindsight actually does

Hindsight organizes memory into three categories:

- **World facts** - Things that are true (&quot;The project uses PostgreSQL 17&quot;)
- **Experiences** - Things that happened (&quot;Last deployment broke because of a migration issue&quot;)
- **Mental models** - Patterns formed by reflecting on facts and experiences (&quot;This user prefers detailed error messages over brief summaries&quot;)

When you add new information through the `retain` operation, Hindsight runs it through an LLM to extract entities, relationships, and temporal data. It stores these as a combination of vector embeddings, keyword indexes, and graph structures.

When you search with `recall`, it runs four retrieval strategies in parallel:

1. Semantic search (vector similarity)
2. Keyword matching (BM25)
3. Graph traversal (entity and relationship links)
4. Temporal filtering (time ranges)

Results get merged with reciprocal rank fusion and reranked for relevance.

The third operation, `reflect`, goes deeper. It pulls together related memories and generates new observations. Think of it as the agent thinking about what it knows, rather than just retrieving it.

## Prerequisites

You&apos;ll need:

&lt;ListCheck&gt;
- A VPS or home server running Linux. I recommend [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting
- Docker and Docker Compose installed
- An OpenAI API key (or another supported LLM provider)
- At least 2 GB of RAM available (the slim image uses less, the full image needs more)
&lt;/ListCheck&gt;

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Docker image variants

Hindsight publishes two image variants:

| Variant | Tag | Size (AMD64) | What it includes |
|---------|-----|--------------|------------------|
| Full | `latest` | ~9 GB | Local embedding model (BGE), local reranker (MiniLM), all dependencies |
| Slim | `latest-slim` | ~500 MB | No local models, requires external embedding and reranker providers |

The full image works out of the box but takes up significant disk and RAM. The slim image delegates embeddings and reranking to external services, which is what this guide uses since most people deploying on a VPS want to keep resource usage down.

With the slim image, you need:
- An embedding provider (OpenAI, Cohere, or a local TEI instance)
- A reranker provider (RRF algorithmic reranker works fine and is free, or use an external service)

## Deploy Hindsight with Docker Compose

This setup uses two containers: PostgreSQL with pgvector for the database, and Hindsight itself. The pgvector extension enables the vector similarity search that powers semantic recall.

### Create the project directory

```bash
mkdir -p ~/docker-apps/hindsight
cd ~/docker-apps/hindsight
```

### Create the environment file

```bash
cat &gt; .env &lt;&lt; &apos;EOF&apos;
# Hindsight Deployment
OPENAI_API_KEY=your-openai-api-key-here
DB_PASSWORD=choose-a-strong-password
HINDSIGHT_ACCESS_KEY=choose-an-access-key
EOF
```

Replace the values:
- `OPENAI_API_KEY` - Your OpenAI API key (starts with `sk-`)
- `DB_PASSWORD` - A strong password for the PostgreSQL user
- `HINDSIGHT_ACCESS_KEY` - A key you&apos;ll use to authenticate API calls and log into the web UI

### Create the Docker Compose file

```yaml
services:
  db:
    image: pgvector/pgvector:pg17
    container_name: hindsight-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: hindsight
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: hindsight
    volumes:
      - ./pgdata:/var/lib/postgresql/17/docker
    networks:
      - web

  hindsight:
    image: ghcr.io/vectorize-io/hindsight:latest-slim
    container_name: hindsight-app
    restart: unless-stopped
    ports:
      - &quot;18888:8888&quot;
      - &quot;9999:9999&quot;
    environment:
      # LLM
      - HINDSIGHT_API_LLM_PROVIDER=openai
      - HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY}
      - HINDSIGHT_API_LLM_MODEL=gpt-4o-mini
      # Embeddings
      - HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
      - HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=${OPENAI_API_KEY}
      # Reranker (algorithmic, no cost)
      - HINDSIGHT_API_RERANKER_PROVIDER=rrf
      # Database
      - HINDSIGHT_API_DATABASE_URL=postgresql://hindsight:${DB_PASSWORD}@db:5432/hindsight
      - HINDSIGHT_API_WORKER_ID=hindsight-prod
      # API Authentication (Bearer token required for all API calls)
      - HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
      - HINDSIGHT_API_TENANT_API_KEY=${HINDSIGHT_ACCESS_KEY}
      # Control Plane auth (login required for Web UI)
      - HINDSIGHT_CP_ACCESS_KEY=${HINDSIGHT_ACCESS_KEY}
      # Control Plane -&gt; API auth
      - HINDSIGHT_CP_DATAPLANE_API_KEY=${HINDSIGHT_ACCESS_KEY}
    depends_on:
      - db
    networks:
      - web

networks:
  web:
    external: true
```

### What each setting does

**LLM configuration** - Hindsight needs an LLM for fact extraction, entity resolution, and generating responses. The `gpt-4o-mini` model works well and keeps costs low. You can swap this for `gpt-4o` if you need better extraction quality, or use a different provider entirely (Anthropic, Gemini, Groq, Ollama).

**Embeddings** - These convert text into vector representations for semantic search. OpenAI&apos;s embedding model is the easiest option with the slim image.

**Reranker** - The `rrf` (Reciprocal Rank Fusion) option uses an algorithmic approach that costs nothing. It merges results from the four retrieval strategies without needing a separate ML model. If you want better ranking accuracy, you can point this to an external cross-encoder service.

**Worker ID** - Set this to a stable value. Without it, Docker assigns the container hostname as the worker ID, which changes on every restart. Any task being processed when the container goes down stays parked under the old ID with no way for the new container to pick it up.

**Authentication** - Three related settings that all use the same access key:
- `HINDSIGHT_API_TENANT_API_KEY` - Required as a Bearer token for all API calls
- `HINDSIGHT_CP_ACCESS_KEY` - Login password for the web UI
- `HINDSIGHT_CP_DATAPLANE_API_KEY` - How the web UI authenticates to the API

&lt;Notice type=&quot;info&quot; title=&quot;Network setup&quot;&gt;
This compose file assumes you have an existing Docker network called `web`. Create it with `docker network create web` if you haven&apos;t already. If you&apos;re running this standalone without Traefik or other reverse proxies, you can remove the networks section and the `external: true` line.
&lt;/Notice&gt;

### Start the services

```bash
docker compose up -d
```

Check that both containers are running:

```bash
docker compose ps
```

You should see `hindsight-db` and `hindsight-app` both in the running state.

Check the logs if something isn&apos;t right:

```bash
docker compose logs hindsight
```

### Verify the deployment

The API should be available at `http://your-server-ip:18888` and the web UI at `http://your-server-ip:9999`.

Test the API with a quick health check:

```bash
curl http://localhost:18888/v1/health
```

Open the web UI in your browser and enter your access key when prompted. The Control Plane lets you manage memory banks, browse stored entities, and test queries without writing code.

&lt;Picture src={hindsightUi} alt=&quot;Hindsight Control Plane web UI&quot; formats={[&quot;webp&quot;, &quot;png&quot;]} /&gt;

## Using the Hindsight API

### Install the client

&lt;Tabs&gt;
  &lt;Tab name=&quot;Python&quot;&gt;
    ```bash
    pip install hindsight-client
    ```
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Node.js&quot;&gt;
    ```bash
    npm install @vectorize-io/hindsight-client
    ```
  &lt;/Tab&gt;
  &lt;Tab name=&quot;CLI&quot;&gt;
    ```bash
    curl -fsSL https://hindsight.vectorize.io/get-cli | bash
    ```
  &lt;/Tab&gt;
&lt;/Tabs&gt;

### Basic operations

All three operations work on memory banks. A bank is a namespace for a set of related memories. You might have one bank per user, per project, or per agent, depending on your use case.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Python&quot;&gt;
    ```python
    from hindsight_client import Hindsight

    client = Hindsight(
        base_url=&quot;http://your-server:18888&quot;,
        api_key=&quot;your-access-key&quot;
    )

    # Store a memory
    client.retain(
        bank_id=&quot;my-project&quot;,
        content=&quot;The production database runs PostgreSQL 17 with pgvector&quot;
    )

    # Search for memories
    results = client.recall(
        bank_id=&quot;my-project&quot;,
        query=&quot;What database does production use?&quot;
    )

    # Deep analysis of existing memories
    insights = client.reflect(
        bank_id=&quot;my-project&quot;,
        query=&quot;What do I know about the production infrastructure?&quot;
    )
    ```
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Node.js&quot;&gt;
    ```javascript
    import { HindsightClient } from &apos;@vectorize-io/hindsight-client&apos;;

    const client = new HindsightClient({
      baseUrl: &apos;http://your-server:18888&apos;,
      apiKey: &apos;your-access-key&apos;
    });

    // Store a memory
    await client.retain(&apos;my-project&apos;,
      &apos;The production database runs PostgreSQL 17 with pgvector&apos;
    );

    // Search for memories
    const results = await client.recall(&apos;my-project&apos;,
      &apos;What database does production use?&apos;
    );

    // Deep analysis
    const insights = await client.reflect(&apos;my-project&apos;,
      &apos;What do I know about the production infrastructure?&apos;
    );
    ```
  &lt;/Tab&gt;
  &lt;Tab name=&quot;CLI&quot;&gt;
    ```bash
    # Store a memory
    hindsight memory retain my-project \
      &quot;The production database runs PostgreSQL 17 with pgvector&quot;

    # Search for memories
    hindsight memory recall my-project \
      &quot;What database does production use?&quot;

    # Deep analysis
    hindsight memory reflect my-project \
      &quot;What do I know about the production infrastructure?&quot;
    ```
  &lt;/Tab&gt;
&lt;/Tabs&gt;

### Adding context and timestamps

You can enrich memories with metadata:

```python
client.retain(
    bank_id=&quot;my-project&quot;,
    content=&quot;Migrated from SQLite to PostgreSQL after hitting performance issues&quot;,
    context=&quot;database migration&quot;,
    timestamp=&quot;2026-06-15T10:00:00Z&quot;
)
```

This helps Hindsight organize memories temporally and understand the context in which information was recorded.

### Using the LLM wrapper

The fastest way to add memory to an existing agent is the LLM wrapper. It sits between your code and the LLM API, automatically storing and retrieving memories as you make calls:

```python
from hindsight import HindsightLLMWrapper
from openai import OpenAI

openai_client = OpenAI(api_key=&quot;your-openai-key&quot;)
wrapped_client = HindsightLLMWrapper(
    client=openai_client,
    hindsight_url=&quot;http://your-server:18888&quot;,
    hindsight_api_key=&quot;your-access-key&quot;,
    bank_id=&quot;my-agent&quot;
)

# Use it exactly like the OpenAI client
# Memories are stored and retrieved automatically
response = wrapped_client.chat.completions.create(
    model=&quot;gpt-4o-mini&quot;,
    messages=[{&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: &quot;What did we discuss yesterday?&quot;}]
)
```

## LLM provider options

Hindsight supports several LLM providers. The choice affects both cost and quality:

| Provider | Models | Notes |
|----------|--------|-------|
| OpenAI | gpt-4o-mini, gpt-4o | Good default choice. gpt-4o-mini is cheap and works well |
| Anthropic | Claude models | Strong extraction quality |
| Gemini | Gemini models | Google&apos;s offering |
| Groq | Various | Fast inference, lower cost. Recommended by Hindsight for speed |
| Ollama | Local models | Self-hosted, no API costs, needs more hardware |
| LM Studio | Local models | Another local option |

To switch providers, change `HINDSIGHT_API_LLM_PROVIDER` and the corresponding API key environment variable. For example, to use Groq:

```yaml
environment:
  - HINDSIGHT_API_LLM_PROVIDER=groq
  - HINDSIGHT_API_LLM_API_KEY=${GROQ_API_KEY}
  - HINDSIGHT_API_LLM_MODEL=gpt-oss-20b
```

## Exposing Hindsight securely

Running Hindsight on port 9999 is fine for local access. If you need to reach it from the internet, put it behind a reverse proxy.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Cloudflare Tunnel&quot;&gt;
    The easiest option if you already use Cloudflare. Add the tunnel container to your compose file and configure it to route traffic to the Hindsight services. No ports need to be exposed on the host.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Traefik&quot;&gt;
    Add labels to the hindsight service in your compose file for Traefik to pick up. You&apos;ll need separate routers for the API (port 8888) and the web UI (port 9999).
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Nginx&quot;&gt;
    Set up a reverse proxy config that forwards requests to the Hindsight ports. Make sure to pass the Authorization header through for API calls.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Keep the access key&quot;&gt;
Always keep `HINDSIGHT_API_TENANT_API_KEY` set. Without it, anyone who can reach the API port can read and write memories. The access key protects both the API and the web UI.
&lt;/Notice&gt;

## Backing up your data

The PostgreSQL data lives in the `./pgdata` directory. Back it up regularly:

```bash
# Simple file backup
tar -czf hindsight-backup-$(date +%Y%m%d).tar.gz pgdata/

# Or use pg_dump for a proper database dump
docker exec hindsight-db pg_dump -U hindsight hindsight &gt; hindsight-$(date +%Y%m%d).sql
```

For automated backups, add a cron job that runs one of these commands nightly and copies the output to your backup storage.

## Troubleshooting

&lt;Accordion label=&quot;Container won&apos;t start&quot; group=&quot;troubleshooting&quot;&gt;
Check the logs with `docker compose logs hindsight`. Common issues:

- **Database connection failed** - Make sure the db container is running and healthy. The hindsight container depends on it, but sometimes PostgreSQL takes a moment to initialize.
- **Invalid API key** - Verify your OpenAI key is correct and has credits available.
- **Port conflict** - Something else is using port 18888 or 9999. Change the host port in the compose file.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Memories aren&apos;t being recalled&quot; group=&quot;troubleshooting&quot;&gt;
- Make sure you&apos;re using the same `bank_id` for retain and recall operations.
- Check that the content you stored is relevant to your query. Hindsight uses semantic search, so exact keyword matches aren&apos;t required, but the meaning needs to align.
- Try the `reflect` operation for more thorough analysis if `recall` returns thin results.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;High memory usage&quot; group=&quot;troubleshooting&quot;&gt;
The full image loads local embedding and reranker models that consume 1.5-2 GB of RAM. Switch to the slim image (which this guide uses) to drop to around 500 MB for the Hindsight process itself. PostgreSQL will use whatever you give it, but 512 MB is enough for most workloads.
&lt;/Accordion&gt;

## Integrations with coding agents and AI tools

Hindsight plugs into most of the popular coding agents and AI assistants through its MCP server, direct SDK integrations, and hooks. If you&apos;re already running one of these tools, adding persistent memory is usually a few lines of config.

### Coding agents

**Claude Code** - Hindsight has first-class support through [hooks](https://hindsight.vectorize.io/integrations). Every conversation gets captured automatically, and relevant context is recalled on each prompt. You can also connect it as an [MCP server](https://hindsight.vectorize.io/sdks/integrations/local-mcp) for more control over when memories are stored and retrieved.

**[OpenCode](/opencode-setup-guide/)** - There&apos;s a [community plugin](https://hindsight.vectorize.io/integrations) that auto-retains conversations and recalls context on session start. It adds retain, recall, and reflect tools directly into OpenCode&apos;s tool palette.

**[OpenClaw](/clawdbot-setup-guide/)** - Hindsight integrates with OpenClaw to add memory capabilities to Claude-based agent workflows. The production memory infrastructure includes server-side access control and a plugin with auto-managed embeddings.

**Codex CLI** - Similar to the Claude Code integration, Hindsight hooks capture conversations and recall context automatically.

**Zed** - The Zed editor&apos;s AI assistant gets long-term memory through Hindsight&apos;s MCP server. Add it as an HTTP transport entry in Zed&apos;s MCP configuration.

**Cursor** - Connect Hindsight as an MCP server to give Cursor persistent memory across coding sessions.

### AI assistants and frameworks

**[Hermes Agent](/hermes-agent-setup-guide/)** - Hindsight serves as the memory backend for the Hermes multi-agent messaging framework. If you&apos;re running Hermes, this replaces the built-in MEMORY.md and session search with a more capable vector-based system.

**[Agno](/agno-get-start/)** - There&apos;s a direct integration for Agno agents. Instead of SQLite-backed chat history, you get structured long-term memory with entity extraction and semantic search.

**[Obsidian](https://obsidian.md)** - Through the MCP server, you can connect Obsidian&apos;s AI plugins to Hindsight for persistent memory across your notes and research workflows.

**n8n** - A community node for n8n workflows adds retain, recall, and reflect operations. Drop it into any workflow alongside Slack, Sheets, OpenAI, and 400+ other integrations.

### MCP server

The most flexible option is the MCP server itself. It works with any MCP-compatible client:

```bash
# Connect Claude Code
claude mcp add --transport http hindsight http://localhost:8888/mcp/

# Or use single-bank mode for a specific memory bank
claude mcp add --transport http hindsight http://localhost:8888/mcp/my-bank/
```

The MCP server exposes 29 tools including retain, recall, reflect, mental model management, directive creation, and memory browsing. See the [full integrations list](https://hindsight.vectorize.io/integrations) for all supported tools.

## What&apos;s next

Once Hindsight is running, here are some things to try:

- **Wire it into an existing agent** using the LLM wrapper for automatic memory management
- **Create separate memory banks** for different projects or users to keep memories organized
- **Set up the [Hindsight MCP server](https://github.com/vectorize-io/hindsight)** to give coding agents like Claude or Cursor persistent memory across sessions
- **Monitor memory growth** through the web UI&apos;s entity browser to understand what your agent is learning
- **Compare with [Cognee](/cognee-vs-hindsight/)** if you also need knowledge extraction from documents and multimodal data

Hindsight is one of those tools that gets more valuable the longer you run it. The first few days of memories are useful. A few months in, the agent starts making connections you didn&apos;t explicitly tell it about. That&apos;s the mental models kicking in, and it&apos;s where the real value shows up.</content:encoded><category>ai</category><category>ai-agents</category><category>docker</category><category>self-hosted</category></item><item><title>Coolify Install: Free Heroku and Netlify Self-Hosted Alternative</title><link>https://www.bitdoze.com/coolify-install-heroku-alternative/</link><guid isPermaLink="true">https://www.bitdoze.com/coolify-install-heroku-alternative/</guid><description>Learn how to install Coolify, a free self-hosted alternative to Heroku and Netlify. Deploy apps, databases, and 280+ services on your own VPS with one-click deploys.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import img1 from &quot;../../assets/images/23/02/admin_url_coolify.jpeg&quot;;
import img2 from &quot;../../assets/images/23/02/github-coolify.jpeg&quot;;
import img3 from &quot;../../assets/images/23/02/astro-Coolify1.jpeg&quot;;
import img4 from &quot;../../assets/images/23/02/astro-deploy-coolify.jpeg&quot;;
import img5 from &quot;../../assets/images/23/02/Coolify_astro_build.jpeg&quot;;

If you want a free, self-hosted alternative to Netlify or Heroku, [Coolify](https://coolify.io/) is the best option. It gives you automated deployments, SSL, databases, and 280+ one-click service templates, all running on your own server.

Coolify is open-source (52,000+ GitHub stars) and has been in active development since 2021. The v4.0.0 stable release dropped in April 2026 after two years of beta, and the project ships updates weekly. v4.1.2 is the latest release as of June 2026, with v5 (focused on multi-server scalability) in development.

&gt; **Already familiar with Coolify and want a deeper comparison?** See [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/).

## What Coolify can deploy

**Applications (via Nixpacks, Dockerfile, Docker Compose, or Docker Image)**

- Static sites (Astro, Hugo, Jekyll, plain HTML)
- Node.js (Next.js, Nuxt, Express, NestJS)
- Vue, React, Svelte/SvelteKit
- PHP, Laravel, Symfony
- Python (Django, Flask, FastAPI)
- Ruby on Rails, Phoenix (Elixir)
- Rust, Go, Deno
- Any Dockerfile or Docker Compose project

**Databases**

- PostgreSQL, MySQL, MariaDB
- MongoDB, Redis, KeyDB, Dragonfly
- CouchDB, ClickHouse, Databasus

**One-click services (280+ templates)**

WordPress, Ghost, n8n, Plausible Analytics, Uptime Kuma, MinIO, VaultWarden, Appwrite, Supabase, Directus, NocoDB, Meilisearch, Umami, Hasura, Beszel, Langfuse, and hundreds more. The full list is in the [community templates repo](https://github.com/coollabsio/coolify-community-templates).

**Supported architectures and operating systems**

- AMD64 and ARM64
- Debian-based (Ubuntu LTS 20.04, 22.04, 24.04, Debian)
- Red Hat-based (CentOS, Fedora, AlmaLinux, Rocky Linux)
- SUSE-based (SLES, openSUSE)
- Arch Linux, Alpine Linux, Raspberry Pi OS (64-bit)

Coolify deploys from GitHub, GitLab, Gitea, and Bitbucket. Push to your repo and Coolify rebuilds automatically, just like Netlify or Heroku.

## Minimum server requirements

- 2 CPU cores
- 2 GB RAM
- 30 GB free storage

These are minimums for Coolify itself. If you plan to run builds and multiple apps on the same server, go higher. An 8 GB RAM / 4-core server comfortably runs 10+ apps and several databases.

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/dY-hUI3fHEM&quot;
  label=&quot;Coolify Install A Free Heroku and Netlify Self-Hosted Alternative&quot;
/&gt;

## How to install Coolify on a VPS

You can use any VPS provider. [Hetzner](https://go.bitdoze.com/hetzner) offers the best price-to-performance for self-hosting. Other options: [DigitalOcean](https://go.bitdoze.com/do), [Vultr](https://go.bitdoze.com/vultr), [Hostinger](https://go.bitdoze.com/hostinger-vps). We have VPS benchmarks here: [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/).

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

### Step 1: SSH into your server and run the install script

The recommended install method is a single curl command. SSH to your VPS as root and run:

```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash
```

The script handles everything: installing dependencies (curl, wget, git, jq, openssl), Docker Engine 24+, creating directories under `/data/coolify`, generating SSH keys, and starting Coolify.

**Note for Ubuntu users:** The automatic script works with Ubuntu LTS versions only (20.04, 22.04, 24.04). If you run a non-LTS version like 24.10, use the [manual installation method](https://coolify.io/docs/get-started/installation).

**Docker via Snap is not supported.** If your server has Docker installed via Snap, remove it first and let the Coolify script install Docker Engine.

### Step 2: Access the Coolify dashboard

After installation completes, the script prints your Coolify URL. It will look like:

```
http://your-server-ip:8000
```

Visit that URL in your browser. You will see a registration page to create your first admin account.

**Important:** Create your admin account immediately. The registration page is public until an account exists. If someone else finds it first, they get control of your server.

### Step 3: Point a domain to the server

For production use, access Coolify over HTTPS with a proper domain. Add an A record pointing your domain or subdomain (e.g., `coolify.yourdomain.com`) to your server IP.

### Step 4: Set the admin URL

Go to **Settings &gt; Coolify Settings** and set the **URL (FQDN)** field to your domain. Coolify will automatically provision an SSL certificate via Let&apos;s Encrypt.

&lt;Picture
  src={img1}
  alt=&quot;Coolify admin URL settings&quot;
/&gt;

### Step 5: Connect a Git source

Go to **Create New Resource** and select your Git source (GitHub, GitLab, Gitea, or Bitbucket). Follow the prompts to connect your account. For GitHub, you will install the Coolify GitHub App on your repositories. The video above walks through this in detail.

### Step 6: Deploy an application

Here is a walkthrough deploying a static Astro site (works the same for any framework).

#### Create the application

Go to **Create New Resource &gt; Application** and select GitHub as the source:

&lt;Picture
  src={img2}
  alt=&quot;Selecting GitHub as the source in Coolify&quot;
/&gt;

Choose your repository and branch, then save. Coolify auto-detects the framework. In this case, it recognizes Astro:

&lt;Picture
  src={img3}
  alt=&quot;Coolify detecting Astro framework&quot;
/&gt;

#### Point the domain

Add an A record for your application&apos;s domain or subdomain pointing to the server IP.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

#### Set the domain in Coolify

Add the URL in the application settings. Coolify configures the reverse proxy and SSL automatically:

&lt;Picture
  src={img4}
  alt=&quot;Setting domain for Astro app in Coolify&quot;
/&gt;

Hit save, then hit deploy. The first build takes longer (Coolify pulls base images and installs dependencies). Subsequent builds are faster thanks to Docker layer caching.

#### Automatic deployments

Push a commit to your connected branch and Coolify rebuilds automatically. This is enabled by default under **Features &gt; Enable Automatic Deployment**. It works exactly like Netlify or Vercel deploy hooks.

&lt;Picture
  src={img5}
  alt=&quot;Coolify deployment build logs&quot;
/&gt;

### Step 7: Deploy a database

From the same **Create New Resource** screen, select a database type (PostgreSQL, MySQL, Redis, MongoDB, etc.) and deploy it. Coolify handles the Docker setup, volume persistence, and backup scheduling. You can expose databases publicly or keep them on the internal Docker network.

## More Coolify tutorials

- [Install Uptime Kuma with Coolify](https://www.bitdoze.com/deploy-uptime-kuma/)
- [Install Plausible Analytics with Coolify](https://www.bitdoze.com/install-plausible-analytics/)
- [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/)
- [Coolify v5 self-hosted PaaS review](/coolify-v5-self-hosted-paas-review/)

## Coolify Cloud

Don&apos;t want to manage a server yourself? Coolify Cloud is the managed version. You still get the same UI and features, but Coolify handles the infrastructure. Pricing starts at $5/month for 2 servers. See [coolify.io/cloud](https://coolify.io/cloud) for details.

## Conclusion

Coolify is a solid, free, self-hosted PaaS. It handles the full deployment lifecycle: Git integration, automatic builds, SSL, reverse proxy, database management, backups, and preview deployments. The install is a one-liner. The UI is intuitive. It supports every major framework and language.

If you are paying for Heroku, Vercel, or Netlify and want to cut costs without losing convenience, Coolify on a $5-10/month VPS does the job.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

For managing your server with a reverse proxy panel alongside Coolify, check out:

&lt;Button
  link=&quot;https://webdoze.net/courses/cloudpanel-setup/&quot;
  text=&quot;CloudPanel Setup Course&quot;
/&gt;</content:encoded><category>self-hosting</category><category>coolify</category><category>self-hosted</category></item><item><title>How To Deploy Static Website Astro.JS on VPS Servers</title><link>https://www.bitdoze.com/deploy-astro-on-vps/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-astro-on-vps/</guid><description>Step-by-step guide to deploying an Astro.js static site on a VPS server using CloudPanel, with NVM, SSL, and Cloudflare CDN.</description><pubDate>Thu, 02 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/cloudpanel-change-root.jpeg&quot;;
import img2 from &quot;../../assets/images/23/cloudflare_dns.jpeg&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Free hosting platforms like [Cloudflare Pages](https://www.bitdoze.com/deploy-astrojs-cloudflare/), Vercel, and Netlify work well for Astro sites. But they come with limits: build minutes, bandwidth caps, or platform lock-in. If you want full control over your server, deploying Astro on your own VPS is straightforward.

CloudPanel is a free hosting panel that runs on Nginx. It handles site management, SSL, and user isolation through a web UI. You can deploy Astro as a Static HTML site and let CloudPanel serve the built files directly, no Node.js process running at runtime.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Prerequisites

Before you start, you need:

- **A VPS server.** Any provider works. If you&apos;re choosing one, check the [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/) comparison.
- **CloudPanel installed.** Follow the [Install CloudPanel and Host Node.js Apps](https://www.bitdoze.com/install-cloudpanel-host-nodejs/) guide if you don&apos;t have it yet.
- **A domain name** pointed to your VPS IP.

You can also deploy Astro on a VPS with [Coolify](https://www.bitdoze.com/coolify-install-heroku-alternative/) or [EasyPanel](https://www.bitdoze.com/deploy-astro-easypanel/) if you prefer a different panel.

&gt; If you want to monitor CPU, memory, and disk space on your server, check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/kMtVBvO87pg&quot;
  label=&quot;How To Deploy Static Website Astro.JS on VPS Servers&quot;
/&gt;

## Deploy Astro.js on VPS with CloudPanel

There are two approaches: creating a **Static HTML site** (recommended for pure static output) or a **Node.js site** in CloudPanel. The Static HTML approach is simpler. CloudPanel serves the built files directly from Nginx with no Node.js process running.

### Step 1: Add a static site in CloudPanel

1. Log in to your CloudPanel admin panel.
2. Go to **Sites** &gt; **Add Site** &gt; **Create a Static HTML Site**.
3. Enter your domain name and create the site user credentials.
4. After the site is created, edit it and change the **Root Directory** to point to the `dist` directory:

```
htdocs/www.yourdomain.com/dist
```

Astro outputs its production build into `dist/` by default. CloudPanel needs to serve from that directory, not the project root.

&lt;Picture
  src={img1}
  alt=&quot;CloudPanel change root directory&quot;
/&gt;

### Step 2: Point DNS to your VPS

Add an A record in your DNS provider pointing your domain to the VPS IP address. If you use Cloudflare, add the A record there and enable the proxy for CDN benefits and DDoS protection.

&lt;Picture
  src={img2}
  alt=&quot;Cloudflare DNS settings&quot;
/&gt;

### Step 3: Generate an SSL certificate

In CloudPanel, go to your site&apos;s **SSL/TLS** section and click **New Let&apos;s Encrypt Certificate**. CloudPanel handles the certificate generation and Nginx configuration automatically.

Your site will now be accessible over HTTPS.

### Step 4: Install Node.js on the site user

Astro needs Node.js to build the site. CloudPanel creates isolated Linux users per site, so you need to install Node.js under the site user, not as root.

SSH into your VPS and switch to the site user:

```bash
ssh root@your-server-ip
sudo su - www.yourdomain.com
```

Install NVM (Node Version Manager):

```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
source ~/.bashrc
```

Install Node.js 22 LTS. This is the minimum version Astro 6 requires:

```bash
nvm install 22
nvm alias default 22
```

Verify the installation:

```bash
node -v
# Should output v22.x.x
```

### Step 5: Clone and set up your Astro project

Remove the default CloudPanel placeholder files and clone your project:

```bash
cd htdocs
rm -rf www.yourdomain.com
git clone git@github.com:your-username/your-astro-repo.git www.yourdomain.com
cd www.yourdomain.com
npm install
```

If you don&apos;t have a project yet, you can create one from scratch:

```bash
cd htdocs
rm -rf www.yourdomain.com
npm create astro@latest www.yourdomain.com
cd www.yourdomain.com
npm install
```

For a ready-made blog theme, check [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) or [AstroWind](https://github.com/arthelokyo/astrowind).

Make sure the project name matches the directory CloudPanel expects (`www.yourdomain.com`).

### Step 6: Build the site

Once you&apos;ve configured your site and added content, build the production version:

```bash
npm run build
```

This generates the static files in the `dist/` directory. Since CloudPanel&apos;s root directory is already set to `dist/`, your site is now live.

After building, verify it works by visiting your domain in a browser.

### Step 7: Rebuilding after changes

Every time you push new content or make changes, you need to rebuild:

```bash
cd htdocs/www.yourdomain.com
git pull
npm run build
```

If you want automatic deployments on every push, set up a deploy webhook:

1. Create a simple shell script on your VPS that pulls and rebuilds.
2. Use a GitHub webhook or CloudPanel&apos;s cron jobs to trigger it.

Alternatively, you can set up [CloudPanel&apos;s DPLOY](https://www.cloudpanel.io/docs/v2/dploy/installation/) for Git-based deployments.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Alternative: use CloudPanel&apos;s Node.js site type

Instead of the Static HTML approach, you can create a **Node.js site** in CloudPanel. This gives you a managed Node.js environment with NVM built in, so you don&apos;t need to install it manually.

1. Click **Add Site** &gt; **Create a Node.js Site**.
2. Select Node.js version **22 LTS**.
3. Set the app port (e.g., 3000).
4. SSH in as the site user, clone your project, and build with `npm run build`.
5. Point the root directory to `dist/`.

The Node.js site type is mainly useful if you later want to switch to SSR with an adapter. For a purely static site, the Static HTML approach uses fewer resources since there&apos;s no Node.js process running.

## Conclusions

Deploying Astro on your own VPS with CloudPanel is a practical option when you outgrow free hosting limits or want more control. The Static HTML site type is the simplest approach: CloudPanel serves the built files directly from Nginx, no Node.js process needed at runtime.

Put Cloudflare in front of it for CDN caching and security, and you get performance comparable to any managed hosting platform.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

If you want a web panel that also works as a reverse proxy for Docker containers, check this course:

&lt;Button
  link=&quot;https://webdoze.net/courses/cloudpanel-setup/&quot;
  text=&quot;CloudPanel Setup Course&quot;
/&gt;

For more Astro deployment options:

- [Deploy Astro.js on Cloudflare Pages](https://www.bitdoze.com/deploy-astrojs-cloudflare/) (free, zero-config hosting)
- [Astro deployment docs](https://docs.astro.build/en/guides/deploy/) (covers every supported platform)</content:encoded><category>web-development</category><category>cloudpanel</category><category>astro</category></item><item><title>FreeBuff: Free AI Coding Agent With No Subscription</title><link>https://www.bitdoze.com/freebuff-free-ai-coding-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/freebuff-free-ai-coding-agent/</guid><description>FreeBuff is a free, ad-supported AI coding agent that runs in your terminal and browser. Learn how to install it, what models it supports, and how well it handles real coding tasks.</description><pubDate>Wed, 01 Jul 2026 01:00:00 GMT</pubDate><content:encoded>FreeBuff is a free coding agent that runs in your terminal and browser. No subscription, no API keys to manage, no credit card. You install it, log in, and start coding with AI models like Xiaomi&apos;s MiMo V2.5, DeepSeek V4, and MiniMax M3.

I tested it by asking it to build an Astro website from scratch. Here&apos;s what happened.

&lt;Notice type=&quot;success&quot; title=&quot;What you&apos;ll learn&quot;&gt;

- **How FreeBuff works** and what models it uses
- **Installation and setup** in under a minute
- **FreeBuff Web** for building full-stack apps from a prompt
- **Real test results** building an Astro website with FreeBuff CLI
- **Where it fits** compared to paid tools like Claude Code and Cursor

&lt;/Notice&gt;

## What is FreeBuff?

FreeBuff is the free, ad-supported version of [Codebuff](https://github.com/CodebuffAI/codebuff), an open-source AI coding assistant. While Codebuff requires a subscription, FreeBuff gives you access to AI coding through text ads shown during your session.

The tool works in two ways: a **CLI agent** that runs in your terminal, and **FreeBuff Web** for building full-stack apps directly in your browser.

Unlike tools locked to a single provider, FreeBuff lets you pick from several models depending on what&apos;s available in your region. The multi-agent architecture coordinates specialized agents (file picker, planner, editor, reviewer) to handle tasks, rather than relying on one model to do everything.

&lt;Button
  text=&quot;Try FreeBuff&quot;
  url=&quot;https://go.bitdoze.com/freebuff&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;


## Available models

FreeBuff rotates between open-source and optimized models. What you see depends on your location:

| Model | Provider | Notes |
| --- | --- | --- |
| MiMo V2.5 | Xiaomi | 1T parameters, handles images, sound, and video |
| DeepSeek V4 Pro/Flash | DeepSeek | Strong coding performance |
| MiniMax M3 | MiniMax | Open-weight model released June 2026 |
| Kimi K2.6 | Moonshot | Available in some regions |

MiMo V2.5 stood out during testing. It&apos;s a smaller model compared to the others, but it handled the Astro project without getting stuck. It also has vision capabilities, which means you could potentially feed it screenshots of designs you want to recreate.

## Installing FreeBuff CLI

One command gets you started:

```bash
npm install -g freebuff
```

Then navigate to your project and run it:

```bash
cd your-project
freebuff
```

On first run, it&apos;ll ask you to log in. After that, you pick a model and start chatting.

You get 5 free sessions per day. Each session lets you work on a task until completion. If you refer friends, you get additional usage.

## FreeBuff Web: browser-based app builder

FreeBuff Web is a separate product that lets you type a prompt and get a working full-stack app. Think of it as a free alternative to Lovable, Bolt.new, or v0.

You go to [freebuff.com](https://go.bitdoze.com/freebuff), pick a template or describe what you want, and it generates a deployed app. The web interface supports the same models as the CLI.

There&apos;s also a &quot;Cloud&quot; feature in beta for coding directly in the cloud, though it&apos;s not available in all countries yet.

## Real test: building an Astro website

I put FreeBuff to work on a real task: creating a solar panel company website with Astro. The prompt included specific requirements for pages, Tailwind styling, and a professional layout.

Here&apos;s what happened:

**The good parts:**

- It generated all the pages (index, about, services, contact) without major hiccups
- Components were clean and well-structured with proper descriptions, titles, and meta tags
- The build completed successfully after FreeBuff automatically fixed some build errors
- Total time was around 10 minutes for a complete multi-page website
- The generated code was organized with proper Astro conventions

**The rough edges:**

- It didn&apos;t import Tailwind CSS in the layout file, which broke the styling. I had to add one line manually.
- It removed the SEO component and baked meta tags directly into the layout. Not ideal for a real project.
- Google Fonts weren&apos;t loaded using Astro&apos;s recommended approach
- Some accessibility issues with buttons appeared during the code review
- The AI reviewer subagent caught some problems and fixed them (like a missing favicon), but not all

After a quick manual fix for the Tailwind import, the site actually looked decent. Nice header with animations, working navigation, testimonials section, and a proper footer. For a free tool producing this in 10 minutes, that&apos;s not bad.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/1ZNq97WAfmQ&quot;
  label=&quot;FreeBuff: 100% Free &amp; Unlimited Coding Agent&quot;
/&gt;


&lt;Button
  text=&quot;Try FreeBuff&quot;
  url=&quot;https://go.bitdoze.com/freebuff&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## How it compares to paid tools

FreeBuff won&apos;t replace Claude Code or Cursor for complex backend work. The models are capable for front-end tasks and straightforward coding, but they hit limits with more demanding projects.

Where FreeBuff makes sense:

| Use case | FreeBuff | Paid tools |
| --- | --- | --- |
| Landing pages and static sites | Works well | Overkill |
| Learning to code with AI | Good starting point | Too expensive for experiments |
| Quick prototypes | Gets the job done | Faster but costs money |
| Complex backend systems | Will struggle | Better suited |
| Production applications | Not recommended | More reliable |

The previous attempt at this model (AMP Code) lasted a few months before the ad-supported approach wasn&apos;t sustainable. FreeBuff has been running for several months now, so it seems to be holding up better.

## Who should use FreeBuff?

**Beginners** who want to understand how AI coding agents work without spending money. You get a feel for the CLI workflow, learn how to prompt effectively, and see what these tools can do.

**Developers on a budget** who need help with small tasks. If you&apos;re building a personal project or need a quick prototype, FreeBuff handles that fine.

**Anyone curious about AI coding** who doesn&apos;t want to commit to a $20-50/month subscription just to try things out.

If you&apos;re already paying for Claude Code or Cursor and rely on them for production work, FreeBuff isn&apos;t going to replace that. But as a free tool to have in your toolkit, it&apos;s worth trying.

## Tips for better results

Based on my testing, here&apos;s what helps:

&lt;ListCheck&gt;

- **Be specific in your prompts** — tell it exactly what pages you want, what framework, and what styling approach
- **Provide documentation** — if you&apos;re using a specific framework like Astro, mention the docs so the agent uses the right patterns
- **Check the build** — FreeBuff runs builds automatically, but verify the output yourself
- **Fix small issues manually** — sometimes one missing import is faster to fix yourself than waiting for the agent

&lt;/ListCheck&gt;

## Referral program

FreeBuff offers extra usage if you refer other users. Share your referral link and get additional sessions beyond the daily 5 free ones. This helps if you&apos;re using it regularly for projects.

## FAQ

&lt;Accordion label=&quot;Is FreeBuff really free?&quot; group=&quot;faq&quot;&gt;

Yes. It&apos;s supported by text ads shown during your coding sessions. No credit card, no subscription, no API keys needed. You install it with npm and start using it.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the difference between FreeBuff and Codebuff?&quot; group=&quot;faq&quot;&gt;

Codebuff is the paid product with more models, no ads, and additional features. FreeBuff is the free, ad-supported version that uses optimized open-source models. Both share the same codebase and multi-agent architecture.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use FreeBuff for commercial projects?&quot; group=&quot;faq&quot;&gt;

You can, but the models used in Free mode are optimized for speed over depth. For production work, you&apos;d want to test thoroughly and consider whether the output meets your standards.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How many sessions do I get?&quot; group=&quot;faq&quot;&gt;

5 free sessions per day. Referring other users gives you additional sessions. The web interface has its own limits.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does FreeBuff work offline?&quot; group=&quot;faq&quot;&gt;

No. It needs an internet connection to access the AI models. Your files are edited locally, but the AI processing happens on FreeBuff&apos;s servers.

&lt;/Accordion&gt;

## Wrapping up

FreeBuff fills a gap in the AI coding tools space. Not everyone can or wants to pay $20-50/month for AI assistance, and FreeBuff gives you a working alternative. It has limitations — the models aren&apos;t as powerful as Claude or GPT-5, and you&apos;ll hit walls with complex projects. But for learning, prototyping, and small tasks, it does the job.

If you&apos;re interested in other free AI coding tools, check out [Amp Code Free](https://www.bitdoze.com/amp-code-free-ai-coding-agent/) or our guide on [AI coding tools for beginners](https://www.bitdoze.com/ai-programming-beginners-guide/).

&lt;Button
  text=&quot;Try FreeBuff&quot;
  url=&quot;https://go.bitdoze.com/freebuff&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;</content:encoded><category>ai</category><category>ai-tools</category></item><item><title>Astro 7 Benchmark: Build Times Cut in Half on a 743-Page Site</title><link>https://www.bitdoze.com/astro-7-faster-builds/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-7-faster-builds/</guid><description>Astro 7 ships a Rust compiler and Vite 8. I rebuilt the same 743-page site on Astro 6 and Astro 7 and total build time dropped from 103s to 47s. Real logs, what changed, and how I upgraded my own theme.</description><pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import cfAstro6 from &quot;../../assets/images/26/07/astro-6-cloudflare-page-build.webp&quot;;
import cfAstro7 from &quot;../../assets/images/26/07/astro-7-cloudflare-pages-build.webp&quot;;

Astro 7 went stable on June 22, 2026, and for once the &quot;faster&quot; in the release notes isn&apos;t marketing fluff. The `.astro` compiler, the piece that turns your components into HTML and JS, got rewritten from Go to Rust. I wanted a real number instead of a changelog promise, so I rebuilt this exact site, all 743 pages, on Astro 6 and again on Astro 7, right before and right after running the upgrade.

Total build time dropped from 103 seconds to 47 seconds. Same content, same machine, same `astro.config.mjs`. Here&apos;s the actual CLI output, what&apos;s behind the jump, and how I used the upgrade as an excuse to fix a few long-standing issues in my [Astro blog theme](https://github.com/bitdoze/bitdoze-astro-theme) too.

If you&apos;re starting a blog from zero instead of upgrading an existing one, the [free Astro + Cloudflare guide](/build-astro-blog-free/) now assumes Astro 7 from the first `npm install`.

## The benchmark

Both runs are `bun run build:ci` against the same repository, back to back, with only the dependency versions changed in between.

**Astro 6.1.9:**

```
08:32:14 [build] ✓ Completed in 101.77s.
08:32:14 [build] 743 page(s) built in 103.00s
08:32:14 [build] Complete!
```

**Astro 7.0.4:**

```
09:29:06 [build] ✓ Completed in 45.08s.
09:29:06 [build] 743 page(s) built in 46.92s
09:29:06 [build] Complete!
```

| Metric | Astro 6 | Astro 7 | Change |
|---|---|---|---|
| `✓ Completed in` | 101.77s | 45.08s | 2.3x faster |
| `page(s) built in` (743 pages) | 103.00s | 46.92s | 2.2x faster |
| Pages per second | ~7.2 | ~15.8 | +120% |

Nothing else moved between the two runs. No new caching, no config tweaks, no fewer pages. The version bump alone cut the build in half.

## It&apos;s faster on Cloudflare Pages too

The CLI log above is the clean, controlled number. What you actually feel is how long a deploy takes, so I pulled the Cloudflare Pages build logs for this site from both versions too.

**Astro 6, Cloudflare Pages:**

&lt;Picture src={cfAstro6} alt=&quot;Cloudflare Pages build log for bitdoze.com on Astro 6, finishing in about 7 minutes&quot; /&gt;

**Astro 7, Cloudflare Pages:**

&lt;Picture src={cfAstro7} alt=&quot;Cloudflare Pages build log for bitdoze.com on Astro 7, finishing in about 2 minutes&quot; /&gt;

| Where | Astro 6 | Astro 7 |
|---|---|---|
| Local `astro build` (743 pages) | 103.00s | 46.92s |
| Cloudflare Pages build | ~7 min | ~2 min |

Cloudflare&apos;s number covers more than the compiler: cloning the repo, installing dependencies, running the build, then uploading and deploying the output. It still dropped from about 7 minutes to about 2 minutes, and that&apos;s the number that actually matters, since Cloudflare Pages queues and bills builds by total time, not just the `astro build` step.

## Why it&apos;s actually faster

This isn&apos;t a mystery once you look at what shipped:

- **The compiler is now Rust.** Astro&apos;s `.astro`-to-HTML/JS compiler was rewritten from Go to Rust and ships as `@astrojs/compiler-rs`. Paired with the Rust-based Markdown pipeline that already landed in Astro 6.4, content-heavy sites get the biggest win, and most of this site&apos;s 743 pages are Markdown/MDX posts running straight through that pipeline.
- **Vite 8 underneath.** Astro 7 runs on Vite 8, which keeps leaning on Rolldown, Vite&apos;s Rust-based bundler, for more of the build.
- **Nothing to configure.** Both changes are internal to the toolchain. I didn&apos;t touch a single option in `astro.config.mjs` to get the speedup.

&lt;Notice type=&quot;warning&quot; title=&quot;One breaking change to check before you upgrade&quot;&gt;
Astro 7&apos;s compiler no longer silently fixes invalid HTML the way the old one did. Unclosed tags like `&lt;div&gt;Hello` and unterminated attributes now throw a build error instead of being auto-corrected. Run a full `astro build` locally right after upgrading. If something breaks, the error points at the exact file and line, and it&apos;s almost always one stray tag the old compiler had been quietly patching over.
&lt;/Notice&gt;

## Upgrading your own project

Astro&apos;s own tool does most of the work:

```bash
npx @astrojs/upgrade
```

Two things to check once it finishes:

1. **Node.js 22.12 or newer.** Astro 7 raised the minimum Node version. Check this on your machine and on whatever CI or hosting platform actually builds your site, not just locally. A stale Node version on the host is the most common way a passing local build fails to deploy.
2. **Vite pins.** If your `package.json` has an `overrides` or `resolutions` entry pinning Vite to v7, bump it to v8. Astro 7 requires Vite 8, and a stale pin will hold it back without a build error that obviously points at the real cause.

Then build once locally before you push. If the stricter parser flags anything, fix that one tag and you&apos;re done.

## What I changed on bitdoze.com

The 743-page benchmark above is this actual blog. The relevant part of the `package.json` diff:

| Package | Before | After |
|---|---|---|
| astro | 6.1.9 | 7.0.4 |
| @astrojs/mdx | 5.0.4 | 7.0.0 |
| @tailwindcss/vite | 4.2.4 | 4.3.2 |
| sharp | 0.34.5 | 0.35.2 |
| typescript | 5.9.3 | 6.0.3 |

I also flipped on `dangerouslyProcessSVG: true` in the image config. Every post on this blog uses an SVG for its hero image, this one included, and that setting lets Astro&apos;s `&lt;Image&gt;` and `&lt;Picture&gt;` components run those SVGs through the same processing pipeline as raster images instead of passing them through untouched.

## I updated the Bitdoze Astro Theme too

A lot of readers here start from the [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) instead of a blank Astro project, so I moved it to Astro 7 as well and used the occasion to fix a few things that had been bugging me for a while:

- **Dependency bump:** Astro 6.1.9 → 7.0.3, `@astrojs/mdx` 5 → 7, Vite 7.3.2 → 8.1.0, Tailwind CSS 4.2.4 → 4.3.2.
- **A real test suite.** Vitest now covers the slug and publication logic, and `npm run verify` runs `astro check`, the tests, and a production build in one command.
- **One function decides what&apos;s public.** A new `isPublishedPost()` helper centralizes the draft and future-date check that used to be copy-pasted across pages, RSS, and search.
- **Slugs no longer piggyback on canonical URLs.** There&apos;s now an explicit `slug` field in frontmatter. Previously the route was derived from the `canonical` URL, so changing your canonical could silently change your route too.
- **A stricter content schema.** `description`, `date`, and at least one author are required at build time now instead of quietly falling back to empty defaults.
- **A sitemap fix.** Tag pages and the 404 page no longer sneak into the sitemap.
- **Pinned to Node 22.12** with a `.node-version` file, so CI and every contributor build against the runtime Astro 7 actually expects.

None of that shows up in a release title, but it&apos;s the difference between a theme that happens to build and one where the routing and publishing rules are actually enforced. If you&apos;re running the theme, pull the latest `main` and run `npm run verify` before you deploy.

## Should you upgrade now?

For most content sites, yes. The performance gain is real and free, you don&apos;t rewrite anything to get it. The only risk is the stricter HTML parsing, and that risk is self-limiting: it either builds clean or hands you a precise error to fix. Run the upgrade command, check your Node version and any Vite pin, build locally, then ship.

## Related articles

- [Build a free blog with Astro &amp; Cloudflare](/build-astro-blog-free/)
- [Astro build speed optimization: 35 to 127 pages/second](/astro-ssg-build-optimization/)
- [Deploy an Astro blog to Cloudflare](/deploy-astrojs-cloudflare/)
- [Astro DB with Bunny Database](/astro-db-bunny-database/)</content:encoded><category>web-development</category><category>astro</category><category>performance</category></item><item><title>How To Install CloudPanel and Host Node.js Apps</title><link>https://www.bitdoze.com/install-cloudpanel-host-nodejs/</link><guid isPermaLink="true">https://www.bitdoze.com/install-cloudpanel-host-nodejs/</guid><description>Step-by-step guide to installing CloudPanel on a VPS and deploying Node.js applications like Strapi with PM2 for automatic restarts.</description><pubDate>Wed, 01 Jul 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import cloudpanelNodeSite from &quot;../../assets/images/23/sanity_node.jpeg&quot;;

[CloudPanel](https://www.cloudpanel.io/) is a free hosting panel you install on a VPS. It runs on Nginx and supports PHP, Node.js, Python, and static sites through a clean web UI. No licensing fees, no per-site limits.

This guide walks you through installing CloudPanel on a fresh VPS and deploying a Node.js application (we&apos;ll use Strapi as the example) with PM2 for process management.

## What CloudPanel offers

- File Manager
- IP and bot blocking
- Varnish Cache and Redis
- SSH/FTP access
- Firewall
- Cron jobs
- Vhost editor
- Remote backup with Rclone
- Free Let&apos;s Encrypt certificates
- Cloudflare integration
- User management
- System resource usage graphs
- Multiple PHP versions
- MySQL and MariaDB support
- Node.js and Python support
- Nginx web server

## Minimum requirements

- 1 CPU core (2+ recommended for production)
- 2 GB RAM (4 GB+ recommended for production)
- 10 GB disk space

&gt; If you want to monitor server resources like CPU, memory, and disk space, check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

CloudPanel has direct cloud integrations with [DigitalOcean](https://go.bitdoze.com/do), [Vultr](https://go.bitdoze.com/vultr), [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), AWS, and Google Cloud. These integrations let you create and manage snapshots from the CloudPanel UI.

This article uses Hetzner with Ubuntu 24.04. If you&apos;re choosing a provider, check the [DigitalOcean vs Vultr vs Hetzner](https://www.wpdoze.com/digitalocean-vs-vultr-vs-hetzner/) comparison.

If you want to monitor CPU usage and get automatic email alerts when load is high: [Monitor CPU Usage and Send Email Alerts in Linux](https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/)

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/5KndMFz-VKQ&quot;
  label=&quot;How To Install CloudPanel and Host Node.js Apps&quot;
/&gt;

If you want a web panel that also works as a reverse proxy, check this course:

&lt;Button
  link=&quot;https://webdoze.net/courses/cloudpanel-setup/&quot;
  text=&quot;CloudPanel Setup Course&quot;
/&gt;

&gt; You can also check [Setup CloudPanel with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy for Docker containers and [CloudPanel Remote Backups](https://www.bitdoze.com/cloudpanel-remote-backups/).

&gt; Looking for free self-hosted apps? Check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## 1. Install CloudPanel

After your VPS is created, SSH in and run the commands below.

### 1.1 Update the OS

```bash
apt update &amp;&amp; apt -y upgrade &amp;&amp; apt -y install curl wget sudo
```

### 1.2 Install CloudPanel

CloudPanel supports Ubuntu 24.04/22.04 and Debian 13/12/11. The installer hash below is current as of mid-2026. Always check the [CloudPanel install docs](https://www.cloudpanel.io/docs/v2/getting-started/hetzner-cloud/installation/installer/) for the latest hash and available database options.

For **Hetzner with MariaDB 11.4**:

```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;6eac061df80f08b75224fcd7fce2f115e201696d8a6122e31abf7259a813b462  install.sh&quot; | \
sha256sum -c &amp;&amp; sudo CLOUD=hetzner DB_ENGINE=MARIADB_11.4 bash install.sh
```

For **Hetzner with MySQL 8.4**:

```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;6eac061df80f08b75224fcd7fce2f115e201696d8a6122e31abf7259a813b462  install.sh&quot; | \
sha256sum -c &amp;&amp; sudo CLOUD=hetzner DB_ENGINE=MYSQL_8.4 bash install.sh
```

Available database options depend on your OS. Check the docs for MySQL 8.0, MariaDB 10.11, and other combinations. For non-Hetzner providers, change the `CLOUD=` value or omit it entirely.

### 1.3 Access CloudPanel admin

Access the admin panel at `https://serverIpAddress:8443`. You&apos;ll get a self-signed certificate warning -- click through it.

**Important:** Create the admin user immediately. There&apos;s a short window after install where bots could create the user first. For extra security, restrict port 8443 to your IP via firewall until you&apos;ve set the admin password.

### 1.4 Create an admin subdomain

To access CloudPanel securely with a proper SSL certificate, create a subdomain (e.g., `admin.yourdomain.com`) and point it to your VPS IP. If you use Cloudflare, add an A record under DNS.

Then go to **Settings** in the CloudPanel admin and add the subdomain there to secure the admin area.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## 2. Deploy a Node.js app on CloudPanel

We&apos;ll deploy Strapi, a popular open-source headless CMS built on Node.js. The same process works for any Node.js application.

### 2.1 Add a Node.js site in CloudPanel

Click **Add Site** and choose **Create a Node.js Site**. Fill in your domain, select a Node.js version (22 LTS is recommended), set the app port, and create your site user credentials.

&lt;Picture
  src={cloudpanelNodeSite}
  alt=&quot;CloudPanel add Node.js site&quot;
/&gt;

### 2.2 Point the domain to CloudPanel

Add an A record in your DNS (e.g., Cloudflare) pointing your domain or subdomain to the VPS IP. Enable the Cloudflare proxy if you want CDN benefits.

### 2.3 Generate the SSL certificate

Go to **SSL/TLS** for your site in CloudPanel and generate a Let&apos;s Encrypt certificate so the site works over HTTPS.

### 2.4 Install Strapi

SSH in as the **site user** (not root):

```bash
sudo su - &lt;your-site-username&gt;
cd htdocs &amp;&amp; rm -rf www.yourdomain.com
npx create-strapi@latest www.yourdomain.com --skip-cloud
```

The CLI will ask a few questions (TypeScript or JavaScript, database choice, etc.). For a quick start, accept the defaults -- Strapi v5 uses SQLite by default.

If you prefer to use MySQL or MariaDB (which CloudPanel already has running), pass the database flags:

```bash
npx create-strapi@latest www.yourdomain.com --skip-cloud \
  --dbclient=mysql \
  --dbhost=127.0.0.1 \
  --dbport=3306 \
  --dbname=strapi_db \
  --dbusername=strapi_user \
  --dbpassword=your_password
```

You&apos;ll need to create the database and user first in CloudPanel under **Databases**.

### 2.5 Build Strapi for production

```bash
cd htdocs/www.yourdomain.com/
NODE_ENV=production npm run build
```

### 2.6 Start Strapi

```bash
NODE_ENV=production npm start
```

You&apos;ll see output like:

```
 Project information

┌────────────────────┬──────────────────────────────────────────┐
│ Time               │ ...                                      │
│ Launched in        │ 1047 ms                                  │
│ Environment        │ production                               │
│ Process PID        │ 121469                                   │
│ Version            │ 5.x.x (node v22.x.x)                    │
│ Database           │ sqlite                                   │
└────────────────────┴──────────────────────────────────────────┘

 Create your first administrator by going to:
 http://0.0.0.0:1337/admin
```

### 2.7 Create the admin user

Visit `https://www.yourdomain.com/admin` and fill in your admin credentials to set up the Strapi dashboard.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## 3. Enable auto-start with PM2

A Node.js app won&apos;t restart on its own after a server reboot. PM2 handles that.

### 3.1 Install PM2

For a complete PM2 guide, check [Manage Applications with PM2](https://www.bitdoze.com/pm2-manage-apps/).

```bash
npm install pm2@latest -g
```

### 3.2 Start the app and save the config

```bash
pm2 start npm --name strapi-app -- start
pm2 save
```

### 3.3 Add a cron job to restore PM2 on reboot

Get the current PATH:

```bash
echo $PATH
```

Edit the user crontab:

```bash
crontab -e
```

Add these lines, replacing the PATH value with your actual output:

```bash
PATH=/home/your-site-user/.nvm/versions/node/v22.x.x/bin:/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games
@reboot pm2 resurrect &amp;&gt; /dev/null
```

Verify it was saved:

```bash
crontab -l
```

Reboot the server and confirm the app comes back:

```bash
pm2 status
```

You should see the status as **online**:

```
┌─────┬───────────────┬─────────────┬─────────┬─────────┬──────────┬────────┬──────┬───────────┬──────────┬──────────┬──────────┬──────────┐
│ id  │ name          │ namespace   │ version │ mode    │ pid      │ uptime │ ↺    │ status    │ cpu      │ mem      │ user     │ watching │
├─────┼───────────────┼─────────────┼─────────┼─────────┼──────────┼────────┼──────┼───────────┼──────────┼──────────┼──────────┼──────────┤
│ 0   │ strapi-app    │ default     │ 5.x.x   │ fork    │ 1521     │ 50s    │ 0    │ online    │ 0%       │ 56.7mb   │ bit…     │ disabled │
└─────┴───────────────┴─────────────┴─────────┴─────────┴──────────┴────────┴──────┴───────────┴──────────┴──────────┴──────────┴──────────┘
```

## Conclusions

This is how you install CloudPanel on a VPS and host Node.js apps like Strapi. The panel handles Nginx configuration, SSL certificates, and database management through a web interface, while PM2 keeps your Node.js process alive across reboots.

CloudPanel has been reliable in my experience. It&apos;s free, actively maintained, and covers most use cases for developers who want a simple server management panel without the overhead of cPanel or Plesk.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>hosting</category><category>cloudpanel</category><category>node</category></item><item><title>Add Responsive YouTube Videos to Astro.JS MDX</title><link>https://www.bitdoze.com/responsive-youtube-astrojs/</link><guid isPermaLink="true">https://www.bitdoze.com/responsive-youtube-astrojs/</guid><description>Embed responsive YouTube videos in Astro MDX using astro-embed. Faster loading, better performance scores, and privacy-friendly.</description><pubDate>Mon, 29 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/2210/video-speed-score.jpeg&quot;;

Standard YouTube iframes kill your page performance. They load 1-2 MB of JavaScript before the user even clicks play. Your Lighthouse score drops, and your visitors wait for scripts they may never use.

[astro-embed](https://www.npmjs.com/package/astro-embed) fixes this. It uses [lite-youtube-embed](https://github.com/paulirish/lite-youtube-embed) under the hood — a custom element that shows a thumbnail and only loads the full YouTube player on click. The result is approximately 224x faster than a standard embed.

This guide covers two ways to add YouTube videos to your Astro MDX files: manual imports and automatic URL conversion.

## Option 1: Manual import (recommended)

### Install astro-embed

```bash
npm i astro-embed
```

If you only need YouTube and want a smaller install, you can use the standalone package instead:

```bash
npm i @astro-community/astro-embed-youtube
```

### Import in your MDX file

Add the import statement in the frontmatter section of your `.mdx` file:

```mdx
---
title: &quot;My Blog Post&quot;
description: &quot;A post with a video&quot;
---

import { YouTube } from &quot;astro-embed&quot;;

Your content here.

&lt;YouTube id=&quot;https://youtu.be/NkShQ1wwiCg&quot; /&gt;
```

If you installed the standalone package, import from there instead:

```mdx
import { YouTube } from &quot;@astro-community/astro-embed-youtube&quot;;
```

### YouTube component props

The `&lt;YouTube&gt;` component accepts these props:

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `id` | string | required | Video ID or full YouTube URL |
| `poster` | string | auto | Custom poster image URL |
| `posterQuality` | `&apos;low&apos;` \| `&apos;default&apos;` \| `&apos;high&apos;` \| `&apos;max&apos;` | `&apos;default&apos;` | Thumbnail resolution (120px to 1280px) |
| `params` | string | — | YouTube player parameters (e.g., `start=30&amp;end=60`) |
| `playlabel` | string | `&apos;Play&apos;` | Accessible label for the play button |
| `title` | string | — | Overlay title text |

Examples:

```mdx
&lt;!-- Basic embed --&gt;
&lt;YouTube id=&quot;NkShQ1wwiCg&quot; /&gt;

&lt;!-- Full URL also works --&gt;
&lt;YouTube id=&quot;https://www.youtube.com/watch?v=NkShQ1wwiCg&quot; /&gt;

&lt;!-- Start at 30 seconds, end at 90 --&gt;
&lt;YouTube id=&quot;NkShQ1wwiCg&quot; params=&quot;start=30&amp;end=90&quot; /&gt;

&lt;!-- Custom poster image --&gt;
&lt;YouTube id=&quot;NkShQ1wwiCg&quot; poster=&quot;https://example.com/custom-thumb.jpg&quot; /&gt;

&lt;!-- With overlay title --&gt;
&lt;YouTube id=&quot;NkShQ1wwiCg&quot; title=&quot;Watch the full tutorial&quot; /&gt;
```

## Option 2: Auto-embed URLs in MDX

If you don&apos;t want to manually add `&lt;YouTube&gt;` components, you can install the auto-embed integration. It automatically converts plain YouTube URLs in your MDX content into embed components.

### Install

```bash
npm i @astro-community/astro-embed-integration
```

### Configure

Add the integration to your `astro.config.mjs`:

```js
import { defineConfig } from &apos;astro/config&apos;;
import mdx from &apos;@astrojs/mdx&apos;;
import embeds from &apos;@astro-community/astro-embed-integration&apos;;

export default defineConfig({
  integrations: [mdx(), embeds()],
});
```

### Usage

Just paste a YouTube URL on its own line in your MDX file. No import needed:

```mdx
---
title: &quot;My Post&quot;
---

Check out this video:

https://youtu.be/NkShQ1wwiCg

The integration converts it to a `&lt;YouTube&gt;` component automatically.
```

This also works for Vimeo, Twitter/X posts, and Mastodon posts.

## Supported services

astro-embed supports more than YouTube. You can embed from:

- **YouTube** — `&lt;YouTube&gt;`
- **Vimeo** — `&lt;Vimeo&gt;`
- **Twitter/X** — `&lt;Tweet&gt;`
- **Mastodon** — `&lt;MastodonPost&gt;`
- **GitHub Gist**
- **Baseline status**
- **Open Graph** — `&lt;LinkPreview&gt;`

Import them all at once:

```mdx
import { YouTube, Vimeo, Tweet, MastodonPost, LinkPreview } from &quot;astro-embed&quot;;
```

## Why this is faster than standard embeds

A normal YouTube `&lt;iframe&gt;` loads about 1.3-2.6 MB of JavaScript on page load — even if the visitor never plays the video. This tanks your Core Web Vitals.

The astro-embed approach:

1. Shows a lightweight thumbnail (poster image)
2. Loads zero YouTube JavaScript initially
3. Loads the full iframe only when the user clicks play
4. Uses `youtube-nocookie.com` for better privacy

The result: your page loads fast, your Lighthouse score stays high, and visitors only download YouTube&apos;s scripts if they actually want to watch.

&lt;Picture
  src={imag1}
  alt=&quot;YouTube Speed score with astro-embed&quot;
/&gt;

## In Astro component files

You can also use astro-embed in `.astro` files, not just MDX:

```astro
---
import { YouTube } from &quot;astro-embed&quot;;
---

&lt;section&gt;
  &lt;h2&gt;Featured Video&lt;/h2&gt;
  &lt;YouTube id=&quot;NkShQ1wwiCg&quot; title=&quot;Featured tutorial&quot; /&gt;
&lt;/section&gt;
```

## Troubleshooting

**Component not rendering?** Make sure you have the `@astrojs/mdx` integration installed. Run `npx astro add mdx` if you haven&apos;t set it up yet.

**Import not found?** Check that you installed `astro-embed` (or the standalone package) and that the import path matches your install.

**Auto-embed not working?** Verify that `@astro-community/astro-embed-integration` is listed in your `integrations` array in `astro.config.mjs`, and that it comes after `mdx()`.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category></item><item><title>Best Astro.js Online Courses/Tutorials</title><link>https://www.bitdoze.com/best-astrojs-online-courses/</link><guid isPermaLink="true">https://www.bitdoze.com/best-astrojs-online-courses/</guid><description>The best Astro.js courses for learning one of the fastest-growing content-focused frameworks, from free YouTube tutorials to premium paid courses.</description><pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;

[Astro](https://astro.build/) is a JavaScript web framework built for content-heavy sites. It ships near-zero JavaScript by default, supports React/Vue/Svelte components where you need interactivity, and has grown rapidly since its 1.0 release. Bitdoze.com runs on Astro — we even [published our own Astro theme](https://astro.build/themes/details/bitdoze-astro-theme/) — and if you&apos;re looking to learn it, there are now solid courses at every price point.

The original version of this article listed a handful of YouTube tutorials from 2022. Astro has matured a lot since then (currently at v5, with v6 in development), and the course landscape has caught up. This rewrite covers the best options available now, from free crash courses to comprehensive paid programs.

Before diving in, make sure you have the basics set up: [Install Node.js using NVM](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/) and [Link GitHub with an SSH key](https://www.bitdoze.com/link-github-with-ssh-maco-linux/). If you&apos;re coming from WordPress, you might also want to read [how we migrated a WordPress site to Astro](https://www.bitdoze.com/wordpress-to-astro-migration/).

## Paid Astro courses

### 1. Learn Astro (Coding in Public)

This is the premium course recommended by the [Astro official docs](https://docs.astro.build/en/astro-courses/) themselves. Chris Pennington (Coding in Public on YouTube) has been building Astro sites since the early betas and teaches nearly every feature the framework offers.

The course has 175 lessons across 17 modules, running about 17 hours. It covers Astro basics, content collections, image optimization, dynamic endpoints, Astro DB, middleware, auth, view transitions, internationalization, and three full projects (CMS integration, a CRUD app, and a basic e-commerce site with Stripe).

Currently being updated for Astro 6. Existing students get updates free. $150 one-time.

**[Get the course](https://learnastro.dev/)**

### 2. Astro JS v5 &amp; Headless WordPress (Tom Phillips)

Tom Phillips has 255k+ learners on Udemy and builds project-based courses that skip the fluff. This one updated to Astro v5 and Tailwind v4 in March 2026. You build a real estate website called &quot;Astro Estates&quot; using Astro as the frontend and WordPress as a headless CMS via GraphQL.

The course runs 10.5 hours across 70 lectures. It covers SSR, SSG, Tailwind CSS, view transitions, ACF Pro blocks, and deploying to Vercel. Good if you have WordPress clients who want faster sites.

4.4 rating with 104 reviews and 1,238 students.

**[Get the course](https://www.udemy.com/course/astro-js-wordpress/)**

### 3. AstroJS 101: Build Blazing Fast Frontends (Ohans Emmanuel)

Covers Astro fundamentals with a focus on component islands architecture. You&apos;ll learn how Astro&apos;s island pattern works, how to use React/Vue/Svelte inside Astro, and the template syntax. The course runs 7.5 hours across 92 lectures.

Last updated September 2023, so it&apos;s behind on some newer Astro features (content collections, Astro DB). But the core concepts around components, routing, and island architecture still apply. Reviewers note it explains the concepts well.

4.6 rating with 248 reviews and 2,072 students.

**[Get the course](https://www.udemy.com/course/astrojs-101-build-blazing-fast-frontends/)**

### 4. Intro to Astro (James Q Quick on Scrimba)

This is the other course listed on the [Astro official courses page](https://docs.astro.build/en/astro-courses/). It&apos;s on Scrimba, which means you get interactive lessons where the video and IDE are merged — you can pause and edit the code directly in the browser.

Covers project setup, components, styling, slots, content collections, Markdown/MDX, routing, and deploying to Netlify. 2.1 hours, 35 lessons, 4,000+ students. Good for getting started quickly with a hands-on approach.

Requires a Scrimba Pro subscription ($18/month or less with annual billing).

**[Get the course](https://scrimba.com/intro-to-astro-c00ar0fi5u)**

### 5. Practical Projects with Astro JS (Christina Petit)

Project-focused course where you convert designs to Astro websites. You&apos;ll convert Canva and Figma templates to Astro, build a tutorial curation site, a travel site, and (coming soon) an SSR web app. Runs about 9 hours.

Requires intermediate JavaScript knowledge and some Astro basics. Better as a second course than a first one.

4.6 rating with 2 reviews and 74 students. Small but solid if you want hands-on projects.

**[Get the course](https://www.udemy.com/course/practical-projects-with-astro-js/)**

### 6. Getting Started with Astro (Robert Guss)

A free Udemy course that covers Astro basics: island architecture, components, layouts, pages, routing, Markdown/MDX, and fetching data from REST and GraphQL APIs. You build two projects: a blog (first with Markdown, then with Strapi CMS) and modify a pre-built Astro theme.

Runs about 1 hour. It&apos;s more of an overview than a deep dive, and was last updated November 2025. But it&apos;s free and includes a companion book. Good for getting a quick sense of what Astro can do before committing to a paid course.

4.2 rating with 117 reviews and 1,257 students.

**[Get the course](https://www.udemy.com/course/astro-the-complete-guide/)**

## Free YouTube tutorials

### 1. Learn Astro in 2026 — Crash Course for Beginners

A comprehensive free crash course updated for 2026. Covers Astro from scratch with modern best practices and deployment guidance. This is the most current free Astro tutorial available.

**[Watch on YouTube](https://www.youtube.com/watch?v=brdy8HU03e4)**

### 2. Traversy Media — Astro Crash Course

Brad Traversy&apos;s Astro crash course is one of the most popular free tutorials. You build a project from scratch and deploy to Netlify. About 1 hour 35 minutes. Brad&apos;s teaching style is straightforward and project-focused.

**[Watch on YouTube](https://www.youtube.com/watch?v=Oi9z5gfIHJs)**

### 3. Building a Portfolio with Astro

Walks through creating a portfolio website with Astro from scratch. Covers configuration, components, styling, and deployment. About 1 hour 47 minutes.

**[Watch on YouTube](https://www.youtube.com/watch?v=0kVmdaIquJc)**

### 4. Build a Website with Astro, Tailwind CSS, and React

Shows how to combine Astro with TailwindCSS and React to build a blog. Covers the full configuration and integration between these tools.

**[Watch on YouTube](https://www.youtube.com/watch?v=eVjk3RP8ElE)**

### 5. Coding in Public (Chris Pennington)

Chris Pennington&apos;s YouTube channel has a large collection of free Astro tutorials covering specific topics: content collections, image optimization, middleware, Astro DB, and more. If you&apos;re not ready to buy his premium course, the free videos are still valuable.

**[Coding in Public on YouTube](https://www.youtube.com/c/CodinginPublic)**

### 6. James Q Quick

James Q Quick has Astro tutorials on his YouTube channel that complement his Scrimba course. He also runs the Compressed.fm podcast and has videos covering specific Astro features and integrations.

**[James Q Quick on YouTube](https://www.youtube.com/c/JamesQQuick)**

### 7. Build Fast Websites with Astro (Coursera/Scrimba)

A short Coursera course by James Q Quick (via Scrimba) covering reusable components, Markdown/JSON content, routing, and deployment. About 2 hours with a shareable certificate. Included with Coursera Plus or available separately.

**[Take the course on Coursera](https://www.coursera.org/learn/build-fast-websites-with-astro)**

## Where to start

If you have no experience with Astro and want the most comprehensive option, start with **Learn Astro by Coding in Public**. It&apos;s the most thorough and is officially recommended.

If you want something free to test the waters first, the **Traversy Media crash course** or the **2026 crash course** on YouTube will get you going in under 2 hours.

If you&apos;re coming from WordPress and want to use Astro as a headless frontend, **Tom Phillips&apos; Astro JS v5 &amp; WordPress course** is the clear choice.

The [Astro documentation](https://docs.astro.build/en/getting-started/) is also excellent. Many experienced developers skip courses entirely and learn from the docs plus the [official tutorial](https://docs.astro.build/en/tutorial/0-introduction/).

If you have an Astro course that should be added here, drop me an email at: bitdoze1[@]gmail.com

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category><category>courses</category></item><item><title>How To Deploy An Astro.JS Blog On Cloudflare</title><link>https://www.bitdoze.com/deploy-astrojs-cloudflare/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-astrojs-cloudflare/</guid><description>Deploy an Astro.JS blog or website to CloudFlare Pages for free.</description><pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/2210/use-this-template.jpeg&quot;;
import imag2 from &quot;../../assets/images/2210/deploy-cloudflare-project.jpeg&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Cloudflare Pages is a solid (and free) way to host a static Astro blog. You get 500 builds per month, unlimited bandwidth, and your site runs on Cloudflare&apos;s global CDN. For a blog, that&apos;s more than enough.

Cloudflare also [acquired the Astro team in January 2026](https://blog.cloudflare.com/astro-joins-cloudflare/), so the integration between the two is only getting tighter. If you&apos;re deploying a static Astro site — no SSR, no server islands — you don&apos;t need any special adapter. Just build and push.

This guide walks you through deploying an Astro blog to Cloudflare Pages from scratch. We&apos;ll use one of the popular Astro blog themes as a starting point.

Before you start, make sure you have these set up:

- [Install Node.js using NVM](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/) (Astro requires v22.12.0 or higher)
- [Link GitHub with an SSH key](https://www.bitdoze.com/link-github-with-ssh-maco-linux/)

If you&apos;re coming from WordPress, see [how we migrated a WordPress site to Astro](https://www.bitdoze.com/wordpress-to-astro-migration/).

You can also self-host Astro on your own VPS with [Coolify](https://www.bitdoze.com/coolify-install-heroku-alternative/) or [EasyPanel](https://www.bitdoze.com/deploy-astro-easypanel/) if you prefer more control.

## Video walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/T7PY55WudZ4&quot;
  label=&quot;How To Deploy An Astro.JS Blog On Cloudflare&quot;
/&gt;{&quot; &quot;}

## Step 1: Pick a theme and clone it

You need an Astro project to deploy. You can start from scratch with `npm create astro@latest`, but for a blog it&apos;s faster to start with a theme.

Two good options:

- **[Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme)** — a blog-focused theme with tags, categories, series support, search, and RSS. Built with Tailwind CSS v4.
- **[AstroWind](https://github.com/arthelokyo/astrowind)** — a general-purpose Astro + Tailwind CSS v4 template. Well-maintained, most starred Astro theme on GitHub.

On GitHub, click **Use this template** to create your own repo:

&lt;Picture
  src={imag1}
  alt=&quot;GitHub Use Template&quot;
/&gt;

Then clone it locally:

```bash
git clone git@github.com:your-username/your-repo.git
cd your-repo
npm install
```

## Step 2: Configure the theme

The exact config files depend on which theme you picked. Here&apos;s what to change for each.

### Bitdoze Astro Theme

The config lives in separate files under `src/config/`:

**`src/config/site.ts`** — site metadata:

```ts
export const site = {
  title: &quot;Your Blog Name&quot;,
  description: &quot;What your blog is about&quot;,
  author: &quot;Your Name&quot;,
  logoText: &quot;YourBlog&quot;,
  postsPerPage: 6,
  // ...
};
```

**`astro.config.mjs`** — set your production URL:

```js
export default defineConfig({
  site: &quot;https://your-domain.com&quot;,
  // ...
});
```

**`src/config/menu.json`** — header and footer navigation links.

**`src/config/social.json`** — social media profile URLs.

### AstroWind

AstroWind uses a single `src/config.yaml` file:

```yaml
site:
  name: &quot;Your Blog Name&quot;
  site: &quot;https://your-domain.com&quot;
  base: &quot;/&quot;

metadata:
  title:
    default: &quot;Your Blog Name&quot;
    template: &quot;%s — Your Blog Name&quot;
  description: &quot;What your blog is about&quot;

apps:
  blog:
    isEnabled: true
    postsPerPage: 6
```

Colors and fonts are customized through CSS in `src/components/CustomStyles.astro` and `src/assets/styles/tailwind.css` (Tailwind CSS v4 uses a CSS-first config approach — no `tailwind.config.js` needed for basic changes).

## Step 3: Add your content

Both themes store blog posts as Markdown or MDX files:

- **Bitdoze theme:** `src/content/posts/`
- **AstroWind:** `src/data/post/`

Create a new `.md` or `.mdx` file with frontmatter:

```md
---
title: &quot;Your First Post&quot;
description: &quot;What this post is about&quot;
date: 2026-06-29T00:00:00Z
image: &quot;../../assets/images/your-image.jpg&quot;
categories: [&quot;blog&quot;]
tags: [&quot;astro&quot;, &quot;tutorial&quot;]
---

Your content here.
```

Delete the demo posts that come with the theme and add your own.

## Step 4: Test locally

Start the dev server to make sure everything looks right:

```bash
npm run dev
```

Open `http://localhost:4321` in your browser. Check that your site title, navigation, posts, and styling all look correct.

When you&apos;re happy, build the production version to catch any errors:

```bash
npm run build
```

If the build succeeds, push to GitHub:

```bash
git add .
git commit -m &quot;configured my website&quot;
git push
```

## Step 5: Deploy on Cloudflare Pages

1. Log in to the [Cloudflare dashboard](https://dash.cloudflare.com/).
2. Go to **Workers &amp; Pages** &gt; **Create application** &gt; **Pages** tab.
3. Click **Import an existing Git repository** and connect your GitHub repo.
4. Configure the build settings:

| Setting | Value |
|---------|-------|
| Production branch | `main` |
| Build command | `npm run build` |
| Build output directory | `dist` |

&lt;Picture
  src={imag2}
  alt=&quot;Cloudflare Pages build settings for Astro&quot;
/&gt;

5. Click **Save and Deploy**.

Cloudflare will install your dependencies, run the build, and deploy your site. You&apos;ll get a `*.pages.dev` URL within a couple of minutes.

From now on, every push to your `main` branch triggers an automatic rebuild and deploy. The free plan gives you 500 builds per month — that&apos;s roughly 16 deploys per day, which is plenty for a blog.

### Custom domain

To use your own domain:

1. In your Pages project, go to **Custom domains**.
2. Add your domain. If your domain is already on Cloudflare, the DNS records are configured automatically.
3. If not, you&apos;ll need to add a CNAME record pointing your domain to `&lt;your-project&gt;.pages.dev`.

### Setting the Node.js version

Cloudflare Pages lets you control the Node.js version used during builds. Since Astro requires v22.12.0+, add an environment variable if your build fails with a Node version error:

1. In your Pages project, go to **Settings** &gt; **Environment variables**.
2. Add `NODE_VERSION` with value `22`.

Alternatively, add a `.node-version` or `.nvmrc` file to your project root:

```
22
```

## Step 6: Set up automatic rebuilds with webhooks (optional)

If you want Cloudflare to rebuild when you update content from a CMS (not just Git pushes), you can use Cloudflare Deploy Hooks:

1. In your Pages project, go to **Settings** &gt; **Builds** &gt; **Deploy hooks**.
2. Create a webhook URL. This gives you a unique URL you can POST to trigger a build.
3. Configure your CMS to POST to that URL when content changes.

## What changed since this article was first published

The original version of this article was written in 2022 when Astro was younger and Cloudflare Pages was newer. A few things have changed:

- **Cloudflare acquired the Astro team** in January 2026. Astro remains open-source and platform-agnostic, but Cloudflare is now the company behind it.
- **The `@astrojs/cloudflare` adapter** (v13+) dropped support for Cloudflare Pages and now targets Cloudflare Workers only. This doesn&apos;t affect static sites — you don&apos;t need the adapter for a static blog deployed to Pages.
- **AstroWind moved** from `onwidget/astrowind` to `arthelokyo/astrowind` and upgraded to Astro v6 + Tailwind CSS v4.
- **The Bitdoze theme** was rewritten and is now at [github.com/bitdoze/bitdoze-astro-theme](https://github.com/bitdoze/bitdoze-astro-theme) with a new config structure.
- **Node.js requirement** increased to v22.12.0+ (was v16 in the original article).

## Next steps

- [Add responsive YouTube videos to Astro MDX](https://www.bitdoze.com/responsive-youtube-astrojs/)
- [Best Astro.js online courses and tutorials](https://www.bitdoze.com/best-astrojs-online-courses/)
- [Astro deployment docs](https://docs.astro.build/en/guides/deploy/cloudflare/) — covers Workers deployment if you need SSR later</content:encoded><category>web-development</category><category>astro</category></item><item><title>Link GitHub with A SSH Key to MacOS or Linux</title><link>https://www.bitdoze.com/link-github-with-ssh-maco-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/link-github-with-ssh-maco-linux/</guid><description>A tutorial that you can follow to create your first GitHub repo and link it via SSH to your laptop.</description><pubDate>Sun, 28 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import githubssh1 from &quot;../../assets/images/2210/github_ssh_key.jpeg&quot;;
import githubssh2 from &quot;../../assets/images/2210/github_add_ssh_key.jpeg&quot;;
import githubssh3 from &quot;../../assets/images/2210/create_repo.jpeg&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

GitHub uses SSH keys to authenticate your machine so you can push, pull, and clone repos without typing your password every time. This guide walks through generating an SSH key on macOS or Linux, adding it to your GitHub account, and pushing your first repository.

## Generate an SSH key

Open a terminal and run:

```bash
ssh-keygen -t ed25519 -C &quot;your_email@example.com&quot;
```

Replace `your_email@example.com` with the email tied to your GitHub account. The `-C` flag is just a label (a comment) — it doesn&apos;t affect the key&apos;s security.

When prompted, press Enter to accept the default file location (`~/.ssh/id_ed25519`). You&apos;ll then be asked for a passphrase. **Set one.** It protects the private key if your machine is ever lost or compromised. The ssh-agent will handle it so you don&apos;t have to type it every time.

If you&apos;re on a legacy system that doesn&apos;t support ed25519 (unlikely in 2025+, but possible), fall back to RSA:

```bash
ssh-keygen -t rsa -b 4096 -C &quot;your_email@example.com&quot;
```

**Why ed25519 instead of RSA?** Ed25519 keys are shorter, faster to generate, and cryptographically equivalent to a 4096-bit RSA key. GitHub recommends ed25519 as the default.

## Start the ssh-agent and add your key

### macOS

Start the agent and add your key:

```bash
eval &quot;$(ssh-agent -s)&quot;
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
```

Then configure SSH to automatically load keys into the agent and store the passphrase in your keychain. Create or edit `~/.ssh/config`:

```bash
touch ~/.ssh/config
```

Add these lines:

```
Host github.com
  AddKeysToAgent yes
  UseKeychain yes
  IdentityFile ~/.ssh/id_ed25519
```

If you didn&apos;t set a passphrase, omit the `UseKeychain` line. If you get a `Bad configuration option: usekeychain` error, add `IgnoreUnknown UseKeychain` on a separate line under the same `Host` block.

### Linux

Start the agent and add your key:

```bash
eval &quot;$(ssh-agent -s)&quot;
ssh-add ~/.ssh/id_ed25519
```

On most modern Linux distros (Ubuntu 22.04+, Fedora, etc.), the ssh-agent starts automatically with your desktop session. If `ssh-add` says &quot;Could not open a connection to your authentication agent,&quot; run the `eval` command above first.

## Add the SSH key to GitHub

Copy the public key to your clipboard:

```bash
# macOS
pbcopy &lt; ~/.ssh/id_ed25519.pub

# Linux (with xclip)
xclip -selection clipboard &lt; ~/.ssh/id_ed25519.pub

# Linux (without xclip — just print it, then copy manually)
cat ~/.ssh/id_ed25519.pub
```

Then go to GitHub:

1. Click your profile picture (top right) → **Settings**.
2. In the left sidebar under &quot;Access,&quot; click **SSH and GPG keys**.
3. Click **New SSH key**.
4. Give it a title (e.g., &quot;Personal laptop&quot; or &quot;Work MacBook&quot;).
5. Paste the public key into the **Key** field.
6. Click **Add SSH key**.

&lt;Picture
  src={githubssh1}
  alt=&quot;GitHub SSH key settings page&quot;
/&gt;

&lt;Picture
  src={githubssh2}
  alt=&quot;Adding an SSH key to GitHub&quot;
/&gt;

## Test the connection

```bash
ssh -T git@github.com
```

You should see:

```
Hi username! You&apos;ve successfully authenticated, but GitHub does not provide shell access.
```

If you get a &quot;Permission denied&quot; error, double-check that the key was added correctly and that the ssh-agent has your key loaded (`ssh-add -l` lists loaded keys).

## YouTube walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/aGWACCA2Kcg&quot;
  label=&quot;Link GitHub with A SSH Key to MacOS or Linux&quot;
/&gt;

## Create a repo and push from your machine

Now that SSH is set up, you can create a repo on GitHub and push code to it.

### Create the repo on GitHub

On GitHub, click the **+** icon (top right) → **New repository**. Fill in:

- **Repository name** — whatever you want (e.g., `test-repo`).
- **Description** — optional.
- **Private or Public** — your choice.

Click **Create repository**.

&lt;Picture
  src={githubssh3}
  alt=&quot;Creating a new GitHub repository&quot;
/&gt;

### Push from the terminal

Make sure git is installed:

```bash
# macOS
brew install git

# Ubuntu/Debian
sudo apt install git -y
```

Then initialize and push:

```bash
# Create and enter the project directory
mkdir test-repo &amp;&amp; cd test-repo

# Initialize the git repo
git init

# Create a file
echo &quot;# test-repo&quot; &gt;&gt; README.md

# Stage, commit, and push
git add .
git commit -m &quot;Initial commit&quot;
git branch -M main
git remote add origin git@github.com:yourusername/test-repo.git
git push -u origin main
```

Replace `yourusername` with your actual GitHub username.

### Clone an existing repo

```bash
git clone git@github.com:yourusername/some-repo.git
```

After cloning, make your changes, commit, and push as usual.

## Summary of the SSH path

| File | What it is |
|------|------------|
| `~/.ssh/id_ed25519` | Your private key. Never share this. |
| `~/.ssh/id_ed25519.pub` | Your public key. This goes on GitHub. |
| `~/.ssh/config` | SSH client configuration (optional but useful on macOS). |

The private key stays on your machine. The public key can be added to as many GitHub accounts or servers as you need. If you set up a new machine, generate a new key pair and add the public key to GitHub — don&apos;t copy the private key between machines.</content:encoded><category>web-development</category><category>git</category></item><item><title>Best Gatsby.js Online Courses</title><link>https://www.bitdoze.com/gatsby-js-online-courses/</link><guid isPermaLink="true">https://www.bitdoze.com/gatsby-js-online-courses/</guid><description>Looking for Gatsby.js courses? These are the best options still available in 2026, plus alternatives since Gatsby is in maintenance mode.</description><pubDate>Sat, 27 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;

**Important:** Gatsby is in maintenance mode. Netlify acquired Gatsby Inc. in February 2023, and most of the core team left shortly after. Development has slowed significantly, and many plugins in the ecosystem are no longer maintained. If you&apos;re starting a new project, consider [Astro](https://astro.build/), [Next.js](https://nextjs.org/), or [TanStack Start](https://tanstack.com/start) instead. The courses below are still useful if you&apos;re maintaining an existing Gatsby site or want to understand the patterns Gatsby pioneered.

---

[Gatsby](https://www.gatsbyjs.com/) is a React-based static site generator that uses GraphQL to pull data from various sources and generate fast, pre-rendered websites. It connects to headless CMS systems like WordPress, Contentful, or Sanity.

Gatsby is more complex than WordPress. You need to understand React, GraphQL, and the Node.js toolchain. A structured course saves time compared to cobbling together free tutorials, especially if you&apos;re new to the React ecosystem.

Before diving in, make sure you have the basics set up: [Install Node.js using NVM](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/) and [Link GitHub with an SSH key](https://www.bitdoze.com/link-github-with-ssh-maco-linux/).

## Best Gatsby.js online courses

### 1. Gatsby.js 3 Tutorial and Projects Course

![Gatsby.js Tutorial and Projects Course](//images.ctfassets.net/l6qg42gls3p1/5eI1DWFJekpwhOnIWiGG9M/497cbba83ba06fb8bcc9030d2cb97a27/gatsby_course_1.jpeg)

This is the most comprehensive Gatsby course on Udemy. Yanis Smilga (listed as Jānis Smilga in his bio) covers Gatsby from the basics through multiple projects. The course runs 22 hours across 240 lectures and 8 sections.

You&apos;ll build a recipes site with Contentful, a blog, and a portfolio. It covers GraphQL queries, image optimization, styled components, and deploying to production. The instructor explains things clearly and at a pace that works for beginners.

The course was last updated in October 2024. The Gatsby version used is now outdated (v3), but the core concepts transfer. One recent reviewer noted: &quot;Its true that Gatsby version that John used is completely outdated, but that the true challenge: as a web developer, you must read the docs, search on forums, ask around to update all the stuff by yourself and find the way to link items in order to make things work securely and with stability.&quot;

4.5 rating with 1,748 reviews and 14,899 students enrolled.

**[Get the course](https://go.bitdoze.com/gatsby-c1)**

### 2. Gatsby JS v5 &amp; Headless WordPress

![Gatsby JS: Build Gatsby static sites with React &amp; WordPress](//images.ctfassets.net/l6qg42gls3p1/5Kk8sbJrXbL6qc0qmw6kNV/037485bbaa4b72bc2e5f7ea5f02168d7/gatsby_course_2.jpeg)

Tom Phillips teaches how to use WordPress as a headless CMS with Gatsby as the frontend. This is the practical setup many content teams use: WordPress for the editorial workflow, Gatsby for the fast static output.

The course was updated to Gatsby v5 in January 2023 and covers the WordPress Gutenberg block editor, Tailwind CSS styling, and custom Gutenberg blocks with ACF Pro. It runs 7 hours across 47 lectures and 7 sections. Tom Phillips has 255k+ learners across his 26 Udemy courses and focuses on React, Gatsby, and Next.js.

4.7 rating with 762 reviews and 4,868 students enrolled. Last updated March 2026.

**[Get the course](https://go.bitdoze.com/gatsby-c2)**

### 3. Gatsby on LinkedIn Learning

If you have a LinkedIn Learning subscription, Morten Rand-Hendriksen has two Gatsby courses worth checking. He&apos;s a senior staff instructor at LinkedIn and explains things clearly:

**Learning Gatsby** — About 3 hours covering Gatsby fundamentals: setup, routing, GraphQL data layer, plugins, and deployment. Good starting point if you already have access.

**Building a Headless WordPress Site with Gatsby** — About 2.5 hours focused on connecting WordPress to Gatsby. Covers WPGraphQL, custom post types, and Gatsby&apos;s WordPress source plugin.

Both courses are included with a LinkedIn Learning subscription. No separate purchase needed.

## What to learn instead (if starting fresh)

If you don&apos;t have an existing Gatsby project to maintain, here&apos;s what the community has moved to:

**[Astro](https://astro.build/)** — The closest replacement for Gatsby&apos;s use case. Content-heavy sites, blogs, documentation. Ships near-zero JavaScript by default. Supports React, Vue, Svelte, and MDX. This is where most former Gatsby developers landed.

**[Next.js](https://nextjs.org/)** — Better for full applications with authentication, API routes, and server-side rendering. Steeper learning curve than Gatsby but far more capable.

**[TanStack Start](https://tanstack.com/start)** — A newer option with first-class TypeScript support and fine-grained data loading. Worth watching if you want something leaner than Next.js.

Each of these solved the problems Gatsby solved (file-based routing, image optimization, MDX support, fast defaults) without the dead-ecosystem risk.

## Tips for learning Gatsby in 2026

If you&apos;re maintaining an existing Gatsby site or have a specific reason to learn it:

- **Pin your Node.js version.** Gatsby breaks on newer Node versions more often than other frameworks. Use nvm and test upgrades in a branch first.
- **Audit your plugins.** Many popular Gatsby plugins haven&apos;t been updated in years. Check the last publish date on npm before installing anything.
- **Consider migrating.** Astro has [a migration guide from Gatsby](https://docs.astro.build/en/guides/migrate-to-astro/from-gatsby/) that&apos;s well-documented. The longer you wait, the more brittle the dependency chain gets.
- **Don&apos;t start new projects on Gatsby.** There&apos;s no scenario in 2026 where Gatsby is the best choice for a new project.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>web-development</category><category>gatsby</category><category>courses</category></item><item><title>How to Install Node.js using NVM on MacOS and Ubuntu</title><link>https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/</link><guid isPermaLink="true">https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/</guid><description>Step-by-step guide to installing Node.js via NVM on macOS and Ubuntu, including Apple Silicon Macs and switching between multiple Node versions.</description><pubDate>Sat, 27 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

NVM (Node Version Manager) is the recommended way to install Node.js on macOS and Linux. It lets you install multiple Node.js versions and switch between them without sudo. This guide covers installation on both macOS and Ubuntu, plus how to manage multiple Node versions.

## Why NVM instead of direct install

Installing Node.js directly from the official installer or your OS package manager works, but it has downsides:

- System packages are often outdated (Ubuntu&apos;s default repo can be months behind).
- You need `sudo` to install global npm packages, which is a permission headache.
- Switching between Node versions for different projects is painful.

NVM solves all three. Each Node version installs in your home directory (`~/.nvm/versions/node/`), and switching is instant.

## 1. Install NVM

### macOS

Make sure Xcode Command Line Tools are installed first:

```bash
xcode-select --install
```

Then install NVM using the official install script:

```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
```

This clones NVM to `~/.nvm` and adds the necessary lines to your shell profile (`~/.zshrc` on modern macOS, since zsh is the default shell since macOS 10.15).

Reload your shell:

```bash
source ~/.zshrc
```

Verify it works:

```bash
nvm --version
```

You should see `0.40.5`.

**Note on Apple Silicon (M1/M2/M3/M4) Macs:** Node.js v16+ has native arm64 support, so NVM works out of the box. If you need to run Node.js versions older than v16, you&apos;ll need to use Rosetta — see the [NVM docs for Apple Silicon](https://github.com/nvm-sh/nvm#macs-with-apple-silicon-chips).

**Don&apos;t use Homebrew to install NVM.** The NVM maintainers explicitly say Homebrew installation is not supported. If you already have it installed via Homebrew, uninstall it first with `brew uninstall nvm` and use the script above.

### Ubuntu

Install curl if you don&apos;t have it:

```bash
sudo apt update &amp;&amp; sudo apt install curl -y
```

Then run the official install script:

```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.5/install.sh | bash
```

The script clones NVM to `~/.nvm` and adds the source lines to `~/.bashrc`. Reload your shell:

```bash
source ~/.bashrc
```

Verify:

```bash
nvm --version
```

That&apos;s it. No `sudo` needed, no system-level changes.

## YouTube walkthrough

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/XXrZHWKTJfg&quot;
  label=&quot;How to Install Node.js using NVM on MacOS and Ubuntu&quot;
/&gt;

## 2. Install Node.js with NVM

NVM works the same on macOS and Ubuntu from here on.

### Install the latest LTS version

The `--lts` flag installs the latest Long-Term Support release. As of mid-2026, that&apos;s Node.js 24 (codename Krypton):

```bash
nvm install --lts
```

Verify:

```bash
node --version
# v24.x.x
npm --version
```

### Install a specific version

```bash
nvm install 22
```

This installs the latest v22.x release (codename Jod, also LTS).

### Switch between versions

```bash
nvm use 24
nvm use 22
```

Check what&apos;s installed:

```bash
nvm ls
```

### Set a default version

The first version you install becomes the default for new shells. To change it:

```bash
nvm alias default 24
```

### Use a .nvmrc file (per-project Node version)

Create a `.nvmrc` file in your project root:

```bash
echo &quot;22&quot; &gt; .nvmrc
```

Then when you `cd` into that project directory:

```bash
nvm use
# Found .nvmrc with version &lt;22&gt;
# Now using node v22.22.1
```

If the version isn&apos;t installed yet, `nvm install` will download it automatically.

## Current Node.js release schedule

| Version | Codename | Status | EOL |
|---------|----------|--------|-----|
| v26 | — | Current (May 2026) | — |
| v24 | Krypton | Active LTS | Apr 2028 |
| v22 | Jod | Maintenance LTS | Apr 2027 |
| v20 | Iron | EOL (Apr 2026) | — |
| v18 | Hydrogen | EOL (Apr 2025) | — |
| v16 | Gallium | EOL (Sep 2023) | — |

Use v24 for new projects. Use v22 if your project or dependencies haven&apos;t caught up yet. Avoid v20 and older.

Starting with Node.js 27 (October 2026), the release cycle moves to one major version per year instead of two.

## Useful NVM commands

| Command | What it does |
|---------|--------------|
| `nvm install --lts` | Install latest LTS |
| `nvm install 24` | Install specific major version |
| `nvm install 22.10.0` | Install exact version |
| `nvm use 24` | Switch to version |
| `nvm ls` | List installed versions |
| `nvm ls-remote --lts` | List all available LTS versions |
| `nvm alias default 24` | Set default for new shells |
| `nvm uninstall 18` | Remove a version |
| `nvm current` | Show active version |

## Troubleshooting

**`nvm: command not found` after install**

Your shell profile wasn&apos;t sourced. Try closing and reopening your terminal. If that doesn&apos;t work, check that `~/.zshrc` (macOS) or `~/.bashrc` (Ubuntu) contains the NVM lines. You can also run:

```bash
source ~/.nvm/nvm.sh
```

**npm global packages need sudo**

If you&apos;re using `sudo npm install -g &lt;package&gt;`, something is wrong. NVM-installed Node doesn&apos;t need sudo for global packages. If you have an `~/.npmrc` file with a `prefix` setting, remove it.

**Old Node version shows up after opening a new terminal**

Run `nvm alias default` to see what&apos;s set. Fix it with `nvm alias default &lt;version&gt;`.

**`which nvm` returns nothing**

That&apos;s expected. NVM is a shell function, not a binary. Use `command -v nvm` instead.</content:encoded><category>web-development</category><category>node</category></item><item><title>TinyFish: The Best Free Firecrawl Alternative for AI Agents in 2026</title><link>https://www.bitdoze.com/tinyfish-free-firecrawl-alternative/</link><guid isPermaLink="true">https://www.bitdoze.com/tinyfish-free-firecrawl-alternative/</guid><description>TinyFish gives you free web search, free page fetching, and AI browser agents that handle login flows Firecrawl can&apos;t. Here&apos;s how it compares on pricing, features, and real-world use.</description><pubDate>Sat, 27 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

I have been using Firecrawl for a while. It is a solid scraping tool, and I have recommended it in the past. But every few weeks, I would hit the same wall: credits running out faster than expected, protected sites returning failures, and the realization that &quot;1 credit per page&quot; does not actually mean 1 credit per page once you turn on the features that make scraping useful.

Then I found [TinyFish](https://go.bitdoze.com/tinyfish). The first thing that caught my attention was not the agent capabilities or the benchmark scores. It was the pricing page. Search is free. Fetch is free. Not &quot;free for 1,000 requests then we start charging.&quot; Free. As in zero credits, zero cost, on every plan including the pay-as-you-go tier that costs nothing to start.

This article is about why I think [TinyFish](https://go.bitdoze.com/tinyfish) is the best free Firecrawl alternative right now, where Firecrawl still makes sense, and where the two tools are solving genuinely different problems.

&lt;Notice type=&quot;info&quot; title=&quot;Quick summary&quot;&gt;

TinyFish gives you four tools under one API key: Search, Fetch, Browser, and Web Agent. Search and Fetch are completely free. You get 500 free agent credits to start, no credit card required. If your work involves scraping public pages, logging into portals, or running agent workflows on the live web, TinyFish handles all of it from one endpoint.

&lt;/Notice&gt;

## What is TinyFish?

[TinyFish](https://go.bitdoze.com/tinyfish) is a web infrastructure platform built for AI agents. It gives you four products from a single API key:

1. **Search** — Live web search returning structured JSON results. Browser-rendered, never cached.
2. **Fetch** — Renders any URL in a real browser, returns clean markdown, JSON, or HTML.
3. **Browser** — Stealth browser sessions that bypass anti-bot protection, maintain login state, and handle dynamic content.
4. **Web Agent** — AI agents that navigate pages, fill forms, authenticate into sites, and return structured results.

The pitch is simple: one API key, one credit pool, zero routing code. The platform decides which tool to use and when. You describe what you want in natural language, and TinyFish handles the browser, the proxy, the LLM inference, and the anti-bot infrastructure.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Hu_OGbBEW3M&quot;
  label=&quot;FREE TinyFish Makes AI Agents Actually Useful&quot;
/&gt;

## Why I started looking beyond Firecrawl

Firecrawl is good at what it does. The `/scrape` and `/crawl` endpoints are well-designed, the markdown output is clean, and the open-source repo has over 123,000 GitHub stars. If you need to ingest a documentation site into a RAG pipeline, Firecrawl handles that fast.

But three things kept pushing me to look for alternatives:

### Credit stacking

Firecrawl advertises 1 credit per page. In practice, JSON mode adds 4 credits, Enhanced mode adds another 4. A 100,000-credit Standard plan ($83/month) could deliver as few as 11,000 usable pages if you need both features active. Credits do not roll over. Retries on failed pages consume the same credits as first attempts.

### Protected sites

Independent testing by Proxyway put Firecrawl&apos;s success rate at roughly 34% on protected sites at 2 requests per second. Social media platforms are explicitly restricted. If your targets use modern bot detection, you are paying for failures.

### No authentication

Firecrawl handles public pages. If your workflow requires logging in, navigating a dashboard, or making decisions based on page content, you need to build that orchestration yourself. The `/interact` endpoint helps with basic actions, but for multi-step authenticated flows across many sites, you are stitching together API calls and managing session state on your end.

## Where TinyFish does things Firecrawl cannot

### Free search and fetch

This is the one that got my attention. [TinyFish Search](https://go.bitdoze.com/tinyfish) is free on every plan. Fetch is free on every plan. The free tier gives you 30 search queries per minute and 150 URLs per minute for fetch. No credits consumed, no hidden metering.

For comparison, Firecrawl&apos;s free tier gives you 1,000 credits per month. Search costs 2 credits per 10 results. Scrape costs 1 credit per page. Once those 1,000 credits are gone, you wait until next month or pay.

With TinyFish, I can run search and fetch operations all day, every day, at no cost. The credits only kick in when I need the Browser or Agent APIs.

### Authentication and multi-step workflows

This is where the two tools diverge completely. Firecrawl extracts pages. [TinyFish](https://go.bitdoze.com/tinyfish) completes workflows.

Example: you need to log into a supplier portal, navigate to a pricing section that loads via AJAX after a 2-second delay, check which SKUs changed since last week, and return the difference as structured JSON. Across 50 portals.

Firecrawl cannot do this. It is not built for it. You would need to layer your own browser automation on top of Firecrawl&apos;s API, manage credentials and session state yourself, and handle the orchestration between steps.

TinyFish handles the full sequence in one API call. You describe the goal, the agent handles login, navigation, waiting for dynamic content, and data extraction. One endpoint in, structured JSON out.

### Anti-bot handling

Firecrawl uses Fire-Engine, a proprietary anti-bot layer that only works in the hosted version (not in the open-source self-hosted version). Independent tests show mixed results on heavily protected sites.

TinyFish runs every request through a native Chromium-based browser session with infrastructure-level request handling and residential proxy rotation. You set `browser_profile: &quot;stealth&quot;` and the platform handles the rest. Geographic routing is supported (US, GB, CA, DE, FR, JP, AU) with a single parameter.

Neither tool is perfect against the most aggressive protection systems. But TinyFish&apos;s approach is included in every plan at no extra cost, while Firecrawl&apos;s anti-bot layer is one of the features that costs additional credits.

### Benchmark results

TinyFish scored 91.1% on the WebVoyager benchmark, ranking first against BrowserUse (88.3%), Smooth (86.6%), and Notte (84.2%). The evaluation was run independently by Mersault, with all agents using the same underlying model (Claude Sonnet) and graded by GPT-4o to eliminate self-preference bias.

On Mind2Web, TinyFish scored 89.9% accuracy. These are live-website benchmarks, not cached snapshots.

Firecrawl&apos;s `/agent` endpoint does not have a comparable public benchmark yet, and per Firecrawl&apos;s billing docs, agent requests are billed even on failure.

## Pricing comparison

Here is where the numbers tell a clear story.

### Firecrawl pricing

| Plan | Price | Credits |
|------|-------|---------|
| Free | $0 | 1,000 pages/month |
| Hobby | $16/mo | 5,000 pages |
| Standard | $83/mo | 100,000 pages |
| Growth | $333/mo | 500,000 pages |
| Scale | $599/mo | 1,000,000 pages |

Credit consumption: Scrape = 1/page, Crawl = 1/page, Map = 1/page, Search = 2 per 10 results, Interact = 2 per browser minute. JSON mode adds 4 credits, Enhanced mode adds 4 more. Credits do not roll over.

### TinyFish pricing

| Plan | Price | Credits | Search | Fetch |
|------|-------|----------|--------|-------|
| Pay as you go | $0.015/credit | 500 free to start | 30 req/min | 150 URL/min |
| Starter | $15/mo | 1,650 credits/mo | 60 req/min | 300 URL/min |
| Pro | $150/mo | 16,500 credits/mo | 120 req/min | 600 URL/min |
| Enterprise | Custom | Custom | Custom | Custom |

Credit consumption: Agent = 1 credit/step, Browser = 1 credit per 4 minutes (60 minute cap). Search = 0 credits. Fetch = 0 credits. Failed runs = $0. LLM inference, stealth browser, anti-bot, and proxy are all included.

&lt;ListCheck&gt;

**What is included in every TinyFish plan at no extra cost:**
- Web search (free, rate-limited by plan)
- Page fetch (free, rate-limited by plan)
- LLM inference for agent reasoning
- Stealth browser sessions
- Anti-bot infrastructure
- Residential proxy rotation
- Geographic routing (7 regions)
- SDKs, CLI, and MCP server
- Failed runs (no charge)

&lt;/ListCheck&gt;

&lt;Button href=&quot;https://go.bitdoze.com/tinyfish&quot; text=&quot;Get 500 free TinyFish credits&quot; /&gt;

## Real-world cost example

Let&apos;s say you need to extract product prices from 100 pages.

**Scenario 1: Pages are public and static.**
Firecrawl wins on raw cost. 100 credits on any plan, likely under a dollar. TinyFish would use 3 to 5 agent steps per page (300 to 500 steps), costing $4.50 to $7.50 on pay-as-you-go.

**Scenario 2: Pages require login and navigation.**
Firecrawl requires you to build authentication and navigation logic yourself. Your engineering time is the real cost here, not API credits. TinyFish handles it in one API call at $0.015 per step. A 20-step workflow across 100 sites costs about $30 total, infrastructure included.

**Scenario 3: You just need search and fetch.**
TinyFish costs $0.00. Firecrawl costs 2 credits per 10 search results plus 1 credit per page fetched. For a research pipeline doing 5,000 searches and 10,000 page fetches per month, TinyFish saves you roughly 6,000 Firecrawl credits every month.

## When to use Firecrawl instead

I want to be fair here. Firecrawl is not a bad tool. It is the wrong tool for certain jobs, and the right tool for others.

Use Firecrawl when:

- You need to crawl an entire documentation site or blog into a RAG pipeline. The `/crawl` and `/map` endpoints are purpose-built for this and cheaper than any agent-based approach.
- Your targets are public, static, and not behind aggressive bot detection. Firecrawl is fast and reliable for this use case.
- You need Pydantic schema extraction from static pages. The `/extract` endpoint with typed schemas is clean and predictable.
- You want open-source and self-hostable. The core is AGPL-3.0, and if your organization requires code inspection or self-deployment, Firecrawl gives you that option. TinyFish is a managed service.
- Community and ecosystem matter to you. 123,000 GitHub stars, native LangChain and LlamaIndex integrations, and a large developer community writing extensions.

Use TinyFish when:

- Your workflow involves login, authentication, or multi-step navigation.
- You need to bypass anti-bot protection without managing your own proxy infrastructure.
- You want free web search and page fetch without credit consumption.
- You are running agent workflows across many sites in parallel.
- You need geographic routing for location-specific content.
- You want one API instead of stitching together search, fetch, browser, and agent tools from different vendors.

&lt;Notice type=&quot;info&quot; title=&quot;Using both together&quot;&gt;

A common production pattern uses Firecrawl for bulk public-page ingestion (`/crawl` and `/scrape`) and TinyFish for authenticated or interactive workflows. They cover different parts of the same pipeline. If your budget allows, this is a strong combination.

&lt;/Notice&gt;

## How to get started with TinyFish

Getting set up with [TinyFish](https://go.bitdoze.com/tinyfish) takes about a minute.

1. **Sign up** at [agent.tinyfish.ai/sign-up](https://go.bitdoze.com/tinyfish) and grab your API key. No credit card required. You get 500 free credits.

2. **Pick your integration surface.** TinyFish works with:
   - REST API (`api.search.tinyfish.ai` and `api.fetch.tinyfish.ai`)
   - MCP server (Claude, Cursor, Codex, ChatGPT desktop, any MCP-aware client)
   - CLI (`npm install -g @tiny-fish/cli`)
   - Python SDK (`pip install tinyfish`)
   - TypeScript SDK (`npm install @tiny-fish/sdk`)
   - Agent harnesses: Claude Code, Codex, Cursor, OpenClaw, Hermes Agent, OpenCode, Cline, Goose

3. **Start with free operations.** Search and fetch cost nothing. Test your workflows before spending credits on the Browser or Agent APIs.

Here is a quick search call:

```bash
curl &quot;https://api.search.tinyfish.ai?query=best+web+scraping+tools+2026&quot; \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot;
```

And a fetch call:

```bash
curl -X POST https://api.fetch.tinyfish.ai \
  -H &quot;X-API-Key: $TINYFISH_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;urls&quot;: [&quot;https://example.com&quot;]}&apos;
```

MCP configuration for Claude or Cursor:

```json
{
  &quot;mcpServers&quot;: {
    &quot;tinyfish&quot;: {
      &quot;url&quot;: &quot;https://agent.tinyfish.ai/mcp&quot;
    }
  }
}
```

&lt;Button href=&quot;https://go.bitdoze.com/tinyfish&quot; text=&quot;Start free with 500 credits&quot; /&gt;

## TinyFish referral program

TinyFish has a referral program that is live now. When you [sign up through a referral link](https://go.bitdoze.com/tinyfish), you and the person who referred you both get bonus credits. You need to make your first Search, Fetch, Browser, or Agent run to trigger the reward.

If you found this article useful and want to support it, [signing up through this link](https://go.bitdoze.com/tinyfish) gives you the same 500 free credits and throws some my way. No extra cost to you either way.

## FAQ

&lt;Accordion label=&quot;Is TinyFish really free?&quot; group=&quot;faq&quot;&gt;

The Search and Fetch APIs are free on every plan, including the pay-as-you-go tier that costs nothing to start. You get 30 search queries per minute and 150 fetch URLs per minute on the free tier. The Agent and Browser APIs consume credits, and you get 500 free credits when you [sign up](https://go.bitdoze.com/tinyfish). No credit card required.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can TinyFish replace Firecrawl completely?&quot; group=&quot;faq&quot;&gt;

For scraping public, static pages in bulk, Firecrawl is faster and cheaper. TinyFish does not have a dedicated crawl endpoint. If your work is primarily full-site ingestion into RAG pipelines, Firecrawl is the better tool for that specific job. For anything involving authentication, multi-step navigation, anti-bot bypass, or agent workflows, TinyFish does what Firecrawl cannot. Many teams use both.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does TinyFish pricing compare to Firecrawl at scale?&quot; group=&quot;faq&quot;&gt;

Firecrawl charges per page with credit multipliers for features (JSON mode +4, Enhanced mode +4). A Standard plan at $83/month with 100,000 credits can deliver as few as 11,000 pages if you need both features. TinyFish charges $0.015 per agent step with no multipliers. Search and fetch are free. At 10,000 tasks per month, TinyFish costs around $150. The full cost comparison depends on your workflow complexity and target site protection level.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does TinyFish have an open-source version?&quot; group=&quot;faq&quot;&gt;

TinyFish is a managed service. The [Cookbook](https://github.com/tinyfish-io/tinyfish-cookbook) is open source, and AgentQL components are partially open source. If self-hosting is a requirement, Firecrawl&apos;s AGPL-3.0 core gives you that option, though the proprietary Fire-Engine anti-bot layer is not open source.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is the TinyFish referral program?&quot; group=&quot;faq&quot;&gt;

TinyFish has a live referral program. [Sign up through a referral link](https://go.bitdoze.com/tinyfish), make your first Search, Fetch, Browser, or Agent run, and both you and the referrer get bonus credits. The program is accessible from your TinyFish dashboard after signing up.

&lt;/Accordion&gt;

## Bottom line

If you are paying for Firecrawl and your workflows are mostly hitting public pages, Firecrawl remains a good choice. It is fast, mature, and the crawl endpoint is hard to beat for bulk ingestion.

But if you are spending Firecrawl credits on search queries, page fetches, failed protected-site attempts, and building your own authentication orchestration on top, [TinyFish](https://go.bitdoze.com/tinyfish) is worth a serious look. The free search and fetch alone could cut your API bill meaningfully. The agent capabilities fill the gap that Firecrawl leaves open.

Start with the free tier. Run some searches, fetch a few pages, deploy an agent on a site that Firecrawl struggles with. The 500 free credits will tell you pretty quickly whether TinyFish fits your workflow.

&lt;Button href=&quot;https://go.bitdoze.com/tinyfish&quot; text=&quot;Try TinyFish free&quot; /&gt;</content:encoded><category>ai</category><category>web-scraping</category><category>tinyfish</category></item><item><title>How To Embed Youtube Videos to Gatsby</title><link>https://www.bitdoze.com/embed-youtube-videos-to-gatsby/</link><guid isPermaLink="true">https://www.bitdoze.com/embed-youtube-videos-to-gatsby/</guid><description>Embed YouTube videos in Gatsby markdown and MDX using plugins. Step-by-step setup for Contentful and other headless CMS systems.</description><pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;

**Note:** Gatsby has been in maintenance mode since 2023. The latest release (v5.16.1, February 2026) added React 19 and Node.js 24 support, but active feature development has stopped. If you&apos;re starting a new project, consider [Next.js](https://nextjs.org/) or [Astro](https://astro.build/) instead. The instructions below still work for existing Gatsby sites.

Gatsby doesn&apos;t handle iframes out of the box. You can&apos;t just paste a YouTube embed code into markdown and expect it to work — React sanitizes raw HTML. You need a plugin to convert video URLs into proper iframes during the build.

This guide covers two approaches: using a remark plugin for markdown content, and handling videos in MDX with a custom component.

## Option 1: gatsby-remark-embed-video (recommended)

[gatsby-remark-embed-video](https://www.gatsbyjs.com/plugins/gatsby-remark-embed-video/) is the most maintained option in the Gatsby ecosystem. It supports YouTube, Vimeo, VideoPress, and Twitch. Last published 4 years ago (v3.2.1), but it still works with Gatsby 5.

It works with both `gatsby-transformer-remark` (markdown) and `gatsby-plugin-mdx`.

### Install

```bash
npm i gatsby-remark-embed-video gatsby-transformer-remark
```

If you want responsive iframes (recommended), also install:

```bash
npm i gatsby-remark-responsive-iframe
```

### Configure for markdown (gatsby-transformer-remark)

Add this to your `gatsby-config.js`:

```javascript
{
  resolve: &quot;gatsby-transformer-remark&quot;,
  options: {
    plugins: [
      {
        resolve: &quot;gatsby-remark-embed-video&quot;,
        options: {
          width: 800,
          ratio: 1.77,           // 16/9 aspect ratio
          height: 400,           // overrides ratio if set
          related: false,        // hide related videos at end
          noIframeBorder: true,
          loadingStrategy: &quot;lazy&quot;,
          containerClass: &quot;embedVideo-container&quot;,
          urlOverrides: [
            {
              id: &quot;youtube&quot;,
              embedURL: (videoId) =&gt;
                `https://www.youtube-nocookie.com/embed/${videoId}`,
            },
          ],
        },
      },
      &quot;gatsby-remark-responsive-iframe&quot;,  // must come after embed-video
    ],
  },
},
```

The `youtube-nocookie.com` URL override uses YouTube&apos;s privacy-enhanced mode — no cookies until the user clicks play. Good for GDPR compliance.

### Configure for MDX (gatsby-plugin-mdx)

If you&apos;re using MDX instead of plain markdown:

```javascript
{
  resolve: &quot;gatsby-plugin-mdx&quot;,
  options: {
    gatsbyRemarkPlugins: [
      {
        resolve: &quot;gatsby-remark-embed-video&quot;,
        options: {
          width: 800,
          ratio: 1.77,
          related: false,
          noIframeBorder: true,
          loadingStrategy: &quot;lazy&quot;,
        },
      },
      &quot;gatsby-remark-responsive-iframe&quot;,
    ],
  },
},
```

### Usage in content

Add these tags on their own line in your markdown or MDX files:

```
`video: https://www.youtube.com/embed/2Xc9gXyf2G4`

`youtube: https://www.youtube.com/watch?v=2Xc9gXyf2G4`
`youtube: 2Xc9gXyf2G4`

`vimeo: https://vimeo.com/5299404`
`vimeo: 5299404`

`videoPress: https://videopress.com/v/kUJmAcSf`
`videoPress: kUJmAcSf`

`twitch: https://player.twitch.tv/?channel=dakotaz`
`twitch: https://player.twitch.tv/?autoplay=false&amp;video=v273436948`
`twitch: 273436948`
`twitchLive: dakotaz`
```

You can also add accessibility titles:

```
`youtube: [My Video Title](https://www.youtube.com/watch?v=2Xc9gXyf2G4)`
```

### Using with Contentful CMS

This approach works with Contentful or any headless CMS that outputs markdown. The key requirement: **your content field must be markdown, not rich text.**

In Contentful, create a text field with &quot;Markdown&quot; type. Then add the video tag on its own line in the editor:

```
`youtube: https://youtu.be/2Wmats7Q6ck`
```

When Gatsby builds, `gatsby-transformer-remark` processes the markdown and the embed-video plugin converts the tag into an iframe.

### Important: plugin order matters

If you use `gatsby-remark-responsive-iframe`, `gatsby-remark-images`, or `gatsby-remark-prismjs`, the embed-video plugin must come first:

```javascript
plugins: [
  &quot;gatsby-remark-embed-video&quot;,
  &quot;gatsby-remark-responsive-iframe&quot;,
  &quot;gatsby-remark-prismjs&quot;,
  &quot;gatsby-remark-images&quot;,
]
```

Wrong order will break video embedding.

## Option 2: Custom React component (MDX only)

If you&apos;re using MDX and want more control, skip the plugin entirely and create a YouTube component:

```jsx
// src/components/YouTube.js
const YouTube = ({ id, title = &quot;YouTube video&quot; }) =&gt; (
  &lt;div style={{ position: &quot;relative&quot;, paddingBottom: &quot;56.25%&quot;, height: 0, overflow: &quot;hidden&quot; }}&gt;
    &lt;iframe
      src={`https://www.youtube-nocookie.com/embed/${id}`}
      title={title}
      style={{ position: &quot;absolute&quot;, top: 0, left: 0, width: &quot;100%&quot;, height: &quot;100%&quot; }}
      allow=&quot;accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture&quot;
      allowFullScreen
      loading=&quot;lazy&quot;
    /&gt;
  &lt;/div&gt;
);

export default YouTube;
```

Then import and use it in your MDX files:

```mdx
import YouTube from &quot;../components/YouTube&quot;;

# My Post

Some content here.

&lt;YouTube id=&quot;2Xc9gXyf2G4&quot; title=&quot;Demo video&quot; /&gt;
```

This gives you full control over the iframe attributes, styling, and lazy loading. No plugin dependency.

## Which approach to choose

**Use the plugin if:**
- Your content comes from a headless CMS (Contentful, Sanity, etc.)
- Non-technical editors need to add videos by pasting URLs
- You have existing markdown content with video tags

**Use a custom component if:**
- You write content directly in MDX files
- You want full control over iframe attributes
- You want to avoid plugins that haven&apos;t been updated in years

## Troubleshooting

**Videos not showing up?** Check that your content field is markdown type, not rich text. Rich text in Contentful uses a different rendering pipeline that skips remark plugins.

**Build errors after install?** Clear the Gatsby cache:
```bash
gatsby clean &amp;&amp; gatsby develop
```

**Plugin conflicts?** Make sure embed-video is listed before any other remark plugins in your config.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>web-development</category><category>gatsby</category></item><item><title>Monitor CPU Usage and Send Email Alerts in Linux</title><link>https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/monitor-cpu-usage-and-send-email-alerts-in-linux/</guid><description>Let&apos;s see how we can monitor CPU usage on a server and receive emails.</description><pubDate>Fri, 26 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

Monitoring the CPU is very important on a Linux server as there may be cases when your applications will need more CPU and can consume everything. You would want to be notified via email in case the CPU usage spikes and you need to do some checks.

&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

For your server to be able to send emails you will need to have configured an SMTP Relay or you should have an email server hosted on the VPS that will send the alarm.

In case you are using an online VPS provider like Hetzner or DigitalOcean having such a script can be very useful as it can catch problems coming from your hosting provider or your app. For more details on Hetzner, you can check this review.

In this article, we will configure a script that will run in crontab every 5 minutes and check to see if the CPU usage is above 80% in case that happens you will be notified via email with the:

- Current Usage
- Top 20 processes that consume high CPU
- Top 10 Processes that consume high CPU using the ps command
- Memory Utilization on the server

In case you are interested to have a web panel that can help you manage your applications and be used as a reverse proxy you can check the bellow course:

&lt;Button
  link=&quot;https://webdoze.net/courses/cloudpanel-setup/&quot;
  text=&quot;CloudPanel Setup Course&quot;
/&gt;

Having all of this in an email will help us better understand what is happening on the server we have. To build the script we are going to use shell commands.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## Youtube Video With Details

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/FdjECMq1N2U&quot;
  label=&quot;Monitor CPU Usage and Send Email Alerts in Linux&quot;
/&gt;

## Shell Script To Be Used

Below is the script that we are going to use to help us monitor the CPU usage, this will help you monitor Ubuntu versions like 20.04 or 22.04 or RedHat distros.

```
    #!/bin/bash
    cpu_idle=`top -b -n 1 | grep Cpu | awk &apos;{print $8}&apos;|cut -f 1 -d &quot;.&quot;`
    cpuuse=`expr 100 - $cpu_idle`
    date=$(date &apos;+%Y-%m-%d %H:%M:%S&apos;)
    if [ &quot;$cpuuse&quot; -ge 80 ]; then
    SUBJECT=&quot;ATTENTION: CPU load is high on $(hostname) at $(date)&quot;
    MESSAGE=&quot;/tmp/Mail.out&quot;
    TO=&quot;youremail@domain.com&quot;
      echo &quot;CPU current usage is: $cpuuse%&quot; &gt;&gt; $MESSAGE
      echo &quot;&quot; &gt;&gt; $MESSAGE
      echo &quot;+------------------------------------------------------------------+&quot; &gt;&gt; $MESSAGE
      echo &quot;Top 20 processes that consume high CPU&quot; &gt;&gt; $MESSAGE
      echo &quot;+------------------------------------------------------------------+&quot; &gt;&gt; $MESSAGE
      echo &quot;$(top -bn1 | head -20)&quot; &gt;&gt; $MESSAGE
      echo &quot;&quot; &gt;&gt; $MESSAGE
      echo &quot;+------------------------------------------------------------------+&quot; &gt;&gt; $MESSAGE
      echo &quot;Top 10 Processes which consuming high CPU using the ps command&quot; &gt;&gt; $MESSAGE
      echo &quot;+------------------------------------------------------------------+&quot; &gt;&gt; $MESSAGE
      echo &quot;$(ps -eo pcpu,pid,user,args | sort -k 1 -r | head -10)&quot; &gt;&gt; $MESSAGE
      echo &quot;Memory Utilization on the server&quot; &gt;&gt; $MESSAGE
      echo &quot;+------------------------------------------------------------------+&quot; &gt;&gt; $MESSAGE
      echo &quot;$(ps_mem)&quot; &gt;&gt; $MESSAGE
      mail -s &quot;$SUBJECT&quot; &quot;$TO&quot; &lt; $MESSAGE
      rm /tmp/Mail.out
    else
    echo &quot;$date: Server CPU usage is in under threshold.CPU current usage is: $cpuuse%&quot;
      fi
```

In this script, you need to change the TO with your email and in case you want to change the threshold you put the value you want in the -ge 80 (now is 80).

## Activating the CPU Monitoring Script

### Install ps_mem

This is a tool that will show you better memory usage per process, to install this tool you do:

Get ps_men:

```
      sudo wget -qO /usr/local/bin/ps_mem https://raw.githubusercontent.com/pixelb/ps_mem/master/ps_mem.py
```

Make ps_mem executable:

```
sudo chmod a+x /usr/local/bin/ps_mem
```

Check the version:

```
      ps_mem --version
```

If you are receiving an error message /usr/bin/env: &apos;python&apos;: No such file or directory, you need to create a symbolic link for /usr/bin/python. In Ubuntu 20.04 or Ubuntu 22.04, only Python 3 is installed by default.

```
      sudo ln -s /usr/bin/python3 /usr/bin/python
```

### Put the Script in Crontab

Create a file with the script under /opt/scripts or in any location you want:

```
vi /opt/scripts/cpu-alert.sh
```

Make The File Executable:

```
sudo chmod a+x /opt/scripts/cpu-alert.sh
```

Execute the Script:

```
      /opt/scripts/cpu-alert.sh
```

The output should be on email:

```
    CPU current usage is: 5%
    +------------------------------------------------------------------+
    Top 20 processes that consume high CPU
    +------------------------------------------------------------------+
    top - 11:27:12 up 6 days, 4:31, 1 user, load average: 0.08, 0.08, 0.07
    Tasks: 204 total, 1 running, 202 sleeping, 0 stopped, 1 zombie
    %Cpu(s): 4.1 us, 0.0 sy, 0.0 ni, 95.9 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
    MiB Mem : 3827.7 total, 556.1 free, 1427.2 used, 1844.4 buff/cache
    MiB Swap: 2048.0 total, 1276.7 free, 771.3 used. 1454.0 avail Mem
    PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
    1959 clp 20 0 1355012 63248 21020 S 6.2 1.6 127:54.60 beam.smp
    1 root 20 0 168072 9940 6204 S 0.0 0.3 2:35.24 systemd
    2 root 20 0 0 0 0 S 0.0 0.0 0:00.11 kthreadd
    3 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_gp
    4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 rcu_par_gp
    5 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 netns
    7 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H-events_highpri
    10 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 mm_percpu_wq
    11 root 20 0 0 0 0 S 0.0 0.0 0:00.00 rcu_tasks_rude_
    12 root 20 0 0 0 0 S 0.0 0.0 0:00.00 rcu_tasks_trace
    13 root 20 0 0 0 0 S 0.0 0.0 3:41.85 ksoftirqd/0
    14 root 20 0 0 0 0 I 0.0 0.0 9:58.13 rcu_sched
    15 root rt 0 0 0 0 S 0.0 0.0 0:01.65 migration/0
    +------------------------------------------------------------------+
    Top 10 Processes that consume high CPU using the ps command
    +------------------------------------------------------------------+
    %CPU PID USER COMMAND
    5.0 596375 btdo php-fpm: pool btdo.uk
    3.5 1039 mysql /usr/sbin/mariadbd
    1.4 1959 clp /app/erts-12.0/bin/beam.smp -- -root /app -progname erl -- -home /app -- -noshell -s elixir start_cli -mode embedded -setcookie S4WHAF4LH5WHSPWUHPKIVMSFISQKQJVFWEGHSP7YW6MT5LKCNDCA==== -sname plausible -config /app/releases/0.0.1/sys -boot /app/releases/0.0.1/start -boot_var RELEASE_LIB /app/lib -- -extra --no-halt
    1.2 772 redis /usr/bin/redis-server 127.0.0.1:6379
    0.6 2677 systemd+ /usr/bin/clickhouse-server --config-file=/etc/clickhouse-server/config.xml
    0.4 579396 root nginx: worker process
    0.3 2071 root node server/server.js
    0.1 789 root /usr/bin/containerd
    0.1 14 root [rcu_sched]
    Memory Utilization on the server
    +------------------------------------------------------------------+
    Private + Shared = RAM used    Program
    4.0 KiB + 25.5 KiB = 29.5 KiB    erl_child_setup
    40.0 KiB + 24.5 KiB = 64.5 KiB    epmd
    4.0 KiB + 82.5 KiB = 86.5 KiB    dumb-init
    56.0 KiB + 108.5 KiB = 164.5 KiB    inet_gethost (3)
    144.0 KiB + 56.5 KiB = 200.5 KiB    cron
    148.0 KiB + 52.5 KiB = 200.5 KiB    atd
    320.0 KiB + 0.5 KiB = 320.5 KiB    exim4
    192.0 KiB + 136.0 KiB = 328.0 KiB    agetty (2)
    268.0 KiB + 89.5 KiB = 357.5 KiB    qemu-ga
    340.0 KiB + 104.5 KiB = 444.5 KiB    irqbalance
    304.0 KiB + 361.5 KiB = 665.5 KiB    master
    352.0 KiB + 361.0 KiB = 713.0 KiB    chronyd (2)
    384.0 KiB + 358.5 KiB = 742.5 KiB    unattended-upgr
    728.0 KiB + 111.5 KiB = 839.5 KiB    systemd-udevd
    652.0 KiB + 339.5 KiB = 991.5 KiB    polkitd
    732.0 KiB + 418.5 KiB = 1.1 MiB    systemd-logind
    920.0 KiB + 243.5 KiB = 1.1 MiB    dbus-daemon
    800.0 KiB + 391.5 KiB = 1.2 MiB    systemd-networkd
    4.0 KiB + 1.3 MiB = 1.3 MiB    clckhouse-watch
    820.0 KiB + 516.5 KiB = 1.3 MiB    systemd-resolved
    1.4 MiB + 128.5 KiB = 1.5 MiB    rsyslogd
    328.0 KiB + 1.2 MiB = 1.5 MiB    php-fpm7.1
    660.0 KiB + 902.5 KiB = 1.5 MiB    qmgr
    708.0 KiB + 878.5 KiB = 1.5 MiB    pickup
    1.3 MiB + 458.5 KiB = 1.8 MiB    ModemManager
    776.0 KiB + 1.2 MiB = 1.9 MiB    php-fpm7.2
    2.0 MiB + 49.5 KiB = 2.0 MiB    proftpd
    924.0 KiB + 1.2 MiB = 2.1 MiB    php-fpm7.3
    1.6 MiB + 533.5 KiB = 2.2 MiB    udisksd
    2.5 MiB + 1.1 MiB = 3.6 MiB    tlsmgr
    2.3 MiB + 1.4 MiB = 3.7 MiB    bash (2)
    3.3 MiB + 992.5 KiB = 4.3 MiB    networkd-dispat
    4.3 MiB + 73.5 KiB = 4.4 MiB    memcached
    4.2 MiB + 754.0 KiB = 4.9 MiB    docker-proxy (4)
    3.6 MiB + 1.5 MiB = 5.1 MiB    sshd (2)
    5.4 MiB + 5.0 MiB = 10.5 MiB    systemd (3)
    11.1 MiB + 35.5 KiB = 11.1 MiB    clp-agent
    12.3 MiB + 1.6 MiB = 13.9 MiB    containerd-shim-runc-v2 (5)
    14.5 MiB + 2.6 MiB = 17.1 MiB    php-fpm8.0
    17.4 MiB + 23.5 KiB = 17.4 MiB    containerd
    18.9 MiB + 170.5 KiB = 19.0 MiB    redis-server
    17.0 MiB + 3.8 MiB = 20.8 MiB    php-fpm8.1 (2)
    21.4 MiB + 751.5 KiB = 22.2 MiB    multipathd
    13.9 MiB + 10.2 MiB = 24.1 MiB    systemd-journald
    28.5 MiB + 178.5 KiB = 28.6 MiB    dockerd
    28.9 MiB + 1.3 MiB = 30.2 MiB    clickhouse
    29.6 MiB + 15.6 MiB = 45.2 MiB    postgres (18)
    40.1 MiB + 6.0 MiB = 46.0 MiB    php-fpm7.4 (2)
    47.2 MiB + 8.0 MiB = 55.3 MiB    beam.smp
    82.3 MiB + 82.5 KiB = 82.4 MiB    node
    72.0 MiB + 30.0 MiB = 102.0 MiB    nginx (8)
    807.7 MiB + 398.5 KiB = 808.1 MiB    mariadbd
    ---------------------------------
    1.4 GiB
    =================================
```

Activating the script in crontab and creating a log for the run:

```
    #open crontab in edit mode
    contab -e
    #add the bellow line
    */5 * * * * /bin/bash /opt/scripts/cpu-alert.sh &gt;&gt; /opt/scripts/cpu_check_cron.log 2&gt;&amp;1
```

The above will create a log under /opt/scripts/cpu_check_cron.log with the output when this is not sending an email.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>self-hosting</category><category>linux</category></item><item><title>How to Benchmark Cloud Servers (VPS)</title><link>https://www.bitdoze.com/benchmark-cloud-servers/</link><guid isPermaLink="true">https://www.bitdoze.com/benchmark-cloud-servers/</guid><description>Run disk, network, and CPU benchmarks on your VPS using YABS and other tools. Compare providers before you commit.</description><pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;

Before picking a VPS provider, run a benchmark. Specs on a pricing page don&apos;t tell you much — shared vCPU contention, noisy neighbors, and disk throttling all affect real performance. A quick benchmark shows what you actually get.

This guide covers what to test, which tools to use, and how to read the results.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

## What to benchmark

Three things matter most on a VPS:

**Disk I/O.** Random read/write speeds with small block sizes (4k) affect databases and web apps directly. Sequential speeds (1m blocks) matter for backups and large file operations. Look for NVMe storage — it&apos;s standard on most providers now but some still use SATA SSDs.

**Network throughput.** If you&apos;re serving users across regions, test speeds to multiple locations. A server with 10 Gbps uplink but poor peering to your target region is useless. Check both IPv4 and IPv6.

**CPU performance.** Geekbench scores give you a standardized number you can compare across providers. Single-core matters for most web workloads. Multi-core matters if you&apos;re running parallel tasks.

## YABS: the one-command benchmark

[YABS](https://github.com/masonr/yet-another-bench-script) (Yet-Another-Bench-Script) runs all three tests in one go. It uses fio for disk, iperf3 for network, and Geekbench 6 for CPU. No installation needed — it downloads portable binaries and runs them.

Run it:

```
curl -sL https://yabs.sh | bash
```

That&apos;s it. Takes about 10-15 minutes depending on your server.

**Useful flags:**

- `-r` — reduces iperf locations (less bandwidth usage)
- `-i` — skips network tests entirely
- `-f` — skips disk tests
- `-g` — skips Geekbench
- `-5` — runs Geekbench 5 instead of 6 (for comparing with older results)

You can combine flags: `curl -sL https://yabs.sh | bash -s -- -r` runs a lighter test with fewer network endpoints.

### Reading the output

Here&apos;s what a typical run looks like (Hetzner CX33, 4 vCPU AMD EPYC, €6.49/month):

```
# ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
#              Yet-Another-Bench-Script              #
#                     v2026-04-20                    #
# https://github.com/masonr/yet-another-bench-script #
# ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

Thu Oct 16 11:32:39 AM UTC 2026

Basic System Information:
---------------------------------
Uptime     : 0 days, 0 hours, 30 minutes
Processor  : AMD EPYC-Rome Processor
CPU cores  : 4 @ 2445.404 MHz
AES-NI     : ✔ Enabled
VM-x/AMD-V : ❌ Disabled
RAM        : 7.6 GiB
Swap       : 0.0 KiB
Disk       : 75.0 GiB
Distro     : Ubuntu 24.04.3 LTS
Kernel     : 6.8.0-71-generic
VM Type    : KVM
IPv4/IPv6  : ✔ Online / ✔ Online

fio Disk Speed Tests (Mixed R/W 50/50):
---------------------------------
Block Size | 4k            (IOPS) | 64k           (IOPS)
  ------   | ---            ----  | ----           ----
Read       | 115.01 MB/s  (28.7k) | 988.49 MB/s  (15.4k)
Write      | 115.32 MB/s  (28.8k) | 993.69 MB/s  (15.5k)
Total      | 230.34 MB/s  (57.5k) | 1.98 GB/s    (30.9k)
           |                      |
Block Size | 512k          (IOPS) | 1m            (IOPS)
  ------   | ---            ----  | ----           ----
Read       | 1.78 GB/s     (3.4k) | 2.16 GB/s     (2.1k)
Write      | 1.88 GB/s     (3.6k) | 2.30 GB/s     (2.2k)
Total      | 3.66 GB/s     (7.1k) | 4.46 GB/s     (4.3k)

iperf3 Network Speed Tests (IPv4):
---------------------------------
Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
-----           | -----                     | ----            | ----            | ----
Clouvider       | London, UK (10G)          | 5.16 Gbits/sec  | 5.60 Gbits/sec  | 17.8 ms
Eranium         | Amsterdam, NL (100G)      | 12.3 Gbits/sec  | 12.8 Gbits/sec  | 9.27 ms
Uztelecom       | Tashkent, UZ (10G)        | 1.96 Gbits/sec  | 2.24 Gbits/sec  | 94.6 ms
Leaseweb        | Singapore, SG (10G)       | 665 Mbits/sec   | 841 Mbits/sec   | 166 ms
Clouvider       | Los Angeles, CA, US (10G) | 1.03 Gbits/sec  | 1.21 Gbits/sec  | 158 ms
Leaseweb        | NYC, NY, US (10G)         | 1.88 Gbits/sec  | 2.53 Gbits/sec  | 97.7 ms
Edgoo           | Sao Paulo, BR (1G)        | 616 Mbits/sec   | 1.14 Gbits/sec  | 219 ms

Geekbench 6 Benchmark Test:
---------------------------------
Test            | Value
                |
Single Core     | 1508
Multi Core      | 4919
Full Test       | https://browser.geekbench.com/v6/cpu/14484522

YABS completed in 12 min 35 sec
```

**What to look for in these results:**

- **Disk:** 4k random IOPS above 20k is decent for a shared vCPU plan. The 115 MB/s at 4k block size here is typical for Hetzner&apos;s cost-optimized tier. Dedicated or regular plans will be higher.
- **Network:** 12+ Gbps to Amsterdam is excellent — that&apos;s nearby. The drop to 841 Mbps to Singapore is expected due to distance. Focus on speeds to regions where your users are.
- **CPU:** Geekbench 6 single-core of 1508 is solid for an EPYC-Rome shared vCPU. For reference, dedicated cores typically score 1800-2200+.

## Other benchmark tools worth knowing

YABS covers the basics, but sometimes you need more detail:

**[bench.sh](https://bench.sh/)** — tests disk and network speed. Faster than YABS, no CPU benchmark. Good for a quick check.

```
wget -qO- bench.sh | bash
```

**[nench](https://github.com/n-st/nench)** — similar to bench.sh but adds CPU tests and dual-stack IPv4/IPv6 speed tests by default.

```
curl -sL nench.sh | bash
```

**[fio](https://github.com/axboe/fio)** — the industry standard for disk benchmarking. YABS uses it internally, but running fio directly lets you customize block sizes, queue depths, and test patterns for your specific workload.

```
fio --randrepeat=1 --ioengine=libaio --direct=1 --gtod_reduce=1 \
  --name=test --filename=test --bs=4k --iodepth=64 --size=1G \
  --readwrite=randrw --rwmixread=75
```

**[Geekbench](https://www.geekbench.com/)** — run it standalone if you only care about CPU scores. Version 6 is current (latest is 6.6 as of February 2026). Scores are standardized and comparable across providers.

**[sysbench](https://github.com/akopytov/sysbench)** — tests CPU, memory, file I/O, and MySQL/PostgreSQL performance. If you&apos;re running a database, benchmark it with sysbench before going to production.

## Tips for reliable benchmarks

- **Run tests multiple times.** Shared vCPU performance varies by time of day. Run benchmarks at different hours to see the range.
- **Test during your traffic patterns.** If you&apos;ll run production workloads during business hours, benchmark during business hours.
- **Compare same-tier plans.** Don&apos;t compare a $4/month shared vCPU against a $40/month dedicated core server. Compare like with like.
- **Check the Geekbench browser.** Search [browser.geekbench.com](https://browser.geekbench.com/) for your provider and plan. Many people post results publicly.
- **Save your results.** YABS outputs a URL you can share. Keep a record so you can compare after provider changes.

## Where to compare providers

These sites collect benchmark results from real users:

- [VPSBenchmarks.com](https://www.vpsbenchmarks.com/) — side-by-side comparisons with real test data
- [VPS Metrics](https://vpsmetrics.com/benchmarks/) — filterable benchmark database
- [LowEndTalk](https://lowendtalk.com/) — community forum where people post YABS results

If you&apos;re considering Hetzner specifically, check this [Hetzner Cloud review](https://www.bitdoze.com/hetzner-cloud-review/) with benchmark results across multiple plan tiers.

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;</content:encoded><category>hosting</category><category>linux</category><category>vps</category><category>benchmarks</category></item><item><title>Astro DB with Bunny Database: Local-First Dev, libSQL in Production</title><link>https://www.bitdoze.com/astro-db-bunny-database/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-db-bunny-database/</guid><description>Use Astro DB for local-first development and host the production database on Bunny.net&apos;s managed libSQL instead of Turso. Schema, seeding, remote push, and deploy, step by step.</description><pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Astro DB is one of those features that makes you wonder why every framework doesn&apos;t ship something like it. You define tables in TypeScript, get a local SQLite file for development with zero setup, and query everything through a built-in Drizzle client with full type safety. The catch is that the official docs walk you straight to Turso for the production database, and Turso is the obvious default since they maintain libSQL.

But Astro DB connects to *any* libSQL server, not just Turso. And [Bunny.net](https://go.bitdoze.com/bunny) now runs a managed libSQL database that gives you the same `libsql://` URL and an access token, sits in the EU, idles to zero when nobody&apos;s querying, and bills per usage. So you can keep the whole local-first Astro DB workflow and just point production at Bunny instead. That&apos;s what this guide does.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You&apos;ll need a Bunny account for the production step. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card and get a 14-day trial. My [full Bunny.net review](/bunny-net-review/) covers the rest of the platform.
&lt;/Notice&gt;

## How Astro DB and Bunny fit together

Here&apos;s the mental model, because it confused me at first. Astro DB has two halves:

&lt;ListCheck&gt;
- **Local development**: `astro dev` creates a SQLite file at `.astro/content.db`, regenerates your types from `db/config.ts`, and reseeds it from `db/seed.ts` on every change. No Docker, no network, no credentials
- **Production**: when you build or run with the `--remote` flag, Astro talks to a real libSQL server defined by two environment variables, `ASTRO_DB_REMOTE_URL` and `ASTRO_DB_APP_TOKEN`
&lt;/ListCheck&gt;

The docs fill those two variables with Turso values. We&apos;re going to fill them with Bunny values instead. Everything else about Astro DB, the schema, the seed file, the Drizzle queries, stays exactly the same.

&lt;Notice type=&quot;info&quot; title=&quot;Why this works at all&quot;&gt;
  Bunny Database is built on libSQL, the same SQLite fork Turso maintains and the same engine Astro DB uses under the hood. Bunny exposes the libSQL remote protocol, so as far as Astro is concerned it&apos;s just another libSQL endpoint. The pairing isn&apos;t an official Astro integration, it just falls out of both sides speaking libSQL.
&lt;/Notice&gt;

## Prerequisites

- An existing Astro project, or a fresh one (`npm create astro@latest`)
- Node.js installed
- A [Bunny.net account](https://go.bitdoze.com/bunny) with access to Bunny Database (currently in public preview)

If you don&apos;t have an Astro site yet, I have a guide on [building a free blog with Astro](/build-astro-blog-free/) that gets you a working project to add a database to.

## Step 1: Install the Astro DB integration

From your project root, let Astro wire everything up:

```bash
npx astro add db
```

This installs `@astrojs/db`, adds it to your `astro.config.mjs`, and creates a starter `db/config.ts`:

```ts
// db/config.ts
import { defineDb } from &apos;astro:db&apos;;

export default defineDb({
  tables: {},
})
```

## Step 2: Define your tables

Tables live in `db/config.ts`. Astro reads this file to generate a TypeScript interface for each table, which is what gives you autocomplete and compile-time errors when you query.

Here&apos;s a `Comment` table with an `Author` it references, the same shape the Astro docs use, so it&apos;s easy to cross-check:

```ts
// db/config.ts
import { defineDb, defineTable, column } from &apos;astro:db&apos;;

const Author = defineTable({
  columns: {
    id: column.number({ primaryKey: true }),
    name: column.text(),
  },
});

const Comment = defineTable({
  columns: {
    id: column.number({ primaryKey: true }),
    authorId: column.number({ references: () =&gt; Author.columns.id }),
    body: column.text(),
    published: column.date({ default: new Date() }),
  },
});

export default defineDb({
  tables: { Author, Comment },
})
```

The column types map cleanly onto SQLite: `column.text()`, `column.number()`, `column.boolean()`, `column.date()` (queried as a JavaScript `Date`), and `column.json()` for an untyped blob. The `references` property on `authorId` sets up the foreign-key relationship to `Author.id`.

## Step 3: Seed local development data

In development you don&apos;t touch production data. Astro reseeds a fresh local database from `db/seed.ts` every time the file changes, which keeps your dev environment predictable.

```ts
// db/seed.ts
import { db, Author, Comment } from &apos;astro:db&apos;;

export default async function () {
  await db.insert(Author).values([
    { id: 1, name: &apos;Kasim&apos; },
    { id: 2, name: &apos;Mina&apos; },
  ]);

  await db.insert(Comment).values([
    { authorId: 1, body: &apos;Hope you like Astro DB!&apos; },
    { authorId: 2, body: &apos;Enjoy!&apos; },
  ]);
}
```

Start the dev server and the table is live locally:

```bash
npm run dev
```

## Step 4: Query the database in a page

You query from any Astro page, endpoint, or action using the `db` client exported from `astro:db`. It&apos;s Drizzle under the hood, already configured, no client setup.

```astro
---
// src/pages/index.astro
import { db, Comment, Author, eq } from &apos;astro:db&apos;;

const comments = await db
  .select()
  .from(Comment)
  .innerJoin(Author, eq(Comment.authorId, Author.id));
---

&lt;h2&gt;Comments&lt;/h2&gt;
{
  comments.map(({ Author, Comment }) =&gt; (
    &lt;article&gt;
      &lt;p&gt;Author: {Author.name}&lt;/p&gt;
      &lt;p&gt;{Comment.body}&lt;/p&gt;
    &lt;/article&gt;
  ))
}
```

All the Drizzle query helpers, `eq()`, `gt()`, `like()`, `count()`, and the raw `sql` tag, come straight from `astro:db`:

```ts
import { eq, gt, count, sql } from &apos;astro:db&apos;;
```

At this point everything runs against the local file. Now let&apos;s give it a real home on Bunny.

## Step 5: Create your Bunny Database

1. Log in to the [Bunny.net dashboard](https://go.bitdoze.com/bunny)
2. In the sidebar, click **+ Add**, then **Database**
3. Name it (for example `astro-comments`). The name shows up in your connection URL
4. Pick a deployment mode. **Single region** is cheapest and fine for most sites, **Automatic** lets Bunny choose regions for you, and **Manual** gives you control over primary and replicas
5. Click **Add Database**

Open the database and go to the **Access** tab. You need two things here:

- **Database URL**: looks like `libsql://&lt;your-database-id&gt;.lite.bunnydb.net`
- **Access Token**: click **Generate Tokens** and copy the **Full Access** token (Astro needs write access to push the schema and persist data)

&lt;Notice type=&quot;warning&quot; title=&quot;Copy the token now&quot;&gt;
  Bunny shows tokens once. If you lose it, generate a new one, which invalidates the old. Never commit it to git.
&lt;/Notice&gt;

## Step 6: Point Astro DB at Bunny

This is the only step that&apos;s genuinely Bunny-specific, and it&apos;s just two environment variables. Map the Bunny values onto the variables Astro expects:

| Astro variable | Bunny value |
|---|---|
| `ASTRO_DB_REMOTE_URL` | Your Bunny database URL (`libsql://...lite.bunnydb.net`) |
| `ASTRO_DB_APP_TOKEN` | Your Full Access token |

Put them in a `.env` file at the project root:

```bash
# .env
ASTRO_DB_REMOTE_URL=&quot;libsql://your-database-id.lite.bunnydb.net&quot;
ASTRO_DB_APP_TOKEN=&quot;your-full-access-token&quot;
```

Make sure `.env` is gitignored. That&apos;s the whole swap. Where the Astro docs say &quot;set this to your Turso URL,&quot; you set it to your Bunny URL.

&lt;Notice type=&quot;info&quot; title=&quot;If the libsql:// scheme gives you trouble&quot;&gt;
  Astro DB accepts `libsql:`, `https:`, and `wss:` schemes. Bunny&apos;s libSQL speaks the remote protocol over HTTPS, so if a `libsql://` URL ever fails to connect from a particular runtime, swap the scheme to `https://` on the same host (`https://your-database-id.lite.bunnydb.net`) and keep the same token.
&lt;/Notice&gt;

## Step 7: Push your schema to Bunny

Your local schema exists only in `db/config.ts` so far. Push it to the Bunny database with the `--remote` flag:

```bash
npx astro db push --remote
```

This creates the `Author` and `Comment` tables in Bunny and verifies the change won&apos;t lose existing data. If you make a breaking schema change later and you&apos;re fine wiping production, add `--force-reset`:

```bash
npx astro db push --remote --force-reset
```

To load real data into the remote database (not just local seed data), run a seed or migration file against it with `execute`:

```bash
npx astro db execute db/seed.ts --remote
```

## Step 8: Build and deploy against Bunny

Locally, `dev` and `build` use the local file by default. To make production read from and write to Bunny, add `--remote` to your build command in `package.json`:

```json
{
  &quot;scripts&quot;: {
    &quot;build&quot;: &quot;astro build --remote&quot;
  }
}
```

You can also pass it directly when you want a remote-connected dev session:

```bash
# Build against the Bunny database
astro build --remote

# Develop against the Bunny database
astro dev --remote
```

Set `ASTRO_DB_REMOTE_URL` and `ASTRO_DB_APP_TOKEN` in your deployment platform&apos;s environment too, not just locally. The `--remote` flag uses the connection during the build and on the server, so both environments need the credentials.

&lt;Notice type=&quot;warning&quot; title=&quot;Writes need on-demand rendering&quot;&gt;
  Reading data at build time works with a static site. But to *write* (accept a comment form, for instance) you need [on-demand rendering](https://docs.astro.build/en/guides/on-demand-rendering/) with an adapter for your host (Node, a VPS, Cloudflare, etc.). A purely static build can read from Bunny at build time but can&apos;t persist new rows at runtime.
&lt;/Notice&gt;

## Accepting user data: a comment form

Here&apos;s the write path, using an Astro action so you get Zod validation for free. This assumes you&apos;ve added an adapter and enabled on-demand rendering.

```ts
// src/actions/index.ts
import { db, Comment } from &apos;astro:db&apos;;
import { defineAction } from &apos;astro:actions&apos;;
import { z } from &apos;astro/zod&apos;;

export const server = {
  addComment: defineAction({
    input: z.object({
      authorId: z.number(),
      body: z.string(),
    }),
    handler: async (input) =&gt; {
      const created = await db.insert(Comment).values(input).returning();
      return created;
    },
  }),
};
```

Because the action runs on the server, your Bunny token never reaches the browser. The insert goes straight to the remote libSQL database and `returning()` hands back the new row.

## Embedded replicas for faster reads

One libSQL feature worth knowing about: embedded replicas. You can keep a synced local copy of the Bunny database for very fast reads, with writes forwarded to Bunny. Astro configures this through query parameters on the remote URL:

```bash
# In-memory replica synced from Bunny, refreshing every 60s
ASTRO_DB_REMOTE_URL=&quot;memory:?syncUrl=libsql%3A%2F%2Fyour-database-id.lite.bunnydb.net&amp;syncInterval=60&quot;
ASTRO_DB_APP_TOKEN=&quot;your-full-access-token&quot;
```

The `syncUrl` value must be URL-encoded. This is overkill for a small blog, but if you run a read-heavy site on a single server, it turns most reads into local lookups while Bunny stays the source of truth.

## Why pair Astro DB with Bunny instead of Turso

Both are solid. Here&apos;s how I think about the choice:

| | Bunny Database | Turso |
|---|---|---|
| Engine | libSQL | libSQL |
| Works with Astro DB | Yes (set the two env vars) | Yes (official example) |
| Idle billing | Spins down when inactive | Free tier, then usage |
| Same dashboard as CDN/storage | Yes | No |
| Region | EU company (Slovenia) | US-based |
| Status | Public preview | GA |

If you already run your CDN, storage, or video on Bunny, keeping the database in the same dashboard and the same invoice is the obvious win. If you want the most mature, GA libSQL host with the deepest tooling, Turso is still the safe pick. Since both are libSQL, you can move between them later by changing two environment variables and re-pushing the schema, so this isn&apos;t a one-way door.

## Troubleshooting

&lt;Accordion label=&quot;astro db push --remote fails to connect&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

Check that `ASTRO_DB_REMOTE_URL` points at your exact Bunny database host and that `ASTRO_DB_APP_TOKEN` is the Full Access token, not Read Only. If a `libsql://` URL won&apos;t connect from your runtime, try the `https://` scheme on the same host.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Reads work but writes fail in production&quot; group=&quot;troubleshoot&quot;&gt;

A static build can read at build time but can&apos;t write at runtime. Add an adapter and enable on-demand rendering so your forms and actions can persist data to Bunny.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Local changes don&apos;t show up in production&quot; group=&quot;troubleshoot&quot;&gt;

Local `dev` and `build` use the local file unless you pass `--remote`. Push schema changes with `astro db push --remote`, and make sure your deploy build command includes the `--remote` flag.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission or read-only errors&quot; group=&quot;troubleshoot&quot;&gt;

You&apos;re probably using a Read Only token. Generate a Full Access token in the Bunny database **Access** tab and update `ASTRO_DB_APP_TOKEN` everywhere.

&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is this an official Astro integration?&quot; group=&quot;faq&quot;&gt;

No. There&apos;s no Bunny-specific Astro plugin. It works because Astro DB connects to any libSQL server through `ASTRO_DB_REMOTE_URL` and `ASTRO_DB_APP_TOKEN`, and Bunny Database is libSQL. You&apos;re using the same remote-database mechanism the docs demonstrate with Turso.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I keep developing locally without hitting Bunny?&quot; group=&quot;faq&quot;&gt;

Yes, and that&apos;s the whole appeal. Day-to-day `npm run dev` uses the local SQLite file and your seed data. You only touch Bunny when you push the schema or build with `--remote`. It keeps production data safe while you work.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny charge per query?&quot; group=&quot;faq&quot;&gt;

Bunny Database bills on usage (reads, writes, and storage per active region) and idles when nothing is querying it, so a low-traffic Astro site costs very little. Check the [Bunny pricing](https://go.bitdoze.com/bunny) page for current preview rates.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What if I&apos;d rather use Drizzle directly without Astro DB?&quot; group=&quot;faq&quot;&gt;

You can. Astro DB wraps Drizzle with a nicer config-and-seed workflow, but if you want raw Drizzle against a libSQL database, I wrote a [TanStack Start + Bunny Database + Drizzle guide](/tanstack-start-bunny-database-drizzle/) that uses the `@libsql/client` and Drizzle directly. Same database, different framework and a more hands-on ORM setup.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I move to Turso later, or vice versa?&quot; group=&quot;faq&quot;&gt;

Yes. Both are libSQL, so switching hosts means changing `ASTRO_DB_REMOTE_URL` and `ASTRO_DB_APP_TOKEN` and running `astro db push --remote` against the new database. You can also dump and import data with the respective CLIs.

&lt;/Accordion&gt;

## Wrapping up

Astro DB gives you a genuinely pleasant local-first workflow: typed tables, instant local SQLite, and a Drizzle client that needs no wiring. The docs steer you to Turso for production, but the remote connection is just libSQL, so pointing it at Bunny is a two-variable change. You get a database that lives in the same dashboard as your CDN and storage, idles to zero when traffic is quiet, and stays portable because it&apos;s plain libSQL underneath.

If your site already runs static on Bunny, this closes the loop: static pages from the CDN, dynamic data from Bunny Database, all on one bill.

&lt;Button text=&quot;Try Bunny.net Free for 14 Days&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

## Related articles

- [Bunny.net review](/bunny-net-review/) - the full platform after a year in production
- [TanStack Start + Bunny Database + Drizzle](/tanstack-start-bunny-database-drizzle/) - the same database with raw Drizzle in a React app
- [Deploy an Astro site to Bunny.net](/deploy-astro-bunny-net/) - static hosting on Bunny storage and CDN
- [Build a free blog with Astro](/build-astro-blog-free/) - get a starter project to add a database to
- [Bunny Storage vs S3 vs Backblaze](/bunny-storage-vs-s3-vs-backblaze/) - cloud storage pricing compared
- [Mount an S3 bucket as a filesystem](/s3-bucket-filesystem-vps/) - ZeroFS and JuiceFS on Bunny Storage</content:encoded><category>web-development</category><category>astro</category><category>bunny-net</category><category>self-hosted</category></item><item><title>Build Your First Durable AI Agent with Vercel Eve (Beginner&apos;s Guide)</title><link>https://www.bitdoze.com/vercel-eve-ai-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/vercel-eve-ai-agent/</guid><description>A beginner-friendly guide to Vercel Eve. Scaffold a durable AI agent as plain files, add a tool, connect it to Slack and Discord, swap in open source models, then deploy on Vercel or your own VPS with Dokploy.</description><pubDate>Fri, 19 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Vercel just dropped [Eve](https://github.com/vercel/eve), and the easiest way to describe it is &quot;Next.js, but for agents.&quot; Instead of one giant config object you have to keep in your head, every part of your agent gets a file. Instructions live in one file. Tools live in a folder. Channels (Slack, Discord, a web UI) live in another. Eve reads that folder structure and turns it into a running agent that works locally, serves HTTP, and keeps a conversation alive across many turns.

I&apos;ve built agents with a few different frameworks, and the thing that won me over here is how little ceremony there is. You can read a whole Eve project by looking at the directory tree. That&apos;s it. No registry to keep in sync, no wiring file that drifts out of date.

In this guide we&apos;ll scaffold an agent from scratch, give it a tool, connect it to both Slack and Discord, swap the default Claude model for an open source one, and then deploy it, either on Vercel or on your own VPS with Dokploy. By the end you&apos;ll have a working assistant your team can talk to.

&lt;Notice type=&quot;info&quot; title=&quot;Eve is in beta&quot;&gt;
  Eve is currently in beta under the Vercel beta terms, so APIs and behavior can change before general availability. Pin a version in `package.json` if you&apos;re building something you care about, and re-check the [docs](https://eve.dev/docs/introduction) when you upgrade.
&lt;/Notice&gt;

## What Eve actually is

Eve is a framework for building durable agents as ordinary files in a TypeScript project. &quot;Durable&quot; is the word that matters. An Eve session isn&apos;t one request and one response. It can stream progress while it works, call tools and subagents, pause to ask a human for approval, resume after the answer arrives, and keep state across turns. Under the hood it leans on the open source [Workflow SDK](https://workflow-sdk.dev/) to make sessions resumable and crash-safe, so your code can focus on the work instead of the plumbing.

Here&apos;s what a small project looks like:

```text
my-agent/
├── package.json
└── agent/
    ├── agent.ts          # picks the model, runtime config
    ├── instructions.md   # who the agent is, how it behaves
    ├── tools/            # typed functions the model can call
    │   └── get_weather.ts
    ├── skills/           # longer procedures, loaded on demand
    │   └── plan_a_trip.md
    └── channels/         # Slack, Discord, web, etc.
        └── slack.ts
```

A file&apos;s location says what it does, and its path usually gives it a name. Drop `agent/tools/get_weather.ts` in place and Eve discovers a tool called `get_weather`. Rename the file and the tool name moves with it. That&apos;s the whole mental model.

&lt;ListCheck&gt;
- **`instructions.md`** tells the agent who it is and how to behave (the always-on system prompt)
- **`agent.ts`** chooses the model and sets runtime options
- **`tools/`** holds typed functions the model can call
- **`skills/`** holds longer procedures the model only loads when they&apos;re useful
- **`channels/`** connect the agent to HTTP clients, Slack, Discord, and anywhere else people talk to it
&lt;/ListCheck&gt;

Start with just `instructions.md` and `agent.ts`. Add the other folders when you actually need them.

## Prerequisites

Before you scaffold anything, make sure you have:

&lt;ListCheck&gt;
- **Node 24 or newer** (Eve pins a modern Node runtime)
- **npm**, which ships with Node
- **A model credential**: either a Vercel AI Gateway key (`AI_GATEWAY_API_KEY`), a linked Vercel project for OIDC, or a direct provider key like `ANTHROPIC_API_KEY`
&lt;/ListCheck&gt;

The scaffold defaults to `anthropic/claude-sonnet-4.6` routed through the Vercel AI Gateway. If you skip the credential, the dev terminal flags it and walks you through pasting a key with its `/model` command, so you won&apos;t get stuck.

## Step 1: Scaffold your first agent

`npx` can run `eve init` without installing anything first:

```bash
npx eve@latest init my-agent
```

That command creates the project, installs dependencies, initializes Git, starts the dev server, and opens an interactive terminal UI. Type a message and you&apos;ll watch the model loop run in real time.

A couple of things worth knowing:

&lt;ListCheck&gt;
- Pass `--channel-web-nextjs` if you want a Web Chat app generated alongside the agent
- `eve init` holds the terminal, so hit `Ctrl+C` to get your shell back before you start editing files
- To add Eve to a project you already have, run `eve init .` from a folder that has a `package.json` and no `agent/` files yet. It adds `eve`, `ai`, and `zod` without touching the rest
&lt;/ListCheck&gt;

If you&apos;d rather wire it in by hand, install the three dependencies and declare a Node 24 engine:

```bash
npm install eve@latest ai zod
```

```json
{
  &quot;engines&quot;: {
    &quot;node&quot;: &quot;24.x&quot;
  }
}
```

Then write the two files Eve needs. `agent/instructions.md`:

```md
You are a concise assistant. Use tools when they are available.
```

And `agent/agent.ts`:

```ts
import { defineAgent } from &quot;eve&quot;;

export default defineAgent({
  model: &quot;anthropic/claude-sonnet-4.6&quot;,
});
```

Even at this size the agent can already do real work, because the default harness ships with file, shell, web, and delegation tools out of the box.

&lt;Notice type=&quot;info&quot; title=&quot;Letting a coding agent do the setup&quot;&gt;
  If you&apos;re using Claude Code, Cursor, or a similar tool, hand it this prompt: &quot;Set up an eve agent: read the eve docs (bundled at `node_modules/eve/docs` once eve is installed), scaffold with `npx eve@latest init &lt;name&gt;`, add a typed tool at `agent/tools/get_weather.ts`, run it with `npm run dev`, then create a session, stream it, and send a follow-up.&quot; Once `eve` is installed, the full docs live locally in `node_modules/eve/docs/`, so the model doesn&apos;t have to guess at an unfamiliar API.
&lt;/Notice&gt;

## Step 2: Give the agent a tool

A tool is a typed action the agent can call: hit an API, run a query, write a file. The filename becomes the tool name the model sees, and it has to be snake_case. Create `agent/tools/get_weather.ts`:

```ts
import { defineTool } from &quot;eve/tools&quot;;
import { z } from &quot;zod&quot;;

// The model sees this tool as `get_weather`, from the filename.
export default defineTool({
  description: &quot;Get the current weather for a city.&quot;,
  inputSchema: z.object({ city: z.string().min(1) }),
  async execute({ city }) {
    return { city, condition: &quot;Sunny&quot;, temperatureF: 72 };
  },
});
```

The pieces of a tool:

&lt;ListCheck&gt;
- A **filename slug** under `agent/tools/`, which is the model-facing name
- A **`description`** written for the model, telling it what the tool does
- An **`inputSchema`**, a Zod schema (pass `z.object({})` for no input)
- An **`execute(input, ctx)`** function, sync or async, that does the work
&lt;/ListCheck&gt;

Tools run in your app runtime with full access to `process.env`, not inside the sandbox, so they can import shared code from `lib/` and read your secrets directly. One thing to keep in mind: a step that gets interrupted mid-execution re-runs on resume, so make side effects like charges or emails idempotent, or gate them behind approval (more on that below).

## Step 3: Run it and send a message

A scaffolded app has a `dev` script:

```bash
npm run dev
```

If you wired Eve in by hand and have no `dev` script, run the binary through `npx eve dev` instead. Either way you land in the terminal UI. Type &quot;What&apos;s the weather in Brooklyn?&quot; and you&apos;ll see the calls happen in order: the `get_weather` call, then its result, then the reply.

Every Eve app also exposes the same stable HTTP API. Start a durable session with `curl`:

```bash
curl -X POST http://127.0.0.1:3000/eve/v1/session \
  -H &apos;content-type: application/json&apos; \
  -d &apos;{&quot;message&quot;:&quot;What is the weather in Brooklyn?&quot;}&apos;
```

The response hands you back two things you&apos;ll reuse: a `continuationToken` in the body to resume the conversation, and an `x-eve-session-id` header that identifies the run. Attach to the stream with the session id:

```bash
curl http://127.0.0.1:3000/eve/v1/session/&lt;sessionId&gt;/stream
```

The stream is NDJSON. For this run you&apos;ll see `session.started`, `actions.requested` (the tool call), `action.result`, `message.completed` (the reply), and `session.completed`. To continue the conversation, post a follow-up with the token:

```bash
curl -X POST http://127.0.0.1:3000/eve/v1/session/&lt;sessionId&gt; \
  -H &apos;content-type: application/json&apos; \
  -d &apos;{&quot;continuationToken&quot;:&quot;&lt;token&gt;&quot;,&quot;message&quot;:&quot;Now do Queens.&quot;}&apos;
```

That&apos;s the hello-world. A weather bot isn&apos;t useful to anyone, so let&apos;s make it reachable where people actually work.

## Step 4: Connect the agent to Slack

A channel is the adapter between a platform and your agent. It normalizes incoming messages, owns the resume token for that surface, and decides how replies get delivered. The Slack channel answers `@mentions` and DMs, replies in threads, shows typing indicators, and turns human-in-the-loop prompts into Slack buttons.

The nice part: credentials run through [Vercel Connect](https://eve.dev/docs/guides/auth-and-route-protection), which handles both the outbound bot token and inbound webhook verification. There&apos;s no `SLACK_BOT_TOKEN` or `SLACK_SIGNING_SECRET` for you to babysit.

First, set up a Connect client and point its trigger at Eve&apos;s Slack route:

```bash
npm install -g vercel@latest &amp;&amp; export FF_CONNECT_ENABLED=1
vercel connect create slack --triggers
vercel connect detach &lt;uid&gt; --yes
vercel connect attach &lt;uid&gt; --triggers --trigger-path /eve/v1/slack --yes
```

The `create` step provisions a destination at the default Connect path, then `detach`/`attach` re-points it at `/eve/v1/slack`, which is where Eve actually listens. The `--triggers` flag turns on Slack Event Subscriptions; without it, Slack never delivers `app_mention` or `message.im` events.

Now add the channel. Either scaffold it with `eve channels add slack`, or write `agent/channels/slack.ts` by hand:

```ts
import { connectSlackCredentials } from &quot;@vercel/connect/eve&quot;;
import { slackChannel } from &quot;eve/channels/slack&quot;;

export default slackChannel({
  credentials: connectSlackCredentials(&quot;slack/my-agent&quot;),
});
```

`connectSlackCredentials` returns the bot token and webhook verifier, keeping token rotation and request verification inside Connect instead of your code. Deploy once the trigger and channel file are ready:

```bash
VERCEL_USE_EXPERIMENTAL_FRAMEWORKS=1 vercel deploy --prod
```

That flag lets the Vercel CLI recognize Eve as a framework during the build.

### Pulling in thread context

By default the channel gives you the triggering mention, but not the earlier replies in the thread. If you want the agent to read what was said before it was called in, load the prior messages and return them as `context`:

```ts
import { defaultSlackAuth, loadThreadContextMessages, slackChannel } from &quot;eve/channels/slack&quot;;
import { connectSlackCredentials } from &quot;@vercel/connect/eve&quot;;

export default slackChannel({
  credentials: connectSlackCredentials(&quot;slack/my-agent&quot;),
  async onAppMention(ctx, message) {
    const auth = defaultSlackAuth(message, ctx);
    const prior = await loadThreadContextMessages(ctx.thread, message, {
      since: &quot;last-agent-reply&quot;,
    });
    if (prior.length === 0) return { auth };
    const transcript = prior
      .map((m) =&gt; `${m.isMe ? &quot;you&quot; : (m.user ?? &quot;user&quot;)}: ${m.markdown}`)
      .join(&quot;\n&quot;);
    return { auth, context: [`Recent thread messages since your last reply:\n\n${transcript}`] };
  },
});
```

Using `since: &quot;last-agent-reply&quot;` means repeated mentions in one thread only inject what&apos;s new, so you don&apos;t re-feed the whole history every turn.

&lt;Notice type=&quot;warning&quot; title=&quot;Tell people they&apos;re talking to a bot&quot;&gt;
  Eve doesn&apos;t add an &quot;I&apos;m an AI&quot; disclosure for you. Depending on where your users are, you may be legally required to disclose that they&apos;re talking to an automated system. Bake it into your `instructions.md` or your channel responses.
&lt;/Notice&gt;

## Step 5: Connect the agent to Discord

Discord works through HTTP Interactions: slash commands, message components, and modals. Discord enforces a three-second deadline to acknowledge a command, so the channel verifies the signature, acknowledges right away, and runs the actual work in the background. You don&apos;t have to think about any of that; it&apos;s handled.

The minimal `agent/channels/discord.ts`:

```ts
import { discordChannel } from &quot;eve/channels/discord&quot;;

export default discordChannel();
```

Discord needs three environment variables:

```bash
DISCORD_PUBLIC_KEY=...      # verifies the signature headers
DISCORD_APPLICATION_ID=...  # edits the deferred response, sends followups
DISCORD_BOT_TOKEN=...       # proactive messages, fallback, typing indicators
```

The route is `POST /eve/v1/discord` by default. Paste that public URL into your Discord application&apos;s Interactions Endpoint URL field in the Developer Portal.

Registering commands is on you, not the channel. A string option named `message` lines up with Eve&apos;s default prompt extraction:

```bash
curl -X PUT &quot;https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands&quot; \
  -H &quot;Authorization: Bot $DISCORD_BOT_TOKEN&quot; -H &quot;Content-Type: application/json&quot; \
  -d &apos;[{&quot;name&quot;:&quot;ask&quot;,&quot;description&quot;:&quot;Ask the eve agent&quot;,&quot;type&quot;:1,
    &quot;options&quot;:[{&quot;name&quot;:&quot;message&quot;,&quot;description&quot;:&quot;What should the agent do?&quot;,&quot;type&quot;:3,&quot;required&quot;:true}]}]&apos;
```

Use guild commands during development; they propagate much faster than global ones. Here&apos;s a slightly fuller channel that decides auth per command and posts the reply back:

```ts
import { discordChannel } from &quot;eve/channels/discord&quot;;

export default discordChannel({
  onCommand: (ctx, interaction) =&gt; ({
    auth: {
      principalId: interaction.user.id,
      principalType: &quot;user&quot;,
      authenticator: &quot;discord&quot;,
      attributes: { channel_id: interaction.channelId, guild_id: interaction.guildId ?? &quot;&quot; },
    },
  }),
  events: {
    &quot;message.completed&quot;(eventData, channel, ctx) {
      if (eventData.finishReason === &quot;tool-calls&quot;) return;
      if (eventData.message) channel.discord.post(eventData.message);
    },
  },
});
```

One limitation to note: inbound file attachments aren&apos;t supported on the Discord channel today, while Slack does stage them. If your agent needs to read uploaded files, plan around Slack or a web channel.

The thing I appreciate is that the same agent logic serves both platforms. Your `get_weather` tool doesn&apos;t know or care whether the question came from Slack, Discord, the terminal, or a browser. Write the behavior once, expose it everywhere.

## Step 6: Use open source models instead of Claude

The default model is Claude Sonnet, but you&apos;re not married to it. We learned the hard way over the past couple of years that a model you depend on can be deprecated or pulled out from under you, so being able to switch matters. Eve makes the model a single line of config.

You have two routing options.

&lt;Tabs&gt;
&lt;Tab name=&quot;Gateway (string id)&quot;&gt;

A string model id routes through the Vercel AI Gateway. Swap the value and you&apos;re using a different model, no other changes:

```ts
import { defineAgent } from &quot;eve&quot;;

export default defineAgent({
  // any model the gateway exposes
  model: &quot;moonshotai/kimi-k2.6&quot;,
});
```

This works on Vercel with project OIDC, or anywhere else with `AI_GATEWAY_API_KEY` set. The Gateway is the lowest-setup path: one key, many models, easy to A/B between a fast cheap model and a stronger one.

&lt;/Tab&gt;
&lt;Tab name=&quot;Direct provider&quot;&gt;

To skip the Gateway entirely, install the AI SDK package for your provider, pass a model object, and set that provider&apos;s key. This works great with OpenAI-compatible endpoints that serve open source models:

```bash
npm install @ai-sdk/openai
```

```ts
import { createOpenAI } from &quot;@ai-sdk/openai&quot;;
import { defineAgent } from &quot;eve&quot;;

const provider = createOpenAI({
  baseURL: &quot;https://openrouter.ai/api/v1&quot;,
  apiKey: process.env.OPENROUTER_API_KEY,
});

export default defineAgent({
  model: provider(&quot;z-ai/glm-5.1&quot;),
});
```

Point the `baseURL` at OpenRouter, Together, Groq, or your own self-hosted endpoint, and you&apos;re running an open model with the same agent code.

&lt;/Tab&gt;
&lt;/Tabs&gt;

Which open model should you reach for? Models like GLM-5.2, Kimi K2.6, and Qwen 3.6 hold up well for agentic tool-calling work at a fraction of the cost of frontier models. I went deep on the trade-offs in my guide to the [best open source LLMs to replace Claude](/best-open-source-llms-claude-alternative/), so check that if you&apos;re picking one for a real workload. The short version: route cheap, fast models to simple turns and save the expensive ones for the hard problems. Eve lets you do exactly that, even per subagent.

&lt;Notice type=&quot;info&quot; title=&quot;Per-task model routing&quot;&gt;
  Because the model is just config, you can run subagents on different models. A research subagent might use a cheap long-context model while the root agent stays on something stronger. You build the skill once and route it wherever makes sense.
&lt;/Notice&gt;

## Step 7: Add tools from external services with connections

Beyond the tools you write, Eve can pull in tools from external MCP servers and OpenAPI documents. These live in `agent/connections/`, and the model never sees the URL or credentials, it discovers the tools and calls them by name.

A connection to Linear&apos;s MCP server looks like this:

```ts
import { defineMcpClientConnection } from &quot;eve/connections&quot;;

export default defineMcpClientConnection({
  url: &quot;https://mcp.linear.app/sse&quot;,
  description: &quot;Linear workspace: issues, projects, cycles, and comments.&quot;,
  auth: {
    getToken: async () =&gt; ({ token: process.env.LINEAR_API_TOKEN! }),
  },
});
```

For anything that touches money, deletes data, or sends messages, gate it behind approval. The helpers from `eve/tools/approval` give you `never()`, `once()` (ask the first time in a session), and `always()` (ask every time). The same pause-and-resume flow that powers human-in-the-loop tools handles it.

## Step 8: Deploy it

You&apos;ve got a working agent. Now it needs to live somewhere. Eve runs the same way locally, on Vercel, and on a plain Node host, so going to production is mostly mechanical.

### Option A: Deploy on Vercel

`eve build` compiles the agent and writes the host output. On Vercel that&apos;s the Build Output bundle under `.vercel/output`, plus the compiled artifacts under `.eve/`. Then deploy with the CLI or by pushing to a Git-connected project:

```bash
vercel deploy
```

Before that first production request, work through the short checklist:

&lt;ListCheck&gt;
- Set a **model credential** and any **route-auth secrets** in the deployment environment, never in source
- Replace the scaffolded `placeholderAuth()` with a real auth policy (Basic, JWT, OIDC, or a custom verifier); an unconfigured app fails closed and rejects browser traffic, which is the safe default
- Confirm the **sandbox backend** matches the environment (`vercel()` on Vercel, `defaultBackend()` elsewhere)
&lt;/ListCheck&gt;

Once deployed, the platform auto-detects Eve and surfaces an Agent Runs tab under your project&apos;s Observability view, where you can browse sessions and read each conversation&apos;s trace.

### Option B: Self-host on your own VPS

If you&apos;d rather not be tied to Vercel&apos;s platform, Eve runs as a normal Node service behind your own process manager or reverse proxy:

```bash
eve build
PORT=3000 eve start --host 0.0.0.0
```

Outside Vercel, Eve writes the standard Nitro output under `.output/`, the Workflow SDK uses its local world (storing state under `.workflow-data`), and `defaultBackend()` picks a local sandbox backend. A few things to make explicit when self-hosting:

&lt;ListCheck&gt;
- Put `.workflow-data` on **persistent storage** so session state survives restarts
- Use a **direct provider model** with `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`, or keep `AI_GATEWAY_API_KEY` if you still want Gateway routing
- Replace `vercelOidc()` with auth your host can verify
- If your agent uses schedules, make sure your host runs Nitro&apos;s scheduled tasks, or trigger the same work from your own cron
&lt;/ListCheck&gt;

The cleanest way I&apos;ve found to run a Node service like this on a VPS is [Dokploy](/dokploy-install/), an open source, self-hostable alternative to Vercel and Heroku. It gives you Git-based deploys, automatic HTTPS via Traefik, environment variable management, and logs, all from a dashboard you control. My [Dokploy install guide](/dokploy-install/) covers getting it running on a fresh server.

The deploy flow for an Eve agent maps almost exactly onto a normal Node app, so if you&apos;ve deployed anything with Dokploy before, this will feel familiar. My walkthrough on [deploying TanStack Start on a VPS with Dokploy](/tanstack-start-dokploy-deploy/) shows the full pattern (build command, start command, environment variables, domain), and the same steps apply here: set the build command to `eve build`, the start command to `eve start --host 0.0.0.0`, expose the port, and add your model and auth secrets in the environment panel.

&lt;Notice type=&quot;success&quot; title=&quot;Why self-host the agent&quot;&gt;
  Running on your own VPS means your conversation data and credentials stay on infrastructure you control, costs are predictable, and you&apos;re not exposed to platform pricing changes. The trade-off is you handle backups, scaling, and the persistent `.workflow-data` storage yourself.
&lt;/Notice&gt;

### Verify the deployment

Whichever path you took, smoke-test the live routes. Health first, then a real turn:

```bash
curl https://&lt;your-app&gt;/eve/v1/health

curl -X POST https://&lt;your-app&gt;/eve/v1/session \
  -H &apos;content-type: application/json&apos; \
  -d &apos;{&quot;message&quot;:&quot;Hello from production&quot;}&apos;
```

You can also drive the live deployment with the dev terminal, which is handy for a quick production check:

```bash
eve dev https://&lt;your-app&gt;
```

## What to build next

The weather bot was just a way to see the loop run. The interesting part is what you put in `tools/` and `skills/`. A content-repurposing agent that drafts social posts and calls image generation tools. A lead-research agent that pulls from your CRM. A support triage bot living in your Slack. The scaffolding is the same every time, which is the whole point: you stop reinventing the harness and spend your time on the actual capability.

&lt;ListCheck&gt;
- Add a **skill** in `agent/skills/` for any multi-step procedure the model should load only when relevant
- Spin up **subagents** for parallel or specialist work, each on its own model
- Add **schedules** for recurring jobs like a daily digest
- Put a **Next.js front end** in front of the agent with the `useEveAgent` hook
&lt;/ListCheck&gt;

## FAQ

&lt;Accordion label=&quot;Is Vercel Eve free and open source?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes, Eve is open source and lives on [GitHub](https://github.com/vercel/eve). You can run it entirely on your own infrastructure with your own model keys. The optional conveniences (AI Gateway, hosted Sandbox, Vercel Connect, the Agent Runs dashboard) are Vercel platform features you can opt into, not requirements. It&apos;s in beta, so expect some churn.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need a Vercel account to use Eve?&quot; group=&quot;faq&quot;&gt;
No. You need a model credential, which can be a direct provider key like `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`. A Vercel account makes the AI Gateway, Slack credentials via Connect, and hosted sandboxes easier, but you can self-host the whole thing on a VPS with Dokploy and never link a Vercel project.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can one agent serve Slack and Discord at the same time?&quot; group=&quot;faq&quot;&gt;
Yes. Add both `agent/channels/slack.ts` and `agent/channels/discord.ts`. The agent&apos;s instructions, tools, and skills are shared; each channel just adapts the platform&apos;s input and output. Your tools don&apos;t need to know which surface a message came from.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use open source models instead of Claude?&quot; group=&quot;faq&quot;&gt;
Yes. Change the `model` value in `agent/agent.ts`. Use a gateway string id like `z-ai/glm-5.1`, or install an AI SDK provider and point its `baseURL` at OpenRouter, Together, Groq, or a self-hosted endpoint. See my [open source LLM comparison](/best-open-source-llms-claude-alternative/) for picking one.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What does &apos;durable&apos; mean for an Eve session?&quot; group=&quot;faq&quot;&gt;
A session can stream progress, call tools and subagents, pause for human approval, resume after the answer arrives, and keep state across many turns. It&apos;s built on the open source Workflow SDK, which makes runs resumable and crash-safe. A completed step never re-runs; an interrupted step does, so make side effects idempotent.
&lt;/Accordion&gt;

## Wrapping up

Eve takes the part of agent-building that&apos;s usually a tangle (channels, durable state, model routing, deploys) and turns it into a folder you can read top to bottom. You scaffold with one command, add a tool as a single file, point a channel at Slack or Discord, and pick whatever model fits the job and your budget. Deploy it on Vercel for the zero-config path, or self-host it on a VPS with Dokploy when you want to own the whole stack.

If you&apos;re coming from another framework, the switch costs you almost nothing, since your tools and skills are plain TypeScript and Markdown you can carry elsewhere. Start with the weather hello-world, then replace it with something your team would actually use.

&lt;Button text=&quot;Read the Eve docs&quot; link=&quot;https://eve.dev/docs/introduction&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>vercel</category><category>self-hosted</category></item><item><title>Build a Todo App with TanStack Start, Bunny Database &amp; Drizzle ORM</title><link>https://www.bitdoze.com/tanstack-start-bunny-database-drizzle/</link><guid isPermaLink="true">https://www.bitdoze.com/tanstack-start-bunny-database-drizzle/</guid><description>Build a fully type-safe todo app with TanStack Start, Bunny Database (managed libSQL), and Drizzle ORM. Schema, migrations, server functions, and a shadcn UI, step by step.</description><pubDate>Thu, 18 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I wanted a way to ship a small full-stack TypeScript app without standing up a Postgres box or paying for a database that bills me while it sits idle. TanStack Start gives me server-side rendering and type-safe server functions, Drizzle gives me a schema I can trust, and [Bunny.net](https://go.bitdoze.com/bunny) recently added a managed libSQL database that spins down to zero when nobody&apos;s hitting it. Put the three together and you get type safety from the database row all the way to the button in the browser.

This is the exact setup I ran myself: create the database in the dashboard, scaffold a TanStack Start project with shadcn, define a Drizzle schema, generate and run migrations, then build a UI that lists, adds, toggles, and deletes todos. Nothing fancy, just a clean walkthrough you can copy line for line.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You&apos;ll need a Bunny account to follow along. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card and get a 14-day trial. My [full Bunny.net review](/bunny-net-review/) covers the rest of the platform if you want the bigger picture first.
&lt;/Notice&gt;




&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ry0oQlbm_mc&quot;
  label=&quot;The Ultimate Modern Stack? TanStack Start, Bunny DB, Drizzle, &amp; Shadcn UI&quot;
/&gt;

## What we&apos;re building

A single-page todo app with:

&lt;ListCheck&gt;
- A live `todos` table stored in Bunny Database (managed libSQL)
- A Drizzle ORM layer with generated, versioned SQL migrations
- Server functions through TanStack Start&apos;s `createServerFn`, so database access never leaves the server
- A React UI styled with shadcn and Tailwind, hydrated from an SSR render
&lt;/ListCheck&gt;

The end result is a small interface where you type a todo, hit enter, tick it off, or delete it. Every action round-trips to Bunny Database.

## Prerequisites

Before you start, you need:

- A [Bunny.net account](https://go.bitdoze.com/bunny) with access to Bunny Database (currently in public preview)
- Node.js or Bun installed. I used Bun here because installs and scripts run fast
- Working knowledge of TypeScript, React, and SQL

## Step 1: Scaffold the TanStack Start project with shadcn

The quickest path to a TanStack Start project that already has shadcn wired up is the shadcn CLI with the TanStack Start template. You get Tailwind, the shadcn component system, and file-based routing out of the box.

```bash
bunx --bun shadcn@latest init \
  --preset b1aIcEaeG \
  --base base \
  --template start \
  --pointer
```

What each flag does:

- `--preset b1aIcEaeG` pulls a curated TanStack Start starter (router, SSR, Vite config)
- `--base base` picks the neutral base color theme
- `--template start` scaffolds TanStack Start rather than Next.js or plain Vite
- `--pointer` turns on the pointer utility for cursor interactions

When it&apos;s done, the project looks roughly like this:

```
start-app/
├── src/
│   ├── components/
│   │   └── ui/           # shadcn components (button, etc.)
│   ├── lib/
│   │   └── utils.ts      # cn() helper for class merging
│   ├── routes/
│   │   ├── __root.tsx    # root layout + providers
│   │   └── index.tsx     # home route
│   ├── router.tsx
│   ├── routeTree.gen.ts  # generated, do not edit
│   └── styles.css        # Tailwind entry
├── package.json
├── vite.config.ts
└── tsconfig.json
```

Install dependencies and start the dev server to confirm it boots:

```bash
bun install
bun run dev
```

That gives you a styled, SSR-ready app. The only thing missing is a database.

&lt;Notice type=&quot;info&quot; title=&quot;Deploying TanStack Start elsewhere&quot;&gt;
  This guide hosts the database on Bunny. If you&apos;d rather run the whole app on your own box, I have a separate guide on [deploying TanStack Start on a VPS with Dokploy](/tanstack-start-dokploy-deploy/) that uses Postgres instead.
&lt;/Notice&gt;

## Step 2: Create your Bunny Database

Bunny Database is a managed relational database built on libSQL, a fork of SQLite. You get standard SQL, optional global replication, usage-based billing (it idles when nothing&apos;s querying it), and several ways to connect: HTTP API, native SDKs, and ORMs like Drizzle.

### Create the database in the dashboard

1. Log in to the [Bunny.net dashboard](https://go.bitdoze.com/bunny)
2. In the left sidebar, click **+ Add**, then **Database**
3. Name it (for example `todotest`). This name shows up in your connection URL
4. Pick a deployment mode:
   - **Automatic** - Bunny picks regions based on your location. A fine default for development
   - **Single region** - no replication, cheapest, good for a single VPS or testing
   - **Manual** - you choose the primary and any replica regions, useful for latency or compliance
5. Click **Add Database**

### Grab your credentials

Open the database and go to the **Access** tab:

- **Database URL**: looks like `libsql://&lt;your-database-id&gt;.lite.bunnydb.net`. Every client library uses this endpoint
- **Access Token**: click **Generate Tokens**. You get a **Full Access** token (read and write, use this for the app) and a **Read Only** token (limited to `SELECT`, handy for analytics)

Copy the URL and the Full Access token right away. Tokens show once. Lose one and you generate a new pair from the dashboard.

&lt;Notice type=&quot;warning&quot; title=&quot;Never commit credentials&quot;&gt;
  Keep tokens out of version control. We&apos;ll put them in a `.env` file that&apos;s gitignored.
&lt;/Notice&gt;

### Store credentials in .env

Create a `.env` in the project root:

```bash
BUNNY_DATABASE_URL=&quot;libsql://your-database-id.lite.bunnydb.net/&quot;
BUNNY_DATABASE_AUTH_TOKEN=&quot;your-full-access-token&quot;
BUNNY_DATABASE_READ_ONLY_AUTH_TOKEN=&quot;your-read-only-token&quot;
```

Add a `.env.example` (safe to commit) so the next person knows what the app expects:

```bash
BUNNY_DATABASE_URL=&quot;libsql://your-database-id.lite.bunnydb.net/&quot;
BUNNY_DATABASE_AUTH_TOKEN=&quot;your-full-access-token&quot;
BUNNY_DATABASE_READ_ONLY_AUTH_TOKEN=&quot;your-read-only-token&quot;
```

The starter already gitignores `.env*`, but check anyway:

```gitignore
# .gitignore
.env*
```

## Step 3: Install Drizzle and the libSQL client

Bunny Database speaks libSQL, so we use the official `@libsql/client` as the driver and Drizzle&apos;s libSQL adapter for type-safe queries.

```bash
bun add drizzle-orm @libsql/client
bun add -d drizzle-kit
```

- `drizzle-orm` is the ORM
- `@libsql/client` is the driver that talks the libSQL protocol over HTTP
- `drizzle-kit` is a dev dependency for generating migrations, pushing schema, and opening Drizzle Studio

Add a few scripts to `package.json` to make the database easier to work with:

```json
{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;vite dev --port 3000 --host&quot;,
    &quot;db:generate&quot;: &quot;drizzle-kit generate&quot;,
    &quot;db:migrate&quot;: &quot;bun run src/db/migrate.ts&quot;,
    &quot;db:studio&quot;: &quot;drizzle-kit studio&quot;
  }
}
```

Notice the `--host` flag on `dev`. It binds the server to your network interfaces, which matters when you develop on a VPS and want to reach it from a browser on your laptop.

## Step 4: Configure Drizzle

Create `drizzle.config.ts` in the project root. It tells drizzle-kit where the schema lives, where migrations go, and how to reach the database:

```ts
// drizzle.config.ts
import { defineConfig } from &quot;drizzle-kit&quot;

export default defineConfig({
  schema: &quot;./src/db/schema.ts&quot;,
  out: &quot;./drizzle&quot;,
  dialect: &quot;turso&quot;,
  dbCredentials: {
    url: process.env.BUNNY_DATABASE_URL!,
    authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN!,
  },
})
```

A couple of things to call out:

- `dialect: &quot;turso&quot;` tells drizzle-kit to emit SQLite-compatible SQL, which is exactly what libSQL (and Bunny Database) wants
- `out: &quot;./drizzle&quot;` is where migration files land
- `dbCredentials` reads the same environment variables from `.env`

## Step 5: Define the schema

Create `src/db/schema.ts`. This is the single source of truth for the database shape:

```ts
// src/db/schema.ts
import { sqliteTable, integer, text } from &quot;drizzle-orm/sqlite-core&quot;

export const todos = sqliteTable(&quot;todos&quot;, {
  id: integer(&quot;id&quot;).primaryKey({ autoIncrement: true }),
  title: text(&quot;title&quot;).notNull(),
  completed: integer(&quot;completed&quot;, { mode: &quot;boolean&quot; }).notNull().default(false),
  createdAt: integer(&quot;created_at&quot;, { mode: &quot;timestamp&quot; })
    .notNull()
    .$defaultFn(() =&gt; new Date()),
})

export type Todo = typeof todos.$inferSelect
export type NewTodo = typeof todos.$inferInsert
```

The `todos` table has four columns:

- `id` - auto-incrementing integer primary key
- `title` - the todo text, non-nullable
- `completed` - a boolean stored as 0/1 in SQLite but surfaced as a real `boolean` in TypeScript thanks to `mode: &quot;boolean&quot;`
- `created_at` - an integer Unix timestamp surfaced as a JavaScript `Date` via `mode: &quot;timestamp&quot;`

The two exported types, `Todo` and `NewTodo`, are inferred straight from the schema. You never hand-write them, and they stay in sync as the schema changes.

## Step 6: Create the database client

Create `src/db/index.ts`. It initializes the libSQL client and wraps it with Drizzle:

```ts
// src/db/index.ts
import { drizzle } from &quot;drizzle-orm/libsql&quot;
import { createClient } from &quot;@libsql/client/web&quot;
import * as schema from &quot;./schema&quot;

const client = createClient({
  url: process.env.BUNNY_DATABASE_URL!,
  authToken: process.env.BUNNY_DATABASE_AUTH_TOKEN!,
})

export const db = drizzle(client, { schema })
export { schema }
```

We use `@libsql/client/web` (the HTTP transport) rather than the native socket one. That&apos;s the right call for Bunny Database, which you reach over HTTPS, and it works the same in Node, Bun, and edge runtimes.

## Step 7: Generate the first migration

With the schema in place, generate the SQL migration:

```bash
bun run db:generate
```

drizzle-kit reads `src/db/schema.ts`, diffs it against the migration history, and writes a new file under `./drizzle/`. For the `todos` table it produces something like:

```sql
-- drizzle/0000_certain_bug.sql
CREATE TABLE `todos` (
  `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
  `title` text NOT NULL,
  `completed` integer DEFAULT false NOT NULL,
  `created_at` integer NOT NULL
);
```

That&apos;s plain SQLite syntax, which Bunny Database takes without changes. The random suffix (`certain_bug` here) keeps migrations ordered without name clashes.

## Step 8: Apply the migration

To run migrations programmatically, create `src/db/migrate.ts`:

```ts
// src/db/migrate.ts
import { migrate } from &quot;drizzle-orm/libsql/migrator&quot;
import { db } from &quot;./index&quot;

async function main() {
  console.log(&quot;Running migrations...&quot;)
  await migrate(db, { migrationsFolder: &quot;./drizzle&quot; })
  console.log(&quot;Migrations complete.&quot;)
  process.exit(0)
}

main().catch((err) =&gt; {
  console.error(&quot;Migration failed:&quot;, err)
  process.exit(1)
})
```

Run it:

```bash
bun run db:migrate
```

You should see:

```
Running migrations...
Migrations complete.
```

The `todos` table now exists for real in Bunny Database. You can confirm it through the HTTP API:

```bash
HTTP_URL=$(echo $BUNNY_DATABASE_URL | sed &apos;s/^libsql:/https:/&apos;)
curl -X POST &quot;${HTTP_URL%/}/v2/pipeline&quot; \
  -H &quot;Authorization: Bearer $BUNNY_DATABASE_AUTH_TOKEN&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;requests&quot;:[{&quot;type&quot;:&quot;execute&quot;,&quot;stmt&quot;:{&quot;sql&quot;:&quot;SELECT name FROM sqlite_master WHERE type=&apos;&quot;&apos;&quot;&apos;table&apos;&quot;&apos;&quot;&apos;&quot;}}]}&apos;
```

The response lists `todos`, `__drizzle_migrations`, and `sqlite_sequence`. That `__drizzle_migrations` table is how Drizzle tracks which migrations have run.

&lt;Notice type=&quot;info&quot; title=&quot;The bsql shell&quot;&gt;
  Bunny also ships an interactive SQL shell called `bsql`. If you have the Bunny CLI, connect with `bsql libsql://your-database-id.lite.bunnydb.net --token your-token` and run `.tables`, `SELECT * FROM todos;`, and the like. It&apos;s a quick way to poke at data without writing code.
&lt;/Notice&gt;

## Step 9: Build the server functions

With the database layer ready, we expose operations to the frontend through TanStack Start&apos;s server functions. These run only on the server, even though you import and call them like normal async functions from client code. That keeps the database client and your credentials off the browser bundle entirely.

Create `src/db/queries.ts`:

```ts
// src/db/queries.ts
import { eq } from &quot;drizzle-orm&quot;
import { createServerFn } from &quot;@tanstack/react-start&quot;
import { db, schema } from &quot;./index&quot;
import type { NewTodo } from &quot;./schema&quot;

export const getTodos = createServerFn({ method: &quot;GET&quot; }).handler(async () =&gt; {
  const rows = await db.select().from(schema.todos).orderBy(schema.todos.id)
  return rows
})

export const addTodo = createServerFn({ method: &quot;POST&quot; })
  .validator((title: string) =&gt; ({ title }))
  .handler(async ({ data }) =&gt; {
    const newTodo: NewTodo = { title: data.title }
    const [created] = await db.insert(schema.todos).values(newTodo).returning()
    return created
  })

export const toggleTodo = createServerFn({ method: &quot;POST&quot; })
  .validator((input: { id: number; completed: boolean }) =&gt; ({
    id: input.id,
    completed: input.completed,
  }))
  .handler(async ({ data }) =&gt; {
    const [updated] = await db
      .update(schema.todos)
      .set({ completed: data.completed })
      .where(eq(schema.todos.id, data.id))
      .returning()
    return updated
  })

export const deleteTodo = createServerFn({ method: &quot;POST&quot; })
  .validator((id: number) =&gt; ({ id }))
  .handler(async ({ data }) =&gt; {
    await db.delete(schema.todos).where(eq(schema.todos.id, data.id))
    return { id: data.id }
  })
```

Every function follows the same shape:

1. `.validator()` parses and validates the incoming argument, handing you a typed `data` object
2. `.handler()` runs the Drizzle query against Bunny Database and returns the result
3. The function is async and returns the typed result, so the React caller gets full type safety

## Step 10: Wire up the QueryClientProvider

TanStack Start does SSR by default. Since the UI uses TanStack Query for data fetching, you need a `QueryClientProvider` at the root so both the server and client renders share the same query client.

Update `src/routes/__root.tsx`:

```tsx
// src/routes/__root.tsx
import { HeadContent, Scripts, createRootRoute } from &quot;@tanstack/react-router&quot;
import { TanStackRouterDevtoolsPanel } from &quot;@tanstack/react-router-devtools&quot;
import { TanStackDevtools } from &quot;@tanstack/react-devtools&quot;
import { QueryClient, QueryClientProvider } from &quot;@tanstack/react-query&quot;

import appCss from &quot;../styles.css?url&quot;

const queryClient = new QueryClient()

export const Route = createRootRoute({
  head: () =&gt; ({
    meta: [
      { charSet: &quot;utf-8&quot; },
      { name: &quot;viewport&quot;, content: &quot;width=device-width, initial-scale=1&quot; },
      { title: &quot;Todos&quot; },
    ],
    links: [{ rel: &quot;stylesheet&quot;, href: appCss }],
  }),
  notFoundComponent: () =&gt; (
    &lt;main className=&quot;container mx-auto p-4 pt-16&quot;&gt;
      &lt;h1&gt;404&lt;/h1&gt;
      &lt;p&gt;The requested page could not be found.&lt;/p&gt;
    &lt;/main&gt;
  ),
  shellComponent: RootDocument,
  defaultPreload: &quot;intent&quot;,
})

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;head&gt;
        &lt;HeadContent /&gt;
      &lt;/head&gt;
      &lt;body&gt;
        &lt;QueryClientProvider client={queryClient}&gt;
          {children}
        &lt;/QueryClientProvider&gt;
        &lt;TanStackDevtools
          config={{ position: &quot;bottom-right&quot; }}
          plugins={[
            { name: &quot;Tanstack Router&quot;, render: &lt;TanStackRouterDevtoolsPanel /&gt; },
          ]}
        /&gt;
        &lt;Scripts /&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}
```

Skip this provider and SSR throws `No QueryClient set, use QueryClientProvider to set one`, then falls back to client-only rendering. That defeats the point of SSR and slows your first paint.

## Step 11: Build the todo UI

Last step: replace `src/routes/index.tsx` with the todo interface. It uses `useQuery` to load the list and `useMutation` for each action, invalidating the `todos` query after every mutation so the list refreshes.

The layout is a centered column: a heading, an input-and-button row to add todos, and the list below. Each item has a circular toggle button, the todo text (struck through when complete), and a trash icon. Every mutation calls `queryClient.invalidateQueries({ queryKey: [&quot;todos&quot;] })` on success, which refetches through the server function and keeps the UI honest about what&apos;s actually in the database.

&lt;Accordion label=&quot;Full src/routes/index.tsx&quot; group=&quot;code&quot; expanded=&quot;false&quot;&gt;

```tsx
// src/routes/index.tsx
import { useState } from &quot;react&quot;
import { createFileRoute } from &quot;@tanstack/react-router&quot;
import { useQuery, useMutation, useQueryClient } from &quot;@tanstack/react-query&quot;
import { Check, Plus, Trash2 } from &quot;lucide-react&quot;
import { getTodos, addTodo, toggleTodo, deleteTodo } from &quot;@/db/queries&quot;
import { Button } from &quot;@/components/ui/button&quot;
import { cn } from &quot;@/lib/utils&quot;

export const Route = createFileRoute(&quot;/&quot;)({ component: TodoApp })

function TodoApp() {
  const queryClient = useQueryClient()
  const [newTitle, setNewTitle] = useState(&quot;&quot;)

  const { data: todos = [], isLoading } = useQuery({
    queryKey: [&quot;todos&quot;],
    queryFn: () =&gt; getTodos(),
  })

  const addMutation = useMutation({
    mutationFn: (title: string) =&gt; addTodo({ data: title }),
    onSuccess: () =&gt; {
      queryClient.invalidateQueries({ queryKey: [&quot;todos&quot;] })
      setNewTitle(&quot;&quot;)
    },
  })

  const toggleMutation = useMutation({
    mutationFn: (vars: { id: number; completed: boolean }) =&gt;
      toggleTodo({ data: vars }),
    onSuccess: () =&gt; queryClient.invalidateQueries({ queryKey: [&quot;todos&quot;] }),
  })

  const deleteMutation = useMutation({
    mutationFn: (id: number) =&gt; deleteTodo({ data: id }),
    onSuccess: () =&gt; queryClient.invalidateQueries({ queryKey: [&quot;todos&quot;] }),
  })

  return (
    &lt;div className=&quot;flex min-h-svh justify-center p-6&quot;&gt;
      &lt;div className=&quot;flex w-full max-w-md flex-col gap-6&quot;&gt;
        &lt;div&gt;
          &lt;h1 className=&quot;text-2xl font-semibold tracking-tight&quot;&gt;Todos&lt;/h1&gt;
          &lt;p className=&quot;text-sm text-muted-foreground&quot;&gt;
            Bunny Database + Drizzle + TanStack Start
          &lt;/p&gt;
        &lt;/div&gt;

        &lt;form
          className=&quot;flex gap-2&quot;
          onSubmit={(e) =&gt; {
            e.preventDefault()
            const title = newTitle.trim()
            if (!title) return
            addMutation.mutate(title)
          }}
        &gt;
          &lt;input
            value={newTitle}
            onChange={(e) =&gt; setNewTitle(e.target.value)}
            placeholder=&quot;What needs to be done?&quot;
            className=&quot;flex-1 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring&quot;
          /&gt;
          &lt;Button type=&quot;submit&quot; disabled={addMutation.isPending} size=&quot;icon&quot;&gt;
            &lt;Plus className=&quot;size-4&quot; /&gt;
          &lt;/Button&gt;
        &lt;/form&gt;

        {isLoading ? (
          &lt;p className=&quot;text-sm text-muted-foreground&quot;&gt;Loading...&lt;/p&gt;
        ) : todos.length === 0 ? (
          &lt;p className=&quot;text-sm text-muted-foreground&quot;&gt;
            No todos yet. Add one above.
          &lt;/p&gt;
        ) : (
          &lt;ul className=&quot;flex flex-col gap-1&quot;&gt;
            {todos.map((todo) =&gt; (
              &lt;li
                key={todo.id}
                className=&quot;flex items-center gap-3 rounded-md border border-border px-3 py-2&quot;
              &gt;
                &lt;button
                  type=&quot;button&quot;
                  onClick={() =&gt;
                    toggleMutation.mutate({
                      id: todo.id,
                      completed: !todo.completed,
                    })
                  }
                  className={cn(
                    &quot;flex size-5 shrink-0 items-center justify-center rounded-full border transition-colors&quot;,
                    todo.completed
                      ? &quot;border-primary bg-primary text-primary-foreground&quot;
                      : &quot;border-input hover:border-primary&quot;,
                  )}
                  aria-label={todo.completed ? &quot;Mark incomplete&quot; : &quot;Mark complete&quot;}
                &gt;
                  {todo.completed &amp;&amp; &lt;Check className=&quot;size-3&quot; /&gt;}
                &lt;/button&gt;

                &lt;span
                  className={cn(
                    &quot;flex-1 text-sm&quot;,
                    todo.completed &amp;&amp; &quot;text-muted-foreground line-through&quot;,
                  )}
                &gt;
                  {todo.title}
                &lt;/span&gt;

                &lt;button
                  type=&quot;button&quot;
                  onClick={() =&gt; deleteMutation.mutate(todo.id)}
                  className=&quot;text-muted-foreground hover:text-destructive&quot;
                  aria-label=&quot;Delete todo&quot;
                &gt;
                  &lt;Trash2 className=&quot;size-4&quot; /&gt;
                &lt;/button&gt;
              &lt;/li&gt;
            ))}
          &lt;/ul&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  )
}
```

&lt;/Accordion&gt;

## Running it on a VPS

Server functions need a Node or Bun runtime, so you can&apos;t drop this on a static host. A VPS or a container platform is the right home.

When you develop on the VPS itself, the `--host` flag binds Vite to `0.0.0.0` so you can reach it from your laptop:

```bash
bun run dev
# Local:   http://localhost:3000/
# Network: http://your-vps-ip:3000/
```

For production, build and preview:

```bash
bun run build
bun run preview
```

For anything long-running, wrap the start command in a process manager like PM2 or systemd, or put it in a container. Bunny&apos;s own Magic Containers fit nicely here, partly because you can attach the database credentials as environment variables straight from the database dashboard.

&lt;Notice type=&quot;info&quot; title=&quot;Attaching credentials to a Magic Container&quot;&gt;
  Deploy to a Bunny Magic Container and you can skip the `.env` file. From the database dashboard, click **Add Secrets to Magic Container Apps**, pick your container app, and Bunny injects `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` for you. Your code already reads `process.env`, so nothing changes.
&lt;/Notice&gt;

## Why this stack works

A few reasons the three play well together:

&lt;ListCheck&gt;
- **Type safety end to end**: the Drizzle schema is the source of truth, and those types flow into server functions and React. A column rename becomes a compile error, not a 2am runtime surprise
- **SQL you already know**: Bunny Database is libSQL, which is SQLite-compatible, so there&apos;s no proprietary dialect to learn
- **Idle when inactive**: the database spins down when nothing&apos;s querying it and bills on usage, which keeps side projects cheap
- **SSR out of the box**: TanStack Start renders the first HTML on the server, so the todo list is there before hydration
- **You own the UI code**: shadcn copies components into your project instead of hiding them behind a package
- **Cheap schema changes**: edit `schema.ts`, run `db:generate` then `db:migrate`, and the change is live in seconds
- **Portable credentials**: the `.env` pattern is standard, and Magic Containers can inject the same variables, so local-to-deploy needs no code changes
&lt;/ListCheck&gt;

## Troubleshooting

A handful of things tripped me up while building this:

&lt;Accordion label=&quot;No QueryClient set during SSR&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

You forgot to wrap the app in `QueryClientProvider` inside `__root.tsx`. Add it as shown in Step 10.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Protocol libsql not supported in libcurl&quot; group=&quot;troubleshoot&quot;&gt;

This shows up when you try to curl the database URL directly. The `libsql://` scheme is only for client libraries. For raw HTTP calls, convert it to `https://` and POST to `/v2/pipeline`, as in Step 8.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Tokens shown only once&quot; group=&quot;troubleshoot&quot;&gt;

Lose a Bunny access token and you generate a new one from the dashboard. Regenerating invalidates the existing tokens, so update every app that used them.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Migration fails with a schema error&quot; group=&quot;troubleshoot&quot;&gt;

Check that `dialect: &quot;turso&quot;` is set in `drizzle.config.ts`. Bunny Database uses the libSQL/SQLite dialect, not PostgreSQL.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Port already in use&quot; group=&quot;troubleshoot&quot;&gt;

Vite tries the next free port (3001, 3002, and so on). Read the startup output for the port it actually picked.

&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is Bunny Database production-ready?&quot; group=&quot;faq&quot;&gt;

It&apos;s in public preview, so I&apos;d treat it like any preview product: great for side projects, internal tools, and read-heavy apps, but read the current terms before you put critical workloads on it. Since it&apos;s libSQL, your data and queries stay portable to any SQLite-compatible host if you need to move. My [Bunny.net review](/bunny-net-review/) has more on where the platform is mature and where it&apos;s still maturing.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Postgres instead?&quot; group=&quot;faq&quot;&gt;

Not with Bunny Database, which is libSQL only. If you want Postgres with TanStack Start and Drizzle, my [Dokploy deployment guide](/tanstack-start-dokploy-deploy/) walks through exactly that on a self-hosted VPS.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why server functions instead of a REST API?&quot; group=&quot;faq&quot;&gt;

Server functions keep your database client and credentials on the server while letting you call them like plain async functions from the client. You skip writing and maintaining a separate API layer, and the types carry through automatically. For a small app like this, that&apos;s a lot less boilerplate.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny charge per query?&quot; group=&quot;faq&quot;&gt;

Bunny Database bills on usage (reads, writes, and storage per active region) and idles when nothing&apos;s hitting it, so a quiet app costs very little. Check the [Bunny pricing](https://go.bitdoze.com/bunny) page for current preview rates, since they can change while it&apos;s in preview.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I add more tables later?&quot; group=&quot;faq&quot;&gt;

Yes, that&apos;s the nice part. Add a table to `src/db/schema.ts`, run `bun run db:generate` to create the migration, then `bun run db:migrate` to apply it. The new types are available immediately and the change is live in Bunny within seconds.

&lt;/Accordion&gt;

## Wrapping up

In about an hour I went from an empty folder to a type-safe, SSR todo app backed by a managed database that costs next to nothing when idle, with versioned migrations and a UI I fully own. Growing the schema is a two-command operation, and the server function pattern keeps credentials and query logic where they belong.

If you want to push it further, the obvious next steps are user auth with per-user todos, due dates and filtering, optimistic updates instead of query invalidation, and a Magic Container deploy with credentials injected automatically. The [Bunny Database docs](https://docs.bunny.net/database) and the [Drizzle libSQL guide](https://orm.drizzle.team/docs/get-started-sqlite) cover the corners I didn&apos;t.

&lt;Button text=&quot;Try Bunny.net Free for 14 Days&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

## Related articles

- [Bunny.net review](/bunny-net-review/) - the full platform after a year in production
- [Astro DB with Bunny Database](/astro-db-bunny-database/) - the same database with Astro&apos;s local-first workflow
- [Deploy TanStack Start on a VPS with Dokploy](/tanstack-start-dokploy-deploy/) - the same stack with Postgres, fully self-hosted
- [Mount an S3 bucket as a filesystem](/s3-bucket-filesystem-vps/) - ZeroFS and JuiceFS on Bunny Storage
- [Bunny Storage vs S3 vs Backblaze](/bunny-storage-vs-s3-vs-backblaze/) - cloud storage pricing compared
- [Deploy an Astro site to Bunny.net](/deploy-astro-bunny-net/) - static hosting on Bunny storage and CDN
- [Bunny Stream guide](/bunny-stream-guide/) - video hosting on Bunny</content:encoded><category>web-development</category><category>tanstack</category><category>bunny-net</category><category>self-hosted</category></item><item><title>Manifest V3 Broke Your Ad Blocker? Block Ads Everywhere with NextDNS</title><link>https://www.bitdoze.com/block-ads-manifest-v3-nextdns/</link><guid isPermaLink="true">https://www.bitdoze.com/block-ads-manifest-v3-nextdns/</guid><description>Chrome 150 disables uBlock Origin and other Manifest V2 extensions. Here&apos;s how to keep ads blocked on every device using NextDNS, no browser extension required.</description><pubDate>Wed, 17 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

If you open Chrome one morning and find uBlock Origin greyed out with a message saying it has been turned off, you are not alone. Google&apos;s switch to Manifest V3 is finally hitting users, and the classic ad blockers that millions of people relied on are being switched off for good. The fix that keeps working no matter which browser you use is DNS-level blocking, and that&apos;s what I want to walk you through here.

&lt;Button text=&quot;Try NextDNS Free&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## What Actually Changed in Chrome

Every browser extension ships with a manifest file. It tells the browser what the extension is allowed to do. Manifest V2 (the old system) let an extension like uBlock Origin use the `webRequest` API to inspect and block network requests as they happened. When a page tried to pull in an ad script or a tracker, the extension caught it and stopped it before it loaded.

Manifest V3 replaces that with `declarativeNetRequest`. Instead of reacting to requests in real time, an extension now hands the browser a fixed set of rules and lets the browser do the filtering. That sounds harmless until you look at the limits:

- There&apos;s a cap on rules (around 30,000 by default), while real filter lists carry hundreds of thousands.
- Filter updates are slower because the lists get processed differently.
- The clever, adaptive rules that fought sites trying to detect ad blockers no longer work the same way.

The timeline is already set. Chrome 150, landing at the end of June 2026, removes the internal flag that kept Manifest V2 extensions alive. Chrome 151 strips the leftover Manifest V2 code out of Chromium entirely. Once that happens, classic uBlock Origin has no way to run.

&lt;Notice type=&quot;warning&quot; title=&quot;uBlock Origin Lite is not the same&quot;&gt;

The official Manifest V3 replacement, uBlock Origin Lite, works but it&apos;s noticeably weaker than the original. Fewer rules, slower filter updates, and a harder time dealing with anti-adblock scripts. It&apos;s &quot;good enough&quot; for casual browsing, but it is a step down.

&lt;/Notice&gt;

## Why DNS Blocking Is the Smarter Long-Term Fix

Browser extensions live and die by the rules of whatever browser you happen to use. DNS-level blocking sits one layer lower, between your device and the internet, so it doesn&apos;t care about Manifest V3 at all.

Here&apos;s the basic idea. Every time a device loads a page, it asks a DNS server &quot;where do I find this domain?&quot; A filtering DNS service like [NextDNS](https://go.bitdoze.com/nextdns) checks that domain against blocklists first. If the domain belongs to an ad network or a tracker, it returns nothing and the ad never loads. This happens for ads, trackers, malware domains, and telemetry alike.

Because the filtering happens at the DNS level, it covers things a browser extension never could:

&lt;ListCheck&gt;

- Ads inside mobile apps and games, not just websites
- Smart TVs, streaming sticks, and game consoles
- Every browser on the machine at once, Chrome included
- IoT gadgets that phone home to tracking servers
- Devices where you cannot install an extension at all

&lt;/ListCheck&gt;

I already wrote a full comparison of cloud and self-hosted DNS filtering if you want the deeper technical background:

&lt;Notice type=&quot;info&quot; title=&quot;Want the full DNS protection guide?&quot;&gt;

I covered DNS encryption protocols, NextDNS, and the self-hosted AdGuard Home route in a separate guide. It explains how each piece fits together.

&lt;Button text=&quot;Read the Complete DNS Protection Guide&quot; link=&quot;/block-ads-malware-dns-protection/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;book-open&quot; /&gt;

&lt;/Notice&gt;

## Extension vs DNS Blocking

| | uBlock Origin (MV2) | uBlock Origin Lite (MV3) | NextDNS |
|---|---|---|---|
| Still works in Chrome 150+ | No | Yes | Yes |
| Blocks ads in apps | No | No | Yes |
| Covers every device | No | No | Yes |
| Rule limit | Unlimited | ~30,000 | Provider lists |
| Per-element page hiding | Yes | Limited | No |
| Needs a browser | Yes | Yes | No |

DNS blocking can&apos;t hide individual page elements the way a browser extension can, and it won&apos;t touch YouTube ads served from the same domain as the video. For everything else, it keeps working long after Manifest V2 is gone. Pairing NextDNS with a lightweight extension covers both gaps, but the DNS layer is the part that survives the Chrome change.

## How to Block Ads with NextDNS After Manifest V3

### Step 1: Create a NextDNS Account

Head to [NextDNS](https://go.bitdoze.com/nextdns), sign up with your email, and you&apos;ll get a configuration ID that looks something like `abc123`. That ID is your profile. Everything you turn on or off lives there.

&lt;Button text=&quot;Create Your NextDNS Profile&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

### Step 2: Add Your Blocklists

Open the **Privacy** tab and add a couple of well-maintained lists. I run these three:

- **OISD** covers most ads and trackers without breaking sites.
- **AdGuard DNS filter** is a solid, balanced list.
- **Steven Black&apos;s Unified Hosts** rounds things out.

Two or three lists with good overlap beat ten lists that just slow down resolution. Under **Native Tracking Protection**, switch on the device types you own (Apple, Windows, Samsung, and so on) to cut off telemetry at the source.

### Step 3: Turn On Security Filtering

In the **Security** tab, enable the protections that block dangerous domains:

```
- Threat Intelligence Feeds: ON
- Google Safe Browsing: ON
- Cryptojacking Protection: ON
- DNS Rebinding Protection: ON
- Typosquatting Protection: ON
```

These catch malware, phishing, and crypto-mining domains, which is something an ad blocker extension was never really built to do.

### Step 4: Point Your Devices at NextDNS

This is where DNS blocking shows its strength. Set it once and it covers everything.

**Whole network (router):** Change your router&apos;s DNS to NextDNS. If your router supports DNS-over-HTTPS, use `https://dns.nextdns.io/YOUR_CONFIG_ID`. Otherwise enter the linked IP addresses from your dashboard. Every device on the network is now filtered, Chrome included.

**Single devices:** Install the NextDNS app on Windows, Mac, Linux, iOS, or Android, then paste in your configuration ID. It runs as a system service and filters every app on the device.

**Chrome itself:** You can even set secure DNS inside Chrome. Go to **Settings &gt; Privacy and security &gt; Security &gt; Use secure DNS &gt; Custom** and enter `https://dns.nextdns.io/YOUR_CONFIG_ID`. Now Chrome blocks ads through DNS, no extension involved.

&lt;Notice type=&quot;info&quot; title=&quot;This is the part Manifest V3 can&apos;t take away&quot;&gt;

Because the filtering happens at the DNS layer, Chrome can disable every extension it wants and your ads stay blocked. The browser is just asking NextDNS for addresses, and NextDNS refuses to hand over the ad servers.

&lt;/Notice&gt;

### Step 5: Confirm It&apos;s Working

Visit [test.nextdns.io](https://test.nextdns.io). You should see &quot;All good! You are using NextDNS.&quot; Your dashboard will start filling with queries, and you can watch in real time as ad and tracker domains get blocked.

### Step 6: Tune It Over a Few Days

Check your logs after a few days of normal use:

- If a legitimate site breaks, add the domain to your **allowlist**.
- If something annoying slips through, drop it on your **denylist**.
- Adjust your blocklists if you see too many false positives.

## What About YouTube and On-Page Ads?

Two honest limits. DNS blocking can&apos;t remove YouTube ads, because they come from the same domains as the videos themselves, and it can&apos;t hide individual elements on a page the way a full extension does.

For those cases, run a Manifest V3 extension or switch to a browser that still supports the classic ones. Firefox keeps full uBlock Origin support because it isn&apos;t built on Chromium, and Brave ships a built-in blocker plus Manifest V2 support. NextDNS handles the network-wide blocking; one of those handles the page-level polish. Together they replace what a single Chrome extension used to do.

## Why I&apos;d Pick NextDNS for This

I have been running [NextDNS](https://go.bitdoze.com/nextdns) across my devices for months, and the appeal during this Chrome transition is that there&apos;s nothing to break. No extension to get disabled, no Manifest version to worry about. You set the DNS once and it keeps filtering on the phone, the laptop, the TV, and every app in between.

Setup takes about five minutes, the dashboard is clean, and the free tier (300,000 queries per month) is plenty for testing. A typical household will want the Pro plan, which removes the query cap and still costs under $2 a month, cheaper than most VPNs.

| Plan | Queries/Month | Price |
|------|---------------|-------|
| Free | 300,000 | $0 |
| Pro | Unlimited | $1.99/month |

If you want my longer take on the service itself (features, privacy settings, what I run day to day), I covered it in detail here:

&lt;Button text=&quot;Read My Full NextDNS Review&quot; link=&quot;/nextdns-review/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;book-open&quot; /&gt;

## The Bottom Line

Manifest V3 is real, the dates are set, and the classic ad blockers you knew in Chrome are on their way out. You don&apos;t have to fight it. Move your blocking down to the DNS layer with NextDNS and you sidestep the whole problem: ads stay blocked on every device, in every browser, and inside apps that never had an ad blocker to begin with.

If you also want page-level control, keep a Manifest V3 extension or a privacy-friendly browser around. But the part that quietly works in the background, on everything you own, is DNS. Set it up once and forget about the next Chrome update.

&lt;Button text=&quot;Get Started with NextDNS&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

---

**Related Articles:**
- [NextDNS Review: Cloud DNS Protection That Actually Works](/nextdns-review/)
- [How to Block Ads, Malware &amp; Stop ISP Tracking with NextDNS and AdGuard Home](/block-ads-malware-dns-protection/)
- [How to Self-Host SearXNG - Privacy-Focused Metasearch Engine](https://www.bitdoze.com/searxng-self-host-privacy-search/)</content:encoded><category>tools</category><category>privacy</category><category>security</category></item><item><title>Mount an S3 Bucket as a Filesystem on a VPS with ZeroFS &amp; JuiceFS</title><link>https://www.bitdoze.com/s3-bucket-filesystem-vps/</link><guid isPermaLink="true">https://www.bitdoze.com/s3-bucket-filesystem-vps/</guid><description>Turn a Bunny.net S3 bucket into a real POSIX filesystem on your VPS. Step-by-step setup for ZeroFS and JuiceFS, with caching, systemd services, and which one to pick.</description><pubDate>Tue, 16 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Object storage is cheap. A VPS with a big local disk is not. So the obvious move is to keep your bulk data in an S3 bucket and only pay for the storage you actually use, while your server stays small and fast. The catch is that S3 speaks HTTP, not POSIX, so most apps can&apos;t write to it like a normal folder.

That gap is what filesystem layers solve. They sit between your applications and the bucket, presenting a regular mount point like `/mnt/data` while shuffling bytes to and from object storage behind the scenes. I&apos;ve been running this setup on a few servers, and Bunny.net adding an [S3-compatible API to their storage](https://docs.bunny.net/storage/s3) made it a lot more attractive, mostly because their storage is $0.01/GB with no egress fees through their CDN.

This guide walks through two tools that do the job well: **ZeroFS** and **JuiceFS**. Both turn an S3 bucket into a mountable filesystem on a VPS, but they take very different paths to get there. I&apos;ll show you how to set up each one against a Bunny.net bucket, then help you pick.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You&apos;ll need an S3-compatible bucket for this guide. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card and get a 14-day trial. My [full Bunny.net review](/bunny-net-review/) covers the rest of the platform.
&lt;/Notice&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/P_ZoBPAFsOc&quot;
  label=&quot;Is Your VPS Running Out of Space? Use S3 as a Drive&quot;
/&gt;



## Why mount S3 as a filesystem

Before the how, here&apos;s the why. Pointing a filesystem at object storage gets you a few things a plain VPS disk can&apos;t:

&lt;ListCheck&gt;
- **Cheap, elastic capacity**: Pay per GB instead of resizing a volume every time you run low
- **Off-server durability**: Files live in replicated object storage, not on one disk that can die
- **Shared storage**: Multiple servers can read from the same bucket-backed mount
- **No app changes**: Software writes to a path, not an SDK, so existing tools just work
- **Easy backups and media**: Great for media libraries, backups, archives, and static assets
&lt;/ListCheck&gt;

It&apos;s not magic, though. S3 round trips take 50-300ms, so without local caching, a naive mount feels painfully slow for small files. Both tools below solve this with a local cache, which is the main reason to use them over something basic like `s3fs`.

## The two approaches compared

ZeroFS and JuiceFS reach the same destination through different architectures. This matters for setup and for which workloads each one handles well.

| | **ZeroFS** | **JuiceFS** |
|---|---|---|
| Language | Rust | Go |
| Metadata | Stored in the bucket (LSM tree) | Separate database (Redis, etc.) |
| Extra services | None needed | Needs a metadata engine |
| Protocols | NFS, 9P, NBD | FUSE mount, S3 gateway |
| Encryption | Always on (XChaCha20) | Optional |
| Compression | zstd / LZ4 | LZ4 / zstd |
| Block devices | Yes (NBD, runs ZFS) | No |
| Setup effort | Low (single binary) | Medium (binary + database) |
| Best for | Simple single-node mounts, block storage | Shared/multi-client, large scale |
| License | AGPL-3.0 / commercial | Apache 2.0 |

The short version: **ZeroFS** is simpler because it stores everything (including metadata) in the bucket, so there&apos;s no database to run. **JuiceFS** is more battle-tested at scale and lets many clients share one filesystem, but it needs a metadata engine like Redis alongside the object storage.

&lt;Notice type=&quot;info&quot; title=&quot;A note on metadata&quot;&gt;
  JuiceFS keeps file metadata (names, sizes, directory structure) in a separate database for speed and consistency. That database is critical, lose it and you lose the map to your data. ZeroFS sidesteps this by keeping metadata in the bucket itself as a log-structured merge tree.
&lt;/Notice&gt;

## Step 1: Create a Bunny.net S3 bucket

Both tools need an S3-compatible bucket. Here&apos;s how to set one up on Bunny.net.

&lt;Notice type=&quot;warning&quot; title=&quot;S3 compatibility is set at creation&quot;&gt;
  Bunny&apos;s S3 API is still in beta and must be enabled when you create the storage zone. You can&apos;t toggle it on an existing zone, so create a fresh one for this.
&lt;/Notice&gt;

1. In the Bunny dashboard, go to **Storage** → **Add Storage Zone**
2. Give it a name (4+ characters, letters, numbers, and dashes only). This name becomes your bucket name and your access key
3. Enable the **S3 Compatibility** option
4. Pick a storage tier and a region close to your VPS
5. Turn on replication for at least one extra region (recommended for durability)
6. Confirm and add the zone

Once it&apos;s created, open the **Access** tab and grab your credentials. You&apos;ll map them to standard S3 values like this:

| S3 setting | Bunny value |
|---|---|
| Access Key ID | Your storage zone name |
| Secret Access Key | Your storage zone password |
| Endpoint | `https://[region]-s3.storage.bunnycdn.com` |
| Region | `de`, `ny`, `sg`, `uk`, `se`, `la`, or `jh` |

S3 compatibility currently works in Frankfurt (`de`), New York (`ny`), Singapore (`sg`), London (`uk`), Stockholm (`se`), Los Angeles (`la`), and Johannesburg (`jh`). Bunny only supports **path-style URLs** (`endpoint/bucket-name/key`), which both tools handle fine.

You can test the credentials with the AWS CLI before going further:

```sh
aws s3 ls s3://your-zone-name/ \
  --endpoint-url https://de-s3.storage.bunnycdn.com
```

If you want a deeper look at how Bunny Storage compares on price, I wrote a [Bunny Storage vs S3 vs Backblaze](/bunny-storage-vs-s3-vs-backblaze/) breakdown.

## Prerequisites

For either tool you&apos;ll want:

- A Linux VPS (Ubuntu/Debian works well here)
- Root or `sudo` access
- A local SSD with some free space for the cache (10 GB+ is a good start)
- The Bunny S3 credentials from above

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;

If you&apos;re new to running services on a VPS, my [Docker on Ubuntu guide](https://www.bitdoze.com/install-docker-ubuntu-arm/) and [adding a new drive with LVM](/add-new-drive-lvm/) cover the basics around storage and self-hosting.

## Option A: ZeroFS

[ZeroFS](https://github.com/Barre/zerofs) is a Rust tool that serves an S3 bucket as a POSIX filesystem over NFS and 9P, or as a raw block device over NBD. Everything runs in a single userspace process, and data is always compressed and encrypted before it leaves your server. There&apos;s no separate database, which makes it the simpler of the two to stand up.

### Install ZeroFS

The install script grabs the right prebuilt binary for your platform:

```sh
curl -sSfL https://sh.zerofs.net | sh
```

To pin a version and install without root:

```sh
curl -sSfL https://sh.zerofs.net | VERSION=v1.2.6 INSTALL_DIR=$HOME/.local/bin sh
```

&lt;Notice type=&quot;warning&quot; title=&quot;CPU requirement&quot;&gt;
  The prebuilt Linux amd64 binary needs a CPU with AVX2 (Intel Haswell 2013+ or AMD Excavator 2015+). On older hardware the binary exits with an illegal-instruction error, and you&apos;ll need to build from source for a baseline x86-64 target.
&lt;/Notice&gt;

### Configure ZeroFS

Generate a starter config and edit it:

```sh
zerofs init   # writes zerofs.toml
```

Here&apos;s a working `zerofs.toml` for a Bunny.net bucket. Note the `conditional_put` line, which I&apos;ll explain below:

```toml
[cache]
dir = &quot;/var/cache/zerofs&quot;
disk_size_gb = 10.0
memory_size_gb = 1.0

[storage]
url = &quot;s3://your-zone-name/zerofs-data&quot;
encryption_password = &quot;${ZEROFS_PASSWORD}&quot;

[filesystem]
compression = &quot;zstd-3&quot;

[servers.nfs]
addresses = [&quot;127.0.0.1:2049&quot;]

[aws]
access_key_id = &quot;${AWS_ACCESS_KEY_ID}&quot;
secret_access_key = &quot;${AWS_SECRET_ACCESS_KEY}&quot;
endpoint = &quot;https://de-s3.storage.bunnycdn.com&quot;
default_region = &quot;de&quot;
conditional_put = &quot;redis://localhost:6379&quot;
```

&lt;Notice type=&quot;info&quot; title=&quot;Why the Redis line&quot;&gt;
  ZeroFS needs conditional writes (put-if-not-exists) to fence against corruption. AWS S3 supports this natively, but many S3-compatible stores don&apos;t yet expose it. Setting `conditional_put` to a Redis URL lets ZeroFS coordinate those writes through Redis instead. Install Redis with `sudo apt install redis-server` and keep it on localhost. If you confirm your Bunny zone handles conditional puts, you can drop this line.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Bunny.net S3 compatibility issue with ZeroFS&quot;&gt;
  As of mid-2026, Bunny&apos;s S3 API (still in closed preview) does not return an ETag header on PUT responses. ZeroFS requires ETags to verify writes, so startup fails with a `MissingEtag` error even with `conditional_put` configured. This is a Bunny-side limitation they&apos;ll likely fix as the API matures. Until then, use JuiceFS (Option B) with Bunny, or pair ZeroFS with a different S3 provider like Cloudflare R2 or AWS S3.
&lt;/Notice&gt;

The `encryption_password` is not optional, ZeroFS has no unencrypted mode. Store it safely, because losing it means losing access to everything in the bucket.

### Run ZeroFS

Export your secrets and start the server:

```sh
export AWS_ACCESS_KEY_ID=&quot;your-zone-name&quot;
export AWS_SECRET_ACCESS_KEY=&quot;your-storage-password&quot;
export ZEROFS_PASSWORD=&quot;a-long-random-passphrase&quot;

zerofs run -c zerofs.toml
```

### Mount the filesystem

ZeroFS is now serving NFS on `127.0.0.1:2049`. Mount it like any NFS share:

```sh
sudo mkdir -p /mnt/data
sudo mount -t nfs -o nolock,vers=3 127.0.0.1:/ /mnt/data
```

That&apos;s it. Anything you write to `/mnt/data` gets compressed, encrypted, and pushed to your Bunny bucket, with hot reads served from the local cache in microseconds.

### Run it as a service

For anything beyond testing, run ZeroFS under systemd so it survives reboots. Create `/etc/systemd/system/zerofs.service`:

```ini
[Unit]
Description=ZeroFS S3-backed filesystem
After=network-online.target redis-server.service
Wants=network-online.target

[Service]
Environment=AWS_ACCESS_KEY_ID=your-zone-name
Environment=AWS_SECRET_ACCESS_KEY=your-storage-password
Environment=ZEROFS_PASSWORD=a-long-random-passphrase
ExecStart=/usr/local/bin/zerofs run -c /etc/zerofs/zerofs.toml
Restart=on-failure

[Install]
WantedBy=multi-user.target
```

Then enable it:

```sh
sudo systemctl daemon-reload
sudo systemctl enable --now zerofs
```

&lt;Notice type=&quot;warning&quot; title=&quot;Keep secrets out of the unit file&quot;&gt;
  Putting credentials directly in a systemd unit is fine for a quick start, but for production move them into an `EnvironmentFile=` with `600` permissions, or use a secrets manager. Never commit these to git.
&lt;/Notice&gt;

ZeroFS can also expose the bucket as a raw block device over NBD, which is what makes the &quot;run ZFS on top of S3&quot; demos possible. That&apos;s beyond a basic mount, but it&apos;s there if you need it.

## Option B: JuiceFS

[JuiceFS](https://github.com/juicedata/juicefs) is a mature, widely deployed distributed filesystem built on object storage plus a separate metadata engine. It&apos;s fully POSIX-compatible, supports thousands of concurrent clients, and is used in production for big data and machine learning workloads. The tradeoff is that you run a metadata database alongside it.

### Install Redis for metadata

JuiceFS stores file metadata in an engine like Redis, MySQL, or SQLite. For a single VPS, Redis is the easy choice. You can install it straight on the host or run it in Docker, whichever fits how you manage the box.

&lt;Tabs&gt;
  &lt;Tab name=&quot;System package&quot;&gt;

```sh
sudo apt update
sudo apt install redis-server
sudo systemctl enable --now redis-server
```

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Docker&quot;&gt;

If you already run Docker (or just prefer keeping services in containers), a small `compose.yaml` does the job. This also applies to the ZeroFS `conditional_put` Redis from Option A, same container works for both.

```yaml
services:
  redis:
    image: redis:7-alpine
    container_name: juicefs-redis
    restart: unless-stopped
    command: redis-server --appendonly yes
    ports:
      - &quot;127.0.0.1:6379:6379&quot;
    volumes:
      - ./redis-data:/data
```

Bring it up:

```sh
docker compose up -d
```

The `--appendonly yes` flag turns on AOF persistence so your metadata survives a container restart, and binding to `127.0.0.1` keeps Redis off the public internet. The volume keeps the data on the host. If you manage stacks through a UI, my [Dockge install guide](/dockge-install/) makes deploying this a click or two.

  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Protect your metadata engine&quot;&gt;
  The Redis database is the index to your entire filesystem. Back it up regularly (enable RDB/AOF persistence) and never expose it to the public internet. If you lose the metadata, the data blocks in your bucket become unusable.
&lt;/Notice&gt;

### Install JuiceFS

One command pulls the latest client:

```sh
curl -sSL https://d.juicefs.com/install | sh -
```

Check it installed:

```sh
juicefs version
```

### Format the filesystem

The `format` command initializes the filesystem, linking your metadata engine to the Bunny bucket. Run it once:

```sh
juicefs format \
  --storage s3 \
  --bucket https://de-s3.storage.bunnycdn.com/your-zone-name \
  --access-key your-zone-name \
  --secret-key your-storage-password \
  redis://localhost:6379/1 \
  myjfs
```

A few notes on the flags:

- `--bucket` uses the path-style URL Bunny requires, with your zone name as the last path segment
- `redis://localhost:6379/1` is the metadata engine (database 1 on local Redis)
- `myjfs` is the volume name, used in later commands

### Mount the filesystem

Now mount it, pointing at the same Redis URL:

```sh
sudo mkdir -p /mnt/data
juicefs mount redis://localhost:6379/1 /mnt/data --background
```

JuiceFS keeps a local cache (default under `/var/jfsCache`) so repeat reads stay fast. You can tune the cache size at mount time:

```sh
juicefs mount redis://localhost:6379/1 /mnt/data \
  --cache-size 10240 \
  --background
```

`--cache-size` is in MB, so `10240` gives you a 10 GB local cache.

### Run it as a service

JuiceFS can generate a systemd-friendly mount, but the simplest durable approach is a unit file at `/etc/systemd/system/juicefs.service`:

```ini
[Unit]
Description=JuiceFS mount
After=network-online.target redis-server.service
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/juicefs mount redis://localhost:6379/1 /mnt/data --cache-size 10240
ExecStop=/usr/local/bin/juicefs umount /mnt/data
Restart=on-failure

[Install]
WantedBy=multi-user.target
```

Enable it:

```sh
sudo systemctl daemon-reload
sudo systemctl enable --now juicefs
```

### Check the filesystem

JuiceFS ships handy inspection commands:

```sh
juicefs status redis://localhost:6379/1   # show volume info
juicefs bench /mnt/data                   # run a quick benchmark
```

## Performance and caching

Neither tool can beat physics, S3 latency is what it is, so the local cache is what makes the experience usable. A few things worth knowing:

&lt;ListCheck&gt;
- **Size the cache for your hot set**: Give the cache enough room to hold the files you read often. A 10 GB cache on a small VPS covers most media and web workloads
- **Use a fast cache disk**: Put the cache on local NVMe, not a network volume, or you defeat the purpose
- **Writes are async**: Both tools batch and upload in the background, so write throughput feels local until the cache fills
- **Pick a nearby region**: Match your Bunny region to your VPS location to cut round-trip time
- **Mind small-file workloads**: Millions of tiny files stress metadata more than bandwidth, where JuiceFS&apos;s dedicated engine has an edge
&lt;/ListCheck&gt;

For workloads that are mostly large sequential reads and writes (media, backups, archives), both tools fly once the cache is warm. For random small-file access, performance depends heavily on cache hit ratio.

## Which one should you pick?

After running both, here&apos;s my rule of thumb:

**Choose ZeroFS if** you want the simplest setup, you&apos;re on a single node, you like that encryption is mandatory, or you need block-device features like running ZFS on top of S3. No extra database to babysit is a real advantage for a small VPS. Pair it with Cloudflare R2 or AWS S3 (Bunny&apos;s S3 API currently doesn&apos;t return ETags, which ZeroFS needs).

**Choose JuiceFS if** you need multiple servers sharing one filesystem, you&apos;re operating at larger scale, you want the most proven option with the widest community, you already run Redis, or you&apos;re using Bunny.net S3 (JuiceFS works fine with Bunny&apos;s S3 API). The metadata engine is extra work, but it buys you consistency and concurrency that a single-node tool can&apos;t match.

For a home server or a single VPS holding a media library or backups, I lean ZeroFS for the simplicity. For anything shared across machines or headed toward serious scale, JuiceFS is the safer bet.

## Troubleshooting

&lt;Accordion label=&quot;Mount is extremely slow for small files&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

This is almost always a cold cache or an undersized one. Check that your cache directory is on local NVMe (not a network disk), and bump the cache size so it can hold your frequently accessed files. Also confirm your Bunny region matches your VPS location, a Frankfurt bucket served to a Singapore VPS adds latency to every miss.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;ZeroFS errors about conditional puts or fencing&quot; group=&quot;troubleshoot&quot;&gt;

ZeroFS needs put-if-not-exists support, which many S3-compatible stores don&apos;t expose. Set `conditional_put = &quot;redis://localhost:6379&quot;` in the `[aws]` section of your `zerofs.toml` and make sure Redis is running locally. This lets ZeroFS coordinate those writes through Redis instead of the bucket.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;JuiceFS mount fails or hangs&quot; group=&quot;troubleshoot&quot;&gt;

Check that Redis is reachable (`redis-cli ping` should return `PONG`) and that you&apos;re using the exact same Redis URL you formatted with. A mismatched database number (the `/1` at the end) points JuiceFS at an empty metadata store. Also verify the bucket URL is path-style, with your zone name as the last segment.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission denied writing to the mount&quot; group=&quot;troubleshoot&quot;&gt;

NFS and FUSE mounts map UIDs/GIDs from the server process. For ZeroFS over NFS, mount with `nolock` and check ownership of `/mnt/data`. For JuiceFS, run the mount as the user that needs write access, or adjust the directory permissions after mounting.

&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Is this faster than just using the disk on my VPS?&quot; group=&quot;faq&quot;&gt;

No, local disk is always faster for data that fits on it. The point of an S3-backed filesystem is cheap, elastic, off-server capacity for data that doesn&apos;t need disk-speed access: media, backups, archives, and shared storage. The local cache narrows the gap for hot files, but it&apos;s a different tool for a different job.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use this with Docker containers?&quot; group=&quot;faq&quot;&gt;

Yes. Once the filesystem is mounted at a path like `/mnt/data`, you can bind-mount that path into containers as a volume. It works well for media servers or backup containers. If you manage stacks with a UI, my [Dockge install guide](/dockge-install/) shows how to point compose volumes at any host path.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny.net charge for the requests these tools make?&quot; group=&quot;faq&quot;&gt;

Bunny Storage doesn&apos;t charge per API request, which is a real advantage here since both tools make a lot of small calls. You pay for storage ($0.01/GB single region) and for delivery bandwidth if you serve through the CDN. There are no egress fees when delivering through Bunny CDN. See my [Bunny.net review](/bunny-net-review/) for the full pricing picture.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will I lose data if the metadata is lost?&quot; group=&quot;faq&quot;&gt;

For JuiceFS, yes, the Redis metadata engine is the map to your data blocks, so back it up and never run it without persistence. For ZeroFS, metadata lives in the bucket alongside the data, so there&apos;s no separate database to lose, but the encryption password is equally critical: lose it and the data is unrecoverable.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use another S3 provider instead of Bunny?&quot; group=&quot;faq&quot;&gt;

Absolutely. Both tools work with AWS S3, Cloudflare R2, Backblaze B2, MinIO, and any S3-compatible store. Just swap the endpoint and credentials. I focused on Bunny here because of the flat $0.01/GB pricing and no request fees, which suit the chatty access pattern of a filesystem layer.

&lt;/Accordion&gt;

## Wrapping up

Mounting an S3 bucket as a filesystem used to mean fighting with `s3fs` and accepting terrible small-file performance. ZeroFS and JuiceFS both fix that with smart local caching, and they make object storage genuinely usable as a working filesystem on a VPS.

Pair either one with Bunny.net&apos;s flat-rate, no-egress storage and you get elastic capacity that costs a fraction of resizing your server&apos;s disk. Start with ZeroFS if you want the least moving parts, reach for JuiceFS when you need shared or large-scale storage.

&lt;Button text=&quot;Try Bunny.net Free for 14 Days&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

## Related articles

- [Bunny.net review](/bunny-net-review/) - the full platform after a year in production
- [Bunny Storage vs S3 vs Backblaze](/bunny-storage-vs-s3-vs-backblaze/) - cloud storage pricing compared
- [TanStack Start + Bunny Database + Drizzle](/tanstack-start-bunny-database-drizzle/) - build a type-safe full-stack app on Bunny
- [Deploy an Astro site to Bunny.net](/deploy-astro-bunny-net/) - static hosting on Bunny storage and CDN
- [Bunny Stream guide](/bunny-stream-guide/) - video hosting on Bunny
- [Add a new drive with LVM](/add-new-drive-lvm/) - growing local storage on a VPS
- [Install Dockge](/dockge-install/) - manage Docker compose stacks with bucket-backed volumes
- [Install Docker on Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/) - Docker and Compose setup
- [Best Docker containers for a home server](/docker-containers-home-server/) - what to run with your new storage</content:encoded><category>hosting</category><category>self-hosted</category><category>cdn</category></item><item><title>Cheapest AI Models for Hermes Agent in 2026 (Under $1/M Tokens)</title><link>https://www.bitdoze.com/best-cheap-models-hermes-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/best-cheap-models-hermes-agent/</guid><description>8 affordable models for Hermes Agent — DeepSeek V4 Flash at $0.10/M tokens, MiMo V2.5, MiniMax M3, and more. Pricing benchmarks and which to pick for coding vs chat.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Hermes Agent runs 24/7. It answers messages, executes scheduled jobs, runs skills, and searches the web around the clock. That kind of usage adds up fast if you pick the wrong model. I have been testing different providers on my Hermes instance for months, and the open source landscape has changed a lot since my earlier [model recommendations for OpenClaw](/best-opensource-models-for-openclaw/).

I narrowed it down to eight models that work with Hermes Agent, cost a fraction of what Claude or GPT API access runs you, and in some cases match or beat those proprietary models on coding and agent benchmarks.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Eight affordable open source models that work well with Hermes Agent&lt;/li&gt;
&lt;li&gt;Per-token pricing, context windows, and coding benchmarks for each&lt;/li&gt;
&lt;li&gt;Which model is cheapest, which is strongest, and which sits in the middle&lt;/li&gt;
&lt;li&gt;OpenCode Go as a single subscription that bundles all of these models&lt;/li&gt;
&lt;li&gt;How to set each model in Hermes Agent&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you have not installed Hermes Agent yet, the [setup guide](/hermes-agent-setup-guide/) walks through the full process. For dashboard options to manage your agent from a browser, see the [best Hermes dashboards](/best-hermes-dashboards/) roundup. And if you want the built-in web UI, the [Hermes dashboard guide](/hermes-dashboard-guide/) covers SSH tunnels, Caddy, and Docker deployment.

## The models at a glance

| Model | Input $/M tokens | Output $/M tokens | Context | Best For |
|-------|------------------|--------------------|---------|----------|
| **DeepSeek V4 Flash** | $0.098 | $0.28 | 1M | Ultra-cheap, fast tasks |
| **MiMo V2.5** | $0.14 | $0.28 | 1M | Budget omnimodal |
| **MiniMax M2.7** | $0.25 | $1.00 | 204K | Cheapest quality, daily use |
| **DeepSeek V4 Pro** | $0.435 | $0.87 | 1M | Long context on a budget |
| **Kimi K2.6** | $0.67 | $3.39 | 1T MoE | Coding + agent swarm |
| **MiMo V2.5 Pro** | $0.43 | $0.87 | 1M | Strongest agent, long tasks |
| **GLM 5.2** | $1.30 | $4.05 | 1M | Best overall coding |
| **MiniMax M3** | $0.30 | $1.20 | 1M | Frontier coding + multimodal |

&lt;Notice type=&quot;success&quot; title=&quot;Bottom line&quot;&gt;
**Cheapest:** DeepSeek V4 Flash at $0.098/M input — under $5/month for 24/7 use. **Best value for quality:** MiniMax M3 at $0.30/M input with 1M context and 59% SWE-Bench Pro. **Most powerful:** GLM 5.2 and MiMo V2.5 Pro.
&lt;/Notice&gt;

## 1. MiniMax M2.7 — The budget pick

This is the model I keep coming back to for everyday Hermes use. At $0.30 per million input tokens and $1.20 per million output tokens, running Hermes 24/7 costs roughly $7 to $15 per month depending on how much you use it. That is less than a coffee subscription.

&lt;Button text=&quot;MiniMax M2.7 (10% Off)&quot; link=&quot;https://go.bitdoze.com/minimax&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### What M2.7 delivers

M2.7 is no slouch for the price. It scores 56.2% on SWE-Bench Pro, which puts it in the same range as models that cost three to five times as much. On the GDPval-AA benchmark for economically valuable tasks, it hits ELO 1495, the highest score among open source models. Debugging, root cause analysis, document generation, multi-step tool calls — it handles all of those without falling apart.

MiniMax also offers M2.7-highspeed, which runs the same model at higher throughput for a slightly higher price. For interactive Hermes sessions where response time matters, it is worth trying.

| Spec | Value |
|------|-------|
| **Architecture** | Mixture-of-Experts (MoE) |
| **Context Window** | 196K tokens |
| **SWE-Bench Pro** | 56.2% |
| **GDPval-AA ELO** | 1,495 |
| **Input Cost** | $0.30/M tokens |
| **Output Cost** | $1.20/M tokens |
| **Cache Read** | $0.059/M tokens |

### Token Plan pricing

MiniMax offers a [Token Plan](https://platform.minimax.io/subscribe/token-plan) with discounted rates. If you sign up through [this link](https://go.bitdoze.com/minimax), you get 10% off the Token Plan.

&lt;Notice type=&quot;info&quot; title=&quot;Coding plan tip&quot;&gt;
The MiniMax Token Plan gives you a flat pool of tokens at a discount. For Hermes Agent, the base M2.7 plan covers most use cases. Subscribe through [go.bitdoze.com/minimax](https://go.bitdoze.com/minimax) for 10% off.
&lt;/Notice&gt;

### Setting M2.7 in Hermes

```bash
hermes config set model minimax/minimax-m2.7
```

Or set it through the model picker:

```bash
hermes model
```

Select MiniMax and authenticate with your API key.

## 2. DeepSeek V4 Pro — Long context, low price

DeepSeek V4 Pro gives you a 1 million token context window for $0.435 per million input tokens. Both the longest context and the second cheapest price on this list. If your Hermes conversations get long or you feed it large codebases, this is the model that handles it without losing track.

It runs 1.6 trillion total parameters with 49 billion activated per token and supports both thinking and non-thinking modes.

| Spec | Value |
|------|-------|
| **Architecture** | MoE (1.6T total, 49B active) |
| **Context Window** | 1M tokens |
| **AA Intelligence Index** | 51.5 (better than 96% of models) |
| **AA Agentic Index** | 67.2 (better than 98% of models) |
| **Input Cost** | $0.435/M tokens |
| **Output Cost** | $0.87/M tokens |
| **Cache Read** | $0.003625/M tokens |

### Where DeepSeek V4 Pro stands out

The hallucination rate on this model is 6.0% on the AA-Omniscience benchmark, the lowest on this list by far. When Hermes runs commands on a live server, that difference matters. It also scores 96.2% on tau2-Bench Telecom for conversational agent reliability.

Output cost is $0.87/M tokens, also the cheapest on this list. If your Hermes usage involves a lot of output — research summaries, code generation, document writing — DeepSeek V4 Pro keeps the bill down.

&lt;Button text=&quot;DeepSeek V4 Pro Announcement&quot; link=&quot;https://api-docs.deepseek.com/news/news260424&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### Setting DeepSeek V4 Pro in Hermes

```bash
hermes config set model deepseek/deepseek-v4-pro
```

Add your DeepSeek API key to `~/.hermes/.env`:

```bash
echo &quot;DEEPSEEK_API_KEY=your-key-here&quot; &gt;&gt; ~/.hermes/.env
```

## 3. Kimi K2.6 — Agent swarm built in

Kimi K2.6 from Moonshot AI does something the other models on this list don&apos;t: an agent swarm that spins up hundreds of parallel sub-agents to break down and tackle complex tasks on its own. You don&apos;t have to decompose the work yourself — K2.6 figures it out.

| Spec | Value |
|------|-------|
| **Architecture** | MoE (1T total, 32B active) |
| **Context Window** | 262K tokens |
| **AA Intelligence Index** | 53.9 (better than 98% of models) |
| **AA Coding Index** | 47.1 (better than 95% of models) |
| **AA Agentic Index** | 66.0 (better than 96% of models) |
| **Input Cost** | $0.75/M tokens |
| **Output Cost** | $3.50/M tokens |

### Why K2.6 works for Hermes

K2.6 scores 91.1% on GPQA Diamond for graduate-level scientific reasoning — the highest on this list. It also handles Python, Rust, and Go coding across long-horizon tasks. The Agent Swarm feature means that when Hermes hits a complex task, K2.6 can internally decompose it and work on pieces in parallel.

Moonshot AI offers [Kimi Code](https://www.kimi.com/code) as a subscription service. Plans start at $15/month for the Moderato tier. If you use Hermes primarily for coding tasks, the Kimi Code subscription gives you a managed experience with K2.6 baked in.

&lt;Button text=&quot;Kimi K2.6 Model Page&quot; link=&quot;https://www.kimi.com/ai-models/kimi-k2-6&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### Setting K2.6 in Hermes

```bash
hermes config set model moonshotai/kimi-k2.6
```

Add your Moonshot API key:

```bash
echo &quot;MOONSHOT_API_KEY=your-key-here&quot; &gt;&gt; ~/.hermes/.env
```

## 4. Xiaomi MiMo V2.5 Pro — The agent powerhouse

MiMo V2.5 Pro is Xiaomi&apos;s flagship model and one of the two strongest options on this list. It was built from the ground up for agent scenarios — complex software engineering, long-horizon tasks, and workflows that involve hundreds of tool calls in a single session.

During internal testing, MiMo V2.5 Pro completed a full SysY compiler in Rust in 4.3 hours with 672 tool calls, scoring a perfect 233/233 on the hidden test set. A task that takes undergraduate students at Peking University several weeks. It also built a working video editor web application — 8,192 lines of code across 1,868 tool invocations — in 11.5 hours of autonomous work.

&lt;Button text=&quot;MiMo V2.5 Pro Docs&quot; link=&quot;https://platform.xiaomimimo.com/docs/en-US/news/v2.5-news&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

| Spec | Value |
|------|-------|
| **Context Window** | 1M tokens |
| **AA Intelligence Index** | 53.8 (better than 98% of models) |
| **AA Coding Index** | 45.5 (better than 94% of models) |
| **AA Agentic Index** | 67.4 (better than 98% of models) |
| **Input Cost (up to 256K)** | $1.00/M tokens |
| **Output Cost (up to 256K)** | $3.00/M tokens |
| **Input Cost (over 256K)** | $2.00/M tokens |
| **Output Cost (over 256K)** | $6.00/M tokens |
| **Cache Read** | $0.20/M tokens |

### Token efficiency advantage

MiMo V2.5 Pro is optimized for token efficiency. On the ClawEval agent benchmark, it achieves the same score as Kimi K2.6 while using 42% fewer tokens. That means the higher per-token price gets offset by needing fewer tokens to complete the same task.

The [MiMo Token Plan](https://platform.xiaomimimo.com/token-plan) starts at $72/year for the Lite tier (720 million credits). The Pro tier at $600/year gives 8.4 billion credits. Off-peak hours (16:00-24:00 UTC) get a 20% discount on top of the plan rate.

&lt;Button text=&quot;MiMo Token Plan ($2 Bonus)&quot; link=&quot;https://go.bitdoze.com/mimo&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;MiMo bonus&quot;&gt;
Sign up through [go.bitdoze.com/mimo](https://go.bitdoze.com/mimo) and get a $2 bonus credit on the MiMo Token Plan.
&lt;/Notice&gt;

### Setting MiMo V2.5 Pro in Hermes

```bash
hermes config set model xiaomi/mimo-v2.5-pro
```

Add your MiMo API key:

```bash
echo &quot;MIMO_API_KEY=your-key-here&quot; &gt;&gt; ~/.hermes/.env
```

MiMo V2.5 Pro is also available on OpenRouter, so if you already have Hermes configured with an OpenRouter key, you can select it from the model list without adding a new provider.

## 5. MiniMax M3 — Frontier coding with 1M context

MiniMax M3 is the latest flagship from MiniMax, released June 1, 2026. It is the first open-weight model to combine frontier coding, a 1-million-token context window, and native multimodality (image and video input). Built on MiniMax Sparse Attention (MSA), it cuts per-token compute at 1M context to one-twentieth of the prior M2.7 generation while running 9x faster prefill and 15x faster decoding. At the same $0.30/M input price as M2.7, M3 delivers significantly more capability.

&lt;Button text=&quot;MiniMax M3 (10% Off)&quot; link=&quot;https://go.bitdoze.com/minimax&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

| Spec | Value |
|------|-------|
| **Architecture** | MiniMax Sparse Attention (MSA) |
| **Context Window** | 1M tokens |
| **Max Output** | 512K tokens |
| **SWE-Bench Pro** | 59.0% |
| **Terminal-Bench 2.1** | 66.0% |
| **BrowseComp** | 83.5 |
| **Multimodal** | Native (image + video input) |
| **Input Cost** | $0.30/M tokens |
| **Output Cost** | $1.20/M tokens |
| **Cache Read** | $0.06/M tokens |

### What M3 brings over M2.7

M3 keeps the same aggressive pricing as M2.7 but adds three things M2.7 never had:

- **1M context that actually works**: MSA makes long-context affordable at 1/20 the compute cost of full attention. For Hermes conversations that span hours or involve large codebases, this matters.
- **59.0% SWE-Bench Pro**: Beats GPT-5.5 and Gemini 3.1 Pro, approaches Claude Opus. M2.7 scored 56.2%.
- **Native multimodality**: Built-in image and video understanding, so Hermes can read screenshots, mockups, and documents without a separate vision model.
- **83.5 BrowseComp**: Surpasses Opus 4.7&apos;s 79.3 on web search and browsing tasks.
- **66.0% Terminal-Bench 2.1**: Strong command-line agent performance for server tasks.

### Long-horizon demonstrations

MiniMax backed M3&apos;s launch with three autonomous task demonstrations:

- **Paper reproduction**: Autonomously reproduced an ICLR 2025 paper in 12 hours (18 commits, 23 figures)
- **CUDA kernel optimization**: Pushed FP8 hardware utilization from 7.6% to 71.3% over a 24-hour run
- **Autonomous model training**: Scored 0.37 on PostTrainBench, training another model end-to-end

### Token Plan pricing

MiniMax offers monthly token plans for M3: $20/month (Plus, ~1.7B tokens), $50/month (Max, ~5.1B tokens), and $120/month (Ultra, ~9.8B tokens). Sign up through [go.bitdoze.com/minimax](https://go.bitdoze.com/minimax) for 10% off.

### Setting M3 in Hermes

```bash
hermes config set model minimax/minimax-m3
```

M3 is available on OpenRouter as `minimax/minimax-m3`, so if you already have Hermes configured with an OpenRouter key, you can select it from the model list.

## 6. GLM 5.2 — The strongest overall

GLM 5.2 from Z.AI is the newest and strongest model on this list, released June 16, 2026. On SWE-Bench Pro, it scores 62.1% — ahead of GPT-5.5 and Gemini 3.1 Pro. On Terminal-Bench 2.1, it hits 81.0%, within 4 points of Claude Opus 4.8. It introduces a 1M token context window (up from 200K) and effort level control for balancing capability against cost.

&lt;Button text=&quot;GLM 5.2 Announcement&quot; link=&quot;https://z.ai/blog/glm-5.2&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

| Spec | Value |
|------|-------|
| **Parameters** | 753B |
| **Context Window** | 1M tokens |
| **Max Output** | 128K tokens |
| **SWE-Bench Pro** | 62.1% |
| **Terminal-Bench 2.1** | 81.0% |
| **Input Cost** | $1.40/M tokens |
| **Output Cost** | $4.40/M tokens |

### What makes GLM 5.2 different

GLM 5.2 is the strongest open source model available. It scores 62.1% on SWE-Bench Pro and 81.0% on Terminal-Bench 2.1, making it the closest open source model to Claude Opus 4.8. The new 1M context window uses IndexShare to reduce compute cost, and effort level control lets you choose between High (faster, cheaper) and Max (best results) modes.

For Hermes Agent, that means GLM 5.2 handles long-running scheduled tasks — morning briefings, server monitoring, complex research jobs — without losing the thread mid-execution. The 1M context also means it can process entire codebases in a single session.

Z.AI offers [GLM Coding Plans](https://z.ai/subscribe) starting at $18/month for the Lite tier. GLM-5.2 consumes quota at 3× during peak hours and 2× during off-peak hours. Through September 2026, off-peak usage is billed at 1×.

&lt;Button text=&quot;GLM Coding Plans (10% Off)&quot; link=&quot;https://go.bitdoze.com/glm&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;GLM discount&quot;&gt;
Sign up through [go.bitdoze.com/glm](https://go.bitdoze.com/glm) and get 10% off GLM Coding Plans.
&lt;/Notice&gt;

### Setting GLM 5.2 in Hermes

```bash
hermes config set model z-ai/glm-5.2
```

Add your Z.AI API key:

```bash
echo &quot;ZAI_API_KEY=your-key-here&quot; &gt;&gt; ~/.hermes/.env
```

GLM 5.2 is available on OpenRouter as well.

## OpenCode Go — All five models, one subscription

If you do not want to manage separate API keys and billing for each provider, the [OpenCode Go $10/month plan](/opencode-go-plan/) bundles these models (plus Grok 4.5, Kimi K3, and others) into a single subscription. For a detailed look at limits and real-world usage, see the full [OpenCode Go review](/opencode-go-plan/).

&lt;Button text=&quot;OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### What OpenCode Go includes

- **$5 for your first month**, then $10/month
- Access to MiniMax M3, MiniMax M2.7, MiMo V2.5 Pro, GLM 5.2, Kimi K2.6, DeepSeek V4 Pro, and more
- Models hosted in the US, EU, and Singapore for stable global access
- Zero-retention policy — providers do not use your data for training

### Usage limits

OpenCode Go caps usage at $12 per 5 hours, $30 per week, and $60 per month. Cheaper models like MiniMax M2.7 let you make more requests within those limits. The estimated request counts:

| Model | Requests per 5 hours | Requests per week | Requests per month |
|-------|---------------------|-------------------|--------------------|
| MiniMax M3 | 3,400 | 8,500 | 17,000 |
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
| DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 |
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
| MiMo V2.5 Pro | 1,290 | 3,225 | 6,450 |
| GLM 5.2 | 620 | 1,550 | 3,100 |

At $10/month, OpenCode Go costs less than most individual provider plans and gives you the flexibility to switch between models depending on the task. For Hermes Agent, you can set the OpenCode Go endpoint as a custom provider and pick whichever model fits the job.

### Setting up OpenCode Go in Hermes

Add the OpenCode Go endpoint to `~/.hermes/.env`:

```bash
echo &quot;OPENAI_BASE_URL=https://opencode.ai/zen/go/v1/chat/completions&quot; &gt;&gt; ~/.hermes/.env
echo &quot;OPENAI_API_KEY=your-opencode-go-key&quot; &gt;&gt; ~/.hermes/.env
```

Then set the model:

```bash
hermes config set model opencode-go/minimax-m3
```

Switch models anytime:

```bash
hermes model
```

## Head-to-head comparison

| Feature | MiniMax M2.7 | DeepSeek V4 Pro | Kimi K2.6 | MiMo V2.5 Pro | MiniMax M3 | GLM 5.2 |
|---------|-------------|-----------------|-----------|---------------|------------|----------|
| **Input $/M** | $0.25 | $0.435 | $0.67 | $0.43 | $0.30 | $1.30 |
| **Output $/M** | $1.00 | $0.87 | $3.39 | $0.87 | $1.20 | $4.05 |
| **Context** | 204K | 1M | 1T MoE | 1M | 1M | 1M |
| **SWE-Bench Pro** | 56.2% | — | — | — | 59.0% | 62.1% |
| **Terminal-Bench 2.1** | — | — | — | — | 66.0% | 81.0% |
| **Multimodal** | No | No | No | No | Yes (img+video) | No |
| **License** | Open weights | MIT | Open weights | Open source | Open weights | Open source |
| **Monthly est.** | $7-15 | $10-20 | $15-30 | $15-35 | $7-15 | $20-50 |

### Which one should you pick?

**On a tight budget:** MiniMax M2.7 or MiniMax M3. Both cost $0.30/M input. M3 adds 1M context, native multimodality, and higher SWE-Bench Pro (59.0% vs 56.2%). M2.7 is the proven workhorse, M3 is the upgrade.

**Need long context:** MiniMax M3 or DeepSeek V4 Pro. M3 gives you 1M context at $0.30/M with frontier coding benchmarks. DeepSeek V4 Pro at $0.435/M has the lowest hallucination rate on the list (6.0%).

**Want the strongest agent:** MiMo V2.5 Pro or GLM 5.2. Both are top performers on agent benchmarks. MiMo V2.5 Pro is slightly better at sustained long-horizon tasks with its token efficiency. GLM 5.2 has the edge on pure coding with its 62.1% SWE-Bench Pro score and 81.0% Terminal-Bench 2.1.

**Do not want to choose:** OpenCode Go at $10/month gives you all the models. Switch between them based on the task.

&lt;Notice type=&quot;warning&quot; title=&quot;Subscription risk reminder&quot;&gt;
Using your Claude Code, Gemini CLI, or Codex subscription OAuth tokens with Hermes Agent can get your account banned. These providers monitor for automated usage patterns. Use API keys from the providers listed above instead. See our [OpenClaw models guide](/best-opensource-models-for-openclaw/) for the full breakdown on why API access is the safe route.
&lt;/Notice&gt;

## What I actually run

My Hermes setup uses MiniMax M3 as the default model for everyday chat, quick tasks, and long-context work. For complex coding jobs and research tasks, I switch to GLM 5.2 or MiMo V2.5 Pro. DeepSeek V4 Pro handles anything that needs the lowest hallucination rate on live servers.

The fallback configuration looks like this:

```bash
hermes config set model minimax/minimax-m3
```

When I need more power for a specific task:

```bash
hermes model
# Select GLM 5.2 or MiMo V2.5 Pro
```

For most Hermes users, starting with MiniMax M3 and switching up when needed keeps costs low without sacrificing capability. M3&apos;s 1M context and multimodal support make it a significant upgrade over M2.7 at the same price.

## FAQ

&lt;Accordion label=&quot;Which model is cheapest for Hermes Agent?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
MiniMax M2.7 and MiniMax M3 both cost $0.30/M input and $1.20/M output. Running Hermes 24/7 with moderate usage costs $7-15/month. M3 adds 1M context and native multimodality at the same price. DeepSeek V4 Pro is second cheapest at $0.435/M input and $0.87/M output.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which model is strongest for coding?&quot; group=&quot;faq&quot;&gt;
GLM 5.2 scores 62.1% on SWE-Bench Pro, the highest among open source models. MiniMax M3 scores 59.0%. MiMo V2.5 Pro is strong on agentic tasks with AA Agentic Index 67.4.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use OpenCode Go with Hermes Agent?&quot; group=&quot;faq&quot;&gt;
Yes. OpenCode Go provides an OpenAI-compatible API endpoint. Set the base URL to `https://opencode.ai/zen/go/v1/chat/completions` in your Hermes config and use your OpenCode Go API key. At $10/month, it bundles these models plus Grok 4.5, Kimi K3, and more. For a detailed look at limits and benchmarks, see the [OpenCode Go review](/opencode-go-plan/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do these models work through OpenRouter?&quot; group=&quot;faq&quot;&gt;
Yes. MiniMax M3, MiniMax M2.7, MiMo V2.5 Pro, GLM 5.2, Kimi K2.6, and DeepSeek V4 Pro are all available on OpenRouter. If you already have Hermes configured with an OpenRouter key, you can switch between them without adding new providers.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is it safe to use my Claude subscription with Hermes?&quot; group=&quot;faq&quot;&gt;
No. Anthropic monitors for automated usage through OAuth tokens and has suspended accounts for it. Use API keys from the providers listed above. The [OpenClaw models guide](/best-opensource-models-for-openclaw/) explains the risks in detail.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which model has the lowest hallucination rate?&quot; group=&quot;faq&quot;&gt;
DeepSeek V4 Pro at 6.0% on the AA-Omniscience benchmark. GLM 5.2 reports near-zero hallucinations. For running commands on a live server through Hermes, lower hallucination means fewer mistakes.
&lt;/Accordion&gt;

For the full Hermes setup chain: start with the [installer](/hermes-agent-setup-guide/), set up a [dashboard](/best-hermes-dashboards/) for browser access, configure the [built-in web UI](/hermes-dashboard-guide/) if you want SSH-tunneled access, and set up [Kanban task boards](/hermes-kanban-setup-guide/) for structured multi-agent workflows. If you want to try free models first, the [Nous Portal guide](/hermes-agent-mimo-v2-pro/) covers the free promotions that rotate through Hermes partnerships. If you want a terminal coding agent to pair with Hermes, the [OpenCode setup guide](/opencode-setup-guide/) covers the open-source Claude Code alternative. And with [GitHub Copilot moving to usage-based billing](/github-copilot-alternatives-2026/), the alternatives listed there apply to any AI coding workflow. For Qwen 3.6 as a model option, the [Qwen 3.6 guide](/qwen36-ai-coding-agents/) covers setup and benchmarks. If you prefer a minimal coding agent with a TypeScript extension system, our [Pi coding agent setup guide](/pi-coding-agent-setup-guide/) covers installation, model configuration, and the best extensions including LazyPi. For Claude-class open models side by side (including DeepSeek-V4), see [best open-source LLMs](/best-open-source-llms-claude-alternative/). For the full tooling map, see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>hermes</category><category>llm</category></item><item><title>Best Thunderbolt 5 Docks in 2026: Tested &amp; Compared</title><link>https://www.bitdoze.com/best-thunderbolt-5-docks-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/best-thunderbolt-5-docks-guide/</guid><description>Hands-on comparison of 10 Thunderbolt 5 docks — CalDigit TS5 Plus, OWC, iVanky FusionDock Ultra, Kensington, and more. Real pricing, port counts, and which to buy.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>Thunderbolt 5 is available now, with speeds up to 120Gbps and support for multiple 8K displays. As more laptops get this technology (especially Apple M4/M5 Pro and Max), choosing the right Thunderbolt 5 dock matters for your productivity.

This guide covers Thunderbolt 5 docking stations, from budget options starting at $195 on Amazon to premium dual-chip models at $750. Whether you are a content creator, developer, or business professional, there is a Thunderbolt 5 dock that works for you.

&lt;Notice type=&quot;warning&quot; title=&quot;Affiliate Disclosure&quot;&gt;
Some links in this guide are affiliate links. If you buy through them, we may earn a small commission at no extra cost to you. This helps us keep testing and updating these recommendations.
&lt;/Notice&gt;

## Quick Comparison Overview

| **Category** | **Best Budget** | **Best Premium** | **Best Productivity** |
|--------------|----------------|------------------|---------------------|
| **Dock** | Kensington SD5000T5 | iVANKY FusionDock Ultra | CalDigit TS5 Plus |
| **Price Range** | ~$195-300 | ~$650-750 | ~$499 |
| **Best For** | Essential TB5 upgrade | Dual-chip, 26 ports, 10GbE | 20 ports, 10GbE, cross-platform |

## Quick Pick: Choose in 30 Seconds

- Most ports + dual-chip + 10GbE → [iVANKY FusionDock Ultra](#1-ivanky-fusiondock-ultra-dual-chip-powerhouse--top-pick) (26 ports, 10GbE, quad display)
- Maximum ports + professional networking → [CalDigit TS5 Plus](#2-caldigit-ts5-plus-20-port-powerhouse) (20 ports, 10GbE)
- Best overall value → [OWC 11-Port Thunderbolt 5 Dock](#2-owc-11-port-thunderbolt-5-docking-station)
- Content creators + dual card readers → [ASUS Master Thunderbolt 5 Dock DC510](#5-asus-master-thunderbolt-5-dock-dc510) (SD + microSD, 2.5GbE, M.2 slot)
- MacBook Pro/Max + highest charging → [iVANKY FusionDock Pro 3](#6-ivanky-fusiondock-pro-3-thunderbolt-5) (180W PD)
- Gaming, RGB, and internal SSD → [Razer Thunderbolt 5 Dock Chroma](#4-razer-thunderbolt-5-dock-chroma) (M.2 slot)
- Beautiful design + active cooling → [Anker Prime TB5](#3-anker-prime-tb5-docking-station-14-in-1)
- Business/enterprise + remote management → [Dell Pro SD25TB5](#7-dell-pro-thunderbolt-5-smart-dock-sd25tb5) (up to 300W PD on Dell)
- Lowest price with TB5 certification → [Kensington SD5000T5](#1-kensington-sd5000t5-thunderbolt-5-triple-4k-docking-station) ($195 on Amazon)
- First dual HDMI 2.1 TB5 dock → [Plugable TBT-UDH2](#plugable-tbt-udh2-dual-hdmi-thunderbolt-5-dock) (CES 2026)

Tip: Tap a link to jump straight to the model.

## Understanding Thunderbolt 5 Technology

Before looking at specific models, it helps to know what makes Thunderbolt 5 different:

### **⚡ Thunderbolt 5 Key Benefits**

- **120Gbps Bandwidth**: 3x faster than Thunderbolt 4 (with Bandwidth Boost)
- **Multiple 8K Displays**: Support for dual 8K@60Hz or triple 4K@144Hz
- **Enhanced Power Delivery**: Up to 300W for high-performance laptops
- **Backward Compatibility**: Works with Thunderbolt 4, USB4, and USB-C devices

### **🔌 Port Technology Explained**

Thunderbolt 5 docks typically feature a mix of port types optimized for different use cases:
- **Thunderbolt 5 Downstream**: Full 80Gbps (120Gbps with Bandwidth Boost)
- **USB-A 3.2 Gen 2**: 10Gbps for legacy devices
- **USB-C**: Various speeds from 5Gbps to 10Gbps
- **2.5GbE/10GbE**: High-speed networking options

## Budget-Friendly Thunderbolt 5 Docks

### 1. Kensington SD5000T5 Thunderbolt 5 Triple 4K Docking Station


![Kensington SD5000T5 Thunderbolt 5 Triple 4K Docking Station](https://m.media-amazon.com/images/I/71VH4T9De+L._AC_SX679_.jpg)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 (Intel Certified) |
| **Total Ports** | 11 ports |
| **TB5 Downstream Ports** | 3x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 3x USB-A 3.2 Gen2 (10Gbps) |
| **Video Output** | Native TB5 video support |
| **Display Support** | Triple 4K@120Hz / Dual 8K@60Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 2.5GbE |
| **Card Reader** | Dual SD/MicroSD |
| **Audio** | 3.5mm combo jack |
| **Cooling** | Passive cooling |

#### Key Highlights
- **Intel Certified** Thunderbolt 5 technology for guaranteed compatibility
- **Triple 4K display support** at 120Hz for productive multi-monitor setups
- **Plug-and-play operation** with Windows 11 and macOS
- **Compact design** that fits well on any desk
- **Includes TB5 cable** for immediate setup

&lt;Notice type=&quot;info&quot; title=&quot;Best Entry-Level TB5 Dock&quot;&gt;
The Kensington SD5000T5 has the main Thunderbolt 5 features at the lowest price. Street price has dropped to around $195 on Amazon as of mid-2026, making it the cheapest Intel-certified TB5 dock available.
&lt;/Notice&gt;

&gt; **Best For**: Users seeking reliable TB5 performance, triple display setups, professionals who need certified compatibility

&lt;Button text=&quot;Check Kensington SD5000T5 Price&quot; link=&quot;https://amzn.to/46NpbgM&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 2. OWC 11-Port Thunderbolt 5 Docking Station


![OWC 11-Port Thunderbolt 5 Docking Station](https://m.media-amazon.com/images/I/51GIdU5xH0L._AC_SX679_.jpg)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 |
| **Total Ports** | 11 ports |
| **TB5 Downstream Ports** | 3x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 2x USB-A 10Gbps, 1x USB-A 5Gbps |
| **Video Output** | Native TB5 + DisplayPort 2.1 |
| **Display Support** | Triple 8K@60Hz / Quad 4K@60Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 2.5GbE |
| **Card Reader** | SD/MicroSD 4.0 UHS-II |
| **Audio** | 3.5mm in/out jack |
| **Cooling** | Active cooling system |

#### Key Highlights
- **Premium build quality** with sleek aluminum design
- **Advanced display support** for up to three 8K displays
- **UHS-II card readers** for professional photography workflows
- **Compact footprint** despite powerful capabilities
- **Active cooling** ensures consistent performance under load

&gt; **Best For**: Creative professionals, content creators needing 8K support, users wanting premium build quality at mid-range pricing

&lt;Button text=&quot;Check OWC 11-Port TB5 Price&quot; link=&quot;https://amzn.to/4mB1Zs1&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

## Premium Thunderbolt 5 Docks

### 1. iVANKY FusionDock Ultra (Dual-Chip Powerhouse) ⭐ Top Pick

![iVANKY FusionDock Ultra](https://cdn.shopify.com/s/files/1/0576/6833/7827/files/2x-ecomstack_e08f58c0-cdf3-4150-91d6-364eb9f13b67_1024x1024.webp?v=1770113902)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 (Dual-Chip Architecture) |
| **Total Ports** | 26 ports |
| **TB5 Downstream Ports** | 4x USB-C 80/120Gbps downstream + 2x USB-C host |
| **USB Ports** | 4x USB-A 10Gbps, 7x USB-C 10Gbps (front) |
| **Video Output** | TB5 + DisplayPort 2.1 + HDMI 2.0 |
| **Display Support** | Quad 6K (Mac dependent) / Dual 8K@60Hz |
| **Power Delivery** | 140W PD + 45W front PD |
| **Ethernet** | 10GbE (10 Gigabit) |
| **Card Reader** | SD 4.0 UHS-II + microSD 4.0 UHS-II |
| **Audio** | 3.5mm combo + Optical Toslink in/out |
| **Power Supply** | 240W external adapter |
| **Cooling** | Intelligent dual-fan push-pull |

#### Key Highlights
- **Dual-chip architecture** splits workload between two Thunderbolt controllers — no bandwidth throttling under heavy load
- **26 pro-grade ports** — the most of any Thunderbolt 5 dock available
- **10 Gigabit Ethernet** built in for NAS and high-bandwidth networking
- **Quad display support** for Apple Silicon Max-chip Macs (Mac dependent)
- **Magnetic dual USB-C cable** adapts between MacBook Pro and Mac Mini/Studio
- **Whisper-quiet dual-fan cooling** — quieter than competing docks

#### Why I Chose This Dock
After two weeks of daily use, the FusionDock Ultra proved itself as the most capable Thunderbolt 5 dock I have tested. The dual-chip design means no bandwidth drops when running multiple monitors plus an SSD plus Ethernet simultaneously. The 10GbE port is a real differentiator — most docks max out at 2.5GbE. It is Apple Silicon only, but for Mac users running complex setups, nothing else comes close.

&lt;Notice type=&quot;success&quot; title=&quot;Dual-Chip Advantage&quot;&gt;
The FusionDock Ultra&apos;s dual-chip architecture eliminates the bandwidth bottlenecks that plague single-chip docks. Each chip handles its own set of ports and displays independently, so pushing four monitors, an external SSD, and 10GbE at the same time does not cause any slowdown.
&lt;/Notice&gt;

&gt; **Best For**: Apple Silicon power users, developers with multi-monitor setups, content creators needing 10GbE and quad displays, anyone frustrated by single-chip bandwidth limits

&lt;Button text=&quot;Check iVANKY FusionDock Ultra Price&quot; link=&quot;https://ivanky.com/products/fusiondock-ultra&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

**Full Review**: [iVANKY FusionDock Ultra Review](https://www.bitdoze.com/ivanky-fusiondock-ultra-review/)

---

### 2. CalDigit TS5-Plus (20-Port Powerhouse)

![CalDigit TS5-Plus](https://www.caldigit.com/wp-content/uploads/2025/03/TS5P-Diagram.png)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 with Bandwidth Boost |
| **Total Ports** | 20 ports |
| **TB5 Downstream Ports** | 3x Thunderbolt 5 (80Gbps/120Gbps) |
| **USB Ports** | 10x USB 10Gbps (dual controllers) |
| **USB-C Front Port** | 1x USB-C 36W PD |
| **Video Output** | TB5 + DisplayPort 2.1 |
| **Display Support** | Dual 8K@60Hz / Triple 4K@144Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 10GbE (10 Gigabit) |
| **Card Reader** | SD 4.0 UHS-II |
| **Audio** | 3.5mm in/out |
| **Power Supply** | 330W external PSU |

#### Key Highlights
- **20 total ports** - the most comprehensive dock available
- **Dual USB controllers** for maximum bandwidth distribution
- **10 Gigabit Ethernet** for enterprise networking
- **36W front USB-C port** for power-hungry devices
- **330W power supply** handles the most demanding setups

#### Why I Chose This Dock
The CalDigit TS5-Plus has the most ports among Thunderbolt 5 docks. With 20 ports and dual USB controllers, it handles connectivity needs well. The 10GbE port and 330W PSU work for professional workflows.

&lt;Notice type=&quot;success&quot; title=&quot;Professional&apos;s Choice&quot;&gt;
The TS5-Plus has more ports than other docks and performs well, making it a good choice for power users, content creators, and professionals who need many connectivity options.
&lt;/Notice&gt;

&gt; **Best For**: Professional content creators, developers with complex setups, users needing 10GbE networking, and many port options

&lt;Button text=&quot;Check CalDigit TS5-Plus Price&quot; link=&quot;https://www.caldigit.com/thunderbolt-5-dock-ts5-plus/&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 3. Anker Prime TB5 Docking Station (14-in-1)

![Anker Prime TB5 Docking Station](https://m.media-amazon.com/images/I/51mYxUeHDSL._AC_SX300_SY300_QL70_FMwebp_.jpg)



#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 |
| **Total Ports** | 14 ports |
| **TB5 Downstream Ports** | 2x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 6x USB-A 10Gbps, 2x USB-C 10Gbps |
| **Video Output** | TB5 + HDMI 2.1 + DisplayPort 2.1 |
| **Display Support** | Dual 8K@60Hz / Single 8K@60Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 2.5GbE |
| **Card Reader** | SD UHS-II |
| **Audio** | 3.5mm jack |
| **Special Features** | Built-in cooling fan, ambient lighting |

#### Key Highlights
- **Compact cube design** with premium build quality
- **Built-in active cooling** prevents thermal throttling
- **Ambient LED lighting** adds desk aesthetics
- **Multiple video outputs** with HDMI 2.1 and DP 2.1
- **140W fast charging** for high-power laptops

#### Unique Features
- **AnkerDock Manager software** for firmware updates and management
- **Mac Mini-inspired design** complements modern setups perfectly
- **Comprehensive video support** for Windows and Mac systems

&gt; **Best For**: Users wanting premium design, built-in cooling, those who value aesthetics alongside performance

&lt;Button text=&quot;Check Anker Prime TB5 Price&quot; link=&quot;https://amzn.to/42cJK4L&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 4. Razer Thunderbolt 5 Dock Chroma

![Razer Thunderbolt 5 Dock Chroma](https://m.media-amazon.com/images/I/819KwQQfFdL._AC_SX679_.jpg)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 |
| **Total Ports** | 11 ports |
| **TB5 Downstream Ports** | 4x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 2x USB-A 10Gbps, 1x USB-C 10Gbps |
| **Video Output** | Native TB5 support |
| **Display Support** | Triple 4K@144Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 1GbE |
| **Card Reader** | UHS-II SD slot |
| **Audio** | 3.5mm jack with 7.1 surround |
| **Special Features** | M.2 SSD expansion slot, Razer Chroma RGB |

#### Key Highlights
- **M.2 SSD expansion** for up to 8TB internal storage
- **Razer Chroma RGB lighting** with customizable effects
- **Active cooling system** with TB Share technology
- **Four TB5 downstream ports** for connecting multiple devices
- **Premium gaming-focused design** with matte black finish

#### Gaming-Focused Features
- **TB Share technology** for seamless device switching
- **Low-latency connectivity** optimized for gaming peripherals
- **Chroma RGB integration** with Razer ecosystem

&gt; **Best For**: Gamers and enthusiasts, users needing internal storage expansion, RGB lighting enthusiasts

&lt;Button text=&quot;Check Razer TB5 Dock Price&quot; link=&quot;https://amzn.to/3IjvS20&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 5. ASUS Master Thunderbolt 5 Dock DC510

![ASUS Master Thunderbolt 5 Dock DC510](https://m.media-amazon.com/images/I/41NccYPuXSL._AC_SL1500_.jpgassus)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 |
| **Total Ports** | 13 ports |
| **TB5 Downstream Ports** | 3x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 3x USB-A 10Gbps, 1x USB-A 5Gbps |
| **Video Output** | Native TB5 support |
| **Display Support** | Triple 4K@144Hz / Dual 8K@60Hz |
| **Power Delivery** | 140W PD |
| **Ethernet** | 2.5GbE |
| **Card Reader** | SD 4.0 (UHS-II) + microSD (UHS-II) |
| **Audio** | 3.5mm combo jack |
| **Special Features** | M.2 NVMe PCIe 4.0 slot, RGB ambient lighting, SSD cooling pad |

#### Key Highlights
- **M.2 NVMe PCIe 4.0 expansion** with toolless magnetic cover design
- **2.5 Gigabit Ethernet** for fast network transfers
- **Dual card readers** (SD + microSD UHS-II) for working with different card types
- **SSD cooling pad included** for optimal thermal performance
- **RGB ambient lighting** with professional aesthetic
- **180W power adapter** included with Thunderbolt 5 cable

#### Real-World Performance
Tested with MacBook M1 Pro and Mac Mini M4 Pro, achieving:
- Dual 4K displays at 144-165Hz
- SSD read speeds: 5,800 MB/s (with WD_BLACK SN850X)
- SSD write speeds: 4,300 MB/s
- Reliable performance with multiple peripherals

&lt;Notice type=&quot;info&quot; title=&quot;Content Creator&apos;s Choice&quot;&gt;
The ASUS DC510 excels for photographers and videographers with dual card readers, 2.5GbE networking, and impressive SSD performance. Active cooling can be noticeable under load.
&lt;/Notice&gt;

&gt; **Best For**: Content creators, photographers, professionals needing fast networking and dual card readers, users requiring SSD expansion

&lt;Button text=&quot;Check ASUS DC510 Price&quot; link=&quot;https://go.bitdoze.com/asus-dc510&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

**Full Review**: [ASUS Master Thunderbolt 5 Dock DC510 Review](https://www.bitdoze.com/asus-thunderbolt-5-dock-dc510-review/)

---

### 6. iVANKY FusionDock Pro 3 Thunderbolt 5

![iVANKY FusionDock Pro 3 Thunderbolt 5](https://m.media-amazon.com/images/I/61KVPP6lM-L._AC_SX679_.jpg)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 (Intel DBF7052 Certified) |
| **Total Ports** | 11 ports |
| **TB5 Downstream Ports** | 3x Thunderbolt 5 (80Gbps/120Gbps) |
| **USB Ports** | 4x USB-A 10Gbps, 2x USB-C 10Gbps |
| **Video Output** | TB5 + HDMI + DisplayPort |
| **Display Support** | Single 8K / Dual 6K@60Hz (Mac) / Dual 8K@60Hz (Windows) |
| **Power Delivery** | 180W PD |
| **Ethernet** | 2.5GbE |
| **Card Reader** | SD/TF 4.0 |
| **Audio** | 3.5mm jack |
| **Cooling** | Advanced thermal design |

#### Key Highlights
- **180W Power Delivery** - highest in class for demanding laptops
- **Intel certified** DBF7052 chipset for guaranteed compatibility
- **Bandwidth Boost support** for 120Gbps performance
- **Mac-optimized** with excellent MacBook Pro/Max support
- **Premium aluminum construction** with compact design

&gt; **Best For**: MacBook Pro/Max users, professionals needing high power delivery, users who want certified TB5 performance

&lt;Button text=&quot;Check iVANKY FusionDock Pro 3 Price&quot; link=&quot;https://amzn.to/48G4WUB&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 7. Dell Pro Thunderbolt 5 Smart Dock (SD25TB5)

![ Dell Pro Thunderbolt 5 Smart Dock (SD25TB5)](https://m.media-amazon.com/images/I/41XfEtKVPJL._AC_SX679_.jpg)

#### Specifications

| Feature | Details |
|---------|---------|
| **Thunderbolt Standard** | ✅ Thunderbolt 5 |
| **Total Ports** | 12+ ports |
| **TB5 Downstream Ports** | 2x Thunderbolt 5 (80Gbps) |
| **USB Ports** | 6x USB (mixed A/C) |
| **Video Output** | HDMI 2.1 + 2x DisplayPort 2.1 |
| **Display Support** | Up to 4x 4K@120Hz |
| **Power Delivery** | Up to 300W PD (Dell systems) |
| **Ethernet** | 2.5GbE with MAC pass-through |
| **Card Reader** | Not specified |
| **Audio** | 3.5mm jack |
| **Special Features** | Smart dock management, enterprise features |

#### Key Highlights
- **300W Power Delivery** for high-performance Dell workstations
- **Enterprise management** with remote monitoring capabilities
- **Four display support** at 4K@120Hz resolution
- **Dell ecosystem integration** with optimized compatibility
- **Professional build quality** designed for business environments

#### Enterprise Features
- **Remote management** capabilities for IT departments
- **MAC address pass-through** for network authentication
- **Enterprise-grade reliability** with extended warranty options

&gt; **Best For**: Dell laptop users, enterprise deployments, professionals needing high power delivery and management features

&lt;Button text=&quot;Check Dell Pro TB5 Dock Price&quot; link=&quot;https://amzn.to/3VC3Q4L&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### Plugable TBT-UDH2 Dual HDMI Thunderbolt 5 Dock (CES 2026)

Announced at CES 2026, the Plugable TBT-UDH2 is the first Thunderbolt 5 dock with dual HDMI 2.1 outputs. It delivers 140W power to the host plus two 30W USB-C downstream ports, 2.5GbE, and TAA compliance for government use. It works with both Mac and Windows.

&gt; **Best For**: Users who need dual HDMI 2.1 outputs without DisplayPort adapters, government/defense procurement requiring TAA compliance

&lt;Button text=&quot;Check Plugable TBT-UDH2 Details&quot; link=&quot;https://plugable.com/blogs/news/at-ces-plugable-introduces-its-flagship-thunderbolt-5-dock-of-2026&quot; size=&quot;md&quot; color=&quot;blue&quot; variant=&quot;outline&quot; /&gt;

---

## Complete Dock Specifications Comparison

### **Budget-Friendly Options ($400-$550)**

| **Specification** | [**Kensington SD5000T5**](#1-kensington-sd5000t5-thunderbolt-5-triple-4k-docking-station) | [**OWC 11-Port TB5**](#2-owc-11-port-thunderbolt-5-docking-station) |
|-------------------|----------------------------|---------------------------|
| **Thunderbolt Standard** | ✅ TB5 (Intel Certified) | ✅ TB5 |
| **Total Ports** | 11 ports | 11 ports |
| **TB5 Downstream** | 3x TB5 (80Gbps) | 3x TB5 (80Gbps) |
| **USB Ports** | 3x USB-A 10Gbps | 2x USB-A 10Gbps, 1x USB-A 5Gbps |
| **Video Output** | Native TB5 | TB5 + DisplayPort 2.1 |
| **Display Support** | Triple 4K@120Hz / Dual 8K@60Hz | Triple 8K@60Hz / Quad 4K@60Hz |
| **Power Delivery** | 140W PD | 140W PD |
| **Ethernet** | 2.5GbE | 2.5GbE |
| **Card Reader** | Dual SD/MicroSD | SD/MicroSD UHS-II |
| **Audio** | 3.5mm combo jack | 3.5mm in/out |
| **Cooling** | Passive | Active cooling |
| **Build Quality** | Professional plastic/metal | Premium aluminum |
| **Price Range** | ~$400-500 | ~$450-550 |
| **Best For** | Certified compatibility | Creative professionals |

### **Premium &amp; Mid-Range Options ($350-$750)**

| **Specification**        | [**iVANKY FusionDock Ultra**](#1-ivanky-fusiondock-ultra-dual-chip-powerhouse--top-pick) ⭐ | [**CalDigit TS5-Plus**](#2-caldigit-ts5-plus-20-port-powerhouse) | [**Anker Prime TB5**](#3-anker-prime-tb5-docking-station-14-in-1) | [**Razer TB5 Chroma**](#4-razer-thunderbolt-5-dock-chroma) | [**ASUS DC510**](#5-asus-master-thunderbolt-5-dock-dc510) | [**iVANKY FusionDock Pro 3**](#6-ivanky-fusiondock-pro-3-thunderbolt-5) | [**Dell Pro SD25TB5**](#7-dell-pro-thunderbolt-5-smart-dock-sd25tb5) |
| ------------------------ | :--------------------------------------------------------------------------: | :---------------------------------------------------------------: | :--------------------------------------------------------: | :--------------------------------------------------------: | :-------------------------------------------------------: | :---------------------------------------------------------------------: | :------------------------------------------------------------------: |
| **Thunderbolt Standard** |                            ✅ TB5 Dual-Chip Architecture                     |                               ✅ TB5 + Bandwidth Boost              |                            ✅ TB5                           |                            ✅ TB5                           |                           ✅ TB5                           |                         ✅ TB5 (Intel Certified)                         |                                 ✅ TB5                                |
| **Total Ports**          |                                🏆 **26 ports**                               |                              **20 ports**                             |                          14 ports                          |                          11 ports                          |                        **13 ports**                       |                                 11 ports                                |                               12+ ports                              |
| **TB5 Downstream**       |                             4× USB-C (80/120 Gbps) + 2× Host                 |                          3× TB5 (80/120 Gbps)                         |                      2× TB5 (80 Gbps)                      |                      4× TB5 (80 Gbps)                      |                      3× TB5 (80 Gbps)                     |                           3× TB5 (80/120 Gbps)                          |                           2× TB5 (80 Gbps)                           |
| **USB Ports**            |                   4× USB-A + 7× USB-C 10 Gbps (Dual Chips)                   |                    🔥 **10× USB 10 Gbps (Dual Controllers)**                    |                 6× USB-A + 2× USB-C 10 Gbps                |                 2× USB-A + 1× USB-C 10 Gbps                |             3× USB-A 10 Gbps + 1× USB-A 5 Gbps            |                       4× USB-A + 2× USB-C 10 Gbps                       |                            6× USB (mixed)                            |
| **Front USB-C**          |                                   ✅ 45 W PD                                  |                                ✅ 36 W PD                               |                            ❌ No                            |                            ❌ No                            |                            ❌ No                           |                                   ❌ No                                  |                                 ❌ No                                 |
| **Video Output**         |                                 TB5 + DP 2.1 + HDMI 2.0                      |                      TB5 + DP 2.1                                     |                      TB5 + HDMI 2.1 + DP 2.1               |                         Native TB5                         |                         Native TB5                        |                             TB5 + HDMI + DP                             |                         HDMI 2.1 + 2× DP 2.1                         |
| **Display Support**      |                     Quad 6K (Mac dependent) / Dual 8K @ 60 Hz                 |                          Dual 8K @ 60 Hz / Triple 4K @ 144 Hz          |                          Dual 8K @ 60 Hz                          |                     Triple 4K @ 144 Hz                     |            Triple 4K @ 144 Hz / Dual 8K @ 60 Hz           |                       Single 8K / Dual 6K @ 60 Hz                       |                            4× 4K @ 120 Hz                            |
| **Power Delivery**       |                                   140 W PD + 45 W front                      |                              140 W PD                             |                          140 W PD                          |                          140 W PD                          |                          140 W PD                         |                             🔋 **180 W PD**                             |                            🚀 **300 W PD**                           |
| **Ethernet**             |                                 🌐 **10 GbE**                                |                              🌐 **10 GbE**                              |                            2.5 GbE                           |                            1 GbE                           |                          2.5 GbE                          |                                 2.5 GbE                                 |                      2.5 GbE + MAC pass-through                      |
| **Card Reader**          |                                   SD + microSD UHS-II                        |                             SD UHS-II                             |                       SD UHS-II                             |                       UHS-II SD slot                       |                    SD + microSD UHS-II                    |                                SD/TF 4.0                                |                             Not specified                            |
| **Audio**                |                                 3.5 mm + Optical Toslink                      |                               3.5 mm in/out                                |                    3.5 mm                                    |                    3.5 mm + 7.1 surround                   |                        3.5 mm combo                       |                                  3.5 mm                                 |                                3.5 mm                                |
| **Special Features**     |                  Dual-chip architecture, 10GbE, quad display, magnetic cable                  |                        Dual USB controllers, 330 W PSU                       |                   Built-in cooling, RGB lighting                  |                  M.2 SSD slot, Chroma RGB                  |             M.2 SSD slot, SSD cooling pad, RGB            |                             Mac optimization                            |                         Enterprise management                        |
| **Cooling**              |                       ✅ Dual-fan intelligent push-pull                        |                                 External PSU                                 |                      Built-in active cooling                      |                  Active cooling + TB Share                 |                  Advanced thermal design                  |                              Not specified                              |                                   —                                  |
| **Build Quality**        |                               Premium aluminum (2000-ton extrusion)                               |                               Premium aluminum                               |                        Premium cube design                        |                    Gaming-focused matte                    |                      Premium aluminum                     |                             Enterprise-grade                            |                                   —                                  |
| **Power Supply**         |                             🔌 **240 W External**                            |                             🔌 **330 W External**                            |                              Internal                             |                          Internal                          |                          Internal                         |                                 External                                |                                   —                                  |
| **Price Range**          |                                   ~$650–750                                   |                                   ~$650–700                                  |                               ~$400                               |                            ~$390                           |                           ~$350                           |                                ~$500–600                                |                                   —                                  |
| **Best For**             |                🍎 **Apple Silicon + Dual-chip + 10 GbE + Quad display**       |                         💼 **Maximum ports + 10 GbE**                        |                      🎨 **Design + cooling**                      |                     🎮 **Gaming + RGB**                    |                💻 **Mac users + 180 W PD**                |                       🏢 **Enterprise + 300 W PD**                      |                                   —                                  |


### **Quick Decision Matrix**

| **Category** | **Top Pick** | **Runner-Up** | **Budget Pick** |
|--------------|-------------|---------------|----------------|
| **Most Ports** | iVANKY FusionDock Ultra (26 ports) | CalDigit TS5-Plus (20 ports) | Anker Prime TB5 (14 ports) |
| **Best Display Support** | iVANKY FusionDock Ultra (Quad 6K) | OWC 11-Port TB5 (Triple 8K) | CalDigit TS5-Plus (Triple 4K@144Hz) |
| **Highest Power Delivery** | Dell Pro SD25TB5 (300W) | iVANKY FusionDock Pro 3 (180W) | Kensington SD5000T5 (140W) |
| **Best Networking** | iVANKY FusionDock Ultra (10GbE) | CalDigit TS5-Plus (10GbE) | ASUS DC510 (2.5GbE) |
| **Gaming Features** | Razer TB5 Chroma (RGB + M.2) | Anker Prime TB5 (Built-in cooling) | Kensington SD5000T5 |
| **Mac Optimization** | iVANKY FusionDock Ultra (Apple Silicon) | iVANKY FusionDock Pro 3 | ASUS DC510 (Tested M1/M4) |
| **Content Creation** | iVANKY FusionDock Ultra (Dual-chip + 10GbE) | ASUS DC510 (Dual card readers + 2.5GbE) | CalDigit TS5-Plus |
| **Enterprise Use** | Dell Pro SD25TB5 | CalDigit TS5-Plus | Kensington SD5000T5 |
| **Best Value** | OWC 11-Port TB5 | ASUS DC510 | Kensington SD5000T5 |

## Buy or Skip? Quick Bullets for Each Dock

- iVANKY FusionDock Ultra
  - Buy if: you use Apple Silicon Macs with multi-monitor setups and need 26 ports, 10GbE, quad display, and zero bandwidth throttling from a dual-chip architecture.
  - Skip if: you use Windows/Intel Macs, need a built-in NVMe slot, or want a power button.
- Kensington SD5000T5
  - Buy if: you want the lowest-cost Intel‑certified TB5 dock with triple 4K support and 140W PD.
  - Skip if: you need UHS‑II readers, active cooling, or more video outputs beyond native TB5.
- OWC 11‑Port Thunderbolt 5 Dock
  - Buy if: you want active cooling, UHS‑II card readers, and excellent multi‑display options up to triple 8K.
  - Skip if: you require 10GbE networking or more than 11 total ports.
- CalDigit TS5‑Plus
  - Buy if: you need many ports, dual USB controllers, and 10GbE for pro NAS workflows.
  - Skip if: you don&apos;t need 10GbE and prefer to save money or want RGB/internal SSD features.
- Anker Prime TB5
  - Buy if: you value compact, premium design with a built‑in fan and HDMI 2.1 + DP 2.1 outputs.
  - Skip if: you need 10GbE or more than 2 TB5 downstream ports.
- Razer Thunderbolt 5 Dock Chroma
  - Buy if: you want M.2 SSD expansion, Chroma RGB, and four TB5 downstream ports.
  - Skip if: you need 2.5/10GbE or extra dedicated DP/HDMI ports.
- ASUS Master Thunderbolt 5 Dock DC510
  - Buy if: you need dual card readers (SD + microSD), 2.5GbE, M.2 SSD expansion, and excellent performance for content creation.
  - Skip if: active cooling noise is a concern or you need quieter operation.
- iVANKY FusionDock Pro 3
  - Buy if: you need 180W PD for demanding laptops and Mac‑optimized dual‑6K support with 120Gbps Bandwidth Boost.
  - Skip if: you require 10GbE or more than 3 TB5 downstream ports.
- Dell Pro Thunderbolt 5 Smart Dock (SD25TB5)
  - Buy if: you use Dell laptops/workstations and want up to 300W PD plus enterprise management.
  - Skip if: you don&apos;t need enterprise features or want the lowest price.

## Key Feature Breakdown

### **Power Delivery Explained**

#### **🔋 140W Standard Power Delivery**
- **Compatible with**: Most laptops including 16&quot; MacBook Pro
- **Supports**: Fast charging for all consumer laptops
- **Best for**: General users and most professional workflows

#### **⚡ 180W High Power Delivery (iVANKY)**
- **Compatible with**: High-performance laptops and gaming systems
- **Supports**: Fast charging for demanding mobile workstations
- **Best for**: Content creators with power-hungry laptops

#### **🚀 300W Enterprise Power Delivery (Dell)**
- **Compatible with**: Dell Precision workstations and high-end gaming laptops
- **Supports**: Desktop-replacement laptops and workstations
- **Best for**: Enterprise environments with mobile workstations

&lt;Notice type=&quot;info&quot; title=&quot;Power Delivery Guide&quot;&gt;
Choose 140W for most users, 180W for power-hungry laptops, and 300W only if you have Dell enterprise systems or ultra-high-performance mobile workstations.
&lt;/Notice&gt;

### **Display Support and Multi-Monitor Setups**

#### **🖥️ Triple 4K Setup (Most Docks)**
Perfect for productivity workflows requiring multiple high-resolution displays.

#### **🎮 Dual 8K Support (Premium Docks)**
Ideal for content creators working with 8K footage or extreme detail work.

#### **⚡ Thunderbolt 5 Bandwidth Boost**
iVANKY FusionDock Ultra, CalDigit TS5-Plus, and iVANKY FusionDock Pro 3 support 120Gbps speeds for high display bandwidth.

&lt;Notice type=&quot;info&quot; title=&quot;Display Compatibility&quot;&gt;
All docks work with both Mac and Windows systems, but some offer optimized support for specific platforms. Mac users get dual 6K support, while Windows users can utilize dual 8K on compatible docks.
&lt;/Notice&gt;

### **Networking Options**

#### **🌐 10 Gigabit Ethernet (iVANKY FusionDock Ultra &amp; CalDigit TS5-Plus)**
- **Speed**: 10x faster than standard Gigabit
- **Use cases**: Large file transfers, NAS access, enterprise networks
- **Best for**: Video editors, professionals with high-bandwidth needs

#### **⚡ 2.5 Gigabit Ethernet (Most Docks)**
- **Speed**: 2.5x faster than standard Gigabit
- **Use cases**: General professional use, faster internet connections
- **Best for**: Most users upgrading from 1GbE networks

## Which Dock Should You Choose?

### For Maximum Productivity 💼
**iVANKY FusionDock Ultra** - With 26 ports, dual-chip architecture, 10GbE, and quad display support, it handles the most demanding Apple Silicon workflows without bandwidth compromise. **Alternative**: CalDigit TS5-Plus (20 ports, cross-platform).

### For Content Creators 📸
**iVANKY FusionDock Ultra** - Dual-chip performance with 10GbE, quad display, and no bandwidth throttling when running SSD transfers alongside multiple monitors. **Alternative**: ASUS DC510 (dual card readers + M.2 slot).

### For Mac Users 💻
**iVANKY FusionDock Ultra** - Purpose-built for Apple Silicon with dual-chip architecture, quad display, 10GbE, and magnetic cable for MacBook/Mac Mini. **Alternative**: iVANKY FusionDock Pro 3 (180W PD at lower price).

### For Gaming &amp; Enthusiasts 🎮
**Razer Thunderbolt 5 Dock Chroma** - Features M.2 expansion, RGB lighting, and gaming-focused optimizations.

### For Enterprise Use 🏢
**Dell Pro Thunderbolt 5 Smart Dock** - Enterprise management features, 300W PD, and IT-friendly deployment.

### Best Value Overall 💰
**OWC 11-Port Thunderbolt 5 Dock** - Premium build quality and comprehensive features at mid-range pricing.

### For Budget-Conscious Users 🌟
**Kensington SD5000T5** - Intel-certified TB5 performance with essential features at the lowest price point.

### For Design-Focused Setups ✨
**Anker Prime TB5** - Beautiful cube design with built-in cooling and ambient lighting.

## Performance Rankings

### **Overall Performance Rankings**
1. **iVANKY FusionDock Ultra** - Dual-chip, 26 ports, 10GbE, quad display
2. **CalDigit TS5-Plus** - Maximum capability (cross-platform)
3. **Dell Pro SD25TB5** - Enterprise performance + 300W PD
4. **iVANKY FusionDock Pro 3** - Optimized performance + 180W PD
5. **Anker Prime TB5** - Balanced performance + design
6. **Razer TB5 Chroma** - Gaming performance + storage expansion
7. **OWC 11-Port TB5** - Solid performance + build quality
8. **Kensington SD5000T5** - Essential performance + affordability

### **Value for Money Rankings**
1. **OWC 11-Port TB5** - Premium features at fair pricing
2. **Kensington SD5000T5** - Essential TB5 at budget price
3. **iVANKY FusionDock Pro 3** - High-end features at competitive price
4. **Anker Prime TB5** - Premium design with good feature set
5. **Razer TB5 Chroma** - Gaming features justify the price
6. **iVANKY FusionDock Ultra** - Best-in-class performance at premium price
7. **Dell Pro SD25TB5** - Enterprise features at premium price
8. **CalDigit TS5-Plus** - Many features at high price

## Understanding Thunderbolt 5: Deep Dive

### Thunderbolt 5 vs Thunderbolt 4: What&apos;s the Difference?

#### Thunderbolt 5 Advantages
- **3x Higher Bandwidth**: 80Gbps standard, 120Gbps with Bandwidth Boost
- **Superior Display Support**: Dual 8K@60Hz or triple 4K@144Hz
- **Enhanced Power Delivery**: Up to 300W vs 100W on TB4
- **Future-Proofing**: Designed for next-generation workflows and devices

#### When Thunderbolt 4 Is Sufficient
- **Basic productivity**: Office work, web browsing, single 4K display
- **Budget considerations**: TB4 docks remain more affordable
- **Device compatibility**: If your laptop only supports TB4

&lt;Notice type=&quot;info&quot; title=&quot;Upgrade Decision&quot;&gt;
Choose Thunderbolt 5 if you work with high-resolution displays, transfer large files regularly, or want future-proofing. Stick with TB4 for basic productivity and budget-conscious setups.
&lt;/Notice&gt;

## Complete Comparison Table

| **Feature** | **iVANKY FusionDock Ultra** | **CalDigit TS5-Plus** | **Kensington SD5000T5** | **OWC 11-Port TB5** | **Anker Prime TB5** | **Razer TB5 Chroma** | **iVANKY FusionDock Pro 3** | **Dell Pro SD25TB5** |
|-------------|-------------------------|---------------------|-------------------------|---------------------|-------------------|---------------------|---------------------------|-------------------|
| **🔌 Total Ports** | **26** | **20** | 11 | 11 | 14 | 11 | 11 | 12+ |
| **⚡ Power Delivery** | 140W + 45W front | 140W | 140W | 140W | 140W | 140W | **180W** | **300W** |
| **🌐 Networking** | **10GbE** | **10GbE** | 2.5GbE | 2.5GbE | 2.5GbE | 1GbE | 2.5GbE | 2.5GbE |
| **🖥️ Max Displays** | Quad 6K (Mac) | 2x 8K | 3x 4K | 3x 8K | 2x 8K | 3x 4K | 2x 6K (Mac) | 4x 4K |
| **💾 Storage Expansion** | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ M.2 Slot | ❌ | ❌ |
| **🎨 RGB Lighting** | ❌ | ❌ | ❌ | ❌ | ✅ Ambient | ✅ Chroma | ❌ | ❌ |
| **❄️ Active Cooling** | ✅ Dual-fan | External PSU | ❌ | ✅ | ✅ Built-in | ✅ | ✅ | ❌ |
| **🏢 Enterprise Features** | Consumer | Professional | Basic | Basic | Consumer | Gaming | Consumer | **Enterprise** |
| **🍎 Apple Silicon Only** | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
| **💰 Price Range** | $650-750 | $650-700 | $400-500 | $450-550 | $400 | $390 | $350 | $500-600 |

## Step-by-Step Decision Framework

### Step 1: Identify Your Primary Use Case

#### **Content Creation &amp; Video Editing** 🎬
- **Minimum Requirements**: 8K display support, UHS-II card readers, 180W+ PD
- **Recommended**: iVANKY FusionDock Ultra (Apple Silicon) or CalDigit TS5-Plus (cross-platform)
- **Key Features**: 10GbE for NAS access, good USB bandwidth for storage

#### **Software Development &amp; Programming** 💻
- **Minimum Requirements**: Triple display support, reliable connectivity, good build quality
- **Recommended**: iVANKY FusionDock Ultra or OWC 11-Port TB5
- **Key Features**: Multiple TB5 ports for development devices, stable performance

#### **Gaming &amp; Enthusiast Use** 🎮
- **Minimum Requirements**: Low-latency ports, expansion options, customization
- **Recommended**: Razer TB5 Chroma
- **Key Features**: M.2 expansion for game storage, RGB lighting, gaming optimizations

#### **Business &amp; Productivity** 📊
- **Minimum Requirements**: Reliable connectivity, professional build, enterprise features
- **Recommended**: Dell Pro SD25TB5 or Kensington SD5000T5
- **Key Features**: Management capabilities, certified compatibility, business warranty

#### **General Home Use** 🏠
- **Minimum Requirements**: Essential ports, good value, simple setup
- **Recommended**: Kensington SD5000T5 or OWC 11-Port TB5
- **Key Features**: Plug-and-play operation, balanced feature set, reasonable pricing

### Step 2: Set Your Budget

#### **Budget Tier ($400-$550)**
- **Kensington SD5000T5**: Intel-certified, essential features
- **OWC 11-Port TB5**: Premium build, creative-focused features
- **Best for**: Users upgrading from TB4, essential TB5 features

#### **Premium Tier ($600-$750)**
- **iVANKY FusionDock Ultra**: Dual-chip, 26 ports, 10GbE, quad display (Apple Silicon)
- **CalDigit TS5-Plus**: Many ports and professional features (cross-platform)
- **Dell Pro SD25TB5**: Enterprise features and high power delivery
- **Best for**: Professionals who need many features

#### **Mid-Range Options ($350-$450)**
- **iVANKY FusionDock Pro 3**: Mac-optimized with 180W PD
- **Anker Prime TB5**: Design-focused with built-in cooling
- **Razer TB5 Chroma**: Gaming features with M.2 expansion
- **Best for**: Specific use cases and feature preferences

## Common Questions

### Will My Current Laptop Support Thunderbolt 5?

**Thunderbolt 5 Compatible Devices:**
- **2024+ MacBook Pro M4 Pro/Max**
- **2024+ Mac mini M4 Pro**
- **Select Intel 15th-gen laptops**
- **Razer Blade 18 (2024)**

**Backward Compatibility:**
All TB5 docks work with TB4, USB4, and USB-C devices, though at reduced speeds.

&lt;Notice type=&quot;info&quot; title=&quot;Future-Proofing Investment&quot;&gt;
Even if your current laptop doesn&apos;t support TB5, these docks are excellent investments that will unlock full performance when you upgrade your computer.
&lt;/Notice&gt;

### Do I Really Need 10 Gigabit Ethernet?

**You need 10GbE if you:**
- Transfer large video files regularly
- Access NAS storage frequently
- Work in enterprise environments with 10GbE infrastructure
- Edit 8K video content or RAW photography

**2.5GbE is sufficient for:**
- General professional use
- Home office environments
- Occasional large file transfers
- Most internet connections

### How Important is Active Cooling?

**Active cooling helps with:**
- **Sustained performance** under heavy loads
- **Thermal throttling prevention** during long transfers
- **Component longevity** in demanding environments

**Passive cooling works for:**
- **General productivity** tasks
- **Intermittent use** patterns
- **Quieter operation** preferences

### Do Thunderbolt 4 or USB‑C cables work with Thunderbolt 5?
Yes. TB5 is backward‑compatible with TB4/USB4/USB‑C. However, performance scales with the cable:
- TB4 40Gbps cables work but limit bandwidth and display capability to TB4 levels.
- USB‑C-only cables work for USB data/charging but won’t enable full TB5 display or PCIe performance.

### Which cable do I need for dual 8K or triple 4K?
- Use a certified Thunderbolt 5 cable (0.8 m passive for high performance) or a certified active TB5 cable (up to ~2 m).
- For the 120Gbps Bandwidth Boost scenarios, ensure your host, dock, and cable all support TB5. Actual display limits still depend on your GPU and OS.

### How long can my Thunderbolt 5 cable be?
- Passive TB5: typically up to ~0.8 m at full performance.
- Active TB5: up to ~2 m at 80Gbps. Longer cables may fall back to lower bandwidth/USB4 modes.

### Quick compatibility tips
- Macs with TB3/TB4 work with these docks but display counts/refresh rates may be lower than on TB5 hosts.
- If a display doesn’t light up at its full spec, test with a shorter certified TB5 cable and verify the display path (DP 2.1/HDMI 2.1 vs TB video).

## Final Recommendations

### 🏆 Overall Best: iVANKY FusionDock Ultra
**Why**: 26 ports, dual-chip architecture, 10GbE, quad display support, and zero bandwidth throttling make it the ultimate Apple Silicon dock.
**Price**: ~$650-750
**Best for**: Apple Silicon power users, developers, content creators with complex multi-monitor setups
**Read**: [Full FusionDock Ultra Review](https://www.bitdoze.com/ivanky-fusiondock-ultra-review/)

### 💰 Best Value: OWC 11-Port Thunderbolt 5 Dock
**Why**: Premium build quality with comprehensive features at competitive pricing.
**Price**: ~$450-550
**Best for**: Creative professionals wanting quality without premium pricing

### 🎮 Best for Gaming: Razer Thunderbolt 5 Dock Chroma
**Why**: M.2 SSD expansion, RGB lighting, and gaming optimizations.
**Price**: ~$390
**Best for**: Gamers and enthusiasts wanting storage expansion and customization

### 💼 Best for Business: Dell Pro Thunderbolt 5 Smart Dock
**Why**: Enterprise management, 300W PD, and business-grade reliability.
**Price**: ~$500-600
**Best for**: Enterprise deployments and Dell laptop users

### 📸 Best for Content Creators: ASUS Master Thunderbolt 5 Dock DC510
**Why**: Dual card readers (SD + microSD), 2.5GbE networking, M.2 SSD expansion with 5,800 MB/s speeds.
**Price**: ~€420 / $460
**Best for**: Photographers, videographers, and content creators needing fast storage and card reader versatility
**Read**: [Full ASUS DC510 Review](https://www.bitdoze.com/asus-thunderbolt-5-dock-dc510-review/)

### 💻 Best for Mac Users (Premium): iVANKY FusionDock Ultra
**Why**: Purpose-built for Apple Silicon with dual-chip architecture, quad display, 10GbE, and magnetic cable.
**Price**: ~$650-750
**Best for**: MacBook Pro/Max and Mac Mini/Studio users with demanding setups

### 💻 Best for Mac Users (Value): iVANKY FusionDock Pro 3
**Why**: Optimized for MacBook Pro/Max with 180W PD and Intel certification at a lower price.
**Price**: ~$350
**Best for**: MacBook Pro/Max users needing high power delivery without the Ultra price tag

### 🌟 Best Budget Choice: Kensington SD5000T5
**Why**: Intel-certified TB5 performance with essential features at lowest price.
**Price**: ~$400-500
**Best for**: Users upgrading from TB4 who need proven compatibility

## Where to Buy - Quick Links

### **Budget Options**
- [Kensington SD5000T5 Thunderbolt 5 Dock](https://amzn.to/46NpbgM) - Intel-certified, essential features
- [OWC 11-Port Thunderbolt 5 Dock](https://amzn.to/4mB1Zs1) - Premium build, creative focus

### **Premium Options**
- [iVANKY FusionDock Ultra](https://ivanky.com/products/fusiondock-ultra) - Dual-chip, 26 ports, 10GbE, quad display (Apple Silicon)
- [CalDigit TS5-Plus](https://www.caldigit.com/thunderbolt-5-dock-ts5-plus/) - 20 ports and professional features (cross-platform)
- [Anker Prime TB5 Docking Station](https://amzn.to/42cJK4L) - Design and cooling focus
- [ASUS Master Thunderbolt 5 Dock DC510](https://go.bitdoze.com/asus-dc510) - Content creator focus with dual card readers

### **Specialized Options**
- [Razer Thunderbolt 5 Dock Chroma](https://amzn.to/3IjvS20) - Gaming and RGB features
- [iVANKY FusionDock Pro 3](https://amzn.to/48G4WUB) - Mac optimization and 180W PD
- [Dell Pro Thunderbolt 5 Smart Dock](https://amzn.to/3VC3Q4L) - Enterprise features and 300W PD

&lt;Notice type=&quot;warning&quot; title=&quot;Availability Notice&quot;&gt;
Thunderbolt 5 docks are still relatively new products. Check availability and current pricing as stock levels and prices may vary significantly between retailers.
&lt;/Notice&gt;

## Conclusion

Thunderbolt 5 brings high speeds and connectivity to modern workspaces. Whether you&apos;re a content creator working with 8K video, a developer managing multiple monitors, or a professional looking for better productivity, there&apos;s a Thunderbolt 5 dock that works for you.

**For Apple Silicon power users**, the **iVANKY FusionDock Ultra** stands out with its dual-chip architecture, 26 ports, 10GbE, and quad display support — no other dock eliminates bandwidth bottlenecks this effectively. **For most users**, the **OWC 11-Port Thunderbolt 5 Dock** offers a good balance of features, build quality, and pricing. **Cross-platform power users** should consider the **CalDigit TS5-Plus** for its 20 ports and 10GbE connectivity. **Mac users on a budget** will like the optimized performance and 180W PD of the **iVANKY FusionDock Pro 3**.

As Thunderbolt 5 adoption grows in 2026, these docks should work with your current devices and future upgrades. The speed, power, and features they provide will become standard for professional work.

Choose a dock that fits your needs and improve your workspace with Thunderbolt 5 technology.</content:encoded><category>gadgets</category><category>thunderbolt</category><category>docks</category><category>connectivity</category></item><item><title>How to Use the Codex App with Any Model: GLM 5.2, MiniMax M3, MiMo V2.5 Pro, OpenCode Go</title><link>https://www.bitdoze.com/codex-app-any-model/</link><guid isPermaLink="true">https://www.bitdoze.com/codex-app-any-model/</guid><description>The Codex app works with more than OpenAI models. Point it at GLM 5.2, MiniMax M3, MiMo V2.5 Pro, or OpenCode Go with a few lines of config.toml and keep coding when your ChatGPT plan runs out.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

The Codex app is the best coding agent interface I have used so far. Worktrees, automations, the in-app browser, the review flow. Nothing else I run feels this polished. The problem is what happens on Thursday afternoon when my ChatGPT plan hits its weekly limit and the app politely tells me to wait until Monday.

Here is the part most people miss: Codex does not care where the model comes from. The app and the CLI read the same `~/.codex/config.toml`, and that file lets you define custom model providers. Any OpenAI-compatible endpoint works. So when my OpenAI quota runs dry, I switch to GLM 5.2, MiniMax M3, MiMo V2.5 Pro, or my [OpenCode Go subscription](/opencode-go-plan/) and keep working in the same app.

This guide covers the exact configs for all four. Copy, paste your API key, done.

## How Codex talks to other models

Codex stores its configuration in `~/.codex/config.toml`. The [advanced config docs](https://developers.openai.com/codex/config-advanced) describe a `model_providers` table where you define extra endpoints:

```toml
model = &quot;some-model&quot;
model_provider = &quot;my-provider&quot;

[model_providers.my-provider]
name = &quot;My Provider&quot;
base_url = &quot;https://api.example.com/v1&quot;
env_key = &quot;MY_API_KEY&quot;
wire_api = &quot;chat&quot;
```

Three things to know before you start:

&lt;ListCheck&gt;

- `wire_api = &quot;chat&quot;` tells Codex to use the standard Chat Completions format. All four providers below are OpenAI-compatible, so this is what you want.
- `env_key` points at an environment variable, not the key itself. Your API key stays out of the config file.
- You cannot name a custom provider `openai`, `ollama`, or `lmstudio`. Those IDs are reserved for the built-in providers.

&lt;/ListCheck&gt;

The Codex app picks up whatever default `model` and `model_provider` you set in `config.toml`. The CLI does too, plus it supports profiles for fast switching (more on that below).

&lt;Notice type=&quot;warning&quot; title=&quot;One config quirk&quot;&gt;
`model_verbosity` and reasoning summaries only work with the Responses API. Providers using `wire_api = &quot;chat&quot;` ignore those settings. Everything else, including approvals, sandboxing, and MCP servers, works the same regardless of provider.
&lt;/Notice&gt;

## Option 1: OpenCode Go (16 models, one key)

This is the setup I use most. [OpenCode Go](/opencode-go-plan/) is a $10/month subscription that bundles 16 models behind one OpenAI-compatible endpoint: Grok 4.5, Kimi K3, DeepSeek V4, Qwen 3.7, GLM-5.2, MiniMax, MiMo, and more. Instead of juggling four provider accounts, you get one key that covers all of them.

&lt;Button text=&quot;Get $5 in Free Credits&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

Add this to `~/.codex/config.toml`:

```toml
model = &quot;glm-5.1&quot;
model_provider = &quot;opencode-go&quot;

[model_providers.opencode-go]
name = &quot;OpenCode Go&quot;
base_url = &quot;https://opencode.ai/zen/go/v1&quot;
env_key = &quot;OPENCODE_API_KEY&quot;
wire_api = &quot;chat&quot;
```

Then export your key:

```bash
export OPENCODE_API_KEY=&quot;your-go-key&quot;
```

Swap `model` for any model in the Go lineup: `minimax-m3`, `mimo-v2.5-pro`, `deepseek-v4-pro`, `qwen-3.6-plus`, and so on. The full model list and usage limits are in my [OpenCode Go review](/opencode-go-plan/).

If you only set up one provider from this article, make it this one. One key, 16 models, and the limits reset every 5 hours.

## Option 2: GLM 5.2 (strongest coder)

GLM 5.2 is Z.AI&apos;s latest flagship and the strongest open source coding model right now. It scores 62.1% on SWE-Bench Pro and 81.0% on Terminal-Bench 2.1, within 4 points of Claude Opus 4.8. It supports 1M token context and effort level control. I covered it in depth in the [open source Claude alternatives roundup](/best-open-source-llms-claude-alternative/).

Z.AI sells a GLM Coding Plan starting at $18/month, and the coding endpoint is OpenAI-compatible:

```toml
model = &quot;glm-5.1&quot;
model_provider = &quot;zai&quot;

[model_providers.zai]
name = &quot;Z.AI GLM&quot;
base_url = &quot;https://api.z.ai/api/coding/paas/v4&quot;
env_key = &quot;ZAI_API_KEY&quot;
wire_api = &quot;chat&quot;
```

```bash
export ZAI_API_KEY=&quot;your-zai-key&quot;
```

&lt;Button text=&quot;Get GLM Coding Plan (10% Off)&quot; link=&quot;https://go.bitdoze.com/glm&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;GLM discount&quot;&gt;
Sign up through [go.bitdoze.com/glm](https://go.bitdoze.com/glm) for 10% off the GLM Coding Plans.
&lt;/Notice&gt;

## Option 3: MiniMax M3 (cheapest to run)

MiniMax M3 is the newest release in the MiniMax line, and like the M2.7 before it, the pitch is price. This is the model I point Codex at for long refactoring sessions where token count matters more than squeezing out the last benchmark point. MiniMax also offers a Token Plan subscription with discounted rates, which pairs well with always-on agent work.

```toml
model = &quot;MiniMax-M3&quot;
model_provider = &quot;minimax&quot;

[model_providers.minimax]
name = &quot;MiniMax&quot;
base_url = &quot;https://api.minimax.io/v1&quot;
env_key = &quot;MINIMAX_API_KEY&quot;
wire_api = &quot;chat&quot;
```

```bash
export MINIMAX_API_KEY=&quot;your-minimax-key&quot;
```

&lt;Button text=&quot;MiniMax Token Plan (10% Off)&quot; link=&quot;https://go.bitdoze.com/minimax&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

If you are outside the international region and use the mainland China endpoint, swap the base URL for `https://api.minimaxi.com/v1`.

## Option 4: MiMo V2.5 Pro (best for long agent runs)

MiMo V2.5 Pro is Xiaomi&apos;s flagship, built for agent workloads with hundreds of tool calls per session and a 1M token context window. In internal testing it built a full SysY compiler in Rust over 4.3 hours and 672 tool calls. It is the model I reach for when I hand Codex a task and walk away for the afternoon.

```toml
model = &quot;mimo-v2.5-pro&quot;
model_provider = &quot;mimo&quot;

[model_providers.mimo]
name = &quot;Xiaomi MiMo&quot;
base_url = &quot;https://api.xiaomimimo.com/v1&quot;
env_key = &quot;MIMO_API_KEY&quot;
wire_api = &quot;chat&quot;
```

```bash
export MIMO_API_KEY=&quot;your-mimo-key&quot;
```

&lt;Button text=&quot;MiMo Token Plan ($2 Bonus)&quot; link=&quot;https://go.bitdoze.com/mimo&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;MiMo bonus&quot;&gt;
Sign up through [go.bitdoze.com/mimo](https://go.bitdoze.com/mimo) and get a $2 bonus credit on the MiMo Token Plan. The Lite tier starts at $72/year, and off-peak hours (16:00-24:00 UTC) get an extra 20% discount.
&lt;/Notice&gt;

## Switching between providers without editing config.toml

You can keep all four providers in one `config.toml` and only change the two top-level lines (`model` and `model_provider`) when you want to switch. That works, but it gets old fast.

Two better options:

&lt;Tabs&gt;
&lt;Tab name=&quot;CLI flags&quot;&gt;
Override the provider for a single run:

```bash
codex --config model_provider=&apos;&quot;zai&quot;&apos; --config model=&apos;&quot;glm-5.1&quot;&apos;
```

Good for quick tests, too verbose for daily use.
&lt;/Tab&gt;
&lt;Tab name=&quot;Profiles&quot;&gt;
Create one profile file per provider. For example `~/.codex/glm.config.toml`:

```toml
model = &quot;glm-5.1&quot;
model_provider = &quot;zai&quot;
```

And `~/.codex/minimax.config.toml`:

```toml
model = &quot;MiniMax-M3&quot;
model_provider = &quot;minimax&quot;
```

Then launch with:

```bash
codex --profile glm
codex --profile minimax
```

The profile file overlays your base config, so the `[model_providers]` tables you already defined stay available. Note that since Codex 0.134.0, profiles live in separate files, not under `[profiles.name]` in the main config.
&lt;/Tab&gt;
&lt;/Tabs&gt;

My setup: OpenCode Go is the default in `config.toml`, and I keep a `glm` profile for the days I want GLM 5.2 through Z.AI directly with the bigger coding plan limits.

## What still needs your OpenAI account

Custom providers cover local work: the app, the CLI, the IDE extension, file edits, terminal commands, MCP servers. Some pieces stay tied to OpenAI auth:

- Codex cloud tasks and the web environment run on OpenAI infrastructure with your ChatGPT login
- The GitHub integration for cloud-delegated work expects an OpenAI-backed account
- Reasoning summaries and `model_verbosity` need the Responses API, which these chat-based providers do not use

In practice this has not bothered me. I use the app locally, and local is exactly where custom providers work.

## Which one should you pick?

| Provider | Cost | Best for |
|----------|------|----------|
| OpenCode Go | $10/month | One key, 16 models, easiest start |
| GLM 5.2 | from $18/month | Strongest coding, long autonomous tasks |
| MiniMax M3 | Token Plan | Cheapest daily driver, high-volume work |
| MiMo V2.5 Pro | from $72/year | 1M context, marathon agent sessions |

If you want the longer comparison with benchmarks and per-token pricing, the [best open source LLMs roundup](/best-open-source-llms-claude-alternative/) covers GLM 5.2, MiniMax, MiMo, and three others side by side.

&lt;Accordion label=&quot;Does this work with the Codex app or just the CLI?&quot; group=&quot;faq&quot;&gt;
Both. The app, the CLI, and the IDE extension all read the same `~/.codex/config.toml`. Set `model` and `model_provider` there and the app uses your custom provider. Profiles (`--profile`) are a CLI feature, so for the app you set the default in the main config.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will my ChatGPT subscription still work after adding custom providers?&quot; group=&quot;faq&quot;&gt;
Yes. Adding `[model_providers]` entries does not touch your OpenAI login. Switch back anytime by setting `model_provider` to the built-in default or removing the line.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I put these providers in a project .codex/config.toml?&quot; group=&quot;faq&quot;&gt;
No. Codex ignores `model_provider` and `model_providers` in project-local config files for security reasons. Provider definitions belong in your user-level `~/.codex/config.toml`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is one OpenCode Go key enough, or do I need direct provider accounts?&quot; group=&quot;faq&quot;&gt;
Go covers GLM 5.2, MiniMax, and MiMo models under its $12/5-hour usage cap, which I have rarely hit. Direct accounts make sense when you want the bigger limits of a dedicated coding plan, like GLM&apos;s $18/month tier for heavy GLM 5.2 use.
&lt;/Accordion&gt;

## Wrapping up

The Codex app stopped being an OpenAI-only tool the moment custom providers landed in `config.toml`. Ten lines of TOML and an API key get you GLM 5.2, MiniMax M3, MiMo V2.5 Pro, or all 12 OpenCode Go models inside the same interface you already use. My ChatGPT plan still does the heavy thinking early in the week. After that, the open source models take over, and honestly, for most coding tasks I cannot tell the difference.

&lt;Button text=&quot;Try OpenCode Go ($5 Free Credits)&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Related articles

- [OpenCode Go: 12 AI Coding Models for $10/Month](/opencode-go-plan/) — the subscription I use as my Codex fallback
- [Best Open Source LLMs to Replace Opus 4.7 or GPT-5.5](/best-open-source-llms-claude-alternative/) — benchmarks and pricing for GLM 5.2, MiniMax, MiMo, and more
- [Best Cheap Models for AI Coding Agents](/best-cheap-models-hermes-agent/) — per-token pricing breakdown
- [GitHub Copilot Alternatives After the June 2026 Pricing Change](/github-copilot-alternatives-2026/) — more ways to cut AI coding costs</content:encoded><category>ai</category><category>ai-tools</category><category>codex</category><category>llm</category></item><item><title>Self-Host Convex in 2026: Docker Compose &amp; Dokploy Setup Guide</title><link>https://www.bitdoze.com/convex-self-host/</link><guid isPermaLink="true">https://www.bitdoze.com/convex-self-host/</guid><description>Deploy Convex backend on your VPS with Docker Compose or Dokploy. Covers SQLite, PostgreSQL, MySQL, reverse proxy, S3 storage, and Prometheus metrics. Updated June 2026.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>If you are building real-time applications with databases, you have probably run into [Convex](https://go.bitdoze.com/convex). It is a backend-as-a-service platform that combines a real-time database with serverless functions. The cloud free tier gives you 0.5GB database storage and 1M function calls per month, which works for small projects. Self-hosting removes those limits and gives you control over your data and infrastructure.

In this guide, I walk through self-hosting Convex using either Dokploy (simpler approach) or Docker Compose (more control). We cover SQLite for smaller setups, and PostgreSQL or MySQL for production.

## What is Convex?

[Convex](https://go.bitdoze.com/convex) is a backend platform that combines databases, API servers, and caching layers into one system. It handles real-time data synchronization, serverless functions, and caching using a TypeScript API.

### Key Features of Convex

&lt;ListCheck&gt;
- **Real-Time Database**: Automatic data synchronization across all clients with reactive queries
- **TypeScript-First**: End-to-end type safety from backend to frontend
- **Serverless Functions**: Write queries, mutations, and actions in TypeScript without managing servers
- **Built-in Scheduling**: Cron jobs and scheduled functions without external services
- **File Storage**: Built-in file upload and storage with CDN distribution
- **Full-Text Search**: Native search capabilities without Elasticsearch
- **Authentication**: Flexible auth system supporting various providers
- **Atomic Transactions**: ACID guarantees for data consistency
- **Time Travel**: Query historical data and debug with time-travel queries
- **Vector Search**: Built-in vector database for AI applications
&lt;/ListCheck&gt;

### Why Self-Host Convex?

**Self-hosting advantages**:
- You own the data and control where it lives
- No bandwidth or function execution caps
- Customize infrastructure and scaling
- Lower costs at scale
- Run on-premises or in private networks

**Cloud-Hosted Convex Free Tier**:
- 0.5GB database storage
- 1M function calls per month
- 1GB file storage
- Unlimited projects
- Works well for development and small apps

Consider self-hosting when you hit those limits or need more infrastructure control.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/rEcOmzYyb1g&quot;
  label=&quot;Self-Host Convex on Your Own Server – Full Step-by-Step Guide&quot;
/&gt;



## Prerequisites

You&apos;ll need:

&lt;ListCheck&gt;
- **A VPS or Server**: 2GB RAM minimum, 2 CPU cores (4GB RAM recommended for PostgreSQL)
- **A Domain Name**: For your Convex backend (e.g., `api.yourdomain.com`)
- **Docker Installed**: Docker and Docker Compose (Dokploy includes Docker)
- **Basic Command Line Knowledge**: For running commands
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Hosting Recommendations&quot;&gt;
For development, a basic VPS with 2GB RAM works fine with SQLite. For production, use 4GB RAM with PostgreSQL hosted in the same region for optimal performance. Providers like Hetzner, DigitalOcean, or AWS work well.
&lt;/Notice&gt;

## Option 1: Deploy with Dokploy (Easiest Method)

Dokploy is an open-source Platform as a Service for deploying applications. **Good news: Dokploy includes a Convex template**. This means you can deploy Convex with a few clicks. If you haven&apos;t set up Dokploy yet, check out our [Dokploy Installation Guide](https://www.bitdoze.com/dokploy-install/).

### Method A: Using Dokploy&apos;s Built-in Template (Recommended)

This is the fastest way to deploy Convex. Dokploy has a built-in template that you can use as-is or **customize with your own configuration**.

**Step 1: Install Dokploy** (if not already installed)

```sh
curl -sSL https://dokploy.com/install.sh | sh
```

Access Dokploy at `http://your-vps-ip:3000` and complete the setup.

**Step 2: Deploy from Template**

1. Log in to Dokploy dashboard
2. Click **&quot;Create Project&quot;** and name it (e.g., &quot;Convex&quot;)
3. Click **&quot;Templates&quot;** in the left sidebar
4. Search for **&quot;Convex&quot;** in the template gallery
5. Click **&quot;Deploy&quot;** on the Convex template

**Step 3: Customize the Configuration** (Optional)

The template comes with a default configuration, but you can override it with your own settings:

1. After deploying the template, go to the **Docker Compose** tab
2. You&apos;ll see the template&apos;s default YAML - you can edit it if needed
3. Go to the **Environment** tab to set your variables (see below)

&lt;Notice type=&quot;info&quot; title=&quot;Template Flexibility&quot;&gt;
The Dokploy template is a good starting point, but you can customize it by editing the Docker Compose configuration and environment variables.
&lt;/Notice&gt;


```yaml
services:
  backend:
    image: ghcr.io/get-convex/convex-backend:latest
    volumes:
      - data:/convex/data
    environment:
      - INSTANCE_NAME=${INSTANCE_NAME:-convex-self-hosted}
      - INSTANCE_SECRET=${INSTANCE_SECRET:-}
      - CONVEX_RELEASE_VERSION_DEV=${CONVEX_RELEASE_VERSION_DEV:-}
      - ACTIONS_USER_TIMEOUT_SECS=${ACTIONS_USER_TIMEOUT_SECS:-}
      - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:3210}
      - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:3211}
      - POSTGRES_URL=${POSTGRES_URL:-}
      - DISABLE_BEACON=${DISABLE_BEACON:-true}
      - REDACT_LOGS_TO_CLIENT=${REDACT_LOGS_TO_CLIENT:-}
      - RUST_LOG=${RUST_LOG:-info}
      - RUST_BACKTRACE=${RUST_BACKTRACE:-}
      - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-1}
    healthcheck:
      test: curl -f http://localhost:3210/version
      interval: 5s
      start_period: 5s

  dashboard:
    image: ghcr.io/get-convex/convex-dashboard:latest
    environment:
      - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://127.0.0.1:3210}
    depends_on:
      backend:
        condition: service_healthy

  postgres:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=convex_self_hosted
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U postgres&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  data:
  postgres-data:
```

&lt;Notice type=&quot;info&quot; title=&quot;SQLite vs PostgreSQL vs MySQL&quot;&gt;
This configuration includes PostgreSQL for production. If you prefer SQLite (simpler, works well for development), leave `POSTGRES_URL` and `MYSQL_URL` empty and Convex will use SQLite. For production, set `POSTGRES_URL` to connect to PostgreSQL, or `MYSQL_URL` to connect to MySQL. MySQL support was added in early 2026.
&lt;/Notice&gt;


**Step 4: Configure Environment Variables**

Go to the **Environment** tab and add these variables:

**Required:**
- `INSTANCE_SECRET`: Generate with `openssl rand -hex 32`

**URLs** - Choose one option:

**Option A: Use Dokploy&apos;s Free Traefik Domains** (Quick start)
```sh
NEXT_PUBLIC_DEPLOYMENT_URL=http://shhosted-convex-cf33fb-91-98-95-196.traefik.me
CONVEX_CLOUD_ORIGIN=http://shhosted-convex-cf33fb-91-98-95-196.traefik.me
CONVEX_SITE_ORIGIN=http://shhosted-convex-59a34c-91-98-95-196.traefik.me
```

**Option B: Use Your Own Domain** (Production)

First, set up DNS A record: `backend.convex.yourdomain.com` → Your VPS IP

Then set:
```sh
CONVEX_CLOUD_ORIGIN=https://api.convex.yourdomain.com
CONVEX_SITE_ORIGIN=https://backend.convex.yourdomain.com
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.convex.yourdomain.com
```

**PostgreSQL** (optional, leave empty to use SQLite):
```sh
DB_PASSWORD=your-strong-password
POSTGRES_URL=postgresql://postgres:your-strong-password@postgres:5432
```



**Step 5: Configure Domain in Dokploy** (if using custom domain)

1. Go to the **Domains** tab
2. Add your domain: `backend.convex.yourdomain.com`
3. Dokploy&apos;s Traefik will automatically handle SSL

**Step 6: Deploy and Generate Admin Key**

1. Click **&quot;Deploy&quot;** and wait for services to start
2. Once healthy, click **&quot;Terminal&quot;** to access the backend container
3. Navigate and run:
```sh
cd convex
./generate_admin_key.sh
```
4. Save the admin key securely



## Option 2: Deploy with Docker Compose Only

If you prefer deploying without Dokploy or want more control, here&apos;s how to use Docker Compose directly.

### Step 1: Prepare Your Server

Update system and install Docker:

```sh
# Update packages
sudo apt update &amp;&amp; sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Install Docker Compose
sudo apt install docker-compose -y
```

### Step 2: Create Project Directory

```sh
mkdir -p ~/convex-backend
cd ~/convex-backend
```

### Step 3: Create Docker Compose File

**For SQLite (Simple Setup)**:

```sh
nano docker-compose.yml
```

Paste:

```yaml
services:
  backend:
    image: ghcr.io/get-convex/convex-backend:latest
    stop_grace_period: 10s
    stop_signal: SIGINT
    ports:
      - &quot;3210:3210&quot;
      - &quot;3211:3211&quot;
    volumes:
      - data:/convex/data
    environment:
      - INSTANCE_NAME=${INSTANCE_NAME:-convex-self-hosted}
      - INSTANCE_SECRET=${INSTANCE_SECRET}
      - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:3210}
      - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:3211}
      - RUST_LOG=${RUST_LOG:-info}
      - DISABLE_BEACON=${DISABLE_BEACON:-false}
    healthcheck:
      test: curl -f http://localhost:3210/version
      interval: 5s
      start_period: 10s

  dashboard:
    image: ghcr.io/get-convex/convex-dashboard:latest
    stop_grace_period: 10s
    stop_signal: SIGINT
    ports:
      - &quot;6791:6791&quot;
    environment:
      - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://127.0.0.1:3210}
    depends_on:
      backend:
        condition: service_healthy

volumes:
  data:
```

**For PostgreSQL (Production Setup)**:

```yaml
services:
  backend:
    image: ghcr.io/get-convex/convex-backend:latest
    stop_grace_period: 10s
    stop_signal: SIGINT
    ports:
      - &quot;3210:3210&quot;
      - &quot;3211:3211&quot;
    environment:
      - INSTANCE_NAME=${INSTANCE_NAME:-convex-self-hosted}
      - INSTANCE_SECRET=${INSTANCE_SECRET}
      - CONVEX_CLOUD_ORIGIN=${CONVEX_CLOUD_ORIGIN:-http://127.0.0.1:3210}
      - CONVEX_SITE_ORIGIN=${CONVEX_SITE_ORIGIN:-http://127.0.0.1:3211}
      - POSTGRES_URL=${POSTGRES_URL}
      - DO_NOT_REQUIRE_SSL=${DO_NOT_REQUIRE_SSL:-false}
      - RUST_LOG=${RUST_LOG:-info}
      - DOCUMENT_RETENTION_DELAY=${DOCUMENT_RETENTION_DELAY:-172800}
      - DISABLE_BEACON=${DISABLE_BEACON:-false}
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: curl -f http://localhost:3210/version
      interval: 5s
      start_period: 10s

  dashboard:
    image: ghcr.io/get-convex/convex-dashboard:latest
    stop_grace_period: 10s
    stop_signal: SIGINT
    ports:
      - &quot;6791:6791&quot;
    environment:
      - NEXT_PUBLIC_DEPLOYMENT_URL=${NEXT_PUBLIC_DEPLOYMENT_URL:-http://127.0.0.1:3210}
    depends_on:
      backend:
        condition: service_healthy

  postgres:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=convex_self_hosted
    volumes:
      - postgres-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U postgres&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres-data:
```

### Step 4: Create Environment File

```sh
nano .env
```

Add your configuration:

```sh
# Instance Configuration
INSTANCE_NAME=convex-self-hosted
INSTANCE_SECRET=&lt;generate-with-openssl-rand-hex-32&gt;

# Public URLs (update these for production)
CONVEX_CLOUD_ORIGIN=http://127.0.0.1:3210
CONVEX_SITE_ORIGIN=http://127.0.0.1:3211
NEXT_PUBLIC_DEPLOYMENT_URL=http://127.0.0.1:3210

# PostgreSQL Configuration (only if using PostgreSQL)
DB_PASSWORD=&lt;your-secure-db-password&gt;
POSTGRES_URL=postgresql://postgres:${DB_PASSWORD}@postgres:5432
DO_NOT_REQUIRE_SSL=true

# Optional: Logging
RUST_LOG=info

# Optional: Disable telemetry
DISABLE_BEACON=false
```

Generate secrets:
```sh
# Generate instance secret
openssl rand -hex 32
```

### Step 5: Start Convex

```sh
# Start services
docker-compose up -d

# View logs
docker-compose logs -f

# Check status
docker-compose ps
```

### Step 6: Set Up Reverse Proxy with Nginx

For production with custom domains, set up Nginx:

```sh
sudo apt install nginx certbot python3-certbot-nginx -y
```

Create Nginx configuration:

```sh
sudo nano /etc/nginx/sites-available/convex
```

Paste:

```nginx
# Backend API
server {
    listen 80;
    server_name api.yourdomain.com;

    location / {
        proxy_pass http://localhost:3210;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Dashboard
server {
    listen 80;
    server_name dashboard.yourdomain.com;

    location / {
        proxy_pass http://localhost:6791;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Enable site and get SSL:

```sh
# Enable site
sudo ln -s /etc/nginx/sites-available/convex /etc/nginx/sites-enabled/

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

# Get SSL certificates
sudo certbot --nginx -d api.yourdomain.com -d dashboard.yourdomain.com
```

Update your `.env` file with production URLs:

```sh
CONVEX_CLOUD_ORIGIN=https://api.yourdomain.com
CONVEX_SITE_ORIGIN=https://backend.yourdomain.com
NEXT_PUBLIC_DEPLOYMENT_URL=https://api.yourdomain.com
```

Restart services:
```sh
docker-compose down
docker-compose up -d
```

### Step 7: Generate Admin Key

```sh
# Access backend container
docker-compose exec backend /bin/sh

# Navigate to convex directory and generate admin key
cd convex
./generate_admin_key.sh

# Exit container
exit
```

Save the admin key securely - you&apos;ll need it for your projects.

## Using Convex in Your Projects

With your self-hosted Convex backend running, here&apos;s how to connect your applications.

### Configure Your Project Environment

Add these two environment variables to your application&apos;s `.env.local` file (don&apos;t commit this to git):

```sh
CONVEX_SELF_HOSTED_URL=https://backend.convex.yourdomain.com
CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|015dfa7184876e556124a4ad005ffae7ace340d3d230a5c19106d94c1cdbb183bccf3dee06
```

Replace the URL with your actual backend URL (custom domain or traefik.me URL) and the admin key with the one you generated earlier. The Convex CLI will use your self-hosted backend when these variables are set.


## Advanced Configuration

### Using External PostgreSQL (Neon, Supabase, AWS RDS)

For managed PostgreSQL:

1. Create a database named `convex_self_hosted`
2. Get the connection string (without database name and query params)
3. Update environment:

```sh
POSTGRES_URL=********************************************************
DO_NOT_REQUIRE_SSL=false
```

Example for Neon:
```sh
POSTGRES_URL=**********************************************************************
```

&lt;Notice type=&quot;warning&quot; title=&quot;Same Region Required&quot;&gt;
Put your Convex backend in the same region as your PostgreSQL database. Latency between them will slow down query performance.
&lt;/Notice&gt;

### Using MySQL

Convex now supports MySQL as a production database option alongside PostgreSQL. Set the `MYSQL_URL` environment variable:

```sh
MYSQL_URL=mysql://user:password@host:3306/convex_self_hosted
```

This works with managed MySQL from PlanetScale, AWS RDS, or any MySQL 8+ server. The same region advice applies: keep the database close to the Convex backend.

### Monitoring with Prometheus

The self-hosted backend exposes a Prometheus-compatible metrics endpoint at `/metrics`. Enable it by setting:

```sh
DISABLE_METRICS_ENDPOINT=false
```

This gives you request counts, latency histograms, and error rates that you can feed into Grafana or any Prometheus-compatible monitoring stack.

### Tuning Concurrency

The backend exposes environment variables for controlling how many queries, mutations, and actions run at the same time:

```sh
APPLICATION_MAX_CONCURRENT_QUERIES=16
APPLICATION_MAX_CONCURRENT_MUTATIONS=16
APPLICATION_MAX_CONCURRENT_V8_ACTIONS=16
APPLICATION_MAX_CONCURRENT_NODE_ACTIONS=16
```

The default is 16 for each. Increase these if your Convex app handles heavy concurrent traffic and your server has spare CPU and memory.

### Configuring S3 Storage

For production file storage, configure S3:

```yaml
services:
  backend:
    environment:
      # ... other vars ...
      - AWS_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - S3_STORAGE_EXPORTS_BUCKET=convex-snapshot-exports
      - S3_STORAGE_SNAPSHOT_IMPORTS_BUCKET=convex-snapshot-imports
      - S3_STORAGE_MODULES_BUCKET=convex-modules
      - S3_STORAGE_FILES_BUCKET=convex-user-files
      - S3_STORAGE_SEARCH_BUCKET=convex-search-indexes
```

Create the buckets in AWS S3 or use S3-compatible storage like Cloudflare R2:

```sh
S3_ENDPOINT_URL=https://&lt;account-id&gt;.r2.cloudflarestorage.com
AWS_ACCESS_KEY_ID=&lt;your-r2-access-key&gt;
AWS_SECRET_ACCESS_KEY=&lt;your-r2-secret-key&gt;
```

### Custom Domains for HTTP Actions

To serve HTTP actions from a custom domain:

1. Set up DNS for `api.yourdomain.com`
2. Configure in your environment:

```sh
CONVEX_SITE_ORIGIN=https://api.yourdomain.com
```

3. In your frontend, override the environment variable during build:

```sh
CONVEX_SITE_URL=https://api.yourdomain.com
```

### Migration Between Storage Providers

If switching from SQLite to PostgreSQL or changing S3 configuration:

```sh
# Export data from old backend
npx convex export --path backup.zip

# Deploy new backend with different storage
# ...

# Import data to new backend
npx convex import --replace-all backup.zip
```

## Maintenance and Backups

### Regular Backups

**For Dokploy deployments**, you can configure automated backups through Dokploy&apos;s interface or follow our [Dokploy Backups Guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/).

**For Docker Compose with PostgreSQL**:

```sh
# Manual backup
docker-compose exec postgres pg_dump -U postgres convex_self_hosted &gt; backup-$(date +%Y%m%d).sql

# Restore backup
cat backup-20241124.sql | docker-compose exec -T postgres psql -U postgres convex_self_hosted
```

**Automated backup script** (`backup.sh`):

```bash
#!/bin/bash
BACKUP_DIR=&quot;/backups/convex&quot;
DATE=$(date +%Y%m%d-%H%M)
mkdir -p $BACKUP_DIR

docker-compose exec -T postgres pg_dump -U postgres convex_self_hosted | gzip &gt; $BACKUP_DIR/convex-$DATE.sql.gz

# Keep only last 7 days
find $BACKUP_DIR -name &quot;convex-*.sql.gz&quot; -mtime +7 -delete
```

Make it executable and add to crontab:
```sh
chmod +x backup.sh
crontab -e
# Add: 0 2 * * * /path/to/backup.sh
```

### Updating Convex

**With Dokploy**:
1. Go to your service
2. Click **&quot;Redeploy&quot;**
3. Dokploy pulls latest image and restarts

**With Docker Compose**:
```sh
cd ~/convex-backend
docker-compose pull
docker-compose up -d
```

&lt;Notice type=&quot;info&quot; title=&quot;Version Pinning&quot;&gt;
For production, consider pinning to a specific version instead of `:latest`:

```yaml
image: ghcr.io/get-convex/convex-backend:v0.1.0
```

Check [releases](https://github.com/get-convex/convex-backend/releases) for versions.
&lt;/Notice&gt;


## Security Best Practices

&lt;ListCheck&gt;
- **Secure Instance Secret**: Use a strong, random `INSTANCE_SECRET` and never expose it
- **HTTPS Only**: Always use SSL/TLS for production deployments
- **Strong Passwords**: Use complex passwords for PostgreSQL and admin accounts
- **Firewall Configuration**: Only expose necessary ports (80, 443)
- **Regular Updates**: Keep Convex, Docker, and system packages updated
- **Backup Encryption**: Encrypt database backups at rest
- **Environment Variables**: Never commit `.env` files to version control
- **Admin Key Rotation**: Regenerate admin keys periodically
- **Network Isolation**: Use Docker networks to isolate services
- **Monitor Logs**: Set up log monitoring for suspicious activity
&lt;/ListCheck&gt;

## Conclusion

Self-hosting Convex gives you control over your real-time backend infrastructure while keeping Convex&apos;s developer experience. Whether you use Dokploy or Docker Compose, you can have a production-ready Convex deployment in minutes.

Convex handles real-time queries, serverless functions, file storage, and search. Self-hosting adds infrastructure control to those features. The project is licensed under FSL-1.1 (each release converts to Apache 2.0 after two years) and raised $24M from a16z in November 2025.

For smaller applications, SQLite works well with minimal resources. When you need to scale, PostgreSQL and MySQL handle production workloads more reliably.

### Next Steps

&lt;ListCheck&gt;
- Read the [Convex documentation](https://docs.convex.dev/) for advanced features
- Set up automated backups with our [Dokploy Backups Guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/)
- Join the [Convex Discord](https://discord.gg/convex) #self-hosted channel for help
- Consider S3 storage for production file handling
- Monitor your deployment and optimize as needed
&lt;/ListCheck&gt;

&lt;Button link=&quot;https://go.bitdoze.com/convex&quot; text=&quot;Learn More About Convex&quot; /&gt;

Questions about self-hosting Convex? Leave a comment below.

## Frequently Asked Questions

&lt;Accordion label=&quot;Should I use SQLite or PostgreSQL?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
**SQLite** works well for:
- Development and testing
- Small to medium applications (&lt; 10k requests/day)
- Single-server deployments
- Prototypes

**PostgreSQL** is better when:
- You have production workloads with high traffic
- You need high availability
- You want database replication
- You use managed database services (Neon, Supabase, AWS RDS)

Start with SQLite and move to PostgreSQL when you need more reliability.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I migrate from cloud-hosted Convex to self-hosted?&quot; group=&quot;faq&quot;&gt;
Yes! The process is straightforward:

1. Export data from cloud: `npx convex export --prod`
2. Set up self-hosted backend
3. Import data: `npx convex import --replace-all`
4. Update environment variables in your frontend
5. Redeploy functions: `npx convex deploy`

Your application code doesn&apos;t need to change - just update the backend URL.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much does self-hosting Convex cost?&quot; group=&quot;faq&quot;&gt;
**Monthly costs** (example):
- VPS with 4GB RAM (Hetzner): $8/month
- PostgreSQL on Neon: Free tier or $19/month for Pro
- Domain: $1/month
- S3 storage (optional): ~$1-5/month

**Total**: $10-30/month

Self-hosting can save money compared to cloud Convex for high-traffic apps.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use self-hosted Convex in production?&quot; group=&quot;faq&quot;&gt;
Absolutely! Many companies run self-hosted Convex in production. Make sure to:

- Use PostgreSQL instead of SQLite
- Set up automated backups
- Configure proper monitoring
- Use SSL/TLS
- Run in a reliable hosting environment
- Keep the backend updated

Follow the production deployment guidelines in this article for a reliable setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does self-hosted Convex support all cloud features?&quot; group=&quot;faq&quot;&gt;
Self-hosted Convex supports all free-tier features:
- Real-time queries and mutations
- Serverless functions
- File storage
- Scheduled functions
- Full-text search
- Vector search
- HTTP actions

The main difference is that you handle infrastructure management: backups, scaling, and maintenance.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I scale self-hosted Convex?&quot; group=&quot;faq&quot;&gt;
For vertical scaling:
- Increase VPS resources (CPU/RAM)
- Upgrade to a larger PostgreSQL instance
- Use S3 for file storage

For horizontal scaling:
- Use a managed PostgreSQL with read replicas
- Put Convex behind a load balancer
- Configure multiple backend instances

Most applications won&apos;t need horizontal scaling. Vertical scaling handles substantial traffic.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Convex Auth with self-hosted?&quot; group=&quot;faq&quot;&gt;
Convex Auth works with self-hosted deployments. Follow the [manual setup instructions](https://labs.convex.dev/auth/setup/manual) in the Convex Auth documentation. The CLI&apos;s automatic setup doesn&apos;t support self-hosted yet.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if my backend goes down?&quot; group=&quot;faq&quot;&gt;
With proper setup:
- Docker restarts crashed containers
- PostgreSQL data persists in volumes
- Recent operations may need to be retried by clients
- Real-time subscriptions reconnect automatically

For high availability:
- Use a monitoring service (UptimeRobot, Pingdom)
- Set up PostgreSQL replication
- Consider running multiple backend instances
- Set up regular automated backups
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>self-hosted</category><category>docker</category></item><item><title>Hermes Agent Dashboard Setup: SSH, Caddy, Docker &amp; Security (2026)</title><link>https://www.bitdoze.com/hermes-dashboard-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/hermes-dashboard-guide/</guid><description>Run the Hermes Agent web dashboard on your VPS with SSH tunneling, Caddy reverse proxy, Docker, or systemd. Includes Basic Auth config and security risks of exposing AI agents.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you are running [Hermes Agent](/hermes-agent-setup-guide/) on a VPS and want a browser-based way to manage sessions, API keys, memory, and configuration, the built-in dashboard does exactly that. As of v0.16 (June 2026), the dashboard ships as a native browser admin panel. It is a single command away, but the default setup only listens on localhost. This guide covers how to run it locally, access it remotely via SSH tunnel or through a Caddy reverse proxy with password protection, deploy it in Docker, and keep it running permanently with systemd.

&lt;Button text=&quot;Hermes Agent GitHub&quot; link=&quot;https://github.com/NousResearch/hermes-agent&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Starting the Hermes dashboard locally and on a remote server&lt;/li&gt;
&lt;li&gt;Remote access via SSH port forwarding (simplest and safest)&lt;/li&gt;
&lt;li&gt;Exposing it externally with Caddy reverse proxy&lt;/li&gt;
&lt;li&gt;Adding Basic Auth with a username and password&lt;/li&gt;
&lt;li&gt;Running it permanently with systemd or Docker&lt;/li&gt;
&lt;li&gt;Security risks you should know about&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## What the Hermes dashboard does

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/1WaASFqQHgg&quot;
  label=&quot;Hermes v0.9.0 Just Got a Game-Changing Web Dashboard&quot;
/&gt;

The dashboard gives you a browser interface for managing your Hermes Agent instance. From it you can:

- View and manage active chat sessions
- Browse and edit agent memory files
- Configure API keys and model settings
- Monitor token usage and costs
- Manage skills and tool configurations

It connects to the existing Hermes gateway process that is already running on your server — it does not start a second agent.

## Dashboard tour

Once the dashboard is running, the top navigation bar gives you access to several tabs. Here is what each one shows.

### Status

The Status tab is the landing page. It shows whether the agent and gateway are running, connected platforms, and a list of recent sessions with message counts and previews.

![Hermes dashboard status page showing agent health, gateway status, connected platforms, and recent sessions](../../assets/images/26/04/hermes-dash-status.webp)

### Sessions

The Sessions tab lists all past and active sessions. Each entry shows the model used, message count, tools called, and when it was last active. You can search message content across all sessions and delete old ones.

![Hermes dashboard sessions page showing 51 sessions with search, message counts, and session types](../../assets/images/26/04/hermes-dash-sessionsjpeg.webp)

### Analytics

The Analytics tab tracks token usage, session counts, and API calls over time. It shows daily breakdowns and per-model stats so you can see exactly how much your agent is costing.

![Hermes dashboard analytics page showing total tokens, daily usage chart, and per-model breakdown](../../assets/images/26/04/hermes-dash-analytics.webp)

### Cron

The Cron tab lets you create and manage scheduled tasks. You define a prompt, set a cron expression, and choose where results are delivered (local, Discord, etc.). You can pause, manually trigger, or delete jobs from here.

![Hermes dashboard cron page showing job creation form and a scheduled daily tech news scout job](../../assets/images/26/04/hermes-dash-jobs.webp)

### Config

The Config tab gives you a structured editor for `~/.hermes/config.yaml`. It is organized by sections — General, Agent, Terminal, Display, Memory, Security, and more — each with labeled fields. You can edit values directly or toggle to raw YAML mode.

![Hermes dashboard config page showing General settings including model, timezone, and command allowlist](../../assets/images/26/04/hermes-dash-config.webp)

### Keys

The Keys tab manages API keys and OAuth logins stored in `~/.hermes/.env`. It shows which LLM providers are configured, handles OAuth flows (Nous Portal, OpenAI Codex), and lets you add or disconnect credentials without touching the command line.

![Hermes dashboard keys page showing OAuth provider connections and LLM provider API key management](../../assets/images/26/04/hermes-dash-keys.webp)

## Starting the dashboard

The simplest way to start:

```bash
hermes dashboard
```

This builds the web UI (first run only), then starts a server on `http://127.0.0.1:9119`. It will also open a browser tab if you are running it locally.

### Available flags

| Flag | Default | Description |
|---|---|---|
| `--host` | `127.0.0.1` | Interface to bind to |
| `--port` | `9119` | Port to listen on |
| `--no-open` | off | Skip automatic browser launch |

The default `127.0.0.1` binding means only your machine can reach the dashboard. That is the safe default — if you need remote access, keep reading.

## Remote access via SSH port forwarding

If you just want to open the dashboard from your laptop while it runs on a VPS, SSH port forwarding is the simplest option. No reverse proxy, no DNS, no open ports on the server — everything goes through your existing SSH connection.

```bash
ssh -L 9119:127.0.0.1:9119 user@your-vps-ip
```

Then open `http://127.0.0.1:9119` in your browser on your laptop. The `-L` flag forwards your local port 9119 to the VPS&apos;s localhost:9119 through the SSH tunnel. The dashboard sees it as a local connection.

This is the safest way to access the dashboard remotely because:

- The dashboard stays bound to `127.0.0.1` — nothing else on the internet can reach it
- Traffic is encrypted inside SSH — no extra TLS setup needed
- You already have SSH access, so there is nothing new to secure
- No open ports on the VPS beyond SSH

&lt;Notice type=&quot;info&quot; title=&quot;When to use SSH forwarding&quot;&gt;
Use this if you are the only person accessing the dashboard and you always connect from a machine with SSH access. Skip the Caddy and systemd sections entirely — just keep &lt;code&gt;hermes dashboard&lt;/code&gt; running in a tmux or screen session.
&lt;/Notice&gt;

### Background SSH tunnel

If you want the tunnel to stay open in the background:

```bash
ssh -f -N -L 9119:127.0.0.1:9119 user@your-vps-ip
```

- `-f` — forks to background after authentication
- `-N` — no remote command, just the tunnel

To kill it later:

```bash
# Find the process
ps aux | grep &quot;ssh -f -N -L 9119&quot;

# Kill it
kill &lt;PID&gt;
```

### Keep the dashboard running on the VPS

With SSH forwarding, the dashboard still needs to be running on the VPS. Use tmux or screen so it survives SSH disconnections:

```bash
# On the VPS
tmux new -s hermes
hermes dashboard
# Ctrl+B then D to detach

# Later, reattach with
tmux attach -t hermes
```

If you want it running permanently even without SSH, use the systemd service described further below.

## Exposing the dashboard externally

To access the dashboard from another machine (your phone, your laptop, or a teammate), you need two things: bind to all interfaces and put a reverse proxy in front.

### Step 1: Bind to 0.0.0.0

```bash
hermes dashboard --host 0.0.0.0
```

This makes the server listen on all network interfaces. On its own, this means anyone who can reach your server&apos;s IP can access the dashboard — do not stop here.

### Step 2: Reverse proxy with Caddy

If you are already running [Caddy as a reverse proxy](/caddy-docker/) on your server, add a block like this to your Caddyfile:

```caddyfile
hermes.yourdomain.com {
    reverse_proxy your-server-ip:9119
    encode gzip
    header {
        Strict-Transport-Security &quot;max-age=31536000; includeSubDomains; preload&quot;
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        X-XSS-Protection &quot;1; mode=block&quot;
    }
}
```

If your Caddy instance runs inside Docker and the dashboard runs on the host, use `host.docker.internal` instead of the IP address:

```caddyfile
hermes.yourdomain.com {
    reverse_proxy host.docker.internal:9119
}
```

&lt;Notice type=&quot;warning&quot; title=&quot;Host networking requirement&quot;&gt;
If the dashboard binds to 127.0.0.1 (the default), Docker containers cannot reach it through &lt;code&gt;host.docker.internal&lt;/code&gt;. You must either use &lt;code&gt;--host 0.0.0.0&lt;/code&gt; or run Caddy on the host directly (not in Docker).
&lt;/Notice&gt;

Then reload Caddy:

```bash
sudo docker exec caddy caddy reload --config /etc/caddy/Caddyfile
```

Or if Caddy runs on the host:

```bash
sudo systemctl reload caddy
```

## Adding password protection

The Hermes dashboard itself does not have a built-in password feature. But since it sits behind Caddy, you can use HTTP Basic Auth at the proxy level. This adds a browser login prompt before anyone reaches the dashboard.

### Generate a password hash

Caddy stores passwords as bcrypt hashes. Generate one:

```bash
caddy hash-password --plaintext &apos;your-password-here&apos;
```

Or inside a Dockerized Caddy:

```bash
sudo docker exec caddy caddy hash-password --plaintext &apos;your-password-here&apos;
```

This outputs a hash string starting with `$2a$...`.

### Update the Caddyfile

Add a `basic_auth` directive inside the site block:

```caddyfile
hermes.yourdomain.com {
    basic_auth {
        yourusername $2a$14$...your-hash-here...
    }
    reverse_proxy host.docker.internal:9119
    encode gzip
    header {
        Strict-Transport-Security &quot;max-age=31536000; includeSubDomains; preload&quot;
        X-Content-Type-Options nosniff
        X-Frame-Options SAMEORIGIN
        X-XSS-Protection &quot;1; mode=block&quot;
    }
}
```

Replace `yourusername` and the hash with your own. You can add multiple username-hash pairs for different users.

### Verify it works

Without credentials — expect 401:

```bash
curl -sk -o /dev/null -w &quot;%{http_code}&quot; https://hermes.yourdomain.com/
# 401
```

With credentials — expect 200:

```bash
curl -sk -o /dev/null -w &quot;%{http_code}&quot; -u yourusername:your-password-here https://hermes.yourdomain.com/
# 200
```

When you open the URL in a browser, you will see a login popup before the dashboard loads.

### Block direct access to port 9119

With Caddy handling authentication and TLS on port 443, there is no reason to let anyone reach port 9119 directly. If someone discovers your server&apos;s IP address, they could bypass Caddy and hit the dashboard on port 9119 without any password. Block it with iptables:

```bash
# Allow Docker/Caddy internal traffic first
sudo iptables -A INPUT -p tcp --dport 9119 -s 172.16.0.0/12 -j ACCEPT

# Block everything else
sudo iptables -A INPUT -p tcp --dport 9119 -j DROP
```

&lt;Notice type=&quot;warning&quot; title=&quot;The Docker allow rule is critical&quot;&gt;
If you only block 9119 without allowing Docker traffic first, Caddy will also be blocked. Caddy connects to the dashboard through Docker&apos;s internal network (172.16.0.0/12 range). The ACCEPT rule must come before the DROP rule — iptables processes rules in order.
&lt;/Notice&gt;

Verify the rules are in place:

```bash
sudo iptables -L INPUT -n | grep 9119
# ACCEPT  tcp  --  172.16.0.0/12  0.0.0.0/0  tcp dpt:9119
# DROP    tcp  --  0.0.0.0/0      0.0.0.0/0  tcp dpt:9119
```

This blocks all external connections to 9119 while letting Caddy reach the dashboard internally.

&lt;Notice type=&quot;warning&quot; title=&quot;Persist the rule across reboots&quot;&gt;
iptables rules are lost on reboot by default. Save them:
&lt;code&gt;sudo iptables-save | sudo tee /etc/iptables/rules.v4&lt;/code&gt;
If the file does not exist, install &lt;code&gt;iptables-persistent&lt;/code&gt; first: &lt;code&gt;sudo apt install iptables-persistent&lt;/code&gt;
&lt;/Notice&gt;

If you are using `ufw` instead of raw iptables:

```bash
sudo ufw deny 9119/tcp
```

## Running permanently with systemd

The `hermes dashboard` command runs in the foreground. If you close your terminal, it stops. To keep it running permanently, create a systemd service.

### Create the service file

```bash
sudo tee /etc/systemd/system/hermes-dashboard.service &lt;&lt; &apos;EOF&apos;
[Unit]
Description=Hermes Agent Dashboard
After=network.target

[Service]
Type=simple
User=dragos
ExecStart=/home/dragos/.hermes/hermes-agent/venv/bin/python -m hermes_cli.main dashboard --host 0.0.0.0 --port 9119 --no-open
Restart=on-failure
RestartSec=5
Environment=HOME=/home/dragos

[Install]
WantedBy=multi-user.target
EOF
```

Adjust `User` and the Python path to match your setup. You can find the exact path with:

```bash
which hermes
# or
readlink -f $(which hermes)
```

### Enable and start

```bash
sudo systemctl daemon-reload
sudo systemctl enable hermes-dashboard
sudo systemctl start hermes-dashboard
sudo systemctl status hermes-dashboard
```

### Check logs

```bash
sudo journalctl -u hermes-dashboard -f
```

## Running in Docker

If you prefer running the dashboard in a Docker container (consistent with how you deploy other services on the VPS), you can containerize it. The key constraint is that the dashboard needs to reach the Hermes gateway at `127.0.0.1:8642`, so the container must use host networking.

### Dockerfile

```dockerfile
FROM python:3.12-slim

RUN pip install hermes-agent[web]

EXPOSE 9119

CMD [&quot;python&quot;, &quot;-m&quot;, &quot;hermes_cli.main&quot;, &quot;dashboard&quot;, &quot;--host&quot;, &quot;0.0.0.0&quot;, &quot;--port&quot;, &quot;9119&quot;, &quot;--no-open&quot;]
```

### docker-compose.yml

```yaml
services:
  hermes-dashboard:
    build: .
    container_name: hermes-dashboard
    network_mode: host
    restart: unless-stopped
```

`network_mode: host` is required because the gateway binds to `127.0.0.1:8642` — without it, the container cannot reach the gateway even with `host.docker.internal`.

### With Caddy in the same stack

If you are running Caddy in Docker already (typical setup with a `web` external network), the dashboard container using `network_mode: host` is reachable from Caddy at `host.docker.internal:9119`. Add the Caddy block as described in the [Caddy reverse proxy section](#step-2-reverse-proxy-with-caddy) above — no changes needed.

### Build and run

```bash
docker compose up -d --build
```

### Check it is running

```bash
sudo docker ps --filter name=hermes-dashboard
curl -sk -o /dev/null -w &quot;%{http_code}&quot; http://127.0.0.1:9119/
```

&lt;Notice type=&quot;warning&quot; title=&quot;Host networking and security&quot;&gt;
&lt;code&gt;network_mode: host&lt;/code&gt; means the container shares the host&apos;s network stack directly. The dashboard port is accessible on all interfaces — make sure Caddy or a firewall is in front if you expose it publicly.
&lt;/Notice&gt;

## Security risks

Exposing an AI agent dashboard to the internet is not the same as exposing a static website. Here is what you need to understand.

### The dashboard is a control plane

Anyone with access to the dashboard can view sessions, read memory files, modify API keys, and change configuration. It is not just a read-only status page — it is full administrative access to your AI agent.

### Basic Auth sends credentials in base64

HTTP Basic Auth encodes your username and password in base64 inside every request header. Without HTTPS, anyone sniffing your network can decode them instantly. Caddy provides automatic HTTPS with Let&apos;s Encrypt, which solves this — but only if you use a real domain name, not a raw IP address.

&lt;Notice type=&quot;error&quot; title=&quot;Never expose without HTTPS&quot;&gt;
Do not run the dashboard on port 9119 with &lt;code&gt;--host 0.0.0.0&lt;/code&gt; without a TLS-terminating reverse proxy in front. Basic Auth over plain HTTP is essentially no auth at all.
&lt;/Notice&gt;

### Browser credential storage

Once you log in through Basic Auth, most browsers cache the credentials and send them automatically on every request to that domain. If someone gains access to your browser session (shared computer, browser exploit), they have free access to the dashboard.

### No rate limiting by default

Neither the dashboard nor Basic Auth includes brute-force protection. An attacker can try thousands of passwords per minute. Consider adding fail2ban rules for your reverse proxy logs, or using Caddy&apos;s `forward_auth` with a more robust auth provider if you need this.

### IP allowlisting helps

If you always access the dashboard from the same IP (office, home), add firewall rules to restrict access:

```bash
# Allow only your IP
sudo ufw allow from YOUR_IP_ADDRESS to any port 443
sudo ufw deny 443
```

Or in Caddy, use a `@blocked` matcher:

```caddyfile
hermes.yourdomain.com {
    @blocked not remote_ip YOUR_IP_ADDRESS/32
    respond @blocked &quot;Forbidden&quot; 403

    basic_auth {
        yourusername $2a$14$...
    }
    reverse_proxy host.docker.internal:9119
}
```

### The dashboard talks to the gateway

The dashboard communicates with the Hermes gateway API at `127.0.0.1:8642`. If someone compromises the dashboard, they also have indirect access to the gateway — which controls the agent that can execute terminal commands on your server. Treat the dashboard as a privileged surface, not a convenience feature.

### Summary of risks

| Risk | Severity | Mitigation |
|---|---|---|
| Dashboard exposed without HTTPS | Critical | Always use Caddy with TLS |
| Port 9119 open to the internet | Critical | Block with iptables (see above) |
| Weak or reused password | High | Generate a strong random password |
| No brute-force protection | Medium | Add fail2ban or IP allowlisting |
| Browser caches credentials | Medium | Use private browsing on shared machines |
| Dashboard gives gateway access | High | IP restrict + strong auth + monitor logs |

## Quick reference

```bash
# Local only (safe default)
hermes dashboard

# SSH tunnel (remote access, safest)
ssh -L 9119:127.0.0.1:9119 user@your-vps-ip
# Then open http://127.0.0.1:9119 on your laptop

# Remote access (bind to all interfaces)
hermes dashboard --host 0.0.0.0

# Custom port
hermes dashboard --host 0.0.0.0 --port 3000

# With systemd (permanent)
sudo systemctl start hermes-dashboard

# With Docker
docker compose up -d --build

# Check it is running
curl -sk -o /dev/null -w &quot;%{http_code}&quot; https://hermes.yourdomain.com/
```

For more on Hermes Agent setup, messaging integration, and skills, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/) and the [MIMO V2 Pro integration guide](/hermes-agent-mimo-v2-pro/). For the best third-party dashboards and web UIs beyond the built-in one, see the [best Hermes dashboards](/best-hermes-dashboards/) roundup. For affordable model recommendations for Hermes Agent, see the [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide. For structured multi-agent task management with visual boards, the [Hermes Kanban setup guide](/hermes-kanban-setup-guide/) covers Kanban task boards, dependencies, and coordination patterns.

&lt;Button text=&quot;More AI tool guides&quot; link=&quot;/category/ai/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>Hetzner Cloud Pricing After the April 2026 Increase (Still 4x Cheaper)</title><link>https://www.bitdoze.com/hetzner-cloud-cost-optimized-plans/</link><guid isPermaLink="true">https://www.bitdoze.com/hetzner-cloud-cost-optimized-plans/</guid><description>Hetzner raised cloud prices up to 37% in April 2026. Updated CX Gen3 and CAX ARM pricing, how it compares to DigitalOcean and Vultr, and whether it&apos;s still worth it.</description><pubDate>Fri, 12 Jun 2026 00:00:00 GMT</pubDate><content:encoded>Hetzner changed how it organizes cloud servers in EU and Singapore locations. The new setup includes Shared: Cloud Cost-Optimized plans, which use x86 and ARM servers. Note: Hetzner raised prices by up to 37% on April 1, 2026 — this article reflects the updated pricing.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started with Hetzner&quot;&gt;
    New to Hetzner Cloud? [Get €20 credit](https://go.bitdoze.com/hetzner) when you sign up and test these new cost-optimized plans risk-free.
&lt;/Notice&gt;

## What Changed: Server Plan Structure

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/k6H4TmuHF4Q &quot;
  label=&quot;Hetzner Cloud Cost-Optimized Plans: Starting at €3.49/Month&quot;
/&gt;


Hetzner now uses a generation-based system instead of categorizing by hardware type across EU and Singapore datacenters. The new setup has three main server lines:

&lt;Accordion label=&quot;Shared: Cloud Regular Performance (CPX Gen2)&quot; group=&quot;plans&quot;&gt;

These servers run on the latest AMD hardware available at each location. As of 2025, CPX servers use AMD EPYC-Genoa processors.

&lt;ListCheck&gt;
- CPX22: 2 vCPU, 4GB RAM, 80GB SSD - €6.49/month
- CPX32: 4 vCPU, 8GB RAM, 160GB SSD - €10.99/month
- CPX42: 8 vCPU, 16GB RAM, 320GB SSD - €19.99/month
- CPX52: 12 vCPU, 24GB RAM, 480GB SSD - €28.49/month
- CPX62: 16 vCPU, 32GB RAM, 640GB SSD - €38.99/month
&lt;/ListCheck&gt;

Use these for: Production workloads that need consistent performance

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Shared: Cloud Cost-Optimized (CX Gen3 / CAX)&quot; group=&quot;plans&quot;&gt;

These plans use older x86 hardware (CX Gen3) or ARM servers (CAX).

CX Gen3 (x86):
&lt;ListCheck&gt;
- CX23: 2 vCPU, 4GB RAM, 40GB SSD - €3.99/month (+€0.50 IPv4 = €4.49)
- CX33: 4 vCPU, 8GB RAM, 80GB SSD - €6.49/month (+€0.50 IPv4 = €6.99)
- CX43: 8 vCPU, 16GB RAM, 160GB SSD - €11.99/month (+€0.50 IPv4 = €12.49)
- CX53: 16 vCPU, 32GB RAM, 320GB SSD - €22.49/month (+€0.50 IPv4 = €22.99)
&lt;/ListCheck&gt;

CAX (ARM - Ampere):
&lt;ListCheck&gt;
- CAX11: 2 vCPU, 4GB RAM, 40GB SSD - €4.49/month (+€0.50 IPv4 = €4.99)
- CAX21: 4 vCPU, 8GB RAM, 80GB SSD - €7.99/month (+€0.50 IPv4 = €8.49)
- CAX31: 8 vCPU, 16GB RAM, 160GB SSD - €15.99/month (+€0.50 IPv4 = €16.49)
- CAX41: 16 vCPU, 32GB RAM, 320GB SSD - €31.49/month (+€0.50 IPv4 = €31.99)
&lt;/ListCheck&gt;

Use these for: Development, testing, non-critical workloads, and cloud-native ARM applications

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Dedicated: Cloud General Purpose (CCX Gen)&quot; group=&quot;plans&quot;&gt;

These servers run on the latest hardware with dedicated vCPUs.

&lt;ListCheck&gt;
- CCX13: 2 vCPU, 8GB RAM, 80GB SSD - €12.49/month
- CCX23: 4 vCPU, 16GB RAM, 160GB SSD - €24.49/month
- CCX33: 8 vCPU, 32GB RAM, 240GB SSD, 30TB bandwidth - €48.49/month
- CCX43: 16 vCPU, 64GB RAM, 360GB SSD, 40TB bandwidth - €96.49/month
- CCX53: 32 vCPU, 128GB RAM, 600GB SSD, 50TB bandwidth - €192.49/month
- CCX63: 48 vCPU, 192GB RAM, 960GB SSD, 60TB bandwidth - €288.49/month
&lt;/ListCheck&gt;

Use these for: High-traffic applications, databases, and production servers

&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Existing Servers Not Affected&quot;&gt;
    Existing CX Gen2 and CPX Gen1 servers continue to work normally. This change only affects new server deployments in EU and Singapore locations.
&lt;/Notice&gt;

## Performance Testing: CX Gen3 Cloud Cost-Optimized

I tested the CX33 plan (4 vCPU, 8GB RAM, 80GB SSD) at €6.49/month in the Nuremberg datacenter.

### System Specifications
&lt;ListCheck&gt;
- Processor: AMD EPYC-Rome
- CPU Cores: 4 @ 2445.404 MHz
- RAM: 7.6 GiB
- Disk: 75GB SSD (NVMe)
- Location: Nuremberg, Germany
- Bandwidth: 20TB included
&lt;/ListCheck&gt;

### Disk Performance (fio)

| Block Size | Read Speed | Write Speed | Total | IOPS |
|------------|------------|-------------|-------|------|
| 4k | 115.01 MB/s | 115.32 MB/s | 230.34 MB/s | 57.5k |
| 64k | 988.49 MB/s | 993.69 MB/s | 1.98 GB/s | 30.9k |
| 512k | 1.78 GB/s | 1.88 GB/s | 3.66 GB/s | 7.1k |
| 1m | 2.16 GB/s | 2.30 GB/s | 4.46 GB/s | 4.3k |

Peak speeds reached 4.46 GB/s for large block operations. The NVMe drives perform well at this price point.

### Network Performance (iperf3 IPv4)

| Provider | Location | Upload | Download | Latency |
|----------|----------|--------|----------|---------|
| Eranium | Amsterdam | 12.3 Gbits/sec | 12.8 Gbits/sec | 9.27 ms |
| Clouvider | London | 5.16 Gbits/sec | 5.60 Gbits/sec | 17.8 ms |
| Leaseweb | NYC | 1.88 Gbits/sec | 2.53 Gbits/sec | 97.7 ms |
| Uztelecom | Tashkent | 1.96 Gbits/sec | 2.24 Gbits/sec | 94.6 ms |
| Clouvider | Los Angeles | 1.03 Gbits/sec | 1.21 Gbits/sec | 158 ms |
| Leaseweb | Singapore | 665 Mbits/sec | 841 Mbits/sec | 166 ms |

European network speeds were strong, hitting 12+ Gbits/sec to Amsterdam. Transatlantic connectivity performed well, and Asia-Pacific speeds were decent for a European server.

### CPU Performance (Geekbench 6)

| Test | Score |
|------|-------|
| Single Core | 1508 |
| Multi Core | 4919 |

The multi-core score of 4919 is good for the €5.49/month price point.

## Cost Comparison: Regular vs Cost-Optimized

Here&apos;s the pricing difference between Regular Performance and Cost-Optimized plans:

| Specs | Regular (CPX) | Cost-Optimized (CX) | Savings |
|-------|---------------|---------------------|---------|
| 2 vCPU, 4GB | €7.99 (CPX22, 80GB) | €3.99 (CX23, 40GB) | 50% |
| 4 vCPU, 8GB | €13.49 (CPX32, 160GB) | €6.49 (CX33, 80GB) | 52% |
| 8 vCPU, 16GB | €25.49 (CPX42, 320GB) | €11.99 (CX43, 160GB) | 53% |
| 16 vCPU, 32GB | €38.99 (CPX62, 640GB) | €22.49 (CX53, 320GB) | 42% |

Cost-Optimized plans save about 50% while providing similar RAM with less storage. For most workloads, the performance difference is minimal.

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;

## When to Choose Cost-Optimized Plans

&lt;Accordion label=&quot;Good Use Cases&quot; group=&quot;use-cases&quot;&gt;

&lt;ListCheck&gt;
- Development environments - Full-featured dev servers at minimal cost
- Testing servers - Spin up test environments without breaking the budget
- Small WordPress sites - Handle moderate traffic efficiently
- Staging environments - Match production specs for less
- Personal projects - Host hobby projects affordably
- Learning platforms - Practice DevOps without high costs
- CI/CD runners - Cost-effective build and deployment pipelines
- Monitoring tools - Run Grafana, Prometheus, Uptime Kuma cheaply
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;When to Use Regular Performance Instead&quot; group=&quot;use-cases&quot;&gt;

&lt;ListCheck&gt;
- Production applications with consistent traffic
- Database servers requiring reliable performance
- High-traffic WordPress sites (1000+ daily visitors)
- E-commerce platforms where performance impacts revenue
- Real-time applications needing consistent latency
- Video processing or CPU-intensive tasks
- Enterprise workloads with SLA requirements
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## ARM vs x86: Choosing Between CAX and CX

The Cost-Optimized line offers ARM (CAX) and x86 (CX Gen3) servers.

&lt;Tabs&gt;
&lt;Tab name=&quot;CAX (ARM) Advantages&quot;&gt;

&lt;ListCheck&gt;
- Better performance per euro for cloud-native apps
- Good multi-core performance with Ampere processors
- Energy efficient
- Native support for ARM-compiled software
- ARM is gaining wider adoption
- Works well with containers and microservices
&lt;/ListCheck&gt;

Pricing: €3.79 to €24.49/month

&lt;/Tab&gt;
&lt;Tab name=&quot;CX Gen3 (x86) Advantages&quot;&gt;

&lt;ListCheck&gt;
- Compatible with all software
- Proven technology with mature ecosystem
- Legacy application support without recompilation
- Wider OS selection including older distributions
- Standard tooling works out of the box
- No architecture considerations needed
&lt;/ListCheck&gt;

Pricing: €3.49 to €17.49/month

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;ARM Compatibility&quot;&gt;
    Before choosing CAX ARM servers, verify your application stack supports ARM64. Modern frameworks like Node.js, Python, Docker, and Go work, but some legacy software may require x86.
&lt;/Notice&gt;

## Pricing Breakdown: All Plans

### Shared: Cloud Cost-Optimized (EU &amp; SIN)

CX Gen3 (x86 - Intel/AMD), prices excl. IPv4 (add €0.50/mo for IPv4):

| Plan | vCPU | RAM | Storage | Bandwidth | Hourly | Monthly |
|------|------|-----|---------|-----------|--------|---------|
| CX23 | 2 | 4GB | 40GB | 20TB | €0.0064 | €3.99 |
| CX33 | 4 | 8GB | 80GB | 20TB | €0.0104 | €6.49 |
| CX43 | 8 | 16GB | 160GB | 20TB | €0.0192 | €11.99 |
| CX53 | 16 | 32GB | 320GB | 20TB | €0.036 | €22.49 |

CAX (ARM - Ampere), prices excl. IPv4 (add €0.50/mo for IPv4):

| Plan | vCPU | RAM | Storage | Bandwidth | Hourly | Monthly |
|------|------|-----|---------|-----------|--------|---------|
| CAX11 | 2 | 4GB | 40GB | 20TB | €0.0072 | €4.49 |
| CAX21 | 4 | 8GB | 80GB | 20TB | €0.0128 | €7.99 |
| CAX31 | 8 | 16GB | 160GB | 20TB | €0.0256 | €15.99 |
| CAX41 | 16 | 32GB | 320GB | 20TB | €0.0504 | €31.49 |

### Shared: Cloud Regular Performance

As of 2025, CPX servers use AMD EPYC-Genoa processors.

| Plan | vCPU | RAM | Storage | Bandwidth | Hourly | Monthly |
|------|------|-----|---------|-----------|--------|---------|
| CPX22 | 2 | 4GB | 80GB | 20TB | €0.0128 | €7.99 |
| CPX32 | 4 | 8GB | 160GB | 20TB | €0.0216 | €13.49 |
| CPX42 | 8 | 16GB | 320GB | 20TB | €0.0408 | €25.49 |
| CPX52 | 12 | 24GB | 480GB | 20TB | €0.0548 | €34.49 |
| CPX62 | 16 | 32GB | 640GB | 20TB | €0.0625 | €38.99 |

### Dedicated: Cloud General Purpose

| Plan | vCPU | RAM | Storage | Bandwidth | Hourly | Monthly |
|------|------|-----|---------|-----------|--------|---------|
| CCX13 | 2 | 8GB | 80GB | 20TB | €0.02 | €12.49 |
| CCX23 | 4 | 16GB | 160GB | 20TB | €0.0504 | €31.49 |
| CCX33 | 8 | 32GB | 240GB | 30TB | €0.0777 | €48.49 |
| CCX43 | 16 | 64GB | 360GB | 40TB | €0.1546 | €96.49 |
| CCX53 | 32 | 128GB | 600GB | 50TB | €0.3085 | €192.49 |
| CCX63 | 48 | 192GB | 960GB | 60TB | €0.4623 | €288.49 |

## Real-World Example: Cost Savings Scenarios

### Scenario 1: Small WordPress Site
**Before**: CPX22 (2 vCPU, 4GB, 80GB) - €7.99/month
**Now**: CX23 (2 vCPU, 4GB, 40GB) - €3.99/month
**Savings**: €4.00/month (€48/year) - 50% reduction

### Scenario 2: Development Environment
**Before**: CPX32 (4 vCPU, 8GB, 160GB) - €13.49/month
**Now**: CX33 (4 vCPU, 8GB, 80GB) - €6.49/month
**Savings**: €7.00/month (€84/year) - 52% reduction

### Scenario 3: Multi-Server Setup (3 servers)
**Before**: 3x CPX22 (2 vCPU, 4GB) - 3 × €7.99 = €23.97/month
**Now**: 3x CX23 (2 vCPU, 4GB) - 3 × €3.99 = €11.97/month
**Savings**: €12.00/month (€144/year)

## Migration Guide: Moving to Cost-Optimized Plans

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;

&lt;Accordion label=&quot;Step 1: Assess Current Usage&quot; group=&quot;migration&quot;&gt;

&lt;ListCheck&gt;
- Review CPU utilization over the past 30 days
- Check average RAM usage patterns
- Analyze disk space requirements
- Monitor network bandwidth consumption
- Identify peak traffic periods
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Step 2: Choose the Right Plan&quot; group=&quot;migration&quot;&gt;

Decision matrix:

&lt;ListCheck&gt;
- Under 50% average CPU: Cost-Optimized is perfect
- 50-80% average CPU: Consider Regular Performance
- Over 80% average CPU: Stay with Dedicated or Regular
- Storage needs &lt; 100GB: Cost-Optimized works well
- Cloud-native stack: Consider CAX ARM for better value
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Step 3: Create and Test&quot; group=&quot;migration&quot;&gt;

&lt;ListCheck&gt;
- Create new Cost-Optimized server
- Deploy application to test environment
- Run performance benchmarks
- Test under load with realistic traffic
- Verify all services function correctly
- Compare response times with current server
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Comparing with Competitors

How do these Cost-Optimized plans stack up against other providers?

| Provider | vCPU | RAM | Storage | Price | vs Hetzner |
|----------|------|-----|---------|-------|------------|
| Hetzner CX33 | 4 | 8GB | 80GB | €6.49 | Baseline |
| DigitalOcean | 2 | 4GB | 80GB | $24/€22 | 3.5x more expensive |
| Linode | 2 | 4GB | 80GB | $18/€17 | 2.5x more expensive |
| Vultr | 2 | 4GB | 80GB | $18/€17 | 2.5x more expensive |
| AWS Lightsail | 2 | 4GB | 80GB | $24/€23 | 3.5x more expensive |
| Azure B2s | 2 | 4GB | 30GB | $38/€36 | 5.5x more expensive |

Hetzner&apos;s Cost-Optimized plans offer good value, providing 2-4x the resources at lower cost.

## Frequently Asked Questions

&lt;Accordion label=&quot;Can I upgrade from Cost-Optimized to Regular Performance?&quot; group=&quot;faq&quot;&gt;

Yes, you can easily scale up to Regular Performance or Dedicated plans. Hetzner allows vertical scaling with minimal downtime. However, you cannot downgrade from Regular to Cost-Optimized without creating a new server.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the performance difference between CX Gen3 and CPX Gen2?&quot; group=&quot;faq&quot;&gt;

CX Gen3 runs on older hardware generations (formerly CX Gen1/Gen2 and CPX Gen1), while CPX Gen2 uses the latest AMD hardware. In practice, for most web applications, the performance difference is minimal (5-15%). CPU-intensive workloads may see more noticeable differences.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Are Cost-Optimized plans suitable for production?&quot; group=&quot;faq&quot;&gt;

Yes, for many production workloads. If your application runs at moderate CPU utilization (under 60%), Cost-Optimized plans work excellently. High-traffic or CPU-intensive production apps should consider Regular Performance or Dedicated plans.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which datacenters offer Cost-Optimized plans?&quot; group=&quot;faq&quot;&gt;

Cost-Optimized plans are available in:
- EU: Nuremberg (Germany), Falkenstein (Germany), Helsinki (Finland)
- Asia: Singapore

US datacenters (Ashburn, Hillsboro) maintain the previous plan structure.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can CX Gen3 run on Intel or AMD?&quot; group=&quot;faq&quot;&gt;

Yes, CX Gen3 plans can run on either Intel or AMD hardware depending on availability at provisioning time. You cannot choose the specific CPU type, but both deliver similar performance for the price point.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Should I choose ARM (CAX) or x86 (CX) for Docker containers?&quot; group=&quot;faq&quot;&gt;

For modern Docker containers with multi-architecture images, CAX ARM servers often provide better performance per euro. Most official Docker images support ARM64. Check your specific images at Docker Hub before committing.

&lt;/Accordion&gt;

## Best Practices for Cost-Optimized Servers

&lt;ListCheck&gt;
- Monitor resources actively - Set up alerts for CPU/RAM/disk usage
- Implement caching - Use Redis or Memcached to reduce compute load
- Optimize databases - Proper indexing and query optimization help
- Use CDN - Offload static assets to reduce server load
- Schedule intensive tasks - Run backups and processing during low-traffic hours
- Right-size your plan - Start small and scale up if needed
- Use hourly billing - Test different plans without long-term commitment
- Enable backups - 20% server cost for automated daily backups is worth it
&lt;/ListCheck&gt;

## Conclusion

Hetzner&apos;s Cost-Optimized plans still offer good value even after the April 2026 price increase. At €6.49/month for 4 vCPU and 8GB RAM, you get solid infrastructure at low prices.

### Key Takeaways:

&lt;ListCheck&gt;
- 50% cheaper than Regular Performance plans
- Still roughly 3-5x cheaper than DigitalOcean, Linode, and Vultr
- Regular Performance now uses AMD EPYC-Genoa processors
- Good performance for development, testing, and moderate production workloads
- ARM option (CAX) offers better performance per euro for cloud-native apps
- 20TB bandwidth included in European datacenters
- Hourly billing for flexibility
- IPv4 costs an extra €0.50/month on all shared plans
&lt;/ListCheck&gt;

### Who Should Use Cost-Optimized Plans?

- Developers and startups watching costs
- Side projects and personal websites
- Development and staging environments
- Learning and experimentation
- Small to medium WordPress sites
- Microservices and containerized workloads

### Who Should Consider Regular or Dedicated?

- High-traffic production applications
- Database servers with heavy queries
- CPU-intensive processing tasks
- Enterprise applications with SLA requirements
- Applications needing consistent performance guarantees

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;

&lt;Notice type=&quot;success&quot; title=&quot;€20 Free Credit&quot;&gt;
    [Sign up for Hetzner Cloud](https://go.bitdoze.com/hetzner) and receive €20 credit to test these Cost-Optimized plans. That covers about 3 months of a CX33 server (4 vCPU, 8GB RAM) at the new pricing.

For modern workloads: Try CAX21 (€7.99/month excl. IPv4) if your stack supports ARM.

&lt;/Notice&gt;

For more detailed benchmarking and comparisons, check out our [Hetzner Cloud Review](https://www.bitdoze.com/hetzner-cloud-review/).</content:encoded><category>hosting</category><category>hetzner</category></item><item><title>Coolify vs Dokploy vs Kamal 2: Picking the Right Self-Hosted PaaS</title><link>https://www.bitdoze.com/coolify-vs-dokploy-vs-kamal-2/</link><guid isPermaLink="true">https://www.bitdoze.com/coolify-vs-dokploy-vs-kamal-2/</guid><description>A hands-on comparison of Coolify, Dokploy, and Kamal 2 covering resource usage, deployment workflows, database management, and which tool fits different server setups.</description><pubDate>Mon, 25 May 2026 00:00:00 GMT</pubDate><content:encoded>I&apos;ve been paying managed PaaS bills for years. Heroku&apos;s pricing got worse after the free tier died. Vercel starts free but the bandwidth charges sneak up on you once real traffic hits. At some point I did the math: a $6/month Hetzner VPS could run everything I was paying $40-60/month for across Heroku and Vercel combined.

The problem was never the server cost. It was the overhead of managing raw Linux boxes. Writing Nginx configs, setting up systemd services, handling SSL renewals, doing zero-downtime deployments by hand — that&apos;s the stuff that eats your weekends.

That&apos;s why self-hosted PaaS tools exist. They give you the convenience of a managed platform on your own hardware. Right now, three tools dominate this space: **Coolify**, **Dokploy**, and **Kamal 2**. I&apos;ve deployed production apps to all three over the past year, and they each solve the same problem in fundamentally different ways.

This article is the comparison I wish I had when I started. If you already know which tool interests you, we have dedicated guides for [installing Coolify](/coolify-install-heroku-alternative/) and [installing Dokploy](/dokploy-install/).

## Quick comparison

Before digging into the details, here&apos;s the high-level picture:

| | Coolify | Dokploy | Kamal 2 |
| :--- | :--- | :--- | :--- |
| **Interface** | Web dashboard | Web dashboard | CLI only |
| **Idle RAM usage** | ~1.5–2 GB | ~400–500 MB | 0 MB on server |
| **Reverse proxy** | Caddy | Traefik | Kamal Proxy |
| **SSL certificates** | Automatic | Automatic | Automatic |
| **One-click templates** | 280+ apps/databases | ~30 templates | None |
| **Multi-server** | Coming in v5 | Docker Swarm built-in | SSH to multiple hosts |
| **Database management** | Full (backup, restore, UI) | Basic (backup to S3) | Manual |
| **Minimum VPS** | 4 GB RAM | 1–2 GB RAM | 512 MB RAM |
| **License** | Apache 2.0 | Mixed (source-available parts) | MIT |
| **Best for** | All-in-one self-hosting hub | Lightweight VPS deployments | CLI-native devs, Rails teams |

## Coolify: the full-featured dashboard

Coolify is what most people think of when they hear &quot;self-hosted Heroku.&quot; It gives you a polished web interface where you connect your GitHub account, point it at a repository, and it handles the build, deployment, SSL, and routing. If you&apos;ve used Vercel&apos;s dashboard, you&apos;ll feel at home.

I wrote a [full Coolify v5 review](/coolify-v5-self-hosted-paas-review/) recently, so I won&apos;t repeat everything. But here&apos;s the short version of what makes it stand out:

The **template library** is massive. Over 280 one-click services — databases, Redis, monitoring tools, wiki software, you name it. I run [Plausible Analytics](/install-plausible-analytics/), PostgreSQL, and Redis all deployed through Coolify templates, and it took maybe 10 minutes total. Adding a new database is two clicks.

**Git-push deployments** work the way you&apos;d expect. Push to main, Coolify builds and deploys. It supports preview deployments for pull requests too, which is useful if you want to test branches on a real server before merging.

**Backups are built in.** You can schedule PostgreSQL and MySQL backups directly from the UI and push them to S3-compatible storage. I use Cloudflare R2 for this, and Coolify handles the scheduling and rotation.

### Where Coolify struggles

The biggest problem is **resource consumption**. Coolify runs a Laravel backend, a PostgreSQL database, Redis, queue workers, and a Caddy reverse proxy. All of that idles at roughly 1.5–2 GB of RAM before you deploy a single app. If you put Coolify on a 2 GB VPS, you&apos;ll get OOM-killed the moment you try to run anything meaningful alongside it.

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t cheap out on the VPS&quot;&gt;
I tried running Coolify on a 2 GB Hetzner CX22 once. It worked for about a week, then a build triggered during a traffic spike and the whole server froze. You need 4 GB minimum — more like 8 GB if you plan to run several apps and databases. Check our [Hetzner Cloud review](/hetzner-cloud-review/) for VPS recommendations.
&lt;/Notice&gt;

The other annoyance is that **proxy debugging can be painful**. When something goes wrong with Caddy routing, the error messages aren&apos;t always helpful. I&apos;ve spent more time than I&apos;d like restarting Coolify&apos;s proxy container and waiting for it to regenerate configs.

Still, if you have a server with enough RAM and you want one dashboard to manage everything, Coolify is the most complete option available. The [install process](/coolify-install-heroku-alternative/) takes about 10 minutes.

## Dokploy: the lightweight middle ground

Dokploy competes directly with Coolify but takes a leaner approach. It gives you a web dashboard for deployments and basic database management, but it runs on a fraction of the resources.

I covered the [full Dokploy installation](/dokploy-install/) in a separate guide. The short version: run a single curl command, wait two minutes, and you have a working PaaS on your server. Dokploy uses Docker Compose and Traefik under the hood, and the dashboard is clean and fast.

### What Dokploy does well

**Resource efficiency** is the main selling point. Dokploy idles around 400–500 MB of RAM. That means you can actually run it on a cheap 2 GB VPS and still have room for a couple of Node.js apps and a PostgreSQL database. For side projects and small SaaS apps, this matters a lot.

**Docker Swarm is built in.** If you outgrow a single server, Dokploy lets you add worker nodes and distribute containers across them using Docker Swarm. Coolify doesn&apos;t have this yet (it&apos;s coming in v5). For anyone who needs multi-node scaling without jumping to Kubernetes, this is a real advantage.

**Docker Compose support is native.** You can paste a full `docker-compose.yml` file into Dokploy and it runs it as-is. I&apos;ve covered this in detail in [how to deploy Docker Compose apps in Dokploy](/dokploy-docker-compose-app/). If you already have compose files for your self-hosted tools, switching to Dokploy is trivial.

Dokploy also handles [automated backups to Cloudflare R2](/dokploy-backups-cloudflare-r2/) and supports [deploying Python apps with Railpack and uv](/dokploy-python-railpack-uv/), which is nice if you&apos;re running FastHTML or Flask apps. You can also [update Docker Compose stacks](/dokploy-update-docker-compose/) directly from the UI.

### Where Dokploy falls short

The **template library is small** compared to Coolify. If you want to deploy something like Outline or Activepieces with a single click, you might need to write your own compose file. That&apos;s not hard, but it&apos;s not as convenient as Coolify&apos;s massive template catalog.

**Licensing is mixed.** The core is open source, but some features are under a more restrictive source-available license. For most users this doesn&apos;t matter, but it&apos;s worth knowing if you plan to build commercial tooling on top of it.

The **community is smaller.** Coolify has 50,000+ GitHub stars and a very active Discord. Dokploy&apos;s community is growing but you&apos;ll find fewer tutorials and troubleshooting threads. When something breaks, you&apos;re more likely to be reading source code than finding a Stack Overflow answer.

## Kamal 2: the zero-overhead CLI tool

Kamal 2 is built by the team behind Ruby on Rails (37signals, the Basecamp/HEY people). It takes a completely different approach from both Coolify and Dokploy: there is no web dashboard, no control panel installed on your server, nothing.

Kamal is a Ruby gem you run from your laptop or your CI pipeline. It connects to your server over SSH, pulls your Docker image, starts the new container, runs health checks, and swaps traffic with zero downtime. The only thing it installs on the server is a tiny reverse proxy called kamal-proxy (about 10 MB).

### What makes Kamal interesting

**Zero server overhead.** Your VPS resources go entirely to your applications. No dashboard eating 500 MB or 2 GB of RAM. For a single-app deployment on a cheap $4 VPS, this is the most efficient option by far.

**Everything is a file in your repo.** Your deployment config lives in `config/deploy.yml`, your environment variables in `.kamal/secrets`, and your Docker setup in a standard `Dockerfile`. There&apos;s nothing to click, nothing stored in a remote database. `git log` shows you the full history of every infrastructure change you&apos;ve ever made.

**Zero-downtime deploys work out of the box.** Kamal starts the new container, waits for the health check to pass, then tells kamal-proxy to route traffic to it. The old container stays running for a grace period. Rollbacks are one command: `kamal rollback`.

&lt;Tabs&gt;
&lt;Tab name=&quot;deploy.yml example&quot;&gt;
A basic Kamal deployment config looks like this:

```yaml
service: my-app
image: ghcr.io/myuser/my-app

servers:
  web:
    - 49.12.100.50
  workers:
    hosts:
      - 49.12.100.50
    cmd: bin/jobs

proxy:
  ssl: true
  host: myapp.com

registry:
  server: ghcr.io
  username: myuser
  password:
    - KAMAL_REGISTRY_PASSWORD

env:
  secret:
    - DATABASE_URL
    - REDIS_URL

accessories:
  db:
    image: postgres:16
    host: 49.12.100.50
    port: &quot;127.0.0.1:5432:5432&quot;
    env:
      secret:
        - POSTGRES_PASSWORD
    directories:
      - data:/var/lib/postgresql/data
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Common commands&quot;&gt;
Kamal&apos;s CLI is straightforward:

```bash
# First-time server setup
kamal setup

# Deploy latest changes
kamal deploy

# Roll back to previous version
kamal rollback

# Open a Rails console on the server
kamal app exec --interactive &quot;bin/rails console&quot;

# View live logs
kamal app logs -f

# Deploy to a specific server
kamal deploy --hosts=49.12.100.50
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### Where Kamal gets annoying

**No visual interface at all.** Want to check if your app is running? SSH in, or run `kamal app details`. Want to view logs? `kamal app logs`. Want to check database status? You&apos;re on your own. If you manage 10 services and want to glance at everything in one screen, Kamal doesn&apos;t help you.

**Database management is manual.** Kamal can spin up databases as &quot;accessories&quot; (see the config above), but it won&apos;t back them up, it won&apos;t give you a UI to browse tables, and it won&apos;t help you restore data. You need to set up `pg_dump` cron jobs yourself. Both Coolify and Dokploy handle this for you.

**It&apos;s Ruby-centric.** While Kamal can deploy any Docker container, the documentation and community are heavily focused on Rails. If you&apos;re a Python or Node.js developer, the examples and conventions might feel unfamiliar.

**The learning curve is real.** If you&apos;ve never used Docker registries, written Dockerfiles, or configured health check endpoints, Kamal will feel steep. Coolify and Dokploy abstract most of this away.

## Deployment workflows compared

Here&apos;s what the actual deployment experience looks like with each tool:

&lt;Tabs&gt;
&lt;Tab name=&quot;Coolify&quot;&gt;
1. Push code to GitHub
2. Coolify detects the push via webhook
3. Builds using Nixpacks (auto-detected) or your Dockerfile
4. Starts new container, runs health checks
5. Swaps traffic via Caddy reverse proxy
6. Old container is removed

Everything happens automatically. You watch it in the Coolify dashboard or ignore it entirely.
&lt;/Tab&gt;
&lt;Tab name=&quot;Dokploy&quot;&gt;
1. Push code to GitHub
2. Dokploy detects the push via webhook
3. Builds using Nixpacks, Railpack, or your Dockerfile
4. Starts new container, runs health checks
5. Swaps traffic via Traefik reverse proxy
6. Old container is removed

Same automatic flow. Dokploy&apos;s build is typically faster because there&apos;s less system overhead. The Traefik proxy is lightweight and well-documented.
&lt;/Tab&gt;
&lt;Tab name=&quot;Kamal 2&quot;&gt;
1. Push code to GitHub
2. From your terminal (or CI), run `kamal deploy`
3. Kamal builds the Docker image locally or in CI
4. Pushes to your container registry (GHCR, Docker Hub, etc.)
5. SSHs into your server, pulls the image
6. Starts new container, health check passes
7. kamal-proxy routes traffic to the new container
8. Old container runs for a grace period, then stops

You trigger it manually or wire it into GitHub Actions. It&apos;s more explicit but also more controllable.
&lt;/Tab&gt;
&lt;/Tabs&gt;

## When to use each tool

After a year of using all three, here&apos;s how I actually decide:

&lt;Accordion label=&quot;Pick Coolify when...&quot; group=&quot;pick&quot; expanded=&quot;true&quot;&gt;

- You have a VPS with 4+ GB RAM and want to manage apps, databases, and self-hosted services from one place
- You value one-click templates and don&apos;t want to write Docker Compose files for common tools
- You want built-in database backups, monitoring, and webhook-triggered deployments
- You&apos;re coming from Heroku or Vercel and want the closest self-hosted equivalent

Read the [full Coolify v5 review](/coolify-v5-self-hosted-paas-review/) or jump to the [Coolify install guide](/coolify-install-heroku-alternative/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Dokploy when...&quot; group=&quot;pick&quot;&gt;

- You&apos;re on a cheap VPS (1–2 GB RAM) and can&apos;t afford Coolify&apos;s resource overhead
- You already have Docker Compose files and want a UI to manage them
- You need multi-node scaling via Docker Swarm without the complexity of Kubernetes
- You want a PaaS experience but value efficiency over a massive template library

Follow our [Dokploy install guide](/dokploy-install/) or learn about [deploying Docker Compose apps](/dokploy-docker-compose-app/) and [setting up backups](/dokploy-backups-cloudflare-r2/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Kamal 2 when...&quot; group=&quot;pick&quot;&gt;

- You want zero overhead on the server and maximum control over deployments
- You prefer infrastructure-as-code with everything version-controlled in your repo
- You&apos;re comfortable with the terminal and don&apos;t need a web dashboard
- You&apos;re deploying a single app (or a small number of apps) and want the simplest possible server setup
- You&apos;re already in the Ruby/Rails ecosystem

&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot; title=&quot;You can also mix and match&quot;&gt;
I run Coolify on one 8 GB server for managing databases and self-hosted tools (wiki, analytics, monitoring). For my main production web app, I use Kamal 2 deploying to a separate lean VPS. There&apos;s no rule that says you have to pick only one tool.
&lt;/Notice&gt;

## Cost comparison

One of the main reasons to self-host is saving money. Here&apos;s a rough breakdown of what similar setups cost:

| Setup | Managed PaaS cost | Self-hosted cost |
| :--- | :--- | :--- |
| 1 web app + PostgreSQL + Redis | Heroku: $30-50/mo | Coolify on 4 GB VPS: $7/mo |
| 1 web app + 1 database | Vercel + Neon: $20-40/mo | Dokploy on 2 GB VPS: $4-5/mo |
| 1 web app, minimal infra | Railway: $10-20/mo | Kamal on 1 GB VPS: $3-4/mo |
| 5 apps + databases + monitoring | Multi-platform: $100+/mo | Coolify on 8 GB VPS: $15/mo |

These are rough numbers, but the pattern holds: self-hosting is 5-10x cheaper for equivalent workloads. The tradeoff is your time managing the server. Tools like Coolify and Dokploy cut that time to near-zero for common tasks.

For VPS providers, I&apos;ve had good results with [Hetzner](/hetzner-cloud-review/) for European servers.

## FAQ

&lt;Accordion label=&quot;Can I migrate from one tool to another?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes, because all three tools use standard Docker containers. Your applications are packaged as Docker images regardless of which tool deploys them. To migrate from Dokploy to Coolify (or vice versa), you set up the new tool on the same or different server, point it at your Git repository, and deploy. Databases need to be exported and reimported manually using tools like `pg_dump` and `pg_restore`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run multiple apps on one server?&quot; group=&quot;faq&quot;&gt;
Yes, all three tools support this. Coolify and Dokploy use their reverse proxies (Caddy and Traefik) to route traffic based on domain names. Kamal 2 uses kamal-proxy to do the same thing. The limiting factor is your server&apos;s RAM and CPU, not the tools themselves.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which tool handles automatic database backups?&quot; group=&quot;faq&quot;&gt;
Coolify and Dokploy both have built-in backup scheduling. You can push backups to S3-compatible storage like Cloudflare R2, Backblaze B2, or AWS S3. Kamal 2 doesn&apos;t handle backups at all — you need to set up your own cron jobs with `pg_dump` or similar tools.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do any of these work on ARM servers?&quot; group=&quot;faq&quot;&gt;
Yes. All three work on ARM64 servers, including Hetzner&apos;s ARM instances and Oracle Cloud&apos;s free ARM VMs. Coolify and Dokploy run their dashboards on ARM without issues. Kamal deploys Docker images, so as long as your image supports ARM (multi-arch builds), it works fine.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about Kubernetes?&quot; group=&quot;faq&quot;&gt;
None of these tools use Kubernetes. They&apos;re all Docker-based, which keeps them simple and resource-efficient. If you need Kubernetes-level orchestration, look at tools like Rancher or k3s instead. For most small-to-medium deployments, Docker with one of these tools is more than enough.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there vendor lock-in?&quot; group=&quot;faq&quot;&gt;
No. All three tools rely on standard Docker containers and Git repositories. If you stop using any of them, your application code stays in your repo and your containers continue running until you manually stop them. There is nothing proprietary about the deployment artifacts.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>coolify</category><category>dokploy</category><category>kamal</category></item><item><title>Coolify v5 Review: The Self-Hosted PaaS That Replaces Heroku and Vercel</title><link>https://www.bitdoze.com/coolify-v5-self-hosted-paas-review/</link><guid isPermaLink="true">https://www.bitdoze.com/coolify-v5-self-hosted-paas-review/</guid><description>Coolify v4 reached stable and v5 is coming with multi-server scalability. A complete review of features, setup, pricing, and whether it&apos;s worth migrating from Heroku or Vercel.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Heroku&apos;s pricing keeps going up and Vercel&apos;s bandwidth bills catch people off guard. Coolify is the open-source answer -- a self-hostable PaaS where you deploy apps, databases, and 280+ one-click services on your own servers.

Coolify v4.0.0 reached stable in April 2026 after two years in beta. v4.1.0 followed in May with Railpack builds, audit logging, and MCP support. v5 with multi-server scalability is actively being built.

I&apos;ve been running Coolify since the early v4 betas. Here&apos;s what works, what doesn&apos;t, and whether you should switch.

## What is Coolify?

Coolify is an open-source, self-hostable platform-as-a-service. Think Heroku or Vercel, but running on your own VPS. You get:

- Git-push deployments from GitHub, GitLab, or [Forgejo](/forgejo-woodpecker-ci-cicd/)
- One-click service templates (280+ apps, databases, and tools)
- Automatic SSL certificates via Let&apos;s Encrypt
- Reverse proxy management (Caddy-based)
- Database backups with S3/R2 support
- Real-time deployment logs
- Team management with role-based access
- Preview deployments for pull requests

The difference from Heroku: you own the server. No vendor lock-in, no surprise pricing changes, no data on someone else&apos;s infrastructure.

## What changed in v4 stable

v4.0.0 left beta after two years and 474 beta releases. Here&apos;s what stabilized:

- **SPA navigation**: Replaced Livewire full-page reloads with a proper single-page app. Navigation is faster and state persists between page changes.
- **Security hardening**: Webhook secrets are encrypted with HMAC verification. Volume paths and health check commands are validated against injection. Team scoping was tightened across resource creation flows.
- **Proxy reliability**: Database-backed proxy config storage with automatic recovery and versioned backups. Caddy configs no longer silently break after crashes.
- **GitLab integration**: Full GitLab source support with SSH deploy keys and HTTP basic auth. Not just GitHub anymore.
- **Service templates updated**: Beszel 0.18.7, Cap captcha service added, Plane re-enabled, Cal.com removed (went closed-source).

## What&apos;s new in v4.1.0

The v4.1.0 release (May 2026) added substantial features:

- **Railpack build pack**: A new beta build pack option alongside Nixpacks and Docker. Railpack supports build-time environment variables, config merging, and multi-stage builds. See our [Nixpacks vs Railpack comparison](/nixpacks-vs-railpack/).
- **Structured audit logging**: API mutations, webhook events, authentication, and authorization outcomes are now logged in a structured format. Useful for compliance and debugging.
- **MCP support**: Instance-level MCP server with read-only tools for Coolify resources. Enable it through the API or UI for AI agent integration.
- **Collapsible sidebar**: UI improvement with persisted state, tooltips, and a compact team menu.
- **Configurable stop grace periods**: Per-application grace periods for manual stops, previews, and deployments.
- **Skip CI/CD markers**: `[skip ci]` and `[skip cd]` in commit messages or PR titles skip deployment webhooks.
- **Deployment config diff tracking**: Pending changes and build-impacting modifications are surfaced before redeploying.

## What v5 will bring

v5 is the next major milestone. The core feature is **full multi-server scalability**. Currently, Coolify manages one server. v5 adds the ability to connect multiple servers to a single Coolify instance -- giving you cloud-like infrastructure with your own hardware.

From the v4.0.0 release notes: &quot;The biggest feature will be full scalability in the core, so you will have cloud infrastructure, but with your own servers.&quot;

This means:
- Deploy to multiple VPS from one Coolify dashboard
- Load-balance applications across servers
- Scale horizontally without managing each server individually
- Cloud-like orchestration on Hetzner, DigitalOcean, or bare metal

The creator (Andras Bacsai) has a working core implementation already. v5 won&apos;t mean v4 support stops -- both will be maintained in parallel.

## Coolify vs Heroku vs Vercel vs Dokploy

| Feature | Coolify | Heroku | Vercel | Dokploy |
|---|---|---|---|---|
| Self-hosted | Yes | No | No | Yes |
| Open source | Yes | No | No | Yes |
| Git deployments | GitHub, GitLab, Forgejo | GitHub | GitHub, GitLab | GitHub, GitLab |
| Build packs | Nixpacks, Docker, Railpack | Heroku buildpacks | Next.js, Vite | Nixpacks, Docker |
| Databases | PostgreSQL, MySQL, MongoDB, Redis, etc. | PostgreSQL, Redis (add-ons) | Via external services | PostgreSQL, MySQL, Redis |
| One-click services | 280+ | Via add-ons | No | 50+ |
| SSL certificates | Automatic (Let&apos;s Encrypt) | Automatic | Automatic | Automatic |
| Multi-server (v5) | Coming | Yes (dynos) | Yes (edge) | No |
| Free tier | Self-hosted (VPS cost only) | Eco dynos (limited) | Hobby (limited) | Self-hosted |
| Cost at scale | $5/month VPS | $7+/dyno/month | $20+/seat + bandwidth | $5/month VPS |
| Preview deployments | Yes | Yes (review apps) | Yes | Limited |
| MCP/AI agent support | Yes (v4.1) | No | No | No |
| Vendor lock-in | None | High | Medium | None |

&lt;Notice type=&quot;success&quot; title=&quot;Cost advantage&quot;&gt;
A $5/month Hetzner CX22 runs Coolify with enough resources for 5-10 small apps, a PostgreSQL database, and Redis. The same setup on Heroku costs $25+/month minimum. On Vercel, bandwidth alone can exceed $20/month for non-trivial traffic. Coolify saves 70-85% compared to managed platforms for typical deployments.
&lt;/Notice&gt;

## How to install Coolify

&lt;Tabs&gt;
&lt;Tab name=&quot;Fresh install&quot;&gt;

The official install script handles Docker, Coolify, and Caddy setup:

```bash
wget -q https://get.coollabs.io/coolify/install.sh \
  -O install.sh; sudo bash ./install.sh
```

This takes 5-10 minutes. When it finishes, open `http://your-server-ip:3000` and create your admin account.

For detailed setup instructions (including domain configuration and GitHub integration), see our original [Coolify install guide](/coolify-install-heroku-alternative/).

&lt;/Tab&gt;
&lt;Tab name=&quot;Upgrade from v4 beta&quot;&gt;

If you&apos;re running a beta version, upgrading to v4.0.0 stable:

```bash
# Coolify auto-updates by default
# Check your version in Settings &gt; Coolify Settings
# Or trigger manually:
docker pull ghcr.io/coollabsio/coolify:latest
docker compose up -d
```

Your data and configurations persist across upgrades. Back up your database before major version jumps.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Minimum server requirements

&lt;ListCheck&gt;
- **2 CPUs** (4 recommended for production)
- **2 GB RAM** (4 GB for multiple apps)
- **30 GB storage** (more for databases and many services)
- **Ubuntu 22.04+** or Debian 12+ (install script targets Debian-based distros)
- [Hetzner](https://go.bitdoze.com/hetzner) CX22 ($5/month) or [Hostinger](https://go.bitdoze.com/hostinger-vps) KVM1 ($6/month) work well
&lt;/ListCheck&gt;

## Deploying applications

### From Git

1. Add your GitHub or GitLab account in Settings &gt; Git Sources
2. Create a new resource, select Application, choose your repo and branch
3. Coolify detects the framework (Astro, Next.js, Django, etc.) and selects the build pack
4. Set your domain, add environment variables, hit Deploy
5. Coolify builds, deploys, and provisions SSL automatically

Push a new commit and Coolify re-deploys. You can also enable auto-deployment on push.

### From Docker Compose

Coolify supports Docker Compose as a build pack. Paste or upload your `docker-compose.yml`, configure environment variables, and deploy the full stack. This is useful for complex multi-service apps.

See the [Coolify Docker Compose docs](https://coolify.io/docs/knowledge-base/docker/compose) for details on magic environment variables and volume management.

### One-click services

The 280+ service templates include databases (PostgreSQL, MySQL, MongoDB, Redis, ClickHouse), monitoring (Uptime Kuma, Beszel), analytics (Plausible, Umami), automation (n8n), wikis (Outline), and many more. One click, configure a few settings, and the service is running.

Some services we&apos;ve covered in detail:
- [Uptime Kuma deployment](/deploy-uptime-kuma/)
- [Plausible Analytics installation](/install-plausible-analytics/)
- [Dockge install](/dockge-install/)

## What Coolify does well

After running it for months:

**Deployments are reliable.** Git-push deployments work consistently. Build logs are real-time and searchable. Failed deployments show clear error messages. Preview deployments for PRs are useful for teams.

**SSL management is automatic.** Let&apos;s Encrypt certificates are provisioned and renewed without intervention. Caddy handles the proxy layer cleanly.

**One-click services save time.** Deploying a PostgreSQL database with backups configured takes three clicks. Setting up monitoring with [Beszel](/beszel-uptime-kuma/) or Uptime Kuma is equally fast.

**The community is active.** Issues get fixed within days. The Discord server has thousands of members. Service templates are updated regularly.

**Cost savings are real.** Running 8 apps, 2 databases, and 3 services on a single Hetzner CX32 ($8/month) costs less than a single Heroku dyno.

## What&apos;s still rough

**Multi-server is missing.** You can currently manage one server per Coolify instance. If you need apps across multiple VPS, you need multiple Coolify instances. v5 fixes this, but it&apos;s not available yet.

**No built-in CDN.** Coolify serves apps from your VPS. For static assets and global audiences, you need a CDN in front. [Bunny.net](/bunny-net-review/) works well for this.

**Mobile clients don&apos;t exist.** Management is through the web UI only. There&apos;s no mobile app for quick checks.

**Some service templates are outdated.** Community-maintained templates occasionally lag behind upstream releases. Check versions before deploying.

**Debugging proxy issues is still tedious.** Caddy config problems require manual intervention occasionally. The v4.1 database-backed proxy storage helps, but edge cases remain.

## Coolify alternatives

If Coolify isn&apos;t the right fit:

- **Dokploy**: Simpler, fewer features, easier setup. Good for single-app deployments. See our [Dokploy install guide](/dokploy-install/).
- **Kamal 2**: CLI-only, zero server overhead. Built by the Rails team at 37signals. Great if you don&apos;t need a web dashboard.
- **CapRover**: Older but stable. Cluster mode available. Fewer one-click services.
- **Easypanel**: Clean UI, focused on simplicity. See our [Easypanel review](/easypanel-modern-server-control-panel/).
- **Pangolin**: Not a PaaS, but useful for exposing self-hosted services through tunnels. See our [Pangolin guide](/pangolin-cloudflare-tunnels-alternative/).

For a detailed comparison of the top three options, see our [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/) guide.

## Should you switch?

If you&apos;re on Heroku and paying more than $10/month, Coolify saves you money immediately. A $5-8/month VPS handles what Heroku charges $25+/month for.

If you&apos;re on Vercel and hitting bandwidth limits, Coolify gives you unlimited bandwidth on your own server. The tradeoff is no edge network -- your VPS serves everything. Add a CDN if you need global performance.

If you&apos;re self-hosting with raw Docker Compose, Coolify adds a management layer that makes deployments, monitoring, and backups easier. The one-click service templates alone save hours of configuration.

If you need multi-server orchestration right now, Coolify can&apos;t do it yet. v5 will add this. For single-server setups, v4.1.0 is production-ready.

The [Coolify website](https://coolify.io/) has setup guides, and the [GitHub repo](https://github.com/coollabsio/coolify) tracks active development. The community Discord is responsive for troubleshooting.

## FAQ

&lt;Accordion label=&quot;Can I run Coolify alongside other reverse proxies?&quot; group=&quot;coolify-faq&quot; expanded=&quot;true&quot;&gt;
Coolify uses Caddy as its internal reverse proxy. If you have Traefik or Nginx already running on the same server, they&apos;ll conflict on port 80/443. You can either: let Coolify manage the proxy layer (recommended), or run Coolify on a dedicated VPS with no other proxy.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Coolify support ARM servers?&quot; group=&quot;coolify-faq&quot;&gt;
Yes. Coolify runs on ARM (including Hetzner&apos;s ARM64 instances and Oracle Free Tier ARM VMs). Some service templates may have ARM compatibility issues, but core functionality works.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do backups work?&quot; group=&quot;coolify-faq&quot;&gt;
Coolify supports scheduled database backups stored locally or pushed to S3-compatible storage ([Bunny Storage](/bunny-storage-vs-s3-vs-backblaze/), AWS S3, Backblaze). Application data backups require manual volume snapshots. For full server backups, consider [zerobyte-restic-gui](/zerobyte-restic-gui/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Coolify production-ready?&quot; group=&quot;coolify-faq&quot;&gt;
v4.0.0 stable is production-ready for single-server setups. Thousands of companies ran the beta in production for 1-2 years. It has bugs (the creator acknowledges this), but they get fixed fast. For critical production workloads, set up monitoring with [Beszel](/beszel-uptime-kuma/) and keep backups current.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about Coolify Cloud?&quot; group=&quot;coolify-faq&quot;&gt;
Coolify offers a managed cloud version where they run the Coolify instance for you on their servers. Your apps still deploy to your connected VPS. It costs $5/month for the management layer, on top of your VPS cost. Useful if you don&apos;t want to maintain the Coolify instance itself.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>coolify</category></item><item><title>Headscale Setup Guide: Self-Host Your Tailscale Control Server</title><link>https://www.bitdoze.com/headscale-self-hosted-tailscale-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/headscale-self-hosted-tailscale-setup/</guid><description>Learn how to deploy Headscale with Docker on a VPS as a self-hosted Tailscale control server. Full mesh VPN with WireGuard, ACLs, and no vendor lock-in.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Tailscale makes mesh VPNs feel effortless. Install the client, log in, and every device on your account can reach every other device. No config files, no port forwarding, no VPN server to maintain. It&apos;s built on WireGuard, which means fast, encrypted tunnels with minimal overhead.

The catch: Tailscale&apos;s control server runs on their infrastructure. Your network topology, authentication data, and coordination plane all live on Tailscale&apos;s servers. For most people that&apos;s fine. But if you&apos;re self-hosting because you want to own your infrastructure end-to-end -- the same reason you&apos;d switch from [Cloudflare Tunnels to Pangolin](/pangolin-cloudflare-tunnels-alternative/) -- then Tailscale&apos;s control plane is the one piece you still don&apos;t control.

Headscale fixes that. It&apos;s an open-source, self-hosted implementation of the Tailscale control server. You run it on your VPS, and standard Tailscale clients connect to it instead of Tailscale&apos;s cloud. Same client experience, same WireGuard tunnels, but you hold the coordination server.

I&apos;ve been running Headscale for my homelab for about six months. Here&apos;s a complete setup guide.

## Why self-host your Tailscale control server?

Why people switch to Headscale:

- **Data ownership**: Tailscale&apos;s coordination server knows every device on your network, its IP, its online status, and who can reach it. With Headscale, that data stays on your server.
- **No account dependency**: Tailscale free tier gives you 6 user seats now, but if you need more or their pricing changes, you&apos;re stuck. Headscale has no user limits -- it&apos;s your server.
- **Custom authentication**: Tailscale uses their own identity provider. Headscale lets you plug in any OIDC provider -- Authentik, Keycloak, Google, whatever you already run.
- **EU data sovereignty**: If you need network coordination data to stay within a specific jurisdiction, Tailscale can&apos;t guarantee that. Headscale on a Hetzner VPS in Falkenstein can.
- **No vendor lock-in**: If Tailscale changes their terms, pricing, or features, you have no alternative. Headscale gives you a migration path.

Tailscale is still the better choice if you want zero-maintenance networking and don&apos;t care about control plane ownership. Headscale is for people who want the Tailscale experience but need to own the whole stack.

## Headscale vs Tailscale

| Feature | Headscale | Tailscale |
|---|---|---|
| Control server | Your VPS | Tailscale&apos;s cloud |
| Open source | Yes (BSD-3) | Server is proprietary |
| User limit | Unlimited | 6 (free tier) |
| Client | Tailscale clients (unofficial) | Tailscale clients (official) |
| Authentication | Any OIDC provider | Tailscale&apos;s identity |
| MagicDNS | Basic | Full implementation |
| ACLs | Yes (JSON config) | Yes (web UI) |
| Exit nodes | Yes | Yes |
| Funnel (public endpoints) | No | Yes |
| DERP relays | Custom or Tailscale&apos;s | Tailscale&apos;s global network |
| Cost | VPS ($3-5/month) | Free tier / paid plans |
| Setup complexity | Moderate | Near-zero |

&lt;Notice type=&quot;warning&quot; title=&quot;Unofficial client support&quot;&gt;
Headscale is not made by Tailscale. Tailscale clients work with it, but this is unofficial. Tailscale could change their client protocol at any point. In practice, this hasn&apos;t caused problems, but it&apos;s worth knowing before you commit.
&lt;/Notice&gt;

## What you need

Before setting up Headscale:

&lt;ListCheck&gt;
- A **VPS with a public IP** (Ubuntu 22.04+ or Debian 12+). [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps) work well.
- A **domain name** pointing to your VPS (for the control server and optionally a web UI)
- **Docker and Docker Compose** installed on the VPS
- **Port 443** (TCP) open for the control server, and optionally port 3478 (UDP) for DERP relay
- Tailscale clients installed on the devices you want to connect
&lt;/ListCheck&gt;

## Install Headscale with Docker

The simplest way to run Headscale is with Docker Compose. This gives you the control server plus optional web management UIs.

### Step 1: Create the directory structure

```bash
mkdir -p /opt/headscale/{config,data}
cd /opt/headscale
```

### Step 2: Download the config file

```bash
wget -O config/config.yaml \
  https://raw.githubusercontent.com/juanfont/headscale/main/config-example.yaml
```

### Step 3: Edit the configuration

Open `config/config.yaml` and adjust these key settings:

```yaml
# The URL your clients will connect to
server_url: https://headscale.example.com

# Listen address (inside container, 0.0.0.0)
listen_addr: 0.0.0.0:8080

# Metrics endpoint
metrics_listen_addr: 0.0.0.0:9090

# SQLite database path (inside container)
database:
  type: sqlite
  sqlite:
    path: /var/lib/headscale/db.sqlite

# DERP configuration (use Tailscale&apos;s relays or set up your own)
derp:
  urls:
    - https://controlplane.tailscale.com/derpmap/default

# DNS configuration
dns_config:
  base_domain: example.com

# Random key for WireGuard (generate one)
private_key_path: /var/lib/headscale/private.key
```

Generate a WireGuard private key:

```bash
docker run --rm docker.io/headscale/headscale:0.24 \
  headscale generatekey
```

Save the output key and add it to your config or let Headscale auto-generate it on first run.

### Step 4: Create the Docker Compose file

Create `/opt/headscale/docker-compose.yml`:

```yml
services:
  headscale:
    image: docker.io/headscale/headscale:0.24
    restart: unless-stopped
    container_name: headscale
    command: serve
    read_only: true
    tmpfs:
      - /var/run/headscale
    volumes:
      - ./config:/etc/headscale:ro
      - ./data:/var/lib/headscale
    ports:
      - &quot;127.0.0.1:8080:8080&quot;
      - &quot;127.0.0.1:9090:9090&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;headscale&quot;, &quot;health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3

  headplane:
    image: ghcr.io/tale/headplane:0.3
    restart: unless-stopped
    container_name: headplane
    volumes:
      - ./config:/etc/headscale
      - ./data:/var/lib/headscale
    ports:
      - &quot;127.0.0.1:3003:3003&quot;
    environment:
      - HEADSCALE_URL=http://headscale:8080
```

Headplane is a lightweight web UI for managing Headscale. It lets you view users, devices, and routes without SSH-ing into the server every time.

### Step 5: Start the containers

```bash
docker compose up -d
```

Verify Headscale is running:

```bash
docker compose exec headscale headscale health
```

You should see output confirming the server is healthy.

### Step 6: Set up the reverse proxy

You need a reverse proxy to expose Headscale over HTTPS. If you&apos;re already running Traefik or Caddy, add Headscale as a backend. Here&apos;s a quick Caddy setup:

```bash
# Install Caddy
apt install caddy

# Edit /etc/caddy/Caddyfile
headscale.example.com {
    reverse_proxy localhost:8080
}

headplane.example.com {
    reverse_proxy localhost:3003
}
```

Reload Caddy:

```bash
systemctl reload caddy
```

Caddy automatically provisions Let&apos;s Encrypt certificates for both domains.

## Create your first user and register devices

Headscale organizes devices into &quot;users&quot; (similar to Tailscale&apos;s tailnets). Each user gets their own private network.

### Create a user

```bash
docker compose exec headscale headscale users create myuser
```

### Register a device

On each device you want to connect, first configure the Tailscale client to use your Headscale server instead of Tailscale&apos;s:

&lt;Tabs&gt;
&lt;Tab name=&quot;Linux&quot;&gt;

```bash
# Install Tailscale client
curl -fsSL https://tailscale.com/install.sh | sh

# Point it to your Headscale server
tailscale up --login-server=https://headscale.example.com
```

This prints a registration URL. Open it in your browser to complete the auth.

&lt;/Tab&gt;
&lt;Tab name=&quot;macOS&quot;&gt;

Install Tailscale from the Mac App Store, then:

```bash
/Applications/Tailscale.app/Contents/MacOS/Tailscale up \
  --login-server=https://headscale.example.com
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Windows&quot;&gt;

Install Tailscale for Windows. Then edit the login server in the Tailscale GUI settings, or run from Command Prompt:

```
tailscale up --login-server=https://headscale.example.com
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

Alternatively, pre-register a key from the server and use it on the client:

```bash
# On the server: create a pre-auth key
docker compose exec headscale headscale preauthkeys create \
  --user myuser --reusable

# On the client: use the key
tailscale up --login-server=https://headscale.example.com --authkey=tskey-xxxxx
```

This is the preferred method for servers and automated setups.

### Verify the connection

Check registered devices:

```bash
docker compose exec headscale headscale nodes list
```

From any connected device, verify you can reach others:

```bash
tailscale status
tailscale ping other-device-name
```

## Configure ACLs (Access Control Lists)

ACLs define which devices can reach which other devices. In Tailscale, you configure them through the web UI. In Headscale, you write them as JSON.

Create `/opt/headscale/config/acls.json`:

```json
{
  &quot;groups&quot;: {
    &quot;group:admin&quot;: [&quot;myuser@example.com&quot;],
    &quot;group:devices&quot;: [&quot;myuser@example.com&quot;]
  },
  &quot;acls&quot;: [
    {
      &quot;action&quot;: &quot;accept&quot;,
      &quot;src&quot;: [&quot;group:admin&quot;],
      &quot;dst&quot;: [&quot;*:*&quot;]
    },
    {
      &quot;action&quot;: &quot;accept&quot;,
      &quot;src&quot;: [&quot;group:devices&quot;],
      &quot;dst&quot;: [&quot;group:devices:*&quot;]
    }
  ]
}
```

This gives admin users access to everything and device users access to other devices in their group. Update your `config.yaml` to reference the ACL file:

```yaml
acl_file_path: /etc/headscale/acls.json
```

Restart Headscale to load the ACLs:

```bash
docker compose restart headscale
```

## Set up exit nodes

Exit nodes let you route all your traffic through a specific device -- like a VPN tunnel to your home network. Any device on your Headscale network can be an exit node.

On the device that will serve as the exit node:

```bash
tailscale up --login-server=https://headscale.example.com --advertise-exit-node
```

Approve the exit node on the server:

```bash
docker compose exec headscale headscale nodes approve-routes \
  --node &lt;node-id&gt; --routes 0.0.0.0/0
```

On other devices, use the exit node:

```bash
tailscale up --login-server=https://headscale.example.com --exit-node=&lt;exit-node-name&gt;
```

All traffic now routes through the exit node device.

## Optional: Connect an OIDC identity provider

Headscale supports any OIDC-compatible identity provider. If you run Authentik, Keycloak, or Google Workspace, you can use it for authentication instead of Headscale&apos;s built-in auth.

In your `config.yaml`:

```yaml
oidc:
  issuer: &quot;https://authentik.example.com/application/o/headscale/&quot;
  client_id: &quot;your-client-id&quot;
  client_secret: &quot;your-client-secret&quot;
  scope: [&quot;openid&quot;, &quot;profile&quot;, &quot;email&quot;]
  allowed_domains:
    - example.com
```

Users authenticate through your identity provider when they run `tailscale up`. The email domain filter ensures only authorized users can join.

## Optional: Set up custom DERP relays

DERP relays handle traffic when direct WireGuard connections can&apos;t be established (both devices behind NAT, different CGNAT ISPs). Tailscale runs a global DERP network. Headscale defaults to using it, but you can add your own relay for reliability or latency reasons.

Add your custom DERP server to the Headscale config:

```yaml
derp:
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths:
    - /etc/headscale/derp.yaml
  auto_update: true
```

Create `/opt/headscale/config/derp.yaml` with your relay config. The Tailscale DERP server is open source and can be self-hosted.

## How it compares to other self-hosted mesh VPNs

For a detailed comparison, see our [mesh VPN comparison guide](/netbird-vs-headscale-vs-tailscale/) covering NetBird, Headscale, and Tailscale side by side. Here&apos;s a quick summary:

- **Headscale**: Best if you already know Tailscale and want the same client experience with self-hosted control. Requires understanding of Tailscale&apos;s model.
- **NetBird**: Best if you want a fully self-hosted mesh VPN with a clean web UI, built-in SSO, and no dependency on Tailscale clients. See our [NetBird vs Headscale vs Tailscale comparison](/netbird-vs-headscale-vs-tailscale/).
- **Pangolin**: Best if you want to expose specific services through a reverse proxy tunnel, not a full mesh VPN. See our [Pangolin setup guide](/pangolin-cloudflare-tunnels-alternative/).

## Performance and reliability in practice

After six months of running Headscale on a Hetzner CX22 ($5/month):

The good stuff: devices connect and authenticate within seconds. WireGuard tunnels between devices on the same ISP achieve near-native speeds (80-100 Mbps on a 100 Mbps connection). Cross-ISP connections route through DERP relays with expected latency overhead. ACLs work reliably once you get the JSON format right. The Headplane web UI is simple but functional for day-to-day management.

The tradeoffs: you lose MagicDNS&apos;s full feature set -- Headscale supports basic DNS but not the name resolution that Tailscale provides out of the box. Tailscale Funnel (exposing public endpoints) doesn&apos;t work with Headscale. You manage configuration through YAML files rather than a polished web UI. DERP relay performance depends on where you deploy -- Tailscale&apos;s global relay network is hard to beat with a single VPS.

## Should you self-host your mesh VPN?

Headscale is worth setting up if you care about data ownership, need custom authentication, or want to avoid Tailscale&apos;s user limits. The setup takes 30-45 minutes, and after that, adding devices is the same experience as regular Tailscale.

If you just want your devices to talk to each other with minimal effort, Tailscale&apos;s managed service is still the better choice. Headscale adds maintenance overhead (updates, config changes, DERP relay management) that you don&apos;t get with Tailscale&apos;s cloud.

If you&apos;re already self-hosting other infrastructure -- running your [own PaaS with Coolify](/coolify-v5-self-hosted-paas-review/), managing services with [Docker on your home server](/docker-containers-home-server/) -- then Headscale fits naturally into that stack. The maintenance cost is small compared to the control you gain.

The [Headscale GitHub repo](https://github.com/juanfont/headscale) has detailed docs, and the community is active on their Discord server.

## FAQ

&lt;Accordion label=&quot;Can I migrate from Tailscale to Headscale?&quot; group=&quot;headscale-faq&quot; expanded=&quot;true&quot;&gt;
Yes, but it requires re-registering every device. There&apos;s no automatic migration. You&apos;ll need to `tailscale down` each device, then `tailscale up --login-server=https://headscale.example.com` to re-register it with your Headscale server. Plan the migration when you can tolerate a brief network disruption.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Headscale work with the official Tailscale Android/iOS apps?&quot; group=&quot;headscale-faq&quot;&gt;
Not directly. The mobile apps don&apos;t support changing the login server through the GUI. You need to use alternative approaches: on Android, you can change the login server through the app&apos;s debug settings. On iOS, it&apos;s more complicated and may require a custom build. This is one area where Headscale is less convenient than Tailscale.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run Headscale and Tailscale simultaneously?&quot; group=&quot;headscale-faq&quot;&gt;
No on the same device. A Tailscale client connects to one control server at a time. But you can run some devices on Headscale and others on Tailscale -- they&apos;ll be on separate networks and won&apos;t see each other.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if my Headscale VPS goes down?&quot; group=&quot;headscale-faq&quot;&gt;
Existing WireGuard tunnels between already-connected devices stay alive. The control server is only needed for new connections, key rotations, and ACL changes. But devices can&apos;t authenticate new peers or update their network maps until the server comes back. Run monitoring with [Uptime Kuma](/deploy-uptime-kuma/) or [Beszel](/beszel-uptime-kuma/) to catch downtime fast.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>networking</category></item><item><title>NetBird vs Headscale vs Tailscale: Which Mesh VPN Should You Use?</title><link>https://www.bitdoze.com/netbird-vs-headscale-vs-tailscale/</link><guid isPermaLink="true">https://www.bitdoze.com/netbird-vs-headscale-vs-tailscale/</guid><description>A practical comparison of NetBird, Headscale, and Tailscale for mesh VPN networking. Self-hosted vs cloud, setup complexity, features, and cost for homelab and production.</description><pubDate>Wed, 20 May 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Mesh VPNs replaced traditional VPNs for connecting devices across networks. Instead of routing everything through a central server, mesh VPNs build direct peer-to-peer connections. Every node reaches every other node. Traffic only hits a relay when direct connections fail.

WireGuard made this practical -- it&apos;s fast, lightweight, and handles NAT traversal well. Three tools dominate in 2026: Tailscale (cloud-managed, the default choice), Headscale (self-hosted Tailscale control), and NetBird (self-hosted mesh VPN built from scratch).

Which one fits your setup? I&apos;ve run all three. Here&apos;s what I found.

## What each tool is

### Tailscale

Tailscale is a cloud-managed mesh VPN built on WireGuard. You install the client on each device, authenticate through Tailscale&apos;s identity provider, and every device gets a private IP in your tailnet. Tailscale handles the control plane, key distribution, DNS, and relay servers.

It&apos;s the easiest mesh VPN to set up. Zero config. But the control server runs on Tailscale&apos;s infrastructure -- your network data lives on their servers. That&apos;s the tradeoff.

### Headscale

Headscale is an open-source, self-hosted implementation of the Tailscale control server. You run it on your own VPS, and standard Tailscale clients connect to it instead of Tailscale&apos;s cloud. Same client, same WireGuard tunnels, but you control the coordination plane.

It&apos;s for people who want the Tailscale experience but need to own the control server. See our [Headscale setup guide](/headscale-self-hosted-tailscale-setup/) for a full deployment walkthrough.

### NetBird

NetBird is a fully self-hosted mesh VPN platform. Unlike Headscale, which relies on Tailscale clients, NetBird has its own clients, its own control server, its own management UI, and its own relay infrastructure. It&apos;s a complete mesh VPN stack that you can self-host end-to-end.

It supports SSO/MFA, granular access control, and has a polished web dashboard. The management service, signal service, and relay all run on your infrastructure.

## Feature comparison

| Feature | NetBird | Headscale | Tailscale |
|---|---|---|---|
| **Open source** | Yes (client + server) | Yes (server only) | Client yes, server no |
| **Self-hostable** | Fully | Control server only | No |
| **Client** | Own client | Tailscale clients (unofficial) | Tailscale clients (official) |
| **WireGuard** | Yes | Yes | Yes |
| **Authentication** | Any OIDC/SSO/MFA | Any OIDC | Tailscale identity |
| **ACLs** | Yes (web UI + API) | Yes (JSON config) | Yes (web UI) |
| **MagicDNS** | Limited | Basic | Full |
| **Exit nodes** | Yes | Yes | Yes |
| **Public endpoints** | Reverse proxy | No | Funnel |
| **DERP relays** | Own relay (Relay) | Tailscale&apos;s or custom | Global network |
| **Web management UI** | Built-in dashboard | Headplane (3rd party) | Tailscale admin console |
| **Mobile clients** | Android, iOS | Android (debug), iOS limited | Android, iOS |
| **User limits** | Unlimited (self-hosted) | Unlimited | 6 free, paid beyond |
| **Setup complexity** | Moderate | Moderate | Near-zero |
| **Cost (self-hosted)** | VPS ($5/month) | VPS ($5/month) | Free tier or paid |
| **Data ownership** | Full | Full | Tailscale holds it |

## Setup and maintenance

&lt;Tabs&gt;
&lt;Tab name=&quot;Tailscale&quot;&gt;

**Setup time: 5 minutes**

```bash
# Install and connect
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
```

Log in through the browser. Done. Every other device follows the same process. No config files, no server setup, no reverse proxy.

**Maintenance: zero.** Tailscale handles updates, key rotation, relay infrastructure, and DNS. You manage ACLs through the web UI.

The downside: you&apos;re fully dependent on Tailscale&apos;s cloud. If their service goes down, new connections fail. If they change pricing, you adapt or leave.

&lt;/Tab&gt;
&lt;Tab name=&quot;Headscale&quot;&gt;

**Setup time: 30-45 minutes**

Requires a VPS, Docker, a domain name, and a reverse proxy. See our [full setup guide](/headscale-self-hosted-tailscale-setup/) for the step-by-step.

```bash
# On the server
docker compose up -d

# On each client
tailscale up --login-server=https://headscale.example.com
```

**Maintenance: moderate.** You update Headscale, manage the config, handle DERP relay decisions, and keep the VPS running. Config changes are done through YAML files.

The upside: you own the control plane. The downside: you&apos;re responsible for keeping it running, and Tailscale client compatibility is unofficial.

&lt;/Tab&gt;
&lt;Tab name=&quot;NetBird&quot;&gt;

**Setup time: 20-30 minutes**

NetBird has an official self-hosting script that sets up everything on a single VPS:

```bash
curl -fsSL https://pkgs.netbird.io/install.sh | sh
```

For Docker-based self-hosting, they provide a comprehensive `docker-compose.yml` with management, signal, and relay services.

**Maintenance: moderate.** Similar to Headscale -- you manage the VPS, updates, and configuration. NetBird&apos;s web UI makes day-to-day management easier than Headscale&apos;s YAML-only approach.

The upside: fully self-hosted stack with no dependency on any vendor&apos;s clients. The downside: fewer community resources compared to Tailscale/Headscale.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Authentication and access control

This is where the three tools diverge significantly.

**Tailscale** uses its own identity provider (Google, Microsoft, GitHub, or email-based). ACLs are configured through a web UI or policy files. It&apos;s the simplest to set up but the least flexible.

**Headscale** supports any OIDC provider -- Authentik, Keycloak, Google, or whatever you already run. ACLs are JSON config files that you edit on the server. More flexible, but requires manual config management.

**NetBird** supports OIDC/SSO/MFA with any provider. ACLs are managed through the web dashboard or API. The best balance of flexibility and usability in the self-hosted options.

If you&apos;re already running Authentik or Keycloak for your self-hosted services, both Headscale and NetBird integrate cleanly. If you want ACLs managed through a web UI, NetBird wins. If you prefer config files (version-controlled with Git), Headscale&apos;s JSON approach works well.

## Performance and reliability

All three use WireGuard for the actual tunneling, so direct peer-to-peer performance is identical across all tools. The differences come from relay behavior and control plane latency.

**Tailscale&apos;s DERP network** is globally distributed with relay servers in North America, Europe, Asia, and Australia. When direct connections fail, traffic routes through the nearest DERP. This gives Tailscale the best relay performance by default.

**Headscale** defaults to using Tailscale&apos;s DERP relays (the same network), which means relay performance is identical. You can add custom DERP servers for specific regions, but most users don&apos;t bother.

**NetBird** runs its own relay infrastructure. When self-hosted, you deploy the relay on your VPS alongside the management server. This works fine for regional setups but gives you a single relay point. For global reach, you&apos;d need to deploy relays in multiple regions.

In practice, on a stable network where most connections are direct (which is the majority), all three perform the same. Relay performance matters most for cross-continental connections or CGNAT-to-CGNAT scenarios.

## When to choose each one

### Choose Tailscale if:

- You want the simplest setup possible
- You don&apos;t need to own the control plane
- You have fewer than 6 users (free tier covers this)
- You need full mobile client support (iOS/Android)
- You rely on MagicDNS for name resolution
- You want Funnel for exposing public endpoints

### Choose Headscale if:

- You want the Tailscale client experience with self-hosted control
- You need unlimited users without paying Tailscale
- You want to use your own OIDC provider
- You need data sovereignty (EU or specific jurisdiction)
- You&apos;re comfortable managing a VPS and YAML config
- You already use Tailscale clients and want to switch the control plane only

See our [Headscale setup guide](/headscale-self-hosted-tailscale-setup/) for deployment instructions.

### Choose NetBird if:

- You want a fully self-hosted mesh VPN with no vendor dependencies
- You need a web UI for managing users and ACLs
- You want SSO/MFA built into the mesh VPN platform
- You&apos;re building a mesh VPN from scratch (not migrating from Tailscale)
- You want the relay and signal infrastructure under your control
- You&apos;re setting up mesh VPN for a small organization or team

## Cost comparison

| Setup | Monthly cost | Notes |
|---|---|---|
| Tailscale free | $0 | 6 users, 3 devices per user |
| Tailscale Starter | $6/user | More devices, audit logs |
| Headscale on Hetzner CX22 | ~$5 | Unlimited users, your VPS |
| NetBird self-hosted on Hetzner | ~$5 | Unlimited users, your VPS |
| NetBird cloud (managed) | Free for 5 peers | Paid plans beyond that |

The self-hosted options cost roughly the same since they all need a VPS. The difference is in management overhead, not money.

&lt;Notice type=&quot;info&quot; title=&quot;Related guides&quot;&gt;
If you&apos;re setting up a mesh VPN to access self-hosted services, check out our [Pangolin setup guide](/pangolin-cloudflare-tunnels-alternative/) for a tunnel reverse proxy approach. For deploying the services you&apos;re networking, [Coolify v5](/coolify-v5-self-hosted-paas-review/) makes self-hosted deployment straightforward. And for [SSH tunneling](/ssh-tunneling-linux/) basics, our Linux guide covers local, remote, and dynamic port forwarding.
&lt;/Notice&gt;

## Bottom line

**Tailscale** remains the best choice for most people. The setup is trivial, the maintenance is zero, and the client experience is polished. If you don&apos;t have a specific reason to self-host, use Tailscale.

**Headscale** is the right pick when you need self-hosted control but want to keep using Tailscale clients. It&apos;s a pragmatic middle ground -- you gain data ownership without changing the client experience. The tradeoff is unofficial client support and more manual configuration.

**NetBird** is the choice when you want a complete, self-hosted mesh VPN platform with no vendor dependencies. It&apos;s more work to set up than Tailscale, but gives you full control over every component -- clients, control server, relay, authentication, and UI.

For homelab and small-team use, Headscale and NetBird both work well. Headscale has a larger community (because it piggybacks on Tailscale&apos;s ecosystem), while NetBird offers a more polished self-hosted management experience. Pick based on whether you prefer leveraging Tailscale clients (Headscale) or running an independent stack (NetBird).</content:encoded><category>self-hosting</category><category>networking</category><category>self-hosted</category><category>vpn</category></item><item><title>Pangolin: Deploy a Self-Hosted Alternative to Cloudflare Tunnels</title><link>https://www.bitdoze.com/pangolin-cloudflare-tunnels-alternative/</link><guid isPermaLink="true">https://www.bitdoze.com/pangolin-cloudflare-tunnels-alternative/</guid><description>Pangolin is an open-source tunneled reverse proxy built on WireGuard. Learn how to deploy it on a VPS as a self-hosted replacement for Cloudflare Tunnels.</description><pubDate>Fri, 15 May 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Cloudflare Tunnels made exposing local services to the internet easy. One command, no port forwarding, DDoS protection included. But after running it for a while, you hit the walls: a 100 MB upload limit per file, ToS restrictions on video streaming (Jellyfin users know the anxiety), and the fact that Cloudflare terminates your TLS and reads your traffic in plaintext on their network. If you&apos;re self-hosting to own your infrastructure, having a third party in the middle of every connection kind of defeats the purpose.

Pangolin fixes that. It&apos;s an open-source, self-hosted reverse proxy with WireGuard-based tunneling, access control, and a dashboard that&apos;s actually usable. You run it on a VPS you control, and it gives you the same tunnel experience as Cloudflare without handing your traffic to anyone.

I&apos;ve been running Pangolin for a few months and it handles everything I used Cloudflare Tunnels for, plus a few things I couldn&apos;t do there. Here&apos;s how to set it up.

## Why move away from Cloudflare Tunnels?

A few things pushed me (and others) to look for alternatives:

- **100 MB file limit**: Transfers through Cloudflare Tunnels cap at 100 MB per request. If you&apos;re running Immich, Nextcloud, or anything with large files, this breaks things silently.
- **Video streaming ToS**: Cloudflare&apos;s terms prohibit using Tunnels for video content delivery. Jellyfin and Plex work, technically, but you&apos;re one ToS review away from losing access.
- **TLS termination**: Cloudflare decrypts your traffic inside their network before optionally re-encrypting it to your origin. You&apos;re trusting their entire infrastructure with plaintext data.
- **Account dependency**: Your tunnels, DNS, and access rules all live under one Cloudflare account. One ToS violation (real or perceived) and everything goes down.
- **Limited protocol support**: Tunnels only handle HTTP/HTTPS. No TCP, no UDP, no SSH directly through the tunnel.

Cloudflare is still a good option for many setups. But if you want to own your infrastructure end-to-end, you need something self-hosted.

## What is Pangolin?

Pangolin is an open-source remote access platform built on WireGuard. It combines a reverse proxy with VPN capabilities in a single stack. The project is maintained by [Fossorial](https://github.com/fosrl/pangolin) and has picked up traction in the self-hosting community.

What it does:

- Expose web applications through a browser. No client install needed for end users.
- Traffic between your services and the Pangolin VPS is encrypted end-to-end using WireGuard. No plaintext in transit.
- Built-in authentication with role-based access. You can also connect external identity providers like Authentik, Keycloak, or Google via OIDC.
- Each service gets its own access rules. You decide exactly who can reach what.
- The Newt client (Pangolin&apos;s equivalent of `cloudflared`) connects outbound to your VPS. No port forwarding needed.
- Not just HTTP. You can tunnel SSH, databases, and other protocols.

&lt;Notice type=&quot;info&quot; title=&quot;How it works&quot;&gt;
Pangolin runs on a VPS with a public IP. The Newt client runs on your home server or wherever your services live. Newt connects outbound to Pangolin over WireGuard, and Pangolin handles routing, SSL certificates, and access control. Your users hit the Pangolin VPS, not your home IP.
&lt;/Notice&gt;

## Pangolin vs Cloudflare Tunnels

| Feature | Pangolin | Cloudflare Tunnels |
|---|---|---|
| Self-hosted | Yes | No |
| Open source | Yes (AGPL-3) | No |
| TLS termination | On your VPS | On Cloudflare&apos;s network |
| File size limit | VPS bandwidth only | 100 MB per request |
| Video streaming | No restrictions | ToS restrictions |
| Protocols | HTTP, HTTPS, TCP, UDP, SSH | HTTP/HTTPS only |
| WireGuard encryption | End-to-end | Cloudflare in the middle |
| Auth built-in | Yes + OIDC/SSO | Yes (via Cloudflare Access) |
| DDoS protection | CrowdSec (optional) | Cloudflare&apos;s network |
| Cost | VPS cost ($3-5/month) | Free tier available |
| Setup complexity | Moderate | Easy |

## What you need

Before starting, make sure you have:

&lt;ListCheck&gt;
- A **VPS with a public IP** (Ubuntu 22.04+ or Debian 11+ recommended). [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps) work well.
- A **domain name** with DNS pointing to your VPS IP
- **Docker and Docker Compose** installed on the VPS
- **Ports open** on the VPS firewall: 80 (TCP), 443 (TCP), 51820 (UDP), 21820 (UDP)
- A machine running your self-hosted services (home server, another VPS, etc.)
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;VPS bandwidth matters&quot;&gt;
Your VPS becomes the gateway for all traffic. A $3-5/month VPS from Hetzner gives you 1-2 TB of bandwidth, which is enough for most setups. If you&apos;re streaming video or transferring large files regularly, pick a plan with enough data transfer.
&lt;/Notice&gt;

## Install Pangolin on your VPS

The easiest way to install Pangolin is with their official installer script. It handles Docker containers, Traefik configuration, and SSL certificates for you.

### Step 1: Download and run the installer

SSH into your VPS and run:

```bash
curl -fsSL https://static.pangolin.net/get-installer.sh | bash
```

Move the installer to your desired directory first (it installs everything in the current directory):

```bash
mkdir -p /opt/pangolin &amp;&amp; cd /opt/pangolin
curl -fsSL https://static.pangolin.net/get-installer.sh | bash
sudo ./installer
```

### Step 2: Configure the installer

The installer asks you a few questions:

1. **Edition**: Choose Community Edition (free, open source)
2. **Base Domain**: Enter your root domain (e.g., `example.com`)
3. **Dashboard Domain**: Defaults to `pangolin.example.com`, or enter a custom one
4. **Let&apos;s Encrypt Email**: Your email for SSL certificate issuance
5. **Tunneling**: Say yes to install Gerbil (the WireGuard tunnel component)
6. **Email/SMTP**: Optional, skip for now

### Step 3: Start the containers

Confirm the installation and wait 2-3 minutes. The installer pulls three Docker images:

- `pangolin` - the main application
- `gerbil` - the WireGuard tunnel server
- `traefik` - the reverse proxy

When it finishes, you&apos;ll see:

```
Installation complete!
To complete the initial setup, please visit:
https://pangolin.example.com/auth/initial-setup
```

### Step 4: Complete the initial setup

Open the dashboard URL in your browser. Enter the setup token shown in the installer output. Create your admin account with a strong password.

Once logged in, create your first organization. This is the top-level container for your tunnels, users, and access rules. If you&apos;re the only user, one org is enough.

### Step 5: Install Newt on your home server

Newt is the client agent that runs where your services live. Install it on your home server, NAS, or wherever you have Docker containers running.

**Using Docker:**

```bash
docker run -d \
  --name newt \
  --restart unless-stopped \
  -e PANGOLIN_ENDPOINT=https://pangolin.example.com \
  -e NEWT_TOKEN=your-token-from-dashboard \
  fosrl/newt:latest
```

You generate the token from the Pangolin dashboard under Sites &gt; Add Site.

**Using Docker Compose:**

```yml
services:
  newt:
    image: fosrl/newt:latest
    container_name: newt
    restart: unless-stopped
    environment:
      - PANGOLIN_ENDPOINT=https://pangolin.example.com
      - NEWT_TOKEN=your-token-from-dashboard
```

Once Newt connects, it shows as &quot;Online&quot; in the Pangolin dashboard under Sites.

## Expose your first service

With Newt running, you can now expose services through Pangolin.

### Add a resource

1. Go to **Resources** in the dashboard and click **Add Resource**
2. Choose **Public** or **Protected** (protected requires login to access)
3. Set the subdomain (e.g., `grafana` for `grafana.example.com`)
4. Set the target to your service&apos;s local address (e.g., `localhost:3000`)
5. Select the Newt site you created

Pangolin automatically provisions an SSL certificate for the subdomain. Your service is now accessible at `https://grafana.example.com`.

### Protect a resource with authentication

For services you don&apos;t want publicly accessible:

1. Edit the resource and set authentication to **Protected**
2. Assign access to specific users or roles
3. Users authenticate through Pangolin&apos;s built-in login page

You can also connect an external identity provider like Authentik or Google for SSO.

### Expose TCP/UDP services

For SSH, databases, or other non-HTTP services:

1. Create a resource with **TCP/UDP** type
2. Set the target port and address
3. Pangolin handles the tunnel routing

This is useful for accessing a PostgreSQL database or SSH server on your home network without opening ports.

## Optional: Add CrowdSec for DDoS protection

Cloudflare&apos;s main advantage is DDoS protection. You can get some of that back with CrowdSec, which blocks malicious traffic based on community-shared threat intelligence.

Install CrowdSec alongside Pangolin and configure it to work with Traefik. The Pangolin docs have a [CrowdSec guide](https://docs.pangolin.net/self-host/community-guides/crowdsec) with the full setup.

It won&apos;t match Cloudflare&apos;s global network, but it catches most automated attacks and brute-force attempts.

## Optional: Connect an external identity provider

If you already run Authentik, Keycloak, or another OIDC provider, you can connect it to Pangolin for SSO:

1. Go to **Identity Providers** in the dashboard
2. Add a new OIDC provider with your client ID, secret, and discovery URL
3. Map roles from your identity provider to Pangolin roles

Users can then log in with their existing credentials instead of Pangolin-specific accounts.

## How it performs in practice

After a few months of running Pangolin, here&apos;s what I&apos;ve found:

The good stuff: web apps (Grafana, Uptime Kuma, Portainer) load fast with no noticeable latency. Jellyfin streaming works without worrying about ToS violations. Large file transfers in Immich and Nextcloud just work, no 100 MB limit. Adding new services takes under a minute once the tunnel is set up. SSL certificates renew without intervention.

The tradeoffs: your VPS bandwidth is the bottleneck. A cheap VPS with 1 TB/month handles most home lab traffic, but heavy video streaming can eat through it. The VPS becomes a single point of failure -- if it goes down, all your tunnels go down, so pick a reliable provider. You&apos;re responsible for keeping Pangolin updated, though the Docker-based install makes that straightforward. There&apos;s no built-in CDN or caching, so if you need that, put a CDN in front of your Pangolin VPS.

## Cost comparison

| Setup | Monthly cost |
|---|---|
| Cloudflare Tunnels | Free (but Cloudflare owns your data) |
| Pangolin on Hetzner CX22 | ~$5/month (2 TB bandwidth) |
| Pangolin on Hostinger KVM1 | ~$6/month (1 TB bandwidth) |
| Pangolin on Oracle Free Tier | $0 (limited resources, ARM only) |

The Oracle Free Tier works if you want to try Pangolin without spending money. It handles a handful of tunnels, though the ARM architecture may have occasional compatibility quirks.

## Should you switch?

Pangolin isn&apos;t a drop-in replacement for every Cloudflare Tunnels use case. If you&apos;re deep in the Cloudflare ecosystem with DNS, CDN, and WAF all managed there, the migration effort probably isn&apos;t worth it just on principle.

But if you&apos;re self-hosting to own your infrastructure, Cloudflare Tunnels is the one piece you don&apos;t actually own. Pangolin gives you the same tunnel experience, with end-to-end encryption, no file size limits, no ToS worries, and full control over your data.

The setup takes about an hour the first time. After that, adding new tunnels is fast enough that it stops feeling like infrastructure work. Updates are painless since everything runs in Docker, and the community is active enough that issues get resolved quickly.

If you want to try it, the [GitHub repo](https://github.com/fosrl/pangolin) has the full install script and the [docs](https://docs.pangolin.net) cover most edge cases.

If you&apos;re looking for a mesh VPN rather than a tunnel proxy, check out [Headscale](/headscale-self-hosted-tailscale-setup/) (self-hosted Tailscale) or our [mesh VPN comparison guide](/netbird-vs-headscale-vs-tailscale/) covering NetBird, Headscale, and Tailscale. For deploying the services you&apos;re tunneling, [Coolify](/coolify-v5-self-hosted-paas-review/) makes self-hosted app deployment straightforward.

## How to update Pangolin

Since Pangolin runs in Docker, updating it is straightforward. SSH into your VPS and navigate to the install directory:

```bash
cd /opt/pangolin
docker compose pull
docker compose up -d
```

This pulls the latest images for all three containers (pangolin, gerbil, traefik) and restarts them. Your configuration and data persist across updates since they&apos;re stored in Docker volumes.

Check the [Pangolin releases page](https://github.com/fosrl/pangolin/releases) before updating. Some releases include breaking changes that require manual config adjustments. The community Discord is a good place to check if anyone has run into issues with the latest version.

&lt;Notice type=&quot;info&quot; title=&quot;Backup before updating&quot;&gt;
Before any major update, backup your Pangolin data directory. The SQLite database and config files are stored in the volumes defined in your `docker-compose.yml`. Copy them to a safe location before running `docker compose pull`.
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Can I run Pangolin alongside Cloudflare Tunnels?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. You can use Cloudflare Tunnels for some services and Pangolin for others. For example, keep Cloudflare for services that benefit from their CDN and DDoS protection, and route Jellyfin or Nextcloud through Pangolin where the 100 MB limit and ToS are a problem. Just configure different subdomains for each tunnel.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if my VPS goes down?&quot; group=&quot;faq&quot;&gt;
All your tunnels go down with it. This is the main tradeoff compared to Cloudflare&apos;s globally distributed network. To mitigate this: pick a reliable VPS provider, set up monitoring with [Uptime Kuma](/deploy-uptime-kuma/) or [Beszel](/beszel-uptime-kuma/), and consider running a secondary VPS as a cold standby.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Pangolin without the Newt client?&quot; group=&quot;faq&quot;&gt;
Yes. You can connect any WireGuard client directly to Pangolin&apos;s Gerbil server. This gives you VPN access to the network without using the Newt agent. Newt is only required if you want the reverse proxy tunneling experience where services are exposed through the browser without a VPN client.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Pangolin work behind CGNAT?&quot; group=&quot;faq&quot;&gt;
Yes. Newt connects outbound to your VPS, so no inbound ports need to be open on your home network. This works the same way as Cloudflare Tunnels -- the connection initiates from inside your network.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Pangolin compare to Tailscale?&quot; group=&quot;faq&quot;&gt;
Pangolin is a tunnel reverse proxy for exposing specific services. Tailscale is a full mesh VPN that connects all your devices. They solve different problems. If you want users to access services through a browser without installing anything, Pangolin is the right choice. If you want all your devices to see each other on a private network, go with Tailscale (or its self-hosted alternative [Headscale](/headscale-self-hosted-tailscale-setup/)).
&lt;/Accordion&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/CrM8uhD5BIs&quot;
  label=&quot;Pangolin: Free Open Source Alternative to Cloudflare Tunnels&quot;
/&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>networking</category></item><item><title>Self-Host Your Git and CI/CD with Forgejo and Woodpecker CI</title><link>https://www.bitdoze.com/forgejo-woodpecker-ci-cicd/</link><guid isPermaLink="true">https://www.bitdoze.com/forgejo-woodpecker-ci-cicd/</guid><description>Set up a self-hosted GitHub alternative with Forgejo and CI/CD using Woodpecker CI. Full Docker Compose setup on a VPS.</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;

GitHub Actions is great until you look at the bill, hit rate limits, or realize your code runs on someone else&apos;s servers. If you already self-host your apps on a VPS, hosting your own Git server and CI/CD pipeline is the next logical step. And it&apos;s easier than you&apos;d think.

This guide walks through setting up **Forgejo** (a self-hosted Git forge) with CI/CD via **Woodpecker CI**. Forgejo handles your repos, issues, and pull requests. Woodpecker runs your builds, tests, and deployments. Together they replace GitHub and GitHub Actions.

## Why not just keep using GitHub?

A few reasons people move:

- **Cost control**: GitHub Actions free tier runs out fast on private repos. Self-hosted runners help, but you&apos;re still on GitHub&apos;s infrastructure.
- **Data ownership**: Your code, issues, pull requests, and CI logs live on your server. No terms-of-service surprises.
- **Offline/local development**: A VPS in your region with no dependency on GitHub&apos;s uptime.
- **GitHub Actions lock-in**: The more workflows you write, the harder it is to leave. Forgejo + Woodpecker keeps things portable.

That said, plenty of people mirror to GitHub for the social coding side. You can have both.

## Forgejo vs Gitea: what&apos;s the difference?

Forgejo forked from Gitea in October 2022 after a for-profit company took over the Gitea project (domains, trademark, everything). The community wasn&apos;t consulted. An open letter was ignored. So they forked.

As of 2024, Forgejo is a hard fork -- the codebases have diverged. Here&apos;s where they differ:

| | Forgejo | Gitea |
|---|---|---|
| **Governance** | Non-profit (Codeberg e.V.) | For-profit company |
| **License** | Exclusively Free Software | Open Core (some proprietary features) |
| **Developed on** | Forgejo itself | GitHub |
| **CI/CD tested with** | Forgejo Actions | GitHub Actions |
| **Security** | Advance notice for all users | Advance notice for paying customers only |
| **Federation** | Working on ActivityPub support | No federation plans |
| **End-to-end tests** | Yes | No (as of mid-2025) |
| **Migration from Gitea** | Supported, same database schema | N/A |

Functionally, they&apos;re very similar. Same UI, same config format, same Docker setup. If you&apos;re starting fresh, go with Forgejo. If you&apos;re already on Gitea and it works fine, migrating isn&apos;t urgent -- but it&apos;s a one-command upgrade when you&apos;re ready.

## What you&apos;ll need

- A VPS with at least 2 GB RAM (4 GB recommended if running CI/CD on the same machine). [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps) work well.
- Docker and Docker Compose installed
- A domain name (or subdomain) pointed to your VPS
- Basic comfort with the terminal

&lt;Notice type=&quot;info&quot; title=&quot;Single VPS is fine&quot;&gt;

Everything in this guide runs on one server. Forgejo, Woodpecker, and the CI runner can coexist on a 4 GB VPS without issues. You can always split them later.

&lt;/Notice&gt;

## Step 1: Install Forgejo

Create a directory for Forgejo and set up the Docker Compose file:

```sh
mkdir -p /opt/forgejo &amp;&amp; cd /opt/forgejo
```

### Docker Compose

```yaml
services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:10
    container_name: forgejo
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__database__DB_TYPE=sqlite3
      - FORGEJO__server__DOMAIN=git.yourdomain.com
      - FORGEJO__server__ROOT_URL=https://git.yourdomain.com
      - FORGEJO__server__SSH_DOMAIN=git.yourdomain.com
      - FORGEJO__server__SSH_PORT=2222
      - FORGEJO__server__SSH_LISTEN_PORT=22
      - FORGEJO__service__DISABLE_REGISTRATION=true
      - FORGEJO__actions__ENABLED=true
    restart: unless-stopped
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - &quot;3000:3000&quot;
      - &quot;2222:22&quot;
```

A few things to note:

- **SSH on port 2222**: Forgejo&apos;s built-in SSH server runs on 2222 so it doesn&apos;t conflict with your system SSH on 22. You&apos;ll clone repos with `ssh://git@yourdomain.com:2222/user/repo.git`.
- **Registration disabled**: You don&apos;t want random people creating accounts on your Git server. Create accounts manually or re-enable registration briefly.
- **SQLite**: Fine for small teams and personal use. If you&apos;re running 10+ users, switch to PostgreSQL.

### Start Forgejo

```sh
docker compose up -d
```

Visit `https://git.yourdomain.com`, complete the initial setup wizard, and create your admin account.

### Reverse proxy with Nginx

If you&apos;re running Nginx on the host (not in Docker), add a server block:

```nginx
server {
    server_name git.yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    listen 443 ssl;
    ssl_certificate /etc/letsencrypt/live/git.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/git.yourdomain.com/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
```

Get the certificate first:

```sh
certbot --nginx -d git.yourdomain.com
```

&lt;Notice type=&quot;warning&quot; title=&quot;Firewall&quot;&gt;

Make sure ports 80, 443, and 2222 are open. If you&apos;re using UFW:

```sh
sudo ufw allow &apos;Nginx Full&apos;
sudo ufw allow 2222
```

&lt;/Notice&gt;

## Step 2: Install Woodpecker CI

Woodpecker is a standalone CI/CD server that connects to Forgejo via OAuth. It&apos;s a community fork of Drone CI, maintained after Drone changed its license. Each pipeline step runs in its own Docker container, so your build environment is always clean and reproducible.

#### Create an OAuth application in Forgejo

1. Go to **Site Administration → Applications** (or your user settings → Applications)
2. Create a new OAuth2 application:
   - **Name**: Woodpecker CI
   - **Redirect URI**: `https://ci.yourdomain.com/authorize`
3. Save the **Client ID** and **Client Secret**

#### Docker Compose for Woodpecker

```sh
mkdir -p /opt/woodpecker &amp;&amp; cd /opt/woodpecker
```

```yaml
services:
  woodpecker-server:
    image: woodpeckerci/woodpecker-server:v3
    container_name: woodpecker-server
    restart: unless-stopped
    ports:
      - &quot;8000:8000&quot;
    volumes:
      - woodpecker_data:/var/lib/woodpecker
    environment:
      - WOODPECKER_OPEN=false
      - WOODPECKER_HOST=https://ci.yourdomain.com
      - WOODPECKER_GITEA=true
      - WOODPECKER_GITEA_URL=https://git.yourdomain.com
      - WOODPECKER_GITEA_CLIENT=YOUR_CLIENT_ID
      - WOODPECKER_GITEA_SECRET=YOUR_CLIENT_SECRET
      - WOODPECKER_AGENT_SECRET=YOUR_RANDOM_SECRET
      - WOODPECKER_DATABASE_DRIVER=sqlite3
      - WOODPECKER_DATABASE_DATASOURCE=/var/lib/woodpecker/woodpecker.db

  woodpecker-agent:
    image: woodpeckerci/woodpecker-agent:v3
    container_name: woodpecker-agent
    restart: unless-stopped
    depends_on:
      - woodpecker-server
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - WOODPECKER_SERVER=woodpecker-server:9000
      - WOODPECKER_AGENT_SECRET=YOUR_RANDOM_SECRET
      - WOODPECKER_MAX_WORKFLOWS=2

volumes:
  woodpecker_data:
```

Generate the agent secret:

```sh
openssl rand -hex 32
```

Use the same secret for both `WOODPECKER_AGENT_SECRET` entries.

&lt;Notice type=&quot;warning&quot; title=&quot;Docker socket access&quot;&gt;

The agent mounts `/var/run/docker.sock`, which gives it full access to the host&apos;s Docker daemon. For a single-user VPS this is fine. For shared environments, look into rootless Podman or Kaniko.

&lt;/Notice&gt;

Start it up:

```sh
docker compose up -d
```

Add an Nginx reverse proxy for `ci.yourdomain.com` the same way as Forgejo (proxy to port 8000).

#### Your first Woodpecker pipeline

Create `.woodpecker.yml` in the root of a Forgejo repo:

```yaml
pipeline:
  build:
    image: node:20-alpine
    commands:
      - npm ci
      - npm run build

  test:
    image: node:20-alpine
    commands:
      - npm test
    depends_on:
      - build

  deploy:
    image: alpine:3.20
    commands:
      - apk add --no-cache openssh-client
      - ssh -o StrictHostKeyChecking=no deploy@yourserver &quot;cd /var/www/myapp &amp;&amp; git pull &amp;&amp; ./build.sh&quot;
    depends_on:
      - test
    when:
      branch: main
```

Each step runs in its own container. The `deploy` step only runs on the `main` branch.

#### Building and pushing Docker images

Woodpecker has a built-in Docker plugin:

```yaml
pipeline:
  build-image:
    image: plugins/docker
    settings:
      repo: git.yourdomain.com/youruser/myapp
      registry: git.yourdomain.com
      tags:
        - latest
        - ${CI_COMMIT_SHA:0:8}
      username:
        from_secret: docker_username
      password:
        from_secret: docker_password
    when:
      branch: main
```

Add the secrets via the Woodpecker UI or CLI:

```sh
woodpecker-cli secret add \
  --repository youruser/myapp \
  --name docker_username \
  --value &apos;youruser&apos;

woodpecker-cli secret add \
  --repository youruser/myapp \
  --name docker_password \
  --value &apos;your_token&apos;
```

---

## Step 3: Set up automatic deployments

Here&apos;s a real-world pattern. Push code, Woodpecker runs tests, and if they pass, the app deploys to the same VPS.

### The deploy workflow

The simplest approach: SSH into the server, pull the latest code, rebuild.

&lt;Notice type=&quot;info&quot; title=&quot;Same-server deploys&quot;&gt;

If Forgejo, CI, and your app all run on the same VPS, the SSH connection is just localhost. This seems redundant, but it keeps your workflow portable -- change the SSH target to a different server and everything still works.

&lt;/Notice&gt;

### With Docker Compose apps

If your app runs in Docker Compose, the deploy script gets slightly fancier:

```sh
#!/bin/bash
cd /opt/myapp
git pull --force origin main
docker compose down
docker compose up -d --build
docker image prune -f
```

The `--build` flag rebuilds the image from the Dockerfile. `docker image prune` cleans up old images so they don&apos;t eat disk space.

### Zero-downtime deploys

For zero-downtime, you have a few options depending on your stack:

- **Nginx upstream swap**: Build the new container on a different port, test it, then switch the Nginx upstream and reload.
- **Docker Compose with healthchecks**: Define a healthcheck in your compose file. Docker won&apos;t route traffic until the new container is healthy.
- **Use Kamal**: If you want zero-downtime deploys without writing scripts, [Kamal](https://kamal-deploy.org/) handles this out of the box. It&apos;s from the Basecamp/Rails team but works with any Docker app.

## Step 4: Container registry (optional)

Forgejo has a built-in container registry. Enable it in `app.ini`:

```ini
[packages]
ENABLED=true
```

Then push images to `git.yourdomain.com/youruser/myapp:tag` from your CI pipeline. Woodpecker&apos;s Docker plugin handles this out of the box.

This saves you from running a separate Docker registry or paying for Docker Hub.

## Why Woodpecker over the alternatives?

Forgejo actually has a built-in CI system too (Forgejo Actions, compatible with GitHub Actions syntax). So why bother with a separate Woodpecker installation?

A few reasons:

- **Dedicated UI**: Woodpecker has its own web interface for monitoring builds, viewing logs, and managing secrets. It&apos;s cleaner than cramming CI into the Forgejo UI.
- **Matrix builds**: Run the same pipeline across multiple versions of Node, Python, or whatever. Woodpecker handles this natively.
- **Per-step resource limits**: Cap CPU and memory per pipeline step so one build can&apos;t starve the rest of your server.
- **Multi-forge support**: If you also have repos on GitHub or GitLab, Woodpecker can connect to all of them. You&apos;re not locked to one Git provider.
- **Plugin ecosystem**: Woodpecker&apos;s plugin system is purpose-built for CI/CD. Docker image builds, Slack notifications, S3 artifact uploads -- all first-class.

If you&apos;re coming from GitHub and just want to copy your `.github/workflows` files over with minimal changes, Forgejo Actions is the simpler path. But if you&apos;re setting up CI/CD from scratch and want something robust, Woodpecker is the better tool.

## Backing up your setup

Don&apos;t skip this. Your Git server is the source of truth for all your projects.

```sh
#!/bin/bash
DATE=$(date +%Y-%m-%d)
BACKUP_DIR=&quot;/home/backups/forgejo&quot;

# Stop Forgejo for a consistent backup
cd /opt/forgejo &amp;&amp; docker compose stop

# Create compressed archive
tar -czf &quot;$BACKUP_DIR/forgejo-$DATE.tar.gz&quot; -C /opt/forgejo/data .

# Restart Forgejo
docker compose up -d

# Encrypt
gpg --encrypt --armor -r your@email.com \
  -o &quot;$BACKUP_DIR/forgejo-$DATE.tar.gz.gpg&quot; \
  &quot;$BACKUP_DIR/forgejo-$DATE.tar.gz&quot;

# Remove unencrypted backup
rm &quot;$BACKUP_DIR/forgejo-$DATE.tar.gz&quot;
```

Run this nightly via cron. Copy the encrypted backups off-server (rsync to a NAS, rclone to S3, whatever works for you).

For Woodpecker, back up the `woodpecker_data` volume the same way.

## Putting it all together

Here&apos;s what the full setup looks like on one VPS:

```
/opt/
├── forgejo/
│   ├── docker-compose.yml
│   └── data/                  # Forgejo data + SQLite DB
├── woodpecker/
│   ├── docker-compose.yml
│   └── ...
├── myapp/
│   ├── docker-compose.yml
│   └── ...
└── another-app/
    └── docker-compose.yml
```

Push to Forgejo → CI runs tests → deploys to the same server → Nginx serves it. Total cost: one VPS, around 7-15 EUR/month.

No GitHub bills. No Vercel surprises. Your code, your server, your rules.

## Common gotchas

**SSH port conflicts**: Forgejo&apos;s SSH runs on port 2222 by default. Make sure your firewall allows it and your clone URLs include the port.

**Agent not picking up jobs**: Check the agent logs with `docker compose logs woodpecker-agent`. Make sure the `WOODPECKER_AGENT_SECRET` matches between server and agent.

**Docker socket permissions**: If the CI runner can&apos;t access Docker, make sure the container has `/var/run/docker.sock` mounted and the user has Docker permissions.

**Disk space**: CI runners generate a lot of Docker images over time. Add a cron job to prune old images:

```sh
0 3 * * * docker system prune -af --filter &quot;until=72h&quot;
```

This removes unused images older than 3 days.</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>cicd</category></item><item><title>How to Self-Host Traceway as a Free Datadog Alternative</title><link>https://www.bitdoze.com/traceway-self-host-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/traceway-self-host-guide/</guid><description>Deploy Traceway, an OpenTelemetry-native observability platform, with Docker Compose in under 2 minutes. Get logs, traces, metrics, and session replay without vendor lock-in.</description><pubDate>Wed, 13 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Observability bills sneak up on you. One day you&apos;re logging a few services, the next you&apos;re staring at a Datadog invoice that rivals your cloud spend. Traceway flips that model on its head: it&apos;s MIT licensed, OpenTelemetry-native, and you can have the full stack running on your own hardware in about two minutes.

## What is Traceway?

[Traceway](https://github.com/tracewayapp/traceway) is an open-source observability platform that handles logs, traces, metrics, session replay, exceptions, and AI tracing under one roof. It ingests data directly over OTLP/HTTP, so you skip the collector, the vendor SDKs, and the glue code. Point any OpenTelemetry SDK at it, and data starts flowing.

The project sits at about 290 stars on GitHub and ships under a clean MIT license. No BSL, no &quot;open core&quot; tier that reserves features for paid plans. Every feature is in the box.

### What&apos;s in the box

| Feature | What it does |
| --- | --- |
| Logs | Structured, trace-linked search with sub-second performance |
| Traces | End-to-end span waterfalls across every service |
| Metrics | Host, runtime, and custom metrics with configurable dashboard widgets |
| Exceptions | SHA-256 normalized stack traces grouped into ranked issues, source-mapped for JS bundles |
| Session Replay | Watch user sessions leading up to errors across web (any JS framework) and Flutter |
| AI Observability | Track LLM cost, tokens, latency, and full conversations across providers |

### Why Traceway over the alternatives

| | Enterprise (Datadog/New Relic) | DIY OSS (Prometheus + Loki + Tempo) | Traceway |
| --- | --- | --- | --- |
| Pricing | Per-event, per-host, per-seat | Free but heavy ops time | Self-host free, fixed cloud tiers |
| Setup | Vendor SDK per language | Glue 6 tools together | `docker compose up -d` |
| License | Proprietary | Mixed, some BSL/open-core | MIT, no asterisks |
| OTel | Wrapped in vendor SDK | Collector required | Native OTLP/HTTP ingest |
| Replay + traces + AI | Three separate products | Wire it yourself | One system, one trace ID |

&lt;Notice type=&quot;info&quot; title=&quot;New but functional&quot;&gt;
Traceway is a younger project compared to the big names. It&apos;s under active development and patches land frequently. For production use, keep an eye on releases and test upgrades before rolling them out.
&lt;/Notice&gt;

## Architecture overview

Traceway runs as three main services when deployed standalone:

| Service | Technology | Role |
| --- | --- | --- |
| Backend | Go 1.25, Gin | OTLP ingest, REST API, alerts, migrations |
| Frontend | SvelteKit 2, Svelte 5, Tailwind CSS v4 | Dashboard SPA |
| Database | ClickHouse + PostgreSQL | Telemetry storage (ClickHouse) and relational data (PostgreSQL) |

Everything communicates over the internal Docker network. The backend exposes OTLP/HTTP endpoints for traces, metrics, and logs at `/api/otel/v1/traces`, `/api/otel/v1/metrics`, and `/api/otel/v1/logs`.

## Prerequisites

&lt;ListCheck&gt;
- A Linux VPS or dedicated server with Docker and Docker Compose v2 installed
- At least 4 GB RAM (ClickHouse and PostgreSQL run alongside the backend and frontend)
- Root or sudo access
- Port 80 available for the dashboard
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## Deploy Traceway with Docker Compose

This is the core of it. Clone, compose up, done.

### Step 1: Clone the repo

```bash
git clone https://github.com/tracewayapp/traceway
cd traceway
```

### Step 2: Start the stack

```bash
docker compose up -d
```

The dashboard comes up at `http://your-server-ip`. That&apos;s it.

Under the hood, Docker Compose pulls the Traceway images, boots ClickHouse and PostgreSQL, runs any pending migrations, and starts the API and frontend. The first boot takes maybe 90 seconds while databases initialize.

### Step 3: Create your first project

Open the dashboard, log in with the default credentials, and create a project. The UI walks you through it. You&apos;ll get an ingest endpoint and access token for each project.

&lt;Notice type=&quot;warning&quot; title=&quot;Change default credentials&quot;&gt;
The default login ships as `admin@localhost.com` / `admin`. Change the password immediately through the UI, or set your own defaults with environment variables before first boot.
&lt;/Notice&gt;

## Practical example: Instrument a Node.js app

Let&apos;s walk through wiring up a real application. This is what the transition from &quot;I deployed Traceway&quot; to &quot;I can see my app&apos;s data&quot; looks like.

### Set up a basic Express app

If you don&apos;t have an app handy, scaffold one:

```bash
mkdir traceway-demo &amp;&amp; cd traceway-demo
npm init -y
npm install express @opentelemetry/api @opentelemetry/sdk-node \
  @opentelemetry/auto-instrumentations-node \
  @opentelemetry/exporter-trace-otlp-http
```

### Wire up OpenTelemetry

Create a file called `tracing.js`:

```javascript
const { NodeSDK } = require(&apos;@opentelemetry/sdk-node&apos;);
const { OTLPTraceExporter } = require(&apos;@opentelemetry/exporter-trace-otlp-http&apos;);
const { getNodeAutoInstrumentations } = require(&apos;@opentelemetry/auto-instrumentations-node&apos;);

const sdk = new NodeSDK({
  traceExporter: new OTLPTraceExporter({
    url: &apos;http://your-server-ip/api/otel/v1/traces&apos;,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();
```

### Create the server

Create `index.js`:

```javascript
require(&apos;./tracing&apos;);

const express = require(&apos;express&apos;);
const app = express();

app.get(&apos;/&apos;, (req, res) =&gt; {
  res.json({ message: &apos;Traceway is watching&apos; });
});

app.get(&apos;/slow&apos;, async (req, res) =&gt; {
  await new Promise(resolve =&gt; setTimeout(resolve, 2000));
  res.json({ message: &apos;This one took a while&apos; });
});

app.listen(3000, () =&gt; console.log(&apos;App running on port 3000&apos;));
```

Run it with `node index.js`, then curl both endpoints a few times:

```bash
curl http://localhost:3000/
curl http://localhost:3000/slow
```

Switch back to the Traceway dashboard. Traces appear within seconds. Spans break down each request, the `/slow` endpoint visibly takes longer, and you can click through the waterfall to see exactly where time goes.

## Practical example: Instrument a Python app

Python works the same way. Here&apos;s a minimal Flask setup:

```bash
mkdir traceway-python &amp;&amp; cd traceway-python
python3 -m venv venv &amp;&amp; source venv/bin/activate
pip install flask opentelemetry-distro opentelemetry-exporter-otlp-proto-http
```

Create `app.py`:

```python
from flask import Flask
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.instrumentation.flask import FlaskInstrumentor

# Point the exporter at your Traceway instance
exporter = OTLPSpanExporter(
    endpoint=&quot;http://your-server-ip/api/otel/v1/traces&quot;
)

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)

app = Flask(__name__)
FlaskInstrumentor().instrument_app(app)

@app.route(&quot;/&quot;)
def home():
    return {&quot;message&quot;: &quot;Traceway sees this too&quot;}

@app.route(&quot;/error&quot;)
def error():
    1 / 0  # Intentional — shows up in exceptions
    return &quot;never reached&quot;

if __name__ == &quot;__main__&quot;:
    app.run(port=5000)
```

Run it and hit both endpoints. Back in Traceway, you&apos;ll see spans for each Flask route plus a grouped exception entry for the division by zero.

## Practical example: Frontend session replay

Traceway includes session replay out of the box. For any JavaScript frontend, add the Traceway browser SDK:

```html
&lt;script src=&quot;https://cdn.jsdelivr.net/npm/@tracewayapp/browser@latest/dist/traceway.min.js&quot;&gt;&lt;/script&gt;
&lt;script&gt;
  Traceway.init({
    projectToken: &apos;your-project-token&apos;,
    endpoint: &apos;http://your-server-ip/api/otel/v1/traces&apos;,
    sessionReplay: true,
  });
&lt;/script&gt;
```

That snippet captures user sessions, console logs, network requests, and errors. When an exception fires, the error detail page includes a replay of the user&apos;s session leading up to it. You watch exactly what they clicked, typed, and saw.

The browser SDK works with React, Vue, Svelte, Next.js, and plain HTML. There&apos;s also a Flutter package for mobile apps.

## Production hardening

Once you&apos;ve confirmed everything works, lock it down:

&lt;ListCheck&gt;
- Set up HTTPS with Nginx or Caddy as a reverse proxy in front of the Traceway dashboard
- Change the default password before exposing anything to the network
- Restrict the OTLP ingest port to internal services only (don&apos;t expose it publicly)
- Mount ClickHouse and PostgreSQL data directories to named volumes so data survives container restarts
- Set resource limits on containers so ClickHouse doesn&apos;t starve the API under heavy ingest
- Schedule regular backups of both databases
&lt;/ListCheck&gt;

## Updating Traceway

```bash
cd traceway
git pull
docker compose pull &amp;&amp; docker compose up -d
```

Migrations run automatically when the new backend starts. Check the release notes on GitHub before pulling; breaking changes are called out.

## FAQ

&lt;Accordion label=&quot;How does Traceway compare to Grafana + Loki + Tempo?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Grafana&apos;s stack is battle-tested but requires you to configure and maintain Loki for logs, Tempo for traces, Prometheus for metrics, and Grafana for dashboards. Each piece has its own configuration language, storage backend, and upgrade path. Traceway gives you the same capabilities — logs, traces, metrics — in one Docker Compose stack with one configuration surface. If you already run the Grafana stack and it works for you, Traceway probably isn&apos;t worth the migration. If you&apos;re starting fresh or tired of stitching things together, it&apos;s worth a look.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Traceway with existing OpenTelemetry instrumentation?&quot; group=&quot;faq&quot;&gt;
Yes. That&apos;s the whole point. If your services already export OTLP, just repoint the exporter URL at your Traceway instance. No code changes needed beyond the endpoint URL.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the storage footprint?&quot; group=&quot;faq&quot;&gt;
It depends heavily on your traffic. ClickHouse compresses well — expect roughly 1-3 GB per million spans for typical HTTP service data. PostgreSQL stores project configuration and user data, which is negligible in comparison. Plan for at least 20 GB of disk if you&apos;re running in production with a handful of services, and monitor growth over the first few weeks.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Traceway support alerting?&quot; group=&quot;faq&quot;&gt;
Yes. You can set up alerts for error rate thresholds, latency spikes, or custom metric conditions. Notifications go to Slack, GitHub issues, email, or any webhook endpoint.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a managed cloud option?&quot; group=&quot;faq&quot;&gt;
Traceway Cloud exists at cloud.tracewayapp.com if you&apos;d rather not self-host. It runs the same MIT code. Pricing is fixed-tier rather than per-event, so your bill stays predictable.
&lt;/Accordion&gt;

## Wrapping up

Traceway scratches a real itch. Observability shouldn&apos;t require a procurement process and a four-figure monthly commitment just to know why your API is slow. The project gives you logs, traces, metrics, exception tracking, and session replay in a stack you control, with an MIT license that doesn&apos;t pull the rug later.

The trade-off is maturity. Datadog has a decade of polish behind it. Traceway is early and moving fast. But for small teams, side projects, and anyone who&apos;d rather spend money on servers than dashboards, it&apos;s worth your evening to try.

&lt;Button text=&quot;View Traceway on GitHub&quot; link=&quot;https://github.com/tracewayapp/traceway&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;
&lt;Button text=&quot;Traceway Documentation&quot; link=&quot;https://docs.tracewayapp.com&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>observability</category><category>docker</category></item><item><title>Mirage Tutorial: Give Your AI Agent a Universal Filesystem in 10 Minutes</title><link>https://www.bitdoze.com/mirage-virtual-filesystem-ai-agents/</link><guid isPermaLink="true">https://www.bitdoze.com/mirage-virtual-filesystem-ai-agents/</guid><description>Mount S3, Google Drive, Slack, GitHub, and more as a single filesystem your AI agents can navigate with plain bash commands. No per-service SDKs needed.</description><pubDate>Tue, 12 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &apos;@components/widgets/Button.astro&apos;;
import Notice from &apos;@components/widgets/Notice.astro&apos;;
import Accordion from &apos;@components/widgets/Accordion.astro&apos;;
import Tabs from &apos;@components/widgets/Tabs.astro&apos;;
import Tab from &apos;@components/widgets/Tab.astro&apos;;
import ListCheck from &apos;@components/widgets/ListCheck.astro&apos;;

If you&apos;ve built anything with AI agents that talks to more than one service, you&apos;ve felt the pain. Your agent needs to read a file from S3, check a Slack thread, pull a Google Doc, and query a GitHub repo. Each one needs its own SDK, its own authentication flow, its own set of method calls. Your prompt engineering turns into a juggling act of API instructions.

[Mirage](https://github.com/strukto-ai/mirage) takes a different approach. It mounts all those services as branches of a single virtual filesystem tree — S3, Google Drive, Slack, GitHub, Redis, Gmail, whatever you need. Your agent navigates them with `ls`, `cat`, `grep`, and pipes.

The reason this works well is simple: every major LLM already knows bash. The Unix filesystem is probably the interface LLMs have the most training data for. Mirage leans on that existing knowledge instead of asking models to learn yet another API surface.

This guide walks through setting up Mirage, mounting your first backends, and wiring it into an agent framework. You&apos;ll have a working setup in about 10 minutes.

![Mirage Virtual Filesystem for AI Agents](../../assets/images/25/05/mirage-virtual-filesystem-ai-agents.svg)

## What Mirage actually does

Mirage gives you a `Workspace` object. You mount services onto paths in that workspace, then run shell commands against the combined tree.

```python
from mirage import Workspace
from mirage.resource.s3 import S3Config, S3Resource
from mirage.resource.slack import SlackConfig, SlackResource
from mirage.resource.ram import RAMResource

ws = Workspace({
    &quot;/data&quot;:  RAMResource(),
    &quot;/s3&quot;:    S3Resource(S3Config(bucket=&quot;my-bucket&quot;)),
    &quot;/slack&quot;: SlackResource(SlackConfig()),
})

# These commands hit different backends, but the agent sees one tree
await ws.execute(&quot;ls /s3/logs/&quot;)
await ws.execute(&quot;grep error /slack/engineering/*.json | wc -l&quot;)
await ws.execute(&quot;cp /s3/report.csv /data/local.csv&quot;)
```

The shell isn&apos;t a real `/bin/bash` — it&apos;s a tree-sitter parser with a custom executor that routes commands to per-mount handlers. Pipes, globs, `&amp;&amp;`, `||`, and most common Unix verbs work. The agent never touches your host filesystem or spawns subprocesses.

### Supported backends

Mirage covers a solid range of services out of the box:

| Category | Backends |
|---|---|
| **Storage** | S3, R2, OCI, Supabase, GCS, RAM, Disk |
| **Google** | Gmail, Google Drive, Google Docs, Sheets, Slides |
| **Collaboration** | GitHub, Linear, Notion, Trello, Slack, Discord, Telegram, Email |
| **Databases** | Redis, MongoDB, Postgres |
| **Remote** | SSH |

Each one mounts at a path you choose. The agent doesn&apos;t need to know which backend it&apos;s talking to — it just reads and writes files.

### Mirage vs per-service MCP servers

&lt;Accordion label=&quot;How does this compare to MCP?&quot; group=&quot;comparison&quot; expanded=&quot;true&quot;&gt;

| Aspect | Mirage | Per-service MCP servers |
|---|---|---|
| **Setup** | One workspace, mount config per service | One server per service |
| **Agent vocabulary** | Standard bash (`ls`, `cat`, `grep`, pipes) | Custom tool schemas per server |
| **Cross-service pipelines** | Native (pipes across mounts) | Manual orchestration in code |
| **Caching** | Built-in two-layer cache (index + file) | Varies by server |
| **Portability** | Snapshot and clone workspaces | State scattered across servers |
| **Framework support** | OpenAI Agents SDK, Vercel AI SDK, LangChain, Pydantic AI | Depends on MCP client |

MCP servers are still useful for services that need structured tool calls (like &quot;create a calendar event&quot;). Mirage works best when the interaction pattern is read/write/search, which covers a large chunk of what agents actually do.

&lt;/Accordion&gt;

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.12+ or Node.js 20+&lt;/li&gt;
&lt;li&gt;macOS or Linux&lt;/li&gt;
&lt;li&gt;Credentials for at least one backend (S3 bucket, Slack token, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Step 1: Install Mirage

&lt;Tabs&gt;
&lt;Tab name=&quot;Python&quot;&gt;

```bash
uv add mirage-ai
```

This installs both the `mirage` library and the `mirage` CLI binary.

&lt;/Tab&gt;
&lt;Tab name=&quot;TypeScript (Node)&quot;&gt;

```bash
npm install @struktoai/mirage-node
```

For browser or edge runtimes, use `@struktoai/mirage-browser` instead. The `@struktoai/mirage-core` package provides runtime-agnostic primitives if you need fine-grained control.

&lt;/Tab&gt;
&lt;Tab name=&quot;CLI Only&quot;&gt;

```bash
curl -fsSL https://strukto.ai/mirage/install.sh | sh
```

Or via npm/npx:

```bash
npm install -g @struktoai/mirage-cli
# or
npx @struktoai/mirage-cli
# or
uvx mirage-ai
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;No FUSE required&quot;&gt;
Mirage runs the workspace in-process. You don&apos;t need FUSE support unless you want host tools (editors, language servers, `rg`) to also see the mounted filesystem.
&lt;/Notice&gt;

## Step 2: Create a workspace and mount services

The workspace is the central object. You define it with a dictionary mapping mount paths to resource instances.

### Python

```python
from mirage import Workspace
from mirage.resource.ram import RAMResource
from mirage.resource.s3 import S3Config, S3Resource
from mirage.resource.slack import SlackConfig, SlackResource
from mirage.resource.gdocs import GDocsConfig, GDocsResource
from mirage.resource.github import GitHubConfig, GitHubResource

ws = Workspace({
    &quot;/data&quot;:   RAMResource(),
    &quot;/s3&quot;:     S3Resource(S3Config(bucket=&quot;my-logs-bucket&quot;)),
    &quot;/slack&quot;:  SlackResource(SlackConfig()),
    &quot;/docs&quot;:   GDocsResource(GDocsConfig()),
    &quot;/github&quot;: GitHubResource(GitHubConfig()),
})
```

### TypeScript

```ts
import {
  Workspace,
  RAMResource,
  S3Resource,
  SlackResource,
  GDocsResource,
  GitHubResource,
} from &apos;@struktoai/mirage-node&apos;

const ws = new Workspace({
  &apos;/data&apos;:   new RAMResource(),
  &apos;/s3&apos;:     new S3Resource({ bucket: &apos;my-logs-bucket&apos; }),
  &apos;/slack&apos;:  new SlackResource({}),
  &apos;/docs&apos;:   new GDocsResource({}),
  &apos;/github&apos;: new GitHubResource({}),
})
```

Each resource needs its own credentials. Mirage reads them from environment variables or explicit config objects. Check the [resource matrix](https://docs.mirage.strukto.ai/home/resource-matrix) for per-backend setup details.

## Step 3: Run commands against the combined tree

Once the workspace is set up, every command goes through `execute()`:

```python
# List files in S3
await ws.execute(&quot;ls /s3/logs/2026/05/&quot;)

# Search across Slack messages
await ws.execute(&quot;grep &apos;incident&apos; /slack/incidents/*.json&quot;)

# Pipe data between backends
await ws.execute(&quot;cat /s3/report.csv | head -20 &gt; /data/preview.txt&quot;)

# Read a Google Doc
await ws.execute(&quot;cat /docs/meeting-notes.md&quot;)

# Search GitHub repo contents
await ws.execute(&quot;grep -r &apos;TODO&apos; /github/my-repo/src/&quot;)
```

The agent uses the same commands it would use on a local filesystem. No new vocabulary to learn, no SDK method calls to memorize.

### Custom commands

You can register your own commands that work across all mounts:

```python
# Register a summarize command available everywhere
ws.command(&apos;summarize&apos;, summarize_handler)

# Override a command for a specific resource + filetype
# cat on a Parquet file in /s3 renders rows as JSON
ws.command(&apos;cat&apos;, {&apos;resource&apos;: &apos;s3&apos;, &apos;filetype&apos;: &apos;parquet&apos;}, parquet_cat_handler)

await ws.execute(&apos;summarize /github/my-repo/README.md&apos;)
await ws.execute(&apos;cat /s3/events/2026-05-06.parquet | jq .user&apos;)
```

## Step 4: Wire into your agent framework

Mirage ships adapters for the major agent frameworks. Here&apos;s how to plug it in.

### OpenAI Agents SDK

```python
from agents import Runner
from agents.run import RunConfig
from agents.sandbox import SandboxAgent, SandboxRunConfig
from mirage.agents.openai_agents import MirageSandboxClient

client = MirageSandboxClient(ws)
agent = SandboxAgent(
    name=&quot;Filesystem Agent&quot;,
    model=&quot;gpt-4.1&quot;,
    instructions=ws.file_prompt,
)

result = await Runner.run(
    agent,
    &quot;Find all error logs from last week in /s3 and summarize them.&quot;,
    run_config=RunConfig(sandbox=SandboxRunConfig(client=client)),
)
```

The `ws.file_prompt` property generates a system prompt that tells the model about the mounted filesystem layout. The agent runs bash commands against your mounts through the sandbox client.

### Vercel AI SDK (TypeScript)

```ts
import { generateText } from &apos;ai&apos;
import { openai } from &apos;@ai-sdk/openai&apos;
import { mirageTools } from &apos;@struktoai/mirage-agents/vercel&apos;
import { buildSystemPrompt } from &apos;@struktoai/mirage-agents/openai&apos;

const { text } = await generateText({
  model: openai(&apos;gpt-4.1&apos;),
  system: buildSystemPrompt({
    mountInfo: { &apos;/s3&apos;: &apos;S3 bucket&apos;, &apos;/slack&apos;: &apos;Slack messages&apos; }
  }),
  prompt: &quot;Read /s3/data/report.pdf, then describe what&apos;s in it.&quot;,
  tools: mirageTools(ws),
})
```

Adapters for LangChain, Pydantic AI, CAMEL, and OpenHands are also available. The pattern is the same: pass the workspace, get tools back.

### CLI with Claude Code or Codex

The Mirage CLI plugs directly into coding agents. This works with [Hermes Agent](/hermes-agent-setup-guide/), [OpenCode](/opencode-setup-guide/), [Pi Agent](/pi-coding-agent-setup-guide/), and any tool that exposes a shell interface.

```bash
# Create a workspace config
mirage workspace create ws.yaml --id my-agent

# Run commands
mirage execute --workspace_id my-agent --command &quot;grep alert /s3/logs/*.json&quot;

# Snapshot for reuse
mirage workspace snapshot my-agent my-agent.tar
```

## Step 5: Configure caching

Every workspace ships with a two-layer cache so repeated reads don&apos;t hit the network:

- **Index cache** caches directory listings and metadata. First `ls` hits the API; subsequent ones serve from cache until TTL expires.
- **File cache** stores object bytes. First `cat` streams from origin; later reads come from cache.

By default, Mirage uses an in-process RAM cache (512 MB file cache, 10-minute index TTL). For production setups with multiple workers or processes, switch to Redis:

```ts
import { RedisFileCacheStore, RedisIndexCacheStore, Workspace } from &apos;mirage/node&apos;

const ws = new Workspace(
  { &apos;/s3&apos;: new S3Resource({ bucket: &apos;my-bucket&apos; }) },
  {
    cache: new RedisFileCacheStore({
      url: &apos;redis://localhost:6379/0&apos;,
      limit: &apos;8GB&apos;,
    }),
    index: new RedisIndexCacheStore({
      url: &apos;redis://localhost:6379/0&apos;,
      ttl: 600,
    }),
  },
)
```

If you&apos;re running Mirage inside a container, you can pair it with a [Redis Docker setup](/multiple-postgres-databases-docker/) for a fully self-contained stack.

## Portable workspaces

Workspaces are portable. You can snapshot, clone, and version them.

```python
# Save workspace state
ws.snapshot(&quot;my-workspace.tar&quot;)

# Load it elsewhere
restored = Workspace.load(&quot;my-workspace.tar&quot;, id=&quot;restored&quot;)
```

```bash
# CLI equivalent
mirage workspace snapshot my-agent my-agent.tar
mirage workspace load my-agent.tar --id restored
```

You can save an agent&apos;s working environment and restore it on another machine, version workspace configs alongside your code, or reproduce agent runs by restoring the exact same filesystem state.

## Real-world example: multi-service log analysis

Here&apos;s a concrete workflow that pulls data from three services to produce a report:

```python
from mirage import Workspace
from mirage.resource.ram import RAMResource
from mirage.resource.s3 import S3Config, S3Resource
from mirage.resource.slack import SlackConfig, SlackResource
from mirage.resource.github import GitHubConfig, GitHubResource

ws = Workspace({
    &quot;/data&quot;:   RAMResource(),
    &quot;/s3&quot;:     S3Resource(S3Config(bucket=&quot;app-logs&quot;)),
    &quot;/slack&quot;:  SlackResource(SlackConfig()),
    &quot;/github&quot;: GitHubResource(GitHubConfig()),
})

# Find errors in S3 logs
await ws.execute(&quot;grep -i &apos;error&apos; /s3/production/2026-05-*.log &gt; /data/errors.txt&quot;)

# Count by frequency
await ws.execute(&quot;sort /data/errors.txt | uniq -c | sort -rn &gt; /data/top-errors.txt&quot;)

# Check if these were reported in Slack
await ws.execute(&quot;grep -f /data/errors.txt /slack/incidents/*.json &gt; /data/slack-matches.txt&quot;)

# Cross-reference with GitHub issues
await ws.execute(&quot;grep -i &apos;error&apos; /github/my-app/issues/ &gt; /data/github-issues.txt&quot;)

# Combine into a report
await ws.execute(&quot;cat /data/top-errors.txt /data/slack-matches.txt /data/github-issues.txt &gt; /data/report.txt&quot;)
```

The agent handles this the same way a developer would on a local machine — `grep`, `sort`, `uniq`, pipes. The difference is that the data lives across S3, Slack, and GitHub, and the agent doesn&apos;t need to know that.

## Tips for working with Mirage

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Use RAMResource as a local scratch pad for intermediate results, like `/tmp`&lt;/li&gt;
&lt;li&gt;Name mount paths clearly — `/s3`, `/slack`, `/github` are easier for agents to reason about than `/m1`, `/m2`, `/m3`&lt;/li&gt;
&lt;li&gt;Switch to Redis cache for production; the default RAM cache doesn&apos;t survive restarts or share across processes&lt;/li&gt;
&lt;li&gt;Save workspace state before long-running agent tasks so you can restore if something goes sideways&lt;/li&gt;
&lt;li&gt;Combine Mirage with coding agents like [OpenCode](/opencode-setup-guide/) or [Hermes Agent](/hermes-agent-setup-guide/) that already use shell interfaces&lt;/li&gt;
&lt;li&gt;Check the [resource matrix](https://docs.mirage.strukto.ai/home/resource-matrix) before building — not every backend supports every operation&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Troubleshooting

&lt;Accordion label=&quot;Commands return empty results&quot; group=&quot;faq&quot;&gt;
Check that your credentials are set correctly. Mirage reads service credentials from environment variables by default. Verify with:

```bash
# Check what&apos;s mounted
await ws.execute(&quot;ls /&quot;)

# Check a specific mount
await ws.execute(&quot;ls /s3/&quot;)
```

If the mount is empty but shouldn&apos;t be, verify the bucket/token/credential configuration for that specific resource.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pipe commands fail across mounts&quot; group=&quot;faq&quot;&gt;
Most pipes work across mounts, but some commands have resource-specific behavior. If a pipe fails, try splitting it into separate steps using a RAMResource as intermediate storage:

```python
await ws.execute(&quot;cat /s3/data.json &gt; /data/temp.json&quot;)
await ws.execute(&quot;jq &apos;.items[]&apos; /data/temp.json &gt; /data/filtered.json&quot;)
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Performance feels slow&quot; group=&quot;faq&quot;&gt;
First reads always hit the network. Subsequent reads use the cache. If you&apos;re doing heavy I/O:

1. Switch to Redis cache for shared caching across processes
2. Use `mirage provision` (CLI) to pre-warm the cache before agent runs
3. Check if your backend has rate limits (especially Slack and Google APIs)
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which model works best with Mirage?&quot; group=&quot;faq&quot;&gt;
Any model that handles bash well works. Models trained heavily on code and shell commands tend to perform best. If you&apos;re looking for affordable options, check [Best Open Source LLMs for Coding](/best-open-source-llms-claude-alternative/) or [Best Cheap Models for Hermes Agent](/best-cheap-models-hermes-agent/). You can run Mirage with free models through [OpenRouter](/hermes-agent-mimo-v2-pro/) or [LiteLLM](/litellm-docker-install/) as well.
&lt;/Accordion&gt;

## What&apos;s next

Mirage solves a real problem in agent development: connecting agents to multiple services without drowning in SDK boilerplate. The filesystem abstraction is familiar to both developers and LLMs, which makes it a practical foundation for multi-service agent workflows.

- [Mirage GitHub Repository](https://github.com/strukto-ai/mirage) for source code and issues
- [Mirage Documentation](https://docs.mirage.strukto.ai) for the full API reference and resource matrix
- [Mirage Discord](https://discord.gg/u8BPQ65KsS) for community support
- [Hermes Agent Setup Guide](/hermes-agent-setup-guide/) to pair Mirage with a self-hosted AI agent
- [VPS Setup for AI Coding Agents](/vps-ai-coding-setup/) to get a server ready for agent workloads
- [Best Open Source LLMs for Coding](/best-open-source-llms-claude-alternative/) for affordable models that handle bash well

If you&apos;re building agents that touch more than one service, Mirage is worth a serious look. The 10-minute setup time isn&apos;t marketing fluff — once you have credentials for a backend, mounting it is a single line of config.

&lt;Button text=&quot;More AI tool guides&quot; link=&quot;/category/ai/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;</content:encoded><category>ai</category><category>ai-agents</category><category>mirage</category></item><item><title>Best Oh My ZSH Plugins for 2026</title><link>https://www.bitdoze.com/best-oh-my-zsh-plugins/</link><guid isPermaLink="true">https://www.bitdoze.com/best-oh-my-zsh-plugins/</guid><description>A practical list of Oh My Zsh plugins that actually improve your terminal workflow.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Oh My ZSH plugins add command suggestions, syntax highlighting, and shortcuts that speed up your terminal work.

If you spend any real time in the terminal, the right plugins make a noticeable difference. Here are the ones I actually use and recommend.

## Installation and Setup Guide

Getting Oh My ZSH running with plugins takes a few minutes:

&lt;Accordion label=&quot;Installing Oh My ZSH&quot; group=&quot;setup&quot; expanded=&quot;true&quot;&gt;
First, install Oh My ZSH using this simple command:

```shell
sh -c &quot;$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)&quot;
```

The script handles the setup. Your terminal restarts with a new prompt when it finishes.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Installing Plugins&quot; group=&quot;setup&quot;&gt;
Most plugins need to be installed manually before you can use them. Here&apos;s how:

**For custom plugins (like autosuggestions):**
```shell
# Navigate to the plugins directory
cd ~/.oh-my-zsh/custom/plugins

# Clone the plugin repository
git clone https://github.com/zsh-users/zsh-autosuggestions
```

**For built-in plugins:** These come pre-installed with Oh My ZSH and just need to be enabled.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Enabling Plugins&quot; group=&quot;setup&quot;&gt;
Once installed, you need to activate plugins in your configuration file:

1. Open your `.zshrc` file:
```shell
nano ~/.zshrc
```

2. Find the plugins line and add your desired plugins:
```shell
plugins=(git npm node zsh-autosuggestions zsh-syntax-highlighting)
```

3. Save the file and reload your terminal:
```shell
source ~/.zshrc
```
&lt;/Accordion&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Quick Tip&quot;&gt;
Run `source ~/.zshrc` after changes to apply them without restarting.
&lt;/Notice&gt;

## Essential Oh My ZSH Plugins

### Core Productivity Plugins

**1. [zsh-autosuggestions](https://github.com/zsh-users/zsh-autosuggestions)**
- **What it does**: Suggests commands as you type based on your history
- **Installation**: `git clone https://github.com/zsh-users/zsh-autosuggestions ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions`
- **Key benefit**: Press → (right arrow) to accept suggestions and save time on repetitive commands
- **Tutorial**: [How to Enable Command Autocomplete in ZSH](https://www.bitdoze.com/enable-command-autocomplete-in-zsh/)

**2. [zsh-syntax-highlighting](https://github.com/zsh-users/zsh-syntax-highlighting)**
- **What it does**: Colors your commands as you type, showing errors in real-time
- **Installation**: `git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting`
- **Key benefit**: Red highlighting for invalid commands, green for valid ones
- **Tutorial**: [How to Enable Syntax Highlighting in Zsh](https://www.bitdoze.com/enable-syntax-highlighting-zsh/)

**3. z**
- **What it does**: Navigate to frequently used directories instantly without typing full paths
- **Installation**: Built-in with Oh My ZSH
- **Usage**: `z documents` jumps to ~/Documents, `z proj` jumps to ~/Projects
- **Key benefit**: Learns your most visited directories and provides smart shortcuts

**4. git**
- **What it does**: Provides shortcuts and aliases for Git operations
- **Installation**: Built-in with Oh My ZSH
- **Popular aliases**: `gst` (git status), `gco` (git checkout), `gaa` (git add --all), `gcm` (git commit -m)
- **Key benefit**: Speeds up common Git workflows dramatically

**5. [history-substring-search](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/history-substring-search)**
- **What it does**: Search command history by typing partial strings
- **Installation**: Built-in with Oh My ZSH
- **Usage**: Type part of a command, then use ↑/↓ to cycle through matches
- **Key benefit**: Find previous commands without endless scrolling

### Development and DevOps Plugins

**6. docker**
- **What it does**: Adds autocompletion and aliases for Docker commands
- **Installation**: Built-in with Oh My ZSH
- **Features**: Command completion, shortcuts for common operations
- **Key benefit**: Type less when working with containers

**7. docker-compose**
- **What it does**: Autocompletion for multi-container Docker setups
- **Installation**: Built-in with Oh My ZSH
- **Key benefit**: Tab completion catches typos before they break your stack

**8. npm**
- **What it does**: Auto-completion and aliases for npm
- **Installation**: Built-in with Oh My ZSH
- **Features**: Shows npm version and package name in prompt
- **Key benefit**: Faster package management

**9. kubectl**
- **What it does**: Kubernetes CLI completion and shortcuts
- **Installation**: Built-in with Oh My ZSH
- **Features**: Auto-completion and aliases for kubectl commands
- **Key benefit**: Less typing when managing clusters

**10. aws**
- **What it does**: AWS CLI completion and profile management
- **Installation**: Built-in with Oh My ZSH
- **Features**: Completions for awscli and profile switching utilities
- **Key benefit**: Easier AWS resource management

### Utility and Convenience Plugins

**11. [web-search](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/web-search)**
- **What it does**: Search the web directly from your terminal
- **Installation**: Built-in with Oh My ZSH
- **Usage**: `google &quot;search term&quot;`, `bing &quot;query&quot;`, `duckduckgo &quot;topic&quot;`
- **Key benefit**: Quick web searches without leaving the terminal

**12. [extract](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/extract)**
- **What it does**: Extract compressed files with a single command
- **Installation**: Built-in with Oh My ZSH
- **Supported formats**: ZIP, TAR, GZ, BZ2, 7Z, RAR, DMG, and more
- **Usage**: `extract filename.zip` or `extract archive.tar.gz`
- **Key benefit**: No need to remember different extraction commands

**13. [1password](https://github.com/agpenton/1password-zsh-plugin)**
- **What it does**: Integrates 1Password functionality with terminal
- **Installation**: `git clone https://github.com/agpenton/1password-zsh-plugin.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/1password`
- **Usage**: `opswd service-name` copies password to clipboard
- **Key benefit**: Secure password access without leaving terminal

**14. sudo**
- **What it does**: Adds sudo to commands with keyboard shortcut
- **Installation**: Built-in with Oh My ZSH
- **Usage**: Press `ESC` twice to prefix current command with sudo
- **Key benefit**: Perfect for those &quot;permission denied&quot; moments

**15. [colored-man-pages](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/colored-man-pages)**
- **What it does**: Adds colors to man pages for better readability
- **Installation**: Built-in with Oh My ZSH
- **Key benefit**: Makes documentation easier to read and scan

### Advanced Plugins

**16. [fzf](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/fzf)**
- **What it does**: Fuzzy finder for files and command history
- **Installation**: `git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf &amp;&amp; ~/.fzf/install`
- **Key shortcuts**: `Ctrl+T` (find files), `Ctrl+R` (search history), `Alt+C` (change directory)
- **Key benefit**: Interactive searching with fuzzy matching

**17. [thefuck](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/thefuck)**
- **What it does**: Corrects mistyped commands
- **Installation**: Built-in with Oh My ZSH (requires thefuck to be installed)
- **Usage**: Type `fuck` or press `Ctrl+G` after a failed command
- **Key benefit**: Fixes typos without retyping

**18. [virtualenv](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/virtualenv)**
- **What it does**: Manages Python virtual environments
- **Installation**: Built-in with Oh My ZSH
- **Features**: Auto-activation when entering project directories, prompt indicators
- **Key benefit**: Handles Python environments without manual activation

**19. [fast-syntax-highlighting](https://github.com/zdharma-continuum/fast-syntax-highlighting)**
- **What it does**: Faster syntax highlighting with more themes
- **Installation**: `git clone https://github.com/zdharma-continuum/fast-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/fast-syntax-highlighting`
- **Advantages**: Lower latency, switchable themes, better command parsing
- **Key benefit**: Drop-in replacement for zsh-syntax-highlighting with better performance

**20. [zsh-autocomplete](https://github.com/marlonrichert/zsh-autocomplete)**
- **What it does**: Real-time autocompletion as you type
- **Installation**: `git clone --depth 1 -- https://github.com/marlonrichert/zsh-autocomplete.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autocomplete`
- **Features**: Real-time completion, intuitive keybindings
- **Key benefit**: IDE-like completion in your terminal

**21. [alias-finder](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/alias-finder)**
- **What it does**: Shows available aliases when you type full commands
- **Installation**: Built-in with Oh My ZSH
- **Usage**: Type a command and it tells you if there&apos;s a shorter alias
- **Key benefit**: Learn your aliases naturally through usage

**22. [copypath](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/copypath)**
- **What it does**: Copies current directory path to clipboard
- **Installation**: Built-in with Oh My ZSH
- **Usage**: Run `copypath` to copy current path
- **Key benefit**: One command instead of `pwd | pbcopy`

### Bonus Plugins

Other useful plugins worth considering:

- **[command-not-found](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/command-not-found)**: Suggests package installations for unknown commands
- **[jsontools](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/jsontools)**: JSON utilities (`pp_json`, `is_json`, `urlencode_json`)
- **[urltools](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/urltools)**: URL encoding/decoding
- **[battery](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/battery)**: Battery status in prompt (for laptops)
- **[last-working-dir](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/last-working-dir)**: Opens new terminals in your last directory
- **[transfer](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/transfer)**: Upload files via transfer.sh
- **[encode64](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/encode64)**: Base64 encoding/decoding
- **[colorize](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/colorize)**: Syntax highlighting for `cat` and `less`
- **[copyfile](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/copyfile)**: Copy file contents to clipboard
- **[dirhistory](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/dirhistory)**: Navigate directory history with Alt+arrows
- **[eza](https://github.com/ohmyzsh/ohmyzsh/tree/master/plugins/eza)**: Replaces `ls` aliases with `eza` — a modern replacement with colors, icons, git status, and tree view

&lt;Notice type=&quot;success&quot; title=&quot;Tip&quot;&gt;
Combine history-substring-search with zsh-autosuggestions for better command recall.
&lt;/Notice&gt;

## Plugin Installation Methods

&lt;Tabs&gt;
  &lt;Tab name=&quot;Manual Installation&quot;&gt;

    **Step-by-step process:**

    1. Navigate to the custom plugins directory
    2. Clone the plugin repository
    3. Add plugin name to your `.zshrc` file
    4. Reload your configuration

    **Example:**
    ```shell
    cd ~/.oh-my-zsh/custom/plugins
    git clone https://github.com/plugin-author/plugin-name
    nano ~/.zshrc  # Add plugin to plugins list
    source ~/.zshrc
    ```

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Package Manager&quot;&gt;

    **Using Homebrew (macOS):**
    Some plugins can be installed via Homebrew for easier management.

    ```shell
    brew install zsh-autosuggestions
    brew install zsh-syntax-highlighting
    ```

    **Configuration:** Add the source lines to your `.zshrc`:
    ```shell
    source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
    source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
    ```

  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Tips

**Getting started:**
- Start with 5-10 plugins and add more as needed
- Test new plugins in a separate terminal first
- Remove plugins you don&apos;t use

**Performance:**
- 10-15 plugins is a reasonable limit
- If startup feels slow, disable plugins one by one to find the cause

&lt;Notice type=&quot;warning&quot; title=&quot;Performance&quot;&gt;
Too many plugins slow down terminal startup. Keep only what you actually use.
&lt;/Notice&gt;

**Troubleshooting:**
- Plugin not working? Check if it&apos;s in your plugins list
- Conflicts happen. Try disabling other plugins to isolate the issue

## Modern Alternatives

The ZSH ecosystem has more options now:

&lt;Tabs&gt;
  &lt;Tab name=&quot;Modern Prompt Engines&quot;&gt;

    **Starship**
    A blazing-fast, cross-shell prompt written in Rust. Works with any shell and provides consistent theming.

    **Installation:**
    ```shell
    brew install starship
    # Add to .zshrc: eval &quot;$(starship init zsh)&quot;
    ```
    For more you can check [Starship Setup With Ghostty](https://www.bitdoze.com/starship-ghostty-terminal/)


    **Powerlevel10k**
    Currently on life support but still functional. Known for its speed and extensive customization options.

    **Why consider these:**
    - Cross-shell compatibility
    - Better performance
    - Modern config formats

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Plugin Managers&quot;&gt;

    **Modern Plugin Managers:**
    - **Zinit**: Fast with turbo mode for lazy loading
    - **Antidote**: Lightweight, fast startup
    - **Sheldon**: Written in Rust

    **Why switch:**
    - Faster startup times
    - Better dependency management
    - Lazy loading support

  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Note&quot;&gt;
Oh My ZSH works fine for most users. Consider alternatives if startup time bothers you.
&lt;/Notice&gt;

&lt;Button text=&quot;Explore Oh My ZSH Plugins&quot; size=&quot;md&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

## Conclusion

Start with autosuggestions and syntax highlighting. Those two alone make a noticeable difference. Add more plugins as you need them.

Don&apos;t install everything at once. Try a plugin, use it for a week, and decide if it actually helps. Some plugins sound useful but end up unused.

The [official Oh My ZSH plugin repository](https://github.com/ohmyzsh/ohmyzsh/wiki/Plugins) has hundreds more options if you want to explore further.

If you&apos;re curious about Fish Shell as an alternative to Zsh, check out [Fish Shell vs Zsh](/fish-shell-vs-zsh/) or [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) for a full comparison. Fish has many of these features built in without plugins.</content:encoded><category>linux</category><category>zsh</category></item><item><title>How to Build a Free Blog with Astro &amp; Cloudflare in 30 Minutes (No Wallet Required!)</title><link>https://www.bitdoze.com/build-astro-blog-free/</link><guid isPermaLink="true">https://www.bitdoze.com/build-astro-blog-free/</guid><description>Learn how to build a lightning-fast blog using Astro and Cloudflare Pages - completely free, no credit card needed! This step-by-step guide takes you from zero to published in just 30 minutes.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Hey there, future internet superstar! 🌟 Dreaming of launching your own blog but your wallet&apos;s emptier than your fridge before payday? Well, put that credit card away, because today we&apos;re building a blazing-fast, zero-cost blog that&apos;ll make your friends say, &quot;Whoa, you did THAT?!&quot; — all without spending a single shiny penny. 🪙

So grab your favorite beverage (coffee, tea, or unicorn tears 🦄), and let&apos;s get this blog party started!

&lt;Notice type=&quot;success&quot; title=&quot;Update: the theme just got a speed boost 🚀&quot;&gt;
The [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) you&apos;ll fork in Level 4 now runs on **Astro 7**. Astro&apos;s new Rust compiler roughly cut build times in half on this very blog (743 pages, 103s down to 47s), and I used the upgrade as a reason to add a real Vitest test suite, a stricter content schema, and a sitemap fix to the theme too. Nothing below changes for you, `npm install` just pulls in the faster version. Full numbers here: [Astro 7 Benchmark: Build Times Cut in Half](/astro-7-faster-builds/).
&lt;/Notice&gt;

## 🤔 What the Heck is Astro Anyway?

Before we dive in, let&apos;s answer the burning question: &quot;What is this Astro thing and why should I care?&quot;

[Astro](https://astro.build/) is like that friend who&apos;s impossibly good at everything. It&apos;s a modern static site generator that lets you build blazing-fast websites using your favorite JavaScript frameworks (React, Vue, Svelte - take your pick!). But here&apos;s the kicker - Astro ships *zero JavaScript by default*.

That&apos;s right! While other frameworks are sending megabytes of JS to your users&apos; browsers, Astro&apos;s saying &quot;Nah, I&apos;m good&quot; and delivering mostly HTML. The result? Sites that load faster than you can say &quot;why is WordPress so slow?&quot;

### Why Astro is Perfect for Your Blog:

- **Speed that makes Google drool** 🤤 (hello, SEO benefits!)
- **&quot;Islands architecture&quot;** - fancy talk for &quot;only hydrate the interactive parts&quot;
- **Use any UI framework** (or none at all) - it&apos;s like the Switzerland of web dev
- **Markdown support** that makes blogging feel like texting
- **Zero-config by default** because ain&apos;t nobody got time for that

Think of Astro as the mullet of web frameworks: business in the front (static HTML for speed) and party in the back (JavaScript only where you need it). And today, we&apos;re using it to build you a blog that&apos;s faster than your uncle&apos;s hot take on politics at Thanksgiving dinner. Already running a WordPress site? Check out [how I migrated my WordPress site to Astro](https://www.bitdoze.com/wordpress-to-astro-migration/) — it was way less painful than I expected.

---

## 🎯 What You&apos;ll Have by the End (a.k.a. Your Blogging Superpowers)

- A **modern, responsive** blog powered by Astro ⚡
- Your content safely stored on GitHub (like a digital vault, but cooler)
- Your site deployed on Cloudflare Pages (with free SSL, so hackers cry 😭)
- Optionally, a custom domain (because `myawesomeblog.com` &gt; `randomstring.pages.dev`)
- And the best part? It all costs **$0.00**. That&apos;s right — cheaper than instant noodles.

---

## 🧰 What You Need (a.k.a. Your Blogging Starter Pack)

- Basic Markdown knowledge (it&apos;s easier than making toast)
- A computer with internet (duh)
- About 30 minutes of your precious time
- Node.js &amp; npm installed (don&apos;t worry, it&apos;s painless)
- Visual Studio Code (optional, but makes you look like a pro)
- Your favorite drink (hydration = motivation)

---

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EMLuT8YN8Xs&quot;
  label=&quot;How to Build a Free Blog with Astro &amp; Cloudflare in 30 Minutes&quot;
/&gt;

## 🥇 Level 1: Become a GitHubber (5 minutes)

Already got a GitHub account? Skip ahead, you overachiever.
If not:

1. Zoom over to [GitHub.com](https://github.com)
2. Smash that **Sign up** button
3. Pick a cool username (or something embarrassing, your call)
4. Verify your email (check spam, those sneaky emails love hiding)

🎉 **Achievement unlocked:** You&apos;re now part of the world&apos;s biggest dev club! No secret handshake required.


&lt;Notice type=&quot;info&quot; title=&quot;Information Notice&quot;&gt;
For more on github you can check: [Link GitHub SSH](https://www.bitdoze.com/link-github-with-ssh-maco-linux/)
&lt;/Notice&gt;

---

## 🥈 Level 2: Node Your Way to Success (5 minutes)

Before we get coding, let&apos;s install Node.js &amp; npm:

1. Visit [nodejs.org](https://nodejs.org/)
2. Download the **LTS** version (Long Term Support = less drama)
3. Install it like any other app
4. Open Terminal/Command Prompt and type:

```bash
node --version
npm --version
```

If you see numbers, congrats! If not, double-check or yell at your computer (gently).


&lt;Notice type=&quot;info&quot; title=&quot;Information Notice&quot;&gt;
For more on github you can check: [Install Node Mac](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/)
&lt;/Notice&gt;
---

## 🥉 Level 3: Power Up with VS Code (Optional, 3 minutes)

Because coding in Notepad is so 1995.

1. Go to [code.visualstudio.com](https://code.visualstudio.com/)
2. Download &amp; install
3. Open it up and bask in its glory

---

## 🏆 Level 4: Fork That Theme! (2 minutes)

Time to grab your shiny new blog template (And no, we&apos;re not talking about stealing silverware from your local diner):

1. Visit [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme)
2. Hit **Fork** (top-right corner)
3. Name it whatever you want (`my-awesome-blog` works)
4. Click **Create fork**

💥 Boom! You now own a copy. Customize away!

---

## 🕹️ Level 5: Clone Like a Pro (3 minutes)

Let&apos;s get that code on your machine:

1. In your forked repo, click **Code** &gt; copy the HTTPS URL
2. Open VS Code
3. Press `Ctrl+Shift+P` (or `Cmd+Shift+P` on Mac)
4. Type **Git: Clone**, hit Enter
5. Paste the URL, pick a folder
6. Open the repo when prompted

---

## ⚙️ Level 6: Install &amp; Run Locally (5 minutes)

Now we get to play digital plumber - connecting all the pipes that make your blog flow!

1. Open the terminal in VS Code (`Ctrl + backtick \``)
2. Type:

```bash
npm install
npm run dev
```

3. Wait for the magic
4. Visit `http://localhost:4321`

🎉 **Achievement unlocked:** Your blog lives! (Locally, but still cool.)

---

## 🎨 Level 7: Make It Yours (10 minutes)

Let&apos;s add your personal flair.

### Update `src/config/config.json`

```json
{
  &quot;site&quot;: {
    &quot;title&quot;: &quot;Your Amazing Blog Title&quot;,
    &quot;base_url&quot;: &quot;https://yourblog.pages.dev&quot;,
    &quot;logo_text&quot;: &quot;Your Blog Name&quot;
  },
  &quot;metadata&quot;: {
    &quot;meta_author&quot;: &quot;Your Name&quot;,
    &quot;meta_description&quot;: &quot;Your captivating blog description&quot;
  }
}
```

### Update `src/config/site.ts`

```typescript
export const siteConfig = {
  name: &quot;Your Blog Name&quot;,
  description: &quot;Your blog&apos;s awesome description goes here&quot;,
  // Leave the rest alone for now
};
```

💡 _Pro Tip: If you spill coffee on your keyboard, that&apos;s called JavaScript._

---

## ✍️ Level 8: Write Your First Masterpiece (5 minutes)

1. Go to `src/content/posts/`
2. Create `my-first-post.md`
3. Paste this in:

```markdown
---
title: &quot;My First Blog Post&quot;
meta_title: &quot;My First Amazing Blog Post&quot;
description: &quot;This is the beginning of my blogging journey!&quot;
date: 2023-07-15
image: &quot;../../assets/images/web-development.svg&quot;
authors: [&quot;admin&quot;]
categories: [&quot;Personal&quot;]
tags: [&quot;beginnings&quot;, &quot;blogging&quot;]
---

## Hello World!

Welcome to my shiny new blog! I&apos;m excited to overshare my thoughts with the internet.

### Why I Started This Blog

- To rant
- To teach
- To learn
- To procrastinate productively

Stay tuned for more!

![Celebration](https://media.giphy.com/media/3o6fJ1BM7R2EBRDnxK/giphy.gif)
```

4. Save &amp; refresh `http://localhost:4321`

🎉 **Achievement unlocked:** First post published!

Congratulations! You&apos;ve officially joined the ranks of &quot;people with opinions on the internet.&quot; It&apos;s a prestigious club with absolutely no entry requirements.

---

## 💾 Level 9: Save &amp; Push Like a Boss (2 minutes)

1. Click the Source Control icon (branchy thing)
2. Stage all changes (+ button)
3. Write a commit message (&quot;First post, woohoo!&quot;)
4. Click ✔️ to commit
5. Push via the &quot;...&quot; menu &gt; **Push**

---

## ☁️ Level 10: Enter the Cloud (3 minutes)

1. Sign up at [Cloudflare.com](https://cloudflare.com)
2. Verify email
3. Skip &quot;Add a website&quot; (we&apos;re doing Pages instead)

---

## 🚀 Boss Fight: Deploy to Cloudflare Pages (5 minutes)

1. In Cloudflare, go to **Pages**
2. Click **Create a project** &gt; **Connect to Git**
3. Authenticate with GitHub
4. Pick your repo
5. Set:
   - Project name: `my-awesome-blog`
   - Production branch: `main`
   - Build command: `npm run build`
   - Output directory: `dist`
6. Click **Save and Deploy**

☕ Time for a coffee refill while Cloudflare works its magic.

---

## 🌐 Bonus Round: Custom Domain (Optional, 5 minutes)

1. Buy a domain (as cheap as $1/year)
2. In Cloudflare Pages, go to your project &gt; **Custom domains**
3. Click **Set up a custom domain**
4. Follow the DNS instructions

Now your blog looks pro AF.

---

## 📝 Keep Blogging Like a Champ

### Option 1: Edit on GitHub

- Add `.md` files in `src/content/posts/`
- Commit changes
- Done!

### Option 2: Edit Locally (Recommended)

1. Pull latest:

```bash
git pull
```

2. Add new post
3. Preview with:

```bash
npm run dev
```

4. Commit &amp; push:

```bash
git add .
git commit -m &quot;Add awesome new post&quot;
git push
```

Cloudflare will redeploy automagically.

---

## 💡 Pro Tips for Blog Glory

- Post regularly (even if it&apos;s memes)
- Use images &amp; GIFs (because walls of text are boring)
- Share everywhere (spam responsibly)
- Engage with comments (don&apos;t feed the trolls)
- Have fun! Blogging shouldn&apos;t feel like homework.

---

## 🛠️ Local Dev Workflow Cheat Sheet

```bash
npm run dev        # Start local server
npm run build      # Build production version
npm run preview    # Preview production build
git add .          # Stage changes
git commit -m &quot;Your message&quot;  # Commit
git push           # Deploy!
```

---

## 🎨 Customize Even More!

Your project is super flexible! Here&apos;s what you can tweak inside the `src/` folder:

### 🎨 Styles &amp; Colors

- Edit `src/styles/global.css` to change theme colors, fonts, or add custom CSS.

```css
:root {
  --color-primary-500: #3b82f6; /* Change this to your fav color */
}
```

- Customize dark mode colors under `.dark { ... }` in the same file.

### 🔗 Social Links

- Edit `src/config/social.json`:

```json
{
  &quot;facebook&quot;: &quot;https://facebook.com/yourusername&quot;,
  &quot;twitter&quot;: &quot;https://twitter.com/yourusername&quot;,
  &quot;instagram&quot;: &quot;https://instagram.com/yourusername&quot;
}
```

### 🗺️ Navigation Menu

- Edit `src/config/menu.json` to customize your site&apos;s navigation links.

### 🖼️ Images, Logos &amp; Favicons

- Place images in `src/assets/images/` or `public/images/`.
- Replace favicons/logos in `src/assets/favicons/` or `public/`.
- Update references if needed in configs or layout files.

### 📝 Content: Posts, Pages, Authors

- **Blog posts:** `src/content/posts/`
- **Static pages:** `src/content/pages/` (e.g., Privacy, Terms)
- **About page:** `src/content/about/index.md`
- **Author profiles:** `src/content/authors/` (great for multi-author blogs)

### ⚙️ Site Info &amp; Metadata

- `src/config/config.json` for site title, URL, metadata.
- `src/config/site.ts` for site name, description, and more.

### 🧩 Layouts &amp; Components

- Customize how your blog looks by editing components in `src/layouts/` and its subfolders.
- Change headers, footers, post layouts, widgets, etc.

### 🗂️ Pages &amp; Routing

- Add or edit pages in `src/pages/` (e.g., `/about`, `/contact`, `/blog`).
- Create new `.astro` files for custom routes.

---

This way, you can fully personalize your blog&apos;s look, feel, and content!


## 🔄 Keep Your Blog Fresh

1. Add the original repo as upstream:

```bash
git remote add upstream https://github.com/bitdoze/bitdoze-astro-theme.git
```

2. Fetch updates:

```bash
git fetch upstream
```

3. Merge:

```bash
git merge upstream/main
```

4. Fix conflicts, push, done!

---

## 🏎️ &quot;But Why Astro?&quot; (For the Skeptics)

&quot;Couldn&apos;t I just use WordPress/Wix/Medium?&quot; Sure, you could also use a butter knife to cut down a tree. Astro is:

- **10-1000x faster** than traditional CMS platforms
- **More customizable** than those &quot;drag-and-drop&quot; site builders
- **More professional-looking** than that free Blogger site you made in 2009
- **Less likely to get hacked** than that WordPress site you never update
- **Future-proof** because it uses modern web standards, not proprietary lock-in

Plus, can you really put a price on saying &quot;Oh, my blog? Yeah, I built it myself with Astro&quot; at parties? (The answer is no, that level of subtle bragging is priceless.)

---

## 🧯 Troubleshooting (a.k.a. Don&apos;t Panic)

### Site won&apos;t build?

- Check terminal errors
- Validate Markdown syntax
- Run `npm run build` locally for clues

### Images missing?

- Check paths
- Use relative paths or full URLs
- Put images in `public/images/`

### npm install fails?

- Update Node.js
- Clear cache:

```bash
npm cache clean --force
```

- Delete `node_modules` &amp; `package-lock.json`, reinstall

### Everything exploded?
No worries! That&apos;s just the traditional developer initiation ceremony. Try turning it off and on again (seriously, it works more often than we&apos;d like to admit).

---

## 🎉 You Did It!

You now own a **professional, blazing-fast, zero-cost** blog. No hosting fees, no nonsense. Just pure, unfiltered YOU on the internet.

Astro + Cloudflare = 🚀 speed + 💰 savings.

Now go forth and conquer the blogosphere!
Remember: your voice matters, and the world wants to hear it (or at least Google does).

Happy blogging! ✍️✨

---

_P.S. Found this guide helpful? Share it! And star the [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) on GitHub to spread the love._

_P.P.S. Advanced folks: hook up custom domains, automate with GitHub Actions, or just brag about your free, fast blog. You earned it!_</content:encoded><category>web-development</category><category>astro</category></item><item><title>How To Update Node Packages to The Last Version in Bun.sh</title><link>https://www.bitdoze.com/bun-update-packages/</link><guid isPermaLink="true">https://www.bitdoze.com/bun-update-packages/</guid><description>Learn how you can update node packages to last version with Bun.sh including the package.json</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Keeping your Node.js packages up to date matters for security and performance. Bun.sh (v1.3+) is a fast JavaScript runtime with built-in commands that make the update process quick. Here&apos;s how to upgrade your Node packages to the latest version using Bun.

If you want to check more on Bun.sh you can check [Bun vs NPM, Yarn, PNPM, and Others](https://www.bitdoze.com/bun-package-manager/). In case you want to migrate your Astro project to Bun see: [How to Migrate Astro to Bun on CloudFlare](https://www.bitdoze.com/migrate-astro-bun/)

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/UFXpCsqnURY&quot;
  label=&quot;How To Update Node Packages to Last Version in Bun.sh&quot;
/&gt;

## Upgrade Bun to the Latest Version

To ensure you&apos;re using the latest features and improvements of Bun itself, you should start by upgrading Bun to the latest version. This can be done with a simple command:

```sh
bun upgrade
```

This command checks for the latest version of Bun and upgrades it accordingly.

## Update Bun Packages without package.json

If you want to update the packages in your current project and you don&apos;t have a `package.json` file, or you simply want to update globally installed packages, you can use the following command:

```sh
bun update
```

After running this command, you&apos;ll see an output similar to:

```sh
bun update v1.3.9

 + @types/react@19.0.8
 + postcss@8.5.1
 + sharp@0.33.5
 + @astrojs/mdx@4.2.0
 + astro@5.3.0
```

This output lists the packages that have been updated along with their new versions.

## Force Update Bun packages

Sometimes, you may encounter situations where a simple update does not fetch the latest versions due to version locking or other constraints. In such cases, you can force update the packages:

```sh
bun update --force
```

This command ignores the current versions and forcefully updates all packages to their latest versions. The output will list all the packages that were updated, including those that were not updated in a regular update process.

The output will indicate that the packages have been forcefully updated:

```sh
❯ bun update --force
bun update v1.3.9

 + @astrojs/react@4.2.0
 + @astrojs/sitemap@3.3.0
 + @tailwindcss/typography@0.5.16
 + @types/react@19.0.8
 + postcss@8.5.1
 + prettier@3.5.3
 + prettier-plugin-astro@0.14.1
 + prettier-plugin-tailwindcss@0.6.11
 + react@19.0.0
 + react-dom@19.0.0
 + sharp@0.33.5
 + tailwindcss@4.0.6
 + @astrojs/mdx@4.2.0
 + @astrojs/rss@4.0.11
 + astro@5.3.0
 + date-fns@4.1.0
 + fuse.js@7.1.0
 + github-slugger@2.0.0
 + marked@15.0.7
 + react-icons@5.4.0
```

Note that using `--force` will not update the `package.json` file. It will remain as is:

```json
{
  &quot;name&quot;: &quot;bitdoze.com&quot;,
  &quot;version&quot;: &quot;1&quot;,
  &quot;license&quot;: &quot;MIT&quot;,
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;astro dev&quot;,
    &quot;start&quot;: &quot;astro dev&quot;,
    &quot;build&quot;: &quot;astro build&quot;,
    &quot;preview&quot;: &quot;astro preview&quot;,
    &quot;astro&quot;: &quot;astro&quot;,
    &quot;format&quot;: &quot;prettier -w .&quot;
  },
  &quot;dependencies&quot;: {
    &quot;@astrojs/mdx&quot;: &quot;^2.2.0&quot;,
    &quot;@astrojs/partytown&quot;: &quot;^2.0.4&quot;,
    &quot;@astrojs/rss&quot;: &quot;^4.0.5&quot;,
    &quot;astro&quot;: &quot;^4.5.2&quot;,
    &quot;astro-analytics&quot;: &quot;^2.7.0&quot;,
    &quot;astro-seo&quot;: &quot;^0.8.3&quot;,
    &quot;date-fns&quot;: &quot;^3.4.0&quot;,
    &quot;fuse.js&quot;: &quot;^7.0.0&quot;,
    &quot;github-slugger&quot;: &quot;^2.0.0&quot;,
    &quot;marked&quot;: &quot;^12.0.1&quot;,
    &quot;react-icons&quot;: &quot;^4.12.0&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@astrojs/react&quot;: &quot;^3.1.0&quot;,
    &quot;@astrojs/sitemap&quot;: &quot;^3.1.1&quot;,
    &quot;@astrojs/tailwind&quot;: &quot;^5.1.0&quot;,
    &quot;@tailwindcss/forms&quot;: &quot;^0.5.7&quot;,
    &quot;@tailwindcss/typography&quot;: &quot;^0.5.10&quot;,
    &quot;@types/marked&quot;: &quot;^6.0.0&quot;,
    &quot;@types/react&quot;: &quot;^18.2.65&quot;,
    &quot;postcss&quot;: &quot;^8.4.35&quot;,
    &quot;prettier&quot;: &quot;^3.2.5&quot;,
    &quot;prettier-plugin-astro&quot;: &quot;^0.13.0&quot;,
    &quot;prettier-plugin-tailwindcss&quot;: &quot;^0.5.12&quot;,
    &quot;react&quot;: &quot;^18.2.0&quot;,
    &quot;react-dom&quot;: &quot;^18.2.0&quot;,
    &quot;sass&quot;: &quot;^1.71.1&quot;,
    &quot;sharp&quot;: &quot;^0.33.2&quot;,
    &quot;tailwind-bootstrap-grid&quot;: &quot;^5.1.0&quot;,
    &quot;tailwindcss&quot;: &quot;^3.4.1&quot;
  }
}
```

## Update package.json with Bun

To update the `package.json` file with the latest versions of your packages, you&apos;ll need to use a utility like `npm-check-updates`. First, install it globally if you haven&apos;t already:

```sh
npm install -g npm-check-updates
```

Then, run `npm-check-updates` with Bun to update your `package.json`:

```sh
bunx npm-check-updates -ui
```

This command will check for updates and interactively allow you to choose which packages to upgrade. After confirming the selections, npm-check-updates will update your package.json to reflect the latest versions.

```sh
❯ bunx npm-check-updates -ui

Using bun
Upgrading /Users/dbalota/websites/bitdoze-astro-bkw_test/package.json
[====================] 29/29 100%

? Choose which packages to update ›
  ↑/↓: Select a package
  Space: Toggle selection
  a: Toggle all
  Enter: Upgrade

  ◉ @astrojs/mdx    ^2.2.0  →    ^2.2.1
  ◉ @types/react  ^18.2.65  →  ^18.2.71
  ◉ astro           ^4.5.2  →    ^4.5.9
  ◉ date-fns        ^3.4.0  →    ^3.6.0
  ◉ postcss        ^8.4.35  →   ^8.4.38
❯ ◯ react-icons    ^4.12.0  →    ^5.0.1
  ◉ sass           ^1.71.1  →   ^1.72.0
  ◉ sharp          ^0.33.2  →   ^0.33.3
```

After confirming the updates, check your `package.json` to see that it has been updated with the new versions:

```json
{
  &quot;name&quot;: &quot;bitdoze.com&quot;,
  &quot;version&quot;: &quot;1&quot;,
  &quot;license&quot;: &quot;MIT&quot;,
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;astro dev&quot;,
    &quot;start&quot;: &quot;astro dev&quot;,
    &quot;build&quot;: &quot;astro build&quot;,
    &quot;preview&quot;: &quot;astro preview&quot;,
    &quot;astro&quot;: &quot;astro&quot;,
    &quot;format&quot;: &quot;prettier -w .&quot;
  },
  &quot;dependencies&quot;: {
    &quot;@astrojs/mdx&quot;: &quot;^2.2.1&quot;,
    &quot;@astrojs/partytown&quot;: &quot;^2.0.4&quot;,
    &quot;@astrojs/rss&quot;: &quot;^4.0.5&quot;,
    &quot;astro&quot;: &quot;^4.5.9&quot;,
    &quot;astro-analytics&quot;: &quot;^2.7.0&quot;,
    &quot;astro-seo&quot;: &quot;^0.8.3&quot;,
    &quot;date-fns&quot;: &quot;^3.6.0&quot;,
    &quot;fuse.js&quot;: &quot;^7.0.0&quot;,
    &quot;github-slugger&quot;: &quot;^2.0.0&quot;,
    &quot;marked&quot;: &quot;^12.0.1&quot;,
    &quot;npm-check-updates&quot;: &quot;^16.14.18&quot;,
    &quot;react-icons&quot;: &quot;^4.12.0&quot;
  },
  &quot;devDependencies&quot;: {
    &quot;@astrojs/react&quot;: &quot;^3.1.0&quot;,
    &quot;@astrojs/sitemap&quot;: &quot;^3.1.1&quot;,
    &quot;@astrojs/tailwind&quot;: &quot;^5.1.0&quot;,
    &quot;@tailwindcss/forms&quot;: &quot;^0.5.7&quot;,
    &quot;@tailwindcss/typography&quot;: &quot;^0.5.10&quot;,
    &quot;@types/marked&quot;: &quot;^6.0.0&quot;,
    &quot;@types/react&quot;: &quot;^18.2.71&quot;,
    &quot;postcss&quot;: &quot;^8.4.38&quot;,
    &quot;prettier&quot;: &quot;^3.2.5&quot;,
    &quot;prettier-plugin-astro&quot;: &quot;^0.13.0&quot;,
    &quot;prettier-plugin-tailwindcss&quot;: &quot;^0.5.12&quot;,
    &quot;react&quot;: &quot;^18.2.0&quot;,
    &quot;react-dom&quot;: &quot;^18.2.0&quot;,
    &quot;sass&quot;: &quot;^1.72.0&quot;,
    &quot;sharp&quot;: &quot;^0.33.3&quot;,
    &quot;tailwind-bootstrap-grid&quot;: &quot;^5.1.0&quot;,
    &quot;tailwindcss&quot;: &quot;^3.4.1&quot;
```

## Check and see that your project is working

After updating your packages, it&apos;s essential to verify that your project still works as expected. Run your project&apos;s build and test commands to ensure that the updates haven&apos;t introduced any breaking changes.

## Conclusions

Keeping Bun and your packages up to date is basic maintenance that pays off. With Bun v1.3+, updates run fast. Upgrade Bun itself, update your packages, and make sure `package.json` reflects the latest versions. Always test your project after updates to catch any breaking changes.</content:encoded><category>tools</category><category>bun</category></item><item><title>Reclaim Disk Space by Cleaning Up /var/lib/docker/overlay2</title><link>https://www.bitdoze.com/clean-docker-overlay2-dir/</link><guid isPermaLink="true">https://www.bitdoze.com/clean-docker-overlay2-dir/</guid><description>Learn how to safely reclaim disk space on your Docker host when /var/lib/docker/overlay2 grows large, using supported cleanup commands and careful troubleshooting.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;Notice type=&quot;warning&quot; title=&quot;Do not delete overlay2 folders manually&quot;&gt;
Avoid deleting anything inside `/var/lib/docker/overlay2` by hand. Manual deletion can corrupt Docker&apos;s metadata and break running containers/images. Use the supported Docker cleanup commands in this guide, and only take &quot;reset Docker data dir&quot; actions if you fully understand the impact.
&lt;/Notice&gt;

Docker stores image and container data in `/var/lib/docker/overlay2`. This directory keeps growing if you build images often, pull multiple tags, or leave stopped containers around. Eventually it fills up your disk and Docker starts failing.

## What you have in `/var/lib/docker/overlay2`

`overlay2` is Docker&apos;s copy-on-write filesystem for Linux. The directory holds:

- Layer directories: content-addressed folders with the actual filesystem diffs
- Container &quot;upperdir&quot; data: writable layers where containers write data (this is usually what gets big)
- Metadata and linkage: structures Docker uses to track which layers belong to which images

The folder names are random hashes, not readable identifiers. You can&apos;t just look at a folder name and know what image it belongs to. Use Docker commands instead of trying to guess from directory names.

## Reasons for `/var/lib/docker/overlay2` using a lot of space

The directory grows for several reasons:

1. Unused Images and Containers: Stopped containers and old images still take up space if you don&apos;t remove them.

2. Cached Layers: Docker caches layers to make builds faster, but this cache keeps growing if you don&apos;t prune it.

3. Large Images and Containers: Some images are just large, especially ones that include big binaries or data files.

4. Inefficient Image Layers: Images with too many layers or unnecessary files waste space.


&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## Check the space used

Start by checking Docker&apos;s view of disk usage:

```sh
docker system df
docker system df -v
```

This shows space usage for images, containers, volumes, and build cache. The `-v` flag gives more detail.

Then check the actual disk usage:

```sh
du -sh /var/lib/docker/overlay2
```

To see which directories are largest (just for information, not for deleting):

```sh
du -sh /var/lib/docker/overlay2/* 2&gt;/dev/null | sort -hr | head -10
```

Notes:
- Large `overlay2` directories often come from container writes or build cache, not just unused images
- Focus on which category is growing (images, containers, volumes, or cache) rather than specific IDs
- That&apos;s why `docker system df -v` is the first thing to run

## Cleanup `/var/lib/docker/overlay2`

### 1) Quick win: remove unused Docker objects

This is the most common cleanup sequence. Start with a read-only report:

```sh
docker system df -v
```

Then remove unused objects.

Remove stopped containers, unused networks, dangling images, and build cache:

```sh
docker system prune -f
```

If you also want to remove all images not currently used by a container (more aggressive):

```sh
docker system prune -a -f
```

If you want to reclaim unused volumes (be careful: volumes can hold important data):

```sh
docker volume prune -f
```

If you run BuildKit builds, the build cache can be the biggest issue; prune it explicitly:

```sh
docker builder prune -f
docker builder prune -a -f
```

### 2) Identify what is growing (containers vs images vs volumes vs cache)

A large `overlay2` directory often comes from container writes or build cache, not just unused images.

- If `docker system df -v` shows large Local Volumes usage, focus on volumes first.
- If it shows large Build Cache usage, use `docker builder prune`.
- If it shows large Containers usage, check for containers that are writing into their writable layers.

### 3) Map overlay2 usage to Docker objects (advanced)

I&apos;ve created a utility script that helps visualize `overlay2` disk usage:

```sh
curl -sSL https://utils.bitdoze.com/scripts/docker-overlay2-view.sh | bash
```

All scripts can be found at: https://utils.bitdoze.com/

Note: this mapping is best-effort. `overlay2` is an internal structure, and not every directory has a clean &quot;this belongs to image X&quot; relationship. Use this output as guidance for what to investigate via Docker commands, not as a list of directories to delete.

## Why are there overlay2 subfolders not matching an image even after `docker system prune -a`?

You&apos;ll often see directories in `/var/lib/docker/overlay2` that don&apos;t match a current image ID. Here&apos;s why:

1. Container writable layers: Running or stopped containers have a writable layer in `overlay2`. If you have containers, you have overlay data.

2. Build cache: Modern builds (BuildKit) create cache data. `docker system prune -a` doesn&apos;t always remove all of it. Use `docker builder prune` to target it.

3. Shared or referenced layers: Images share layers, and Docker won&apos;t remove anything that&apos;s still referenced by an existing image or container. The directory name in `overlay2` won&apos;t match what you see in `docker image ls`.

4. Init layers and internal metadata: The `*-init` directories and metadata structures are part of how Docker sets up containers and layers.

5. Leaked or orphaned data (rare): Power loss, daemon crashes, or storage corruption can leave leftovers. If you suspect this, check Docker&apos;s reporting (`docker system df -v`) and logs, then consider a controlled cleanup.

### Important: avoid manual deletion

Even if a directory looks unused, deleting overlay2 directories by hand can corrupt Docker&apos;s state.

If you need a fresh start, reset the Docker data directory with Docker stopped, and only after backing up what you need.

### Controlled reset approach (last resort)

1. Stop or remove containers you don&apos;t need.
2. Back up anything important (especially volumes and bind-mounted data).
3. Stop the Docker daemon.
4. Move `/var/lib/docker` to a backup location.
5. Start Docker again (it will recreate `/var/lib/docker`).

Then re-pull images and restore only what you need. This is disruptive, but safer than deleting random overlay directories.

## Summary

A growing `/var/lib/docker/overlay2` directory usually means:

- Unused or stopped containers and images
- Build cache growth (especially with BuildKit)
- Containers writing large amounts of data to their writable layer
- Large or unmanaged volumes

Use Docker&apos;s disk reports (`docker system df -v`) and supported prune commands first. Don&apos;t delete anything inside `/var/lib/docker/overlay2` manually. If you need a full reset, move `/var/lib/docker` while Docker is stopped after backing up important data.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>How to Copy Multiple Files in One Layer Using a Dockerfile</title><link>https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/</link><guid isPermaLink="true">https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/</guid><description>Learn how Dockerfile COPY handles multiple sources and wildcards, what it can&apos;t do, and how to optimize builds with .dockerignore and BuildKit.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>When you build Docker images, you&apos;ll often need to copy multiple files at once. Docker&apos;s `COPY` instruction lets you specify multiple sources in a single command, which creates one filesystem layer for all of them.

The basic syntax looks like this:

```dockerfile
COPY file1.txt file2.txt /app/files/
```

This copies both `file1.txt` and `file2.txt` from your build context into the `/app/files/` directory in your image.

Why does this matter?

- Grouping files that change together keeps your cache from breaking unnecessarily
- Fewer `COPY` lines make your Dockerfile cleaner
- Each `COPY` creates its own layer, so fewer instructions means fewer layers (though the real benefit here is caching, not just layer count)

Some other Docker articles you might find useful:

- [Add Users to a Docker Container](https://www.bitdoze.com/add-users-to-docker-container/)
- [Install Docker &amp; Docker-compose for Ubuntu ARM](https://www.bitdoze.com/install-docker-ubuntu-arm/)
- [Redirect Docker Logs to a Single File](https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/)
- [Environment Variables ARG and ENV in Docker](https://www.bitdoze.com/docker-env-vars/)

## Using the COPY instruction to copy files in a Dockerfile

`COPY` transfers files from your build context (the directory you run `docker build` from, or whatever context you provide) into the image filesystem.

One thing to keep in mind: `COPY` can&apos;t read files outside the build context. If you need that, restructure your context or use CI to assemble one.

### Basic syntax

```dockerfile
COPY &lt;src&gt; &lt;dest&gt;
```

- `&lt;src&gt;`: file(s) or directory in the build context
- `&lt;dest&gt;`: destination path in the image (directory must exist or Docker will create it)

### Common use cases

**Copy a single file:**
```bash
COPY app.js /app/
```

**Copy an entire directory:**
```dockerfile
COPY src/ /app/src/
```

**Copy multiple specific files:**
```dockerfile
COPY file1.txt file2.txt config.json /app/
```

**Copy files with wildcards:**
```dockerfile
COPY *.txt /app/
```

This copies matching `.txt` files from the build context directory into `/app/`.

Note: patterns are evaluated by Docker (not your shell) and don&apos;t support every bash glob feature you might expect.

## Including multiple source files in a single COPY instruction

You can list multiple sources and copy them into a directory destination:

```dockerfile
COPY file1.txt file2.txt config.json /app/
COPY *.csv /data/
COPY src/ /code/
```

Some things to know:

- When you provide multiple sources, the destination should be a directory (ending with `/` is a good convention)
- This is where you want to group &quot;files that change together&quot; (configs together, scripts together, that sort of thing)
- If one file in a grouped `COPY` changes, the cache for that whole layer gets invalidated

## Using wildcards with COPY (and their limitations)

Wildcards help you copy multiple files matching a pattern, but it&apos;s worth knowing what Dockerfile globs can and can&apos;t do.

### Common wildcard patterns

| Pattern | Description | Example |
|---------|-------------|---------|
| `*` | Matches any characters | `*.txt` copies all text files |
| `?` | Matches single character | `file?.txt` matches `file1.txt`, `fileA.txt` |
| `**` | Not reliably supported for recursive matching in Dockerfile globs | Prefer copying a directory and controlling contents with `.dockerignore` |

### Practical examples

```dockerfile
# Copy all configuration files from the context root
COPY *.conf /etc/app/

# Prefer copying a directory, then excluding unwanted files via .dockerignore
COPY src/ /app/src/

# Brace expansion like *.{json,yml,yaml} is a shell feature and isn&apos;t guaranteed in Dockerfile globs
# Either list files explicitly:
COPY config.json config.yml config.yaml /app/config/
# Or copy the whole directory:
# COPY config/ /app/config/
```

The thing is, Dockerfile wildcard behavior isn&apos;t the same as your shell. Avoid fancy patterns. When in doubt, `COPY` the directory and use `.dockerignore` to exclude what you don&apos;t want.

## Organizing Files for Better Dockerfile Management

A well-organized directory structure makes your Dockerfile easier to maintain:

```
project/
├── src/           # Application source code
├── config/        # Configuration files
├── scripts/       # Build and deployment scripts
├── assets/        # Static assets
└── Dockerfile
```

### Best practices

1. Group related files in the same directory
2. Use names that clearly describe what&apos;s inside
3. Avoid deep nesting - keep it simple
4. Add a README explaining your organization

### Example Dockerfile with organized structure:
```dockerfile
# Copy source code
COPY src/ /app/src/

# Copy configuration files
COPY config/*.json /app/config/

# Copy build scripts
COPY scripts/build.sh /app/scripts/
```

### BuildKit features worth knowing (Docker Engine v25+)

If you&apos;re using BuildKit (which is the default now), a couple of useful options:

- **`COPY --link`** — creates the copy as an independent layer that doesn&apos;t depend on parent layers. This means changing a base image doesn&apos;t invalidate your cached copy step. Use it when you can:
  ```dockerfile
  COPY --link package.json package-lock.json /app/
  ```
- **`COPY --parents`** — preserves the directory structure from the source. Useful for copying files from nested directories:
  ```dockerfile
  COPY --parents src/utils/*.ts src/components/*.ts /app/
  ```
  This creates `/app/src/utils/` and `/app/src/components/` in the image, preserving the paths.

## Best practices for efficient file copying

### Use `.dockerignore` (this one&apos;s important)

`.dockerignore` reduces build context size, speeds up builds, and keeps you from accidentally copying secrets and junk into your image.

Example `.dockerignore`:

```
node_modules/
*.log
.git/
.DS_Store
.env
.env.*
```

### Order COPY steps by change frequency

Copy dependency manifests first, install dependencies, then copy the rest. That way, code changes don&apos;t break your dependency layers.

```dockerfile
WORKDIR /app

# Dependencies (change less often)
COPY package.json package-lock.json ./
RUN npm ci

# App source (changes often)
COPY src/ ./src/
```

### Group related files
```dockerfile
# Copy all config files together
COPY config/ /app/config/

# Copy all static assets together
COPY assets/ /app/assets/
```

### Be careful with wildcards

Wildcards are handy, but they can:
- match extra files by accident
- break your cache more than you&apos;d expect

Use explicit copies for critical cache files (like dependency manifests), and use `.dockerignore` to keep the context clean.

```dockerfile
# Good when you control the directory contents (and .dockerignore is set)
COPY config/ /app/config/

# Best for dependency caching (explicit, stable)
COPY package.json package-lock.json /app/
```

These practices help reduce build time and keep your Docker image rebuilds predictable.

## Conclusion

Copying multiple files in one `COPY` instruction is straightforward and keeps Dockerfiles clean. The biggest optimization isn&apos;t fancy wildcards - it&apos;s keeping your build context small and ordering your files so caching works well.

## Quick reference

```dockerfile
# Multiple specific files into a directory
COPY file1.txt file2.txt config.json /app/

# Simple wildcard at context root
COPY *.conf /etc/app/

# Prefer copying directories + .dockerignore over recursive glob tricks
COPY src/ /app/src/
COPY config/ /app/config/
```

### Key points
- Use single COPY instructions for related files
- Wildcards work for pattern matching
- Order files by how often they change (better caching)
- Use .dockerignore to exclude unnecessary files
- Keep your directory structure organized

Practice with different patterns and you&apos;ll find what works for your setup.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>Best 20+ Self-hosted Apps Docker Containers for A Business</title><link>https://www.bitdoze.com/docker-containers-business/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-containers-business/</guid><description>Check out this list with 20+ self hosted apps with docker containers that you can use on your business to grow it.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Businesses of all sizes are turning to self-hosted solutions to keep control over their data, cut costs, and customize their software stack. Docker containers have changed how applications get deployed and managed, offering a lightweight, portable way to run software. This article covers Docker containers for businesses, where to host them, and highlights 20+ containers that can strengthen your business operations.

## Where Docker Containers For Business Can be Hosted

When it comes to hosting self hosted apps on docker containers for your business, you have several options, each with its own advantages. Let&apos;s explore two popular choices: VPS servers and home servers.

&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### VPS Server With Hetzner, Hostinger, DigitalOcean, etc.

Virtual Private Servers (VPS) are a solid choice for hosting Docker containers. [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) and [DigitalOcean](https://go.bitdoze.com/do) all offer reliable options at reasonable prices. Here&apos;s what you get with a VPS:

- **Scalability**: Easily upgrade resources as your business grows
- **High availability**: Benefit from enterprise-grade infrastructure and redundancy
- **Managed services**: Many providers offer managed Kubernetes clusters for easier container orchestration
- **Global reach**: Choose from data centers worldwide to reduce latency for your users
- **Cost-effective**: Pay only for the resources you use, with flexible pricing plans

When selecting a VPS provider, consider factors such as pricing, performance, data center locations, and support options. Hetzner, Hostinger and DigitalOcean all offer user-friendly interfaces and extensive documentation to help you get started with Docker containers.

### Home Server

For businesses that need tighter security or want full control over their hardware, a home server works well for Docker containers. Two main options:

#### Mini PC

Mini PCs have gained popularity as home servers due to their compact size, energy efficiency, and sufficient power for running Docker containers. Some advantages of using a mini PC as a home server include:

- **Low power consumption**: Ideal for 24/7 operation without significant energy costs
- **Quiet operation**: Many models are fanless or have low-noise cooling systems
- **Customizable**: Choose from a variety of models to fit your specific needs
- **Cost-effective**: Often more affordable than traditional server hardware

For more information on selecting the best mini PC for your home server, check out our comprehensive guide on [the best mini PCs for home servers](https://www.bitdoze.com/best-mini-pc-home-server/).

#### NAS

Network Attached Storage (NAS) devices are another popular option for home servers, offering a balance of storage capacity and processing power. Many modern NAS systems support Docker containers, making them versatile solutions for small businesses. Benefits of using a NAS for Docker containers include:

- **Built-in storage**: Large storage capacity with RAID support for data redundancy
- **Easy management**: User-friendly interfaces for managing both storage and containers
- **Power efficiency**: Designed for 24/7 operation with low power consumption
- **Backup solutions**: Often include built-in backup software and cloud integration

Whether you go with a VPS, mini PC, or NAS, any of these can run Docker containers well for a small business.




## Best 20+ Docker Containers for A Business

Docker containers cover a lot of ground when it comes to business tools. Here are some of the most useful ones:


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

| Category | Application | Complexity | Resource Usage | Key Benefit |
|----------|-------------|------------|----------------|-------------|
| Collaboration | NextCloud | Medium | High | Complete office suite replacement |
| Communication | Zulip | Easy | Medium | Organized team discussions |
| Support | Zammad | Medium | Medium | Customer service automation |
| Remote Work | Kasm Workspaces | High | High | Secure remote applications |
| Monitoring | Uptime Kuma | Easy | Low | Service availability tracking |
| Database | NocoDB/Baserow | Easy | Medium | No-code database solutions |
| Finance | Firefly III | Easy | Low | Financial management |
| Document Management | Paperless-ngx | Medium | Medium | Digital document organization |
| Automation | n8n/Activepieces | Medium | Medium | Workflow automation |
| Analytics | Plausible | Easy | Low | Privacy-focused analytics |
| Marketing | Mautic/ListMonk | Medium | Medium | Marketing automation |
| Container Management | Dockge/Portainer | Medium | Low | Docker administration |
| AI Integration | Flowise AI | Medium | High | Custom AI solutions |
| Version Control | Gitea | Easy | Low | Code repository management |
| Knowledge Base | Docmost | Easy | Low | Team documentation |
| Backup | Duplicati | Easy | Low | Data protection |
| Business Management | ERPNext | High | High | Complete business solution |
| Feedback | Formbricks | Easy | Low | User feedback collection |
| Monitoring | beszel | Easy | Low | Resource monitoring |
| Billing | Invoice Ninja | Easy | Low | Invoice management |
| Time Tracking | Kimai | Easy | Low | Time management |

### NextCloud

[NextCloud](https://nextcloud.com/) is an open-source file sharing and collaboration platform that works as a central hub for your business data.

**Key features:**
- File sharing and synchronization across devices
- Collaborative document editing
- Calendar and contact management
- Video conferencing and chat
- Task management and project planning

**How it helps small businesses:**
NextCloud is a self-hosted alternative to Dropbox or Google Drive. You keep full control over your data while getting similar features. It handles file sync, document editing, calendars, and video calls in one place.

### Zulip

[Zulip](https://zulip.com/) is an open-source team chat application that combines the best features of real-time and asynchronous communication.

**Key features:**
- Topic-based threading for organized discussions
- Powerful search functionality
- Integrations with various tools and services
- Mobile apps for iOS and Android
- Customizable notifications

**How it helps small businesses:**
Zulip&apos;s topic-based threading keeps conversations organized, which matters more as your team grows. Instead of messages getting buried in a chat stream, discussions stay grouped by topic. Makes it easier to catch up after being away.

### Zammad

[Zammad](https://zammad.com/) is an open-source help desk and customer support system that can help businesses manage customer inquiries efficiently.

**Key features:**
- Multi-channel support (email, chat, social media)
- Ticket management and automation
- Knowledge base for self-service support
- Customizable workflows and integrations
- Reporting and analytics

**How it helps small businesses:**
Zammad centralizes customer support so your team isn&apos;t juggling separate tools for email, chat, and social media. The automation cuts down response times, and the knowledge base lets customers find answers on their own. For a small team, that&apos;s a big deal.

### Kasm Workspaces

[Kasm Workspaces](https://www.kasmweb.com/) is a Docker container streaming platform that delivers browser-based access to desktops, applications, and web services.

**Key features:**
- Secure remote access to applications and desktops
- Customizable workspaces for different user roles
- Integration with existing authentication systems
- Usage analytics and monitoring
- Support for GPU-accelerated applications

**How it helps small businesses:**
Kasm Workspaces lets employees access applications from any device through a browser. Everything runs inside containers, so sensitive data never leaves your server. It&apos;s a practical way to set up remote work without buying everyone new hardware. Additionally, it can help businesses maintain better control over sensitive data by keeping it within the containerized environment.

### Uptime Kuma

[Uptime Kuma](https://uptime.kuma.pet/) is a self-hosted monitoring tool that helps businesses keep track of their websites and services&apos; availability.

**Key features:**
- Real-time monitoring of websites and services
- Multiple notification channels (email, SMS, chat apps)
- Status page generation
- Supports various monitoring methods (HTTP, TCP, Ping, etc.)
- User-friendly interface with customizable dashboard

**How it helps small businesses:**
If your website goes down and you don&apos;t know about it, you&apos;re losing money and trust. Uptime Kuma checks your sites and services continuously and pings you through email, SMS, or chat when something breaks. You can also set up a public status page so customers know what&apos;s going on during outages.

### NocoDB or Baserow

[NocoDB](https://nocodb.com/) and [Baserow](https://baserow.io/) are open-source alternatives to Airtable, providing flexible database and spreadsheet functionality. You can also see [best aitable self hosted alternatives](https://www.bitdoze.com/self-hosted-airtable-alternatives/) for a more in detail list.

**Key features:**
- Spreadsheet-like interface for database management
- Views: Grid, Gallery, Kanban, Form, and more
- API access for integration with other tools
- User roles and permissions
- Automation and workflow capabilities

**How it helps small businesses:**
Both tools let you manage data through a familiar spreadsheet interface without needing a database admin. You can build custom views for inventory tracking, project management, or CRM. Non-technical team members can use them right away since they look and feel like a regular spreadsheet.

### Firefly III

[Firefly III](https://www.firefly-iii.org/) is a personal finance manager that can be adapted for small business use, helping to track income, expenses, and budgets.

**Key features:**
- Multi-currency support
- Budgeting and financial goal setting
- Bill management and recurring transactions
- Detailed reports and charts
- Import data from various sources

**How it helps small businesses:**
For sole proprietors or small partnerships, Firefly III handles financial tracking without the cost of commercial accounting software. You get clear visibility into cash flow, can set budgets, and track expenses over time.

### Paperless-ngx

[Paperless-ngx](https://docs.paperless-ngx.com/) is a document management system that helps businesses go paperless by digitizing and organizing documents.

**Key features:**
- OCR (Optical Character Recognition) for searchable PDFs
- Automatic tagging and categorization of documents
- Full-text search capabilities
- Mobile-friendly web interface
- Integration with scanners and email

**How it helps small businesses:**
Paperless-ngx scans and OCRs your documents so you can search through them instead of digging through filing cabinets. The automatic tagging saves time on organization, and everything is accessible remotely. Helpful for audits and compliance too.

### n8n or Activepieces

[n8n](https://n8n.io/) and [Activepieces](https://www.activepieces.com/) are workflow automation tools that allow businesses to connect various applications and automate repetitive tasks.

**Key features:**
- Visual workflow builder
- Wide range of integrations with popular services
- Ability to create custom nodes/actions
- Scheduling and trigger-based automation
- Self-hosted for data privacy

**How it helps small businesses:**
Workflow automation saves a surprising amount of time for small teams. You can connect your apps to automate lead generation, sync data between systems, post to social media, or send follow-up emails. Less time on repetitive tasks means more time on work that actually matters.

### Plausible

[Plausible](https://plausible.io/) is a lightweight, open-source website analytics platform that prioritizes user privacy.

**Key features:**
- Simple, intuitive dashboard
- GDPR compliant and cookie-free
- Lightweight script for minimal impact on site performance
- Custom event tracking
- Email reports and API access

**How it helps small businesses:**
If you want website analytics without the privacy headaches of Google Analytics, Plausible is worth a look. It&apos;s cookie-free and GDPR compliant out of the box. The script is tiny, so it won&apos;t slow your site down. The dashboard shows you what matters without drowning you in data.

### Mautic or ListMonk

[Mautic](https://www.mautic.org/) and [ListMonk](https://listmonk.app/) are open-source marketing automation and email marketing platforms that can help businesses manage their marketing campaigns.

**Key features:**
- Email campaign management
- Landing page and form builders
- Lead scoring and segmentation
- Marketing automation workflows
- Integration with CRM systems

**How it helps small businesses:**
These tools give you email marketing and automation without paying for Mailchimp or HubSpot. You can run targeted campaigns, score leads, and segment your audience. For small businesses competing against bigger players, having these capabilities at no licensing cost makes a real difference.

### Dockge, Portainer, or Dockploy

[Dockge](https://dockge.kuma.pet/), [Portainer](https://www.portainer.io/), or [Dockploy](https://dokploy.com/) tools are Docker management platforms that simplify the process of deploying and managing Docker containers.

**Key features:**
- User-friendly web interface for container management
- Container templating and stack deployment
- Resource monitoring and logging
- Role-based access control
- Support for Docker Swarm or Kubernetes (varies by tool)

**How it helps small businesses:**
If you don&apos;t have a dedicated IT person, these management tools make Docker much less intimidating. You get a web interface for deploying and managing containers instead of working purely from the command line. Helpful as you add more services over time.

For more information on installing and using these tools, check out our guides on [Dockge installation](https://www.bitdoze.com/dockge-install/) and [Dockploy installation](https://www.bitdoze.com/dokploy-install/).

### Flowise AI

[Flowise AI](https://flowiseai.com/) is an open-source tool for building customized AI agents and chatbots using a visual interface.

**Key features:**
- Drag-and-drop interface for creating AI workflows
- Integration with various AI models and APIs
- Customizable chatbot interfaces
- API endpoints for integration with other applications
- Support for multiple languages

**How it helps small businesses:**
Flowise AI lets you build chatbots and AI agents without writing much code. You can set up customer service bots, automate repetitive tasks, or add AI features to your products using the drag-and-drop interface.

For a detailed guide on setting up Flowise AI, visit our [Flowise AI installation tutorial](https://www.bitdoze.com/flowiseai-install/).

### Gitea

[Gitea](https://about.gitea.com/) is a lightweight, self-hosted Git service that provides version control and collaboration features similar to GitHub or GitLab.

**Key features:**
- Git repository management
- Issue tracking and project management
- Pull request and code review functionality
- Wiki for documentation
- Integration with CI/CD tools

**How it helps small businesses:**
For teams doing software development, Gitea is a free alternative to GitHub or GitLab that you run on your own server. Your code and intellectual property stay on your hardware. It has the standard features you&apos;d expect: pull requests, issue tracking, wiki, and CI/CD integration.

### Docmost

[Docmost](https://docmost.com/) is an open-source document collaboration platform that combines the features of a wiki and a document editor.

**Key features:**
- Real-time collaborative editing
- Version history and document comparison
- Markdown and WYSIWYG editing modes
- Nested document structure
- Full-text search capabilities

**How it helps small businesses:**
Docmost works well as an internal knowledge base and team wiki. You can write documentation together in real-time, keep project plans organized, and search through everything with full-text search.

For installation instructions, check out our [Docmost Docker installation guide](https://www.bitdoze.com/docmost-docker-install/).

### Duplicati

[Duplicati](https://duplicati.com/) is an open-source backup solution that supports various storage backends, including cloud storage services.

**Key features:**
- Encrypted and compressed backups
- Incremental backups to save space and bandwidth
- Scheduling and retention policies
- Support for multiple storage providers
- Web-based interface for easy management

**How it helps small businesses:**
Losing data can kill a small business. Duplicati handles encrypted, incremental backups to whatever storage you prefer — cloud or local. You set up a schedule and retention policy, and it runs in the background.

### ERPNext or Twenty CRM

[ERPNext](https://erpnext.com/) and [Twenty CRM](https://twenty.com/) are open-source business management solutions that cover various aspects of business operations.

**Key features:**
- Customer Relationship Management (CRM)
- Inventory and warehouse management
- Human Resources and payroll
- Accounting and financial management
- Project management and time tracking

**How it helps small businesses:**
These tools combine CRM, inventory, HR, accounting, and project management into one platform. Instead of paying for separate SaaS subscriptions, you run everything on your own server and customize it to fit how your business actually works.

### Formbricks

[Formbricks](https://formbricks.com/) is an open-source survey and feedback collection tool that helps businesses gather insights from their customers and users.

**Key features:**
- Customizable survey templates
- In-app survey targeting
- Response analysis and reporting
- Integration with various platforms and tools
- GDPR-compliant data collection

**How it helps small businesses:**
Knowing what your customers think is half the battle. Formbricks lets you collect feedback through in-app surveys and analyze the responses. You can embed surveys at specific points in your product to get contextual feedback, not just generic opinions.

### beszel server resource monitoring

[beszel](https://github.com/henrygd/beszel) is a lightweight server monitoring tool that helps businesses keep track of their server resources and performance.

**Key features:**
- Real-time monitoring of CPU, memory, and disk usage
- Network traffic analysis
- Customizable alerts and notifications
- Historical data and trend analysis
- API for integration with other



### Invoice Ninja

[Invoice Ninja](https://invoiceninja.com/) is an open-source invoicing and billing solution that can help small businesses manage their finances more effectively.

**Key features:**
- Customizable invoice templates
- Automated recurring invoices and payments
- Time tracking and project management
- Integration with multiple payment gateways
- Client portal for easy invoice access

**How it helps small businesses:**
Invoice Ninja automates the boring parts of billing: recurring invoices, payment reminders, and tracking. Clients get their own portal to view invoices and pay directly. Supporting multiple payment gateways means fewer excuses for late payments.

### [Kimai](https://www.kimai.org/)

Kimai is an open-source time tracking application that can help businesses monitor employee work hours and project durations.

**Key features:**
- User-friendly interface for time entry
- Project and task management
- Detailed reporting and export options
- User roles and permissions
- Integration with invoicing systems

**How it helps small businesses:**
Tracking time properly matters for billing clients and understanding where your team&apos;s hours actually go. Kimai gives you flexible time tracking with reports you can export. The data helps with client billing accuracy and spotting projects that are eating more time than they should.


## Security Considerations for Self-Hosted Containers

When deploying these containers, consider implementing:
- Reverse proxy with SSL (like [Traefik](https://www.bitdoze.com/traefik-proxy-docker/) or Nginx Proxy Manager)
- Regular backup solutions
- Container update automation
- Network segregation
- Access control and MFA
- Monitoring and logging solutions


## Getting Started with Docker Containers

Essential tools for managing your container infrastructure:
1. **Docker Compose** for container orchestration
2. **Reverse Proxy** (Traefik/Nginx Proxy Manager)
3. **Backup solution** (Duplicati/Borgbackup)
4. **Monitoring stack** (Prometheus/Grafana)
5. **Container management** (Portainer/Dockge)

Basic deployment checklist:
- [ ] Set up server with adequate resources
- [ ] Install Docker and Docker Compose
- [ ] Configure reverse proxy and SSL
- [ ] Implement backup strategy
- [ ] Set up monitoring
- [ ] Document deployment procedures

## Minimum Resource Requirements by Usage Scale

| Scale | Users | CPU | RAM | Storage | Recommended VPS |
|-------|--------|-----|-----|---------|----------------|
| Small | 1-10 | 2 cores | 4GB | 50GB | Basic VPS |
| Medium | 10-50 | 4 cores | 8GB | 100GB | Standard VPS |
| Large | 50+ | 8+ cores | 16GB+ | 200GB+ | Performance VPS |

## Conclusions

Self-hosted Docker containers give small businesses a practical way to deploy and manage applications that can improve their day-to-day operations. From collaboration tools like NextCloud and Zulip to financial management solutions like Firefly III and Invoice Ninja, these containers deliver enterprise-grade functionality without the enterprise price tag.

By using these tools, small businesses can:

1. **Improve collaboration and communication**: Tools like NextCloud, Zulip, and Docmost make teamwork and information sharing easier.

2. **Provide better customer support**: Zammad and Uptime Kuma help with service quality and uptime monitoring.

3. **Streamline operations**: ERPNext, NocoDB, and n8n handle process automation and data management.

4. **Run effective marketing**: Mautic, ListMonk, and Plausible cover marketing automation and analytics.

5. **Maintain tighter security**: Self-hosting gives businesses direct control over their data and helps with regulatory compliance.

6. **Lower software costs**: Open-source solutions replace expensive licenses while providing comparable features.

When deciding which containers to deploy, consider your specific needs, available resources, and technical expertise. Starting with a few core applications and expanding as the business grows is usually the best approach.

Keep in mind that self-hosting brings responsibilities: security management, updates, and backups all need attention. Planning ahead and implementing properly will make for a smooth deployment.

These self-hosted solutions let small businesses compete with larger organizations, run more efficiently, and serve their customers better. The Docker ecosystem keeps growing, and with it the options for businesses to build out their IT infrastructure.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>How to Use Any OpenRouter Model with Google Agent Development Kit (ADK)</title><link>https://www.bitdoze.com/google-adk-openrouter-models/</link><guid isPermaLink="true">https://www.bitdoze.com/google-adk-openrouter-models/</guid><description>See how you can use any model you want in Google Agent Development Kit (ADK) with LiteLLM. Configure OpenRouter models easy.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>The [Google Agent Development Kit (ADK)](https://google.github.io/adk-docs/) is engineered for adaptability, empowering developers to integrate virtually any Large Language Model (LLM) into their agents, far beyond its native Google Gemini ecosystem. Launched on April 9, 2025, as an open-source Python framework, ADK achieves this flexibility through a dual integration approach: direct model strings for Google’s infrastructure (e.g., Gemini via `google-genai`) and wrapper classes like `LiteLlm` for external or custom models. This design allows ADK to tap into diverse LLM providers—such as OpenRouter, Anthropic, or even local setups—without requiring extensive code changes. By abstracting model interactions into a consistent `LlmAgent` interface, ADK enables developers to swap models (e.g., from Gemini to Grok) by simply adjusting the `model` parameter, while its tool and session management features remain intact. This modularity, combined with LiteLLM’s broad compatibility, ensures ADK agents can leverage the strengths of any model—speed, reasoning, or cost-efficiency—making it a versatile platform for building tailored AI solutions.

## What is OpenRouter?

[OpenRouter](https://openrouter.ai) is a platform that simplifies access to a diverse array of Large Language Models by providing a single, OpenAI-compatible API endpoint. Launched to democratize AI model usage, it acts as a gateway to models hosted by various providers, including open-source options like Mistral and proprietary ones like GPT-4, all accessible with one API key. As of April 2025, OpenRouter supports over 50 models, making it a treasure trove for developers seeking flexibility.

**Benefits of Using OpenRouter with ADK**

- Variety: Choose from models optimized for different tasks—speed (e.g., Grok), reasoning (e.g., Claude), or cost (e.g., LLaMA variants).
- Cost Efficiency: OpenRouter’s pricing model often undercuts direct provider rates, with free tiers for experimentation.
- Unified Interface: No need to juggle multiple SDKs or APIs; OpenRouter’s standardized endpoint works seamlessly with LiteLLM, which ADK supports natively.
- Scalability: Easily switch models without rewriting code, thanks to LiteLLM’s abstraction layer.

**Supported Models and LiteLLM Integration**

OpenRouter integrates with LiteLLM, a lightweight library that translates its API calls into a format ADK understands. Whether you want to use xAI’s Grok, Anthropic’s Claude, or an open-source model like Mixtral, LiteLLM ensures compatibility by wrapping OpenRouter’s endpoint into ADK’s LlmAgent. This article will use Grok (created by xAI) as an example, but you can swap it for any OpenRouter-supported model by adjusting the model string.

With OpenRouter and ADK, you’re not just building an agent—you’re building a gateway to the future of AI flexibility.


## What is LiteLLM

[LiteLLM](https://docs.litellm.ai) is a lightweight, open-source Python library designed to simplify interactions with a wide variety of Large Language Models (LLMs) by providing a standardized, OpenAI-compatible API interface. Developed to bridge the gap between diverse LLM providers and developers, LiteLLM abstracts away the complexities of individual model APIs, allowing seamless integration with over 100 models from providers like OpenAI, Anthropic, Hugging Face, and platforms like OpenRouter. As of April 2025, LiteLLM has become a go-to tool for AI developers seeking flexibility without the overhead of managing multiple SDKs.

**Key Features of LiteLLM**

- Unified API: LiteLLM translates calls to different LLMs into a consistent format, mimicking OpenAI’s API structure. This means you write code once and swap models with minimal changes.
- Broad Model Support: It supports models hosted on cloud platforms (e.g., OpenRouter, Anthropic), self-hosted endpoints (e.g., vLLM, Ollama), and even local setups, covering both proprietary and open-source options.
- Tool Calling: LiteLLM supports function/tool calling for models that enable it, making it ideal for ADK’s tool integration needs (e.g., Tavily search in this guide).
- Lightweight and Fast: With minimal dependencies and efficient request handling, it adds little overhead to your application.
- Customizable: You can specify API bases, keys, headers, and other parameters to connect to custom or private endpoints.
- Error Handling: It provides robust fallbacks and logging, helping debug issues across different providers.

**How LiteLLM Works with ADK**

In the context of Google’s Agent Development Kit (ADK), LiteLLM acts as a bridge between ADK’s LlmAgent and external models. ADK natively supports Google’s Gemini models via the google-genai library, but for non-Google models—like those on OpenRouter—you use the LiteLlm wrapper class. This class takes a model identifier (e.g., &quot;openrouter/xai/grok&quot;) and configuration details (e.g., API key, base URL) and handles the communication, ensuring ADK can leverage the model’s capabilities. LiteLLM’s support for tool calling is particularly valuable, as it enables ADK agents to use external tools seamlessly, even with models not originally designed for Google’s ecosystem.

**Why Use LiteLLM?**
LiteLLM’s simplicity and versatility make it a perfect companion for ADK. It eliminates the need to rewrite agent logic for each provider, supports rapid prototyping by letting you test multiple models, and ensures compatibility with ADK’s architecture. For OpenRouter, LiteLLM connects to its unified endpoint (https://openrouter.ai/api/v1), passing requests to the chosen model and returning responses in a format ADK understands.



## Switching Your ADK Agent to OpenRouter Models

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/rLSj47zkTa8&quot;
  label=&quot;How to Build Your First Agent with Google Agent Development Kit (ADK)&quot;
/&gt;


In the previous part, we built an [ADK agent capable of using Tavily search and remembering information](https://www.bitdoze.com/google-adk-start/), powered by a Google Gemini model connected via AI Studio. One of ADK&apos;s strengths is its flexibility in using different Large Language Models (LLMs).

This part will guide you through modifying the agent to use a model hosted on OpenRouter, specifically `openrouter/quasar-alpha`, leveraging ADK&apos;s integration with the LiteLLM library.

**Why Switch Models?**

OpenRouter provides access to a vast array of LLMs from different providers through a unified API. Switching allows you to experiment with models that might offer different performance characteristics, pricing, or capabilities, like `quasar-alpha`, which is noted for long context and coding tasks.

**Prerequisites**

* You should have the complete agent code from the previous article (with `InMemorySessionService`, `Runner`, Tavily search tool, and `remember_something` tool).
* You will need an API key from [OpenRouter](https://openrouter.ai/).

**1. Setup for OpenRouter**

First, let&apos;s configure the necessary components to connect to OpenRouter via LiteLLM.

* **Get OpenRouter API Key:** Sign up or log in to OpenRouter to obtain your API key.
* **Update `.env` File:** Add your OpenRouter API key to the `.env` file in your project&apos;s root directory:
    ```dotenv
    # Add this line to your existing .env file
    OPENROUTER_API_KEY=&quot;PASTE_YOUR_OPENROUTER_API_KEY_HERE&quot;

    # Keep your existing keys as well
    GOOGLE_API_KEY=&quot;YOUR_GOOGLE_API_KEY&quot; # Still needed if other parts use it
    TAVILY_API_KEY=&quot;YOUR_TAVILY_API_KEY&quot;
    ```
* **Install LiteLLM:** If you haven&apos;t already, install the LiteLLM library using `uv`:
    ```bash
    # Run in your activated virtual environment
    uv add litellm
    ```

**2. Modifying the Agent Code**

Now, we&apos;ll update the `agent.py` script to use the OpenRouter model.

* **Import `LiteLlm`:** Add the necessary import at the top of your script:
    ```python
    # Add this import alongside other ADK imports
    from google.adk.models.lite_llm import LiteLlm
    import os # Ensure &apos;os&apos; is imported to read environment variables
    ```

* **Update Agent Definition:** Modify the `model` parameter within your `Agent` definition. Instead of directly providing the Gemini model string, we&apos;ll use the `LiteLlm` wrapper:

    ```python
    # Find your root_agent definition and modify the &apos;model&apos; parameter

    root_agent = Agent(
        name=&quot;my_adk_agent_openrouter&quot;, # Consider updating the name
        # --- MODIFIED PART ---
        model=LiteLlm(
            # Specify the OpenRouter model using &apos;openrouter/&apos; prefix
            model=&quot;openrouter/openrouter/quasar-alpha&quot;,
            # Explicitly provide the API key from environment variables
            api_key=os.getenv(&quot;OPENROUTER_API_KEY&quot;),
            # Explicitly provide the OpenRouter API base URL
            api_base=&quot;https://openrouter.ai/api/v1&quot;
        ),
        # --- END MODIFIED PART ---
        description=&quot;A helpful assistant using OpenRouter Quasar Alpha, capable of web search and memory.&quot;,
        instruction=&quot;&quot;&quot;You are a friendly and helpful assistant powered by Quasar Alpha.
    1. If the user asks for information that might require up-to-date details, recent events, or specific web searching, use the &apos;TavilySearchResults&apos; tool.
    2. If the user asks you to remember something specific, use the &apos;remember_something&apos; tool to save it. Also mention what was previously remembered if anything.
    3. Use information remembered earlier (from session state) if relevant to the current query.
    4. Otherwise, answer directly based on your general knowledge.&quot;&quot;&quot;,
        tools=[adk_tavily_tool, remember_something] # Keep the tools the same
    )

    print(f&quot;Agent &apos;{root_agent.name}&apos; updated to use OpenRouter model.&quot;)

    # IMPORTANT: Ensure the Runner uses this updated agent instance
    # If &apos;runner&apos; was defined after &apos;root_agent&apos;, it should pick up the change.
    # If not, redefine the runner:
    # runner = Runner(agent=root_agent, app_name=APP_NAME, session_service=session_service)
    ```

**Explanation of Changes:**

* We replaced the direct model string (like `&quot;gemini-1.5-flash-latest&quot;`) with an instance of `LiteLlm`.
* Inside `LiteLlm`, we set:
    * `model`: The LiteLLM identifier for the desired OpenRouter model (`openrouter/quasar-alpha`).
    * `api_key`: Explicitly read the OpenRouter key from the environment variables.
    * `api_base`: Explicitly set the standard OpenRouter API endpoint URL (`https://openrouter.ai/api/v1`). While LiteLLM might auto-detect some settings, being explicit is often clearer and safer.

**3. Running the Modified Agent**

That&apos;s it! The rest of your code – the `Runner`, `InMemorySessionService`, tool definitions (`remember_something`, `adk_tavily_tool`), and the interaction loop (`run_conversation`) – should remain unchanged.

When you run the `agent.py` script again:

```bash
uv run python -m agent_module.agent
```
or

```bash
adk web
```

The `Runner` will now direct requests for the `root_agent` through the `LiteLlm` wrapper to OpenRouter, using the `openrouter/quasar-alpha` model to handle reasoning, instruction following, and decisions about when to use the Tavily or memory tools.

## Conclusion

You&apos;ve successfully switched the underlying LLM of your ADK agent from Gemini to an OpenRouter model using the `LiteLlm` wrapper. This highlights the power of ADK&apos;s flexible architecture, allowing you to experiment with and leverage a wide variety of models while keeping your core agent logic and tool integrations intact. You can now easily adapt this process to try other models available through OpenRouter or different providers supported by LiteLLM.</content:encoded><category>ai</category><category>ai-agents</category><category>adk</category><category>uv</category></item><item><title>Hermes Kanban Setup Guide: Task Boards for Multi-Agent AI Workflows</title><link>https://www.bitdoze.com/hermes-kanban-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/hermes-kanban-setup-guide/</guid><description>Learn how to set up and use Hermes Kanban — the built-in task board system that brings structured project management to AI agent workflows.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &apos;@components/widgets/Button.astro&apos;;
import Notice from &apos;@components/widgets/Notice.astro&apos;;
import Accordion from &apos;@components/widgets/Accordion.astro&apos;;
import Tabs from &apos;@components/widgets/Tabs.astro&apos;;
import Tab from &apos;@components/widgets/Tab.astro&apos;;
import ListCheck from &apos;@components/widgets/ListCheck.astro&apos;;
import YouTubeEmbed from &apos;@components/widgets/YouTubeEmbed.astro&apos;;

Hermes Kanban is a board system built into [Hermes Agent](/hermes-agent-setup-guide/) that lets you visually manage tasks, track progress across multiple AI agents, and coordinate complex multi-step workflows. Instead of sending everything through chat, Kanban gives you a structured way to plan, assign, and monitor work — with a visual board you can view from the built-in [dashboard](/hermes-dashboard-guide/) or third-party [Hermes dashboards](/best-hermes-dashboards/).

This guide covers setup, daily usage patterns, the Kanban vs delegate_task decision, and practical multi-agent workflows.

![Hermes Kanban Setup Guide](../../assets/images/25/05/hermes-kanban-setup-guide.svg)

## What Is Hermes Kanban?

Hermes Kanban is a **SQLite-backed task management system** that runs alongside the Hermes gateway. It provides:

- **Visual task boards** with columns for different states (backlog, todo, in progress, review, done)
- **Multi-agent task handoffs** — assign tasks to specific agents or let them pick from a queue
- **Progress tracking** — see which agent is working on what, and how long tasks take
- **Persistent history** — all task state changes are logged with timestamps and agent IDs
- **Dashboard integration** — view and manage boards from Hermes WebUI, Scarf, ClawdBoard, and others

Think of it as Jira or Trello, but designed for AI agents working together.

&lt;Notice type=&quot;info&quot; title=&quot;Hermes v0.1.2+&quot;&gt;
Kanban was introduced in Hermes Agent v0.1.2. Make sure you&apos;re running a recent version. Check with `hermes --version`.
&lt;/Notice&gt;

## Kanban vs delegate_task: When to Use Which

&lt;Accordion label=&quot;Quick comparison&quot; group=&quot;comparison&quot; expanded=&quot;true&quot;&gt;

| Feature | Kanban Board | delegate_task |
|---|---|---|
| **Best for** | Multi-step projects, team coordination | Single tasks, quick delegation |
| **Visibility** | Visual board, full history | Chat-based, ephemeral |
| **Task tracking** | Persistent, stateful | Inline, lost after session |
| **Multi-agent** | Built-in coordination | Manual coordination |
| **Overhead** | Higher (board setup) | Lower (one command) |
| **Use when** | 3+ related tasks, need oversight | One-off task, simple delegation |

&lt;/Accordion&gt;

**Use Kanban when:**

- You have a project with multiple related tasks (e.g., &quot;build a REST API&quot; with auth, endpoints, tests, docs)
- Multiple agents need to coordinate (one writes code, another reviews, another tests)
- You want to track progress over time and see what got done
- Tasks have dependencies (can&apos;t deploy until tests pass)

**Use delegate_task when:**

- You need a quick answer or single piece of work
- The task is self-contained with no follow-up needed
- You&apos;re in a conversation and want to keep momentum

&lt;Notice type=&quot;info&quot; title=&quot;They work together&quot;&gt;
You can use delegate_task to hand off a Kanban task to a specific agent. Kanban is the planning layer; delegate_task is the execution mechanism.
&lt;/Notice&gt;

## Prerequisites

Before setting up Kanban, make sure you have:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Hermes Agent v0.1.2 or later installed&lt;/li&gt;
&lt;li&gt;Hermes gateway running (`hermes gateway start`)&lt;/li&gt;
&lt;li&gt;A dashboard installed (optional but recommended) — see [Hermes Dashboard Guide](/hermes-dashboard-guide/)&lt;/li&gt;
&lt;li&gt;A free model configured if you want to avoid costs — see [Best Cheap Models for Hermes Agent](/best-cheap-models-hermes-agent/)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Step 1: Enable Kanban in Gateway Config

Kanban is enabled by default in recent Hermes versions. Verify it&apos;s active:

```bash
# Check if Kanban is enabled
hermes kanban status
```

If it&apos;s not enabled, add it to your gateway config:

```yaml
# ~/.hermes/gateway.yaml
kanban:
  enabled: true
  default_board: &quot;main&quot;
  auto_archive: true
  archive_after_days: 30
```

Restart the gateway to apply:

```bash
hermes gateway restart
```

## Step 2: Create Your First Board

```bash
# Create a new board
hermes kanban create-board &quot;My Project&quot;

# List all boards
hermes kanban boards

# Set default board
hermes kanban set-default &quot;My Project&quot;
```

Each board has columns that represent task states. The default columns are:

| Column | Purpose |
|---|---|
| **Backlog** | Tasks not yet scheduled |
| **Todo** | Tasks ready to be worked on |
| **In Progress** | Currently being worked on |
| **Review** | Waiting for review or testing |
| **Done** | Completed tasks |

## Step 3: Add Tasks

```bash
# Add a task to the default board
hermes kanban add &quot;Implement user authentication&quot;

# Add with priority and assignee
hermes kanban add &quot;Write API tests&quot; --priority high --assignee &quot;test-agent&quot;

# Add to a specific column
hermes kanban add &quot;Update README&quot; --column backlog

# Add with description
hermes kanban add &quot;Set up CI/CD pipeline&quot; \
  --description &quot;Configure GitHub Actions for automated testing and deployment&quot; \
  --priority medium
```

### Task Properties

Each task supports these properties:

- **title** — what needs to be done
- **description** — detailed requirements
- **priority** — low, medium, high, critical
- **assignee** — which agent (or person) should work on it
- **column** — which board column it starts in
- **tags** — labels for filtering (e.g., `backend`, `frontend`, `docs`)
- **due_date** — when the task should be completed
- **depends_on** — task IDs that must complete first

## Step 4: Move Tasks Through the Board

```bash
# Move a task to a different column
hermes kanban move &quot;Implement user authentication&quot; --to &quot;In Progress&quot;

# Move by task ID
hermes kanban move #12 --to &quot;Review&quot;

# Bulk move all tasks with a tag
hermes kanban move --tag backend --to &quot;Done&quot;
```

### Automatic State Transitions

Hermes Kanban can automatically move tasks when agents perform actions:

- When an agent starts working on a task → moves to **In Progress**
- When an agent submits code for review → moves to **Review**
- When a reviewer approves → moves to **Done**
- When a reviewer requests changes → moves back to **In Progress**

This is configured in the agent&apos;s profile:

```yaml
# ~/.hermes/agents/coder.yaml
kanban:
  auto_move: true
  pick_from: &quot;Todo&quot;
  move_to_on_start: &quot;In Progress&quot;
  move_to_on_complete: &quot;Review&quot;
```

## Step 5: View the Board

### CLI View

```bash
# Show the board in terminal
hermes kanban show

# Show a specific board
hermes kanban show &quot;My Project&quot;

# Show only high-priority tasks
hermes kanban show --priority high

# Show tasks assigned to a specific agent
hermes kanban show --assignee &quot;test-agent&quot;
```

### Dashboard View

The Kanban board is also available in the [Hermes dashboard](/hermes-dashboard-guide/). Open your dashboard and navigate to the Kanban tab to see:

- Drag-and-drop task management
- Real-time updates as agents move tasks
- Task details with full history
- Filter by agent, priority, or tag

For the best visual experience, [Hermes WebUI (EKKO)](/best-hermes-dashboards/) and [Hermes WebUI (nesquena)](/best-hermes-dashboards/) both have excellent Kanban views with drag-and-drop support.

## Multi-Agent Task Workflows

Here are practical patterns for coordinating multiple agents.

### Pattern 1: Code → Review → Deploy Pipeline

Set up three agents with different roles:

```yaml
# ~/.hermes/agents/coder.yaml
name: &quot;Coder&quot;
role: &quot;Writes code based on task descriptions&quot;
kanban:
  pick_from: &quot;Todo&quot;
  move_to_on_complete: &quot;Review&quot;

# ~/.hermes/agents/reviewer.yaml
name: &quot;Reviewer&quot;
role: &quot;Reviews code quality and correctness&quot;
kanban:
  pick_from: &quot;Review&quot;
  move_to_on_approve: &quot;Ready to Deploy&quot;
  move_to_on_reject: &quot;Todo&quot;

# ~/.hermes/agents/devops.yaml
name: &quot;DevOps&quot;
role: &quot;Handles deployment and infrastructure&quot;
kanban:
  pick_from: &quot;Ready to Deploy&quot;
  move_to_on_complete: &quot;Done&quot;
```

Add tasks to the board and let agents pick them up:

```bash
hermes kanban add &quot;Build login endpoint&quot; --priority high
hermes kanban add &quot;Build user profile endpoint&quot; --priority medium
hermes kanban add &quot;Add rate limiting&quot; --priority high

# Agents automatically pick up and process tasks
hermes kanban watch  # Monitor progress in real-time
```

### Pattern 2: Parallel Research with Aggregation

When you need multiple agents to research different aspects of a problem:

```bash
# Create research tasks
hermes kanban add &quot;Research authentication best practices&quot; --assignee &quot;researcher-1&quot; --tag research
hermes kanban add &quot;Research database scaling patterns&quot; --assignee &quot;researcher-2&quot; --tag research
hermes kanban add &quot;Research caching strategies&quot; --assignee &quot;researcher-3&quot; --tag research

# Create an aggregation task that depends on all research
hermes kanban add &quot;Write technical design document&quot; \
  --assignee &quot;architect&quot; \
  --depends-on &quot;Research authentication best practices,Research database scaling patterns,Research caching strategies&quot; \
  --priority high
```

The architect agent won&apos;t pick up its task until all three research tasks are marked as done.

### Pattern 3: Iterative Development Cycle

For tasks that need multiple rounds of refinement:

```yaml
# ~/.hermes/agents/fullstack.yaml
kanban:
  pick_from: &quot;Todo&quot;
  move_to_on_complete: &quot;Testing&quot;
  max_iterations: 3
  on_test_failure: &quot;Todo&quot;
```

This creates a loop: Todo → Testing → (if tests fail) → Todo, up to 3 times. After 3 failures, the task moves to a &quot;Blocked&quot; column for human intervention.

## Advanced Features

### Task Dependencies

Tasks can depend on other tasks. A dependent task won&apos;t be assignable until its dependencies are complete:

```bash
# Create a task with dependencies
hermes kanban add &quot;Deploy to production&quot; \
  --depends-on &quot;Write tests,Code review pass,Security audit&quot;

# View dependency graph
hermes kanban dependencies &quot;Deploy to production&quot;
```

### Filtering and Views

```bash
# Filter by multiple criteria
hermes kanban show --priority high --assignee coder --tag backend

# Show only blocked tasks
hermes kanban show --status blocked

# Show tasks due this week
hermes kanban show --due-before &quot;2026-05-10&quot;

# Export board as JSON
hermes kanban export --format json &gt; board.json
```

### Board Templates

For recurring project types, create board templates:

```bash
# Save current board as template
hermes kanban save-template &quot;web-app&quot; --board &quot;My Project&quot;

# Create new board from template
hermes kanban create-board &quot;New App&quot; --template &quot;web-app&quot;
```

Common templates include:
- **web-app**: Auth, API, Frontend, Tests, Deploy
- **data-pipeline**: Extract, Transform, Validate, Load, Monitor
- **bug-fix**: Reproduce, Diagnose, Fix, Test, Verify

### Notifications

Get notified when tasks change state:

```yaml
# ~/.hermes/gateway.yaml
kanban:
  notifications:
    on_task_complete: true
    on_task_blocked: true
    on_task_overdue: true
    channel: &quot;slack&quot;  # or &quot;discord&quot;, &quot;email&quot;
```

## Real-World Example: Building a REST API

Here&apos;s a complete workflow for building a REST API with Kanban:

```bash
# 1. Create the board
hermes kanban create-board &quot;REST API Project&quot;

# 2. Add all tasks
hermes kanban add &quot;Design database schema&quot; --priority high --tag design
hermes kanban add &quot;Set up project structure&quot; --priority high --tag setup
hermes kanban add &quot;Implement user model&quot; --priority high --tag backend --depends-on &quot;Design database schema&quot;
hermes kanban add &quot;Implement auth endpoints&quot; --priority high --tag backend --depends-on &quot;Implement user model&quot;
hermes kanban add &quot;Implement CRUD endpoints&quot; --priority medium --tag backend --depends-on &quot;Implement user model&quot;
hermes kanban add &quot;Write unit tests&quot; --priority medium --tag testing --depends-on &quot;Implement auth endpoints,Implement CRUD endpoints&quot;
hermes kanban add &quot;Write integration tests&quot; --priority medium --tag testing --depends-on &quot;Implement auth endpoints,Implement CRUD endpoints&quot;
hermes kanban add &quot;Set up CI/CD&quot; --priority low --tag devops
hermes kanban add &quot;Write API documentation&quot; --priority low --tag docs --depends-on &quot;Implement CRUD endpoints&quot;
hermes kanban add &quot;Deploy to staging&quot; --priority medium --tag devops --depends-on &quot;Write unit tests,Write integration tests&quot;

# 3. Assign agents
hermes kanban assign &quot;backend&quot; --tag backend
hermes kanban assign &quot;tester&quot; --tag testing
hermes kanban assign &quot;devops&quot; --tag devops

# 4. Watch progress
hermes kanban watch
```

Each agent picks up tasks in dependency order, works on them, and moves them forward. You can check progress at any time with `hermes kanban show` or through the dashboard.

## Tips for Effective Kanban Usage

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Keep tasks small** — if a task takes more than 30 minutes of agent time, break it down&lt;/li&gt;
&lt;li&gt;**Use dependencies wisely** — don&apos;t over-constrain; let independent tasks run in parallel&lt;/li&gt;
&lt;li&gt;**Set up auto-archive** — keep the board clean by archiving completed tasks automatically&lt;/li&gt;
&lt;li&gt;**Use tags consistently** — create a tag taxonomy and stick to it&lt;/li&gt;
&lt;li&gt;**Monitor blocked tasks** — check `hermes kanban show --status blocked` regularly&lt;/li&gt;
&lt;li&gt;**Use the dashboard** — the visual board makes it much easier to spot bottlenecks than CLI output&lt;/li&gt;
&lt;li&gt;**Start with templates** — don&apos;t build boards from scratch every time&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Troubleshooting

&lt;Accordion label=&quot;Kanban commands not found&quot; group=&quot;faq&quot;&gt;
Make sure you&apos;re running Hermes Agent v0.1.5 or later. Update with:

```bash
hermes update
hermes --version
```

If Kanban still isn&apos;t available, check that it&apos;s enabled in your gateway config (`~/.hermes/gateway.yaml`).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Tasks not being picked up by agents&quot; group=&quot;faq&quot;&gt;
Verify that:
1. The agent is running and connected to the gateway
2. The agent&apos;s `pick_from` column matches where tasks are
3. The agent&apos;s Kanban config has `auto_pick: true`
4. Task dependencies are satisfied

Check agent status with:
```bash
hermes agents status
hermes kanban show --column &quot;Todo&quot;
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Board not showing in dashboard&quot; group=&quot;faq&quot;&gt;
Refresh the dashboard page. If the Kanban tab still doesn&apos;t appear:
1. Make sure you&apos;re running a dashboard that supports Kanban (Hermes WebUI v0.3+, Scarf v1.2+)
2. Check that the gateway is running: `hermes gateway status`
3. Try restarting the dashboard service
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I delete a board?&quot; group=&quot;faq&quot;&gt;

```bash
# Archive a board (recommended — preserves history)
hermes kanban archive-board &quot;My Project&quot;

# Delete a board permanently
hermes kanban delete-board &quot;My Project&quot; --confirm
```

Archiving is better than deleting — you can always restore an archived board later.
&lt;/Accordion&gt;

## What&apos;s Next

Once you have Kanban set up, explore these related guides:

- [Hermes Agent Setup Guide](/hermes-agent-setup-guide/) — if you haven&apos;t installed Hermes yet, start here
- [Hermes Dashboard Guide](/hermes-dashboard-guide/) — configure the built-in dashboard to view your Kanban boards visually
- [Best Hermes Dashboards](/best-hermes-dashboards/) — third-party dashboards with Kanban support (Hermes WebUI, Scarf, etc.)
- [Best Cheap Models for Hermes Agent](/best-cheap-models-hermes-agent/) — free and affordable models to power your agents without breaking the bank

Once you get past the initial board setup, Kanban handles most of the coordination on its own. Start simple — a board with a few tasks — and expand from there as you get comfortable with how agents pick up and move work.

&lt;Button text=&quot;More AI tool guides&quot; link=&quot;/category/ai/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;</content:encoded><category>ai</category><category>hermes-agent</category><category>kanban</category><category>ai-agents</category></item><item><title>Install LiteLLM With Docker Compose and Simplify LLMs</title><link>https://www.bitdoze.com/litellm-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/litellm-docker-install/</guid><description>Discover LiteLLM, the game-changing tool that simplifies LLM management, cuts costs, and boosts efficiency for developers and businesses alike.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/08/litellm-white.png&quot;;

[LiteLLM](https://www.litellm.ai/) is a tool designed to manage and interact with multiple large language models (LLMs) through a unified interface. It supports over 100 different LLMs, including those from HuggingFace, Bedrock, TogetherAI, and others, using the OpenAI format for API calls. This makes it a versatile solution for developers looking to integrate various LLMs into their applications.

## Key Features of LiteLLM

1. **Unified Interface**: LiteLLM offers a single, convenient interface to call over 100 different LLMs, such as those from HuggingFace, Bedrock, and TogetherAI, using the OpenAI API specification. This feature simplifies the integration and management of multiple models.

2. **Cost Efficiency**: The model is optimized to reduce computational costs, making it a more affordable option for NLP tasks. This also contributes to lowering the environmental impact associated with running large-scale models.

3. **Flexibility and Simplicity**: LiteLLM enables seamless transitions between various models with minimal code changes. Users can switch between models like GPT-3.5, O Lama, and Palm 2 effortlessly, which enhances the flexibility of application development.

4. **Load Balancing**: LiteLLM can handle a high volume of requests, supporting up to 1,500 requests per second during load tests. This capability ensures efficient processing and distribution of requests across multiple models and deployments.

5. **Compatibility**: It is compatible with several SDKs, including OpenAI, Anthropic, Mistral, LLamaIndex, and Langchain, allowing for diverse integration options across different platforms.

&lt;Picture src={imag1} alt=&quot;LiteLLM Diagram&quot; /&gt;

## How LiteLLM Can Help You

LiteLLM can significantly benefit developers and organizations by providing a streamlined and efficient approach to working with LLMs. By offering a unified interface, it reduces the complexity involved in managing multiple models, thereby saving time and resources. Its cost-effective nature makes it an attractive option for businesses looking to leverage NLP capabilities without incurring high expenses. Additionally, the tool&apos;s flexibility and compatibility with various SDKs and models make it a versatile solution for a wide range of applications, from chatbots to advanced data analysis.

Overall, LiteLLM&apos;s features and capabilities make it a powerful tool for enhancing productivity and reducing costs in NLP projects.

## LiteLLM Deploy Options

When deploying LiteLLM using Docker Compose, there are notable differences between deploying with and without a database.

### Deployment Without a Database

- **Configuration Simplicity**: Deploying LiteLLM without a database involves fewer components, making the setup process simpler. You primarily need to configure the application using a configuration file (`litellm_config.yaml`) and run the Docker container with necessary environment variables and ports. We are going to see below.
- **Use Cases**: This setup is suitable for scenarios where persistent data storage is not required, or the application can function with in-memory data or external APIs.

### Deployment With a Database

- **Database Requirement**: When deploying with a database, you need to set up a Postgres database and provide a `DATABASE_URL` in the environment variables. This setup is essential for applications that require persistent data storage.
- **Additional Configuration**: You must configure the database connection details in the environment variables and ensure that the database service is up and running before the application starts. This might involve using Docker Compose to define the startup order.
- **Data Persistence**: Using a database allows for persistent data storage, which is crucial for applications that handle significant amounts of data or require data integrity over time. It is important to use Docker volumes to ensure data is not lost when containers are stopped or removed.
- **Complexity and Management**: Deploying with a database adds complexity, as you need to manage database backups, scaling, and performance tuning. It is recommended to use separate containers for the database and application to maintain a clean separation of concerns and facilitate easier management.

In summary, deploying LiteLLM with a database provides the advantage of data persistence and is suitable for production environments, while deploying without a database is simpler and more suited for development or scenarios where persistent storage is not critical.

## Install LiteLLM with Docker Compose

&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/j0IFFoCfihk&quot;
  label=&quot;LiteLLM Docker Install&quot;
/&gt;


&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### Prerequizites

Before you begin, make sure you have the following prerequisites in place:

- VPS where you can host LiteLLM, you can use one from [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) You can use a VPS to have LiteLLM installed but performances will not be that good. In our test we are using a 8 CPUs 16 GB RAM and is bearly moving. Best will be to have a GPU powered system.
- Traefic with Docker set up, you can check: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) or [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/)
- Docker and Dockge installed on your server, you can check the [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/) for the full tutorial.

&gt; **Security note (March 2026):** LiteLLM experienced a supply chain incident in March 2026. All affected packages were deleted and current releases are clean. If you installed LiteLLM before March 2026, update to the latest version immediately. See the [official security update](https://docs.litellm.ai/blog/security-update-march-2026) for details.

Below we are going to check both options without a database and with a database so you can use the one that you need.

### Install LiteLLM with Docker Compose - NO Database

First let&apos;s create the LiteLLM config file, you can do so by checking the list [here](https://litellm.vercel.app/docs/providers)
`litellm_config.yaml`

```yaml
model_list:
  - model_name: gpt-5.5
    litellm_params:
      model: gpt-5.5
  - model_name: claude-sonnet-4-6
    litellm_params:
      model: claude-sonnet-4-6-20260504
```

Docker Compose File

```yaml
litellm:
  image: ghcr.io/berriai/litellm:main-latest
  restart: unless-stopped
  command:
    - &quot;--config=/litellm_config.yaml&quot;
    - &quot;--detailed_debug&quot;
  environment:
    LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
    OPENAI_API_KEY: ${OPENAI_API_KEY}
    ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
  volumes:
    - ./litellm_config.yaml:/litellm_config.yaml
```

- **Image**: Uses the Docker image `ghcr.io/berriai/litellm:main-latest`.
- **Restart Policy**: Set to `unless-stopped`, ensuring automatic restarts unless manually stopped.
- **Command**:
  - `--config=/litellm_config.yaml`: Specifies the configuration file.
  - `--detailed_debug`: Enables verbose logging for troubleshooting.
- **Environment Variables**:
  - `LITELLM_MASTER_KEY`: Master key for LiteLLM.
  - `OPENAI_API_KEY`: API key for OpenAI.
  - `ANTHROPIC_API_KEY`: API key for Anthropic.
- **Volumes**: Mounts `./litellm_config.yaml` from the host to `/litellm_config.yaml` in the container for configuration access.

`.env `file

```sh
LITELLM_MASTER_KEY=sk-1234
OPENAI_API_KEY=&lt;openaiapikey&gt;
ANTHROPIC_API_KEY= &lt;ANTHROPIC key&gt;
```

For a complete file with Open WebUI. I have created the [OpenWebUI deploy with Ollama](https://www.bitdoze.com/ollama-docker-install/) before and if you want to use Opem Web UI with LiteLLM below is the complete file:

```yaml
services:
  openWebUI:
    image: ghcr.io/open-webui/open-webui:main
    container_name: openwebui
    hostname: openwebui
    networks:
      - traefik-net
    restart: unless-stopped
    volumes:
      - ./open-webui-local:/app/backend/data
    labels:
      - traefik.enable=true
      - traefik.http.routers.openwebui.rule=Host(`openwebui.domain.com`)
      - traefik.http.routers.openwebui.entrypoints=https
      - traefik.http.services.openwebui.loadbalancer.server.port=8080
    environment:
      OLLAMA_BASE_URLS: http://ollama:11434
      OPENAI_API_KEY: ${LITELLM_MASTER_KEY}
      OPENAI_API_BASE_URL: http://litellm:4000/v1
  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    hostname: ollama
    networks:
      - traefik-net
    volumes:
      - ./ollama-local:/root/.ollama
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    networks:
      - traefik-net
    restart: unless-stopped
    command:
      - --config=/litellm_config.yaml
      - --detailed_debug
    environment:
      LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY}
    volumes:
      - ./litellm_config.yaml:/litellm_config.yaml
networks:
  traefik-net:
    external: true
```

- OPENAI_API_BASE_URL is pointing to the container with Lite LLM

### Install LiteLLM with Docker Compose - With Database

To have access to advanced features and save details to database you can install LiteLLM with Postgress and have access to the UI also.

```yaml
services:
  litellm:
    image: ghcr.io/berriai/litellm:main-latest
    networks:
      - traefik-net
    restart: unless-stopped
    depends_on:
      - litellm-db
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@litellm-db:5432/${POSTGRES_DB}
      LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY}
      UI_USERNAME: ${UI_USERNAME}
      UI_PASSWORD: ${UI_PASSWORD}
      STORE_MODEL_IN_DB: &quot;True&quot;
    labels:
      - traefik.enable=true
      - traefik.http.routers.litellm.rule=Host(`litellm.domain.com`)
      - traefik.http.routers.litellm.entrypoints=https
      - traefik.http.services.litellm.loadbalancer.server.port=4000
  litellm-db:
    image: postgres:16-alpine
    networks:
      - traefik-net
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - ./litellm-db:/var/lib/postgresql/data:rw
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    restart: on-failure:5
networks:
  traefik-net:
    external: true
```

- **litellm**:

  - **Image**: Uses the `litellm` image from GitHub Container Registry with the `main-latest` tag.
  - **Networks**: Connects the `litellm` service to the `traefik-net` network, allowing it to communicate with Traefik.
  - **Restart Policy**: Sets the restart policy to `unless-stopped`, meaning the container will always restart unless explicitly stopped.
  - **Depends On**: Specifies that `litellm` depends on the `litellm-db` service, ensuring the database is available before starting the application.
  - **Environment Variables**:
    - `DATABASE_URL`: Constructs the PostgreSQL connection string using environment variables for the user, password, and database name.
    - `LITELLM_MASTER_KEY`: A security key used for the application.
    - `UI_USERNAME` and `UI_PASSWORD`: Credentials for accessing the application&apos;s user interface.
    - `STORE_MODEL_IN_DB`: A flag set to `&quot;True&quot;` to indicate that models should be stored in the database.
  - **Labels** (for Traefik):
    - `traefik.enable=true`: Enables Traefik for the `litellm` service.
    - `traefik.http.routers.litellm.rule=Host(litellm.domain.com)`: Specifies the domain name for routing requests to `litellm`.
    - `traefik.http.routers.litellm.entrypoints=https`: Configures Traefik to use the HTTPS entry point for the `litellm` service.
    - `traefik.http.services.litellm.loadbalancer.server.port=4000`: Sets the port for the `litellm` service to 4000.

- **litellm-db**:
  - **Image**: Uses the `postgres:16-alpine` image, which is a lightweight version of PostgreSQL based on Alpine Linux.
  - **Networks**: Connects the `litellm-db` service to the `traefik-net` network for communication.
  - **Health Check**:
    - Command: `pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}` checks if the PostgreSQL server is ready to accept connections using the specified user and database.
    - Interval: Runs the health check every 5 seconds.
    - Timeout: Sets a timeout of 5 seconds for the health check.
    - Retries: Specifies that the health check should retry up to 5 times before marking the service as unhealthy.
  - **Volumes**: Maps a local directory (`./litellm-db`) to the PostgreSQL data directory (`/var/lib/postgresql/data`) using read-write permissions. This ensures data persistence for the database.
  - **Environment Variables**:
    - `POSTGRES_DB`: Specifies the name of the database to create.
    - `POSTGRES_USER`: Sets the username for the PostgreSQL server.
    - `POSTGRES_PASSWORD`: Defines the password for the PostgreSQL user.
  - **Restart Policy**: Sets the restart policy to `on-failure:5`, meaning the container will restart up to 5 times if it fails.

### Networks

- **traefik-net**:
  - Defines an external network named `traefik-net`, which is likely managed by the Traefik reverse proxy. This allows the `litellm` and `litellm-db` services to communicate with Traefik for routing and load balancing.

You can check: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) or [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/) to see hwo to set up Traefik on your server.

`.env` file:
Below are the configs files for you env file, you can change what you don&apos;t like:

```sh
LITELLM_MASTER_KEY=sk-1234
POSTGRES_DB=litellm
POSTGRES_USER=litellm
POSTGRES_PASSWORD=litellm
UI_USERNAME=bitdoze
UI_PASSWORD=bitdoze
```

## Conclusions

That&apos;s how you can install LiteLLM and use it in your projects. With LiteLLM, you will significantly simplify the process of integrating and managing multiple language models in your applications.

LiteLLM&apos;s unified interface and support for over 100 different LLMs make it an invaluable tool for developers looking to leverage the power of various language models without the complexity of managing multiple APIs. Its cost efficiency, flexibility, and load balancing capabilities further enhance its value for both small-scale projects and large-scale deployments.

By following this guide, you&apos;ve set up a powerful infrastructure that can serve as the backbone for your AI-driven applications. Whether you&apos;re building chatbots, content generation tools, or complex NLP systems, LiteLLM provides the flexibility and simplicity to streamline your development process.

For those interested in exploring more Docker containers to enhance your self-hosted setup or complement your LiteLLM installation, don&apos;t forget to check out our comprehensive guide on [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). This resource offers a wealth of options for various applications and services that can be seamlessly integrated into your Docker environment, helping you build a robust and versatile self-hosted ecosystem.

LiteLLM sits next to OmniRoute, Langfuse, and the rest of the agent stack in [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>self-hosted</category></item><item><title>Multiple PostgreSQL Databases in ONE Service: THE Docker Compose WAY!</title><link>https://www.bitdoze.com/multiple-postgres-databases-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/multiple-postgres-databases-docker/</guid><description>Master multiple PostgreSQL databases effortlessly! Discover how Docker Compose simplifies your setup. Don&apos;t miss out – transform your workflow NOW!</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Docker Compose allows you to set up complex application stacks, including database services. While PostgreSQL&apos;s official Docker image supports creating a single database by default, you can configure it to create multiple databases within one container.

There are a few common reasons why you might want to set up multiple databases in the same Docker Compose service:

1. **Development and testing environments:** When developing or testing an application that uses multiple databases, it&apos;s convenient to have all the databases running in a single container for simplicity.

2. **Resource efficiency:** Using a single container for multiple databases can be more resource-efficient than running separate containers for each database, especially in development environments.

3. **Legacy application support:** Some legacy applications may expect multiple databases to be available on the same server. Replicating this setup in Docker can make migration easier.

4. **Microservices architecture:** In a microservices architecture, you might have multiple small databases that are closely related and benefit from being grouped together.

5. **Data isolation:** You may want to isolate different types of data (e.g. user data, product data, analytics) into separate databases for security or organizational reasons, while still keeping them in the same service.

6. **Multi-tenant applications:** For applications serving multiple tenants, you might want a separate database for each tenant, but still keep them managed within a single service.

7. **Testing database migrations:** When testing database migrations or upgrades, it can be useful to have multiple versions or states of a database available simultaneously.

8. **Reducing complexity:** For smaller projects or prototypes, having all databases in one container can reduce the overall complexity of the Docker setup.

However, it&apos;s important to note that while this approach can be useful for development and testing, for production environments it&apos;s generally recommended to use separate containers for different databases to ensure better isolation, scalability, and easier management. The specific needs of your project and environment should guide the decision on whether to use multiple databases in a single service or separate them into different containers.

## Step 1: Create the Initialization Script

First, create a bash script that will initialize multiple databases. Save this script as `init-multiple-databases.sh`:

```sh
#!/bin/bash

set -e
set -u

function create_user_and_database() {
  local database=$1
  echo &quot;  Creating user and database &apos;$database&apos;&quot;
  psql -v ON_ERROR_STOP=1 --username &quot;$POSTGRES_USER&quot; --dbname &quot;postgres&quot; &lt;&lt;-EOSQL
        CREATE USER $database;
        CREATE DATABASE $database;
        GRANT ALL PRIVILEGES ON DATABASE $database TO $database;
EOSQL
}

if [ -n &quot;${POSTGRES_MULTIPLE_DATABASES:-}&quot; ]; then
  echo &quot;Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES&quot;
  for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr &apos;,&apos; &apos; &apos;); do
    create_user_and_database $db
  done
  echo &quot;Multiple databases created&quot;
fi
```

The script is taking a list of databases and connects to the Postgress and creates the databases and grants all privilages.

## Step 2: Create the Docker Compose File

Create a `docker-compose.yml` file with the following content:

```yml
services:
  postgres:
    image: postgres:17-alpine
    container_name: postgres_multi_db
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
      POSTGRES_MULTIPLE_DATABASES: db1,db2,db3
    volumes:
      - ./init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh
      - ./postgres_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    restart: unless-stopped
```

&gt; **Note:** The `version:` top-level key in Docker Compose files is deprecated and ignored by modern Docker Compose. You can safely remove it. The example above omits it.

PostgreSQL 17 Alpine is used here. You can pin to a specific minor version (e.g., `postgres:17.5-alpine`) for reproducible builds. The `POSTGRES_MULTIPLE_DATABASES` has the list with databases to be created. All the DBs will use the same user and pass. The script from previous points `init-multiple-databases.sh` is is loaded from the docker compose file.

## Step 3: Set Up Environment Variables (Optional)

Create a .env file in the same directory as your docker-compose.yml to store sensitive information:

```sh
POSTGRES_USER=myuser
POSTGRES_PASSWORD=mypassword
```

## Step 4: Run the Docker Compose Stack

```sh
docker-compose up -d
```

Log:

```sh
postgres_multi_db  | The files belonging to this database system will be owned by user &quot;postgres&quot;.
postgres_multi_db  | This user must also own the server process.
postgres_multi_db  |
postgres_multi_db  | The database cluster will be initialized with locale &quot;en_US.utf8&quot;.
postgres_multi_db  | The default database encoding has accordingly been set to &quot;UTF8&quot;.
postgres_multi_db  | The default text search configuration will be set to &quot;english&quot;.
postgres_multi_db  |
postgres_multi_db  | Data page checksums are disabled.
postgres_multi_db  |
postgres_multi_db  | fixing permissions on existing directory /var/lib/postgresql/data ... ok
postgres_multi_db  | creating subdirectories ... ok
postgres_multi_db  | selecting dynamic shared memory implementation ... posix
postgres_multi_db  | selecting default max_connections ... 100
postgres_multi_db  | selecting default shared_buffers ... 128MB
postgres_multi_db  | selecting default time zone ... UTC
postgres_multi_db  | creating configuration files ... ok
postgres_multi_db  | running bootstrap script ... ok
postgres_multi_db  | sh: locale: not found
postgres_multi_db  | 2024-07-26 10:41:39.527 UTC [35] WARNING:  no usable system locales were found
postgres_multi_db  | performing post-bootstrap initialization ... ok
postgres_multi_db  | syncing data to disk ... ok
postgres_multi_db  |
postgres_multi_db  |
postgres_multi_db  | Success. You can now start the database server using:
postgres_multi_db  |
postgres_multi_db  |     pg_ctl -D /var/lib/postgresql/data -l logfile start
postgres_multi_db  |
postgres_multi_db  | initdb: warning: enabling &quot;trust&quot; authentication for local connections
postgres_multi_db  | initdb: hint: You can change this by editing pg_hba.conf or using the option -A, or --auth-local and --auth-host, the next time you run initdb.
postgres_multi_db  | waiting for server to start....2024-07-26 10:41:40.720 UTC [42] LOG:  starting PostgreSQL 16.3 on x86_64-pc-linux-musl, compiled by gcc (Alpine 13.2.1_git20240309) 13.2.1 20240309, 64-bit
postgres_multi_db  | 2024-07-26 10:41:40.722 UTC [42] LOG:  listening on Unix socket &quot;/var/run/postgresql/.s.PGSQL.5432&quot;
postgres_multi_db  | 2024-07-26 10:41:40.730 UTC [45] LOG:  database system was shut down at 2024-07-26 10:41:40 UTC
postgres_multi_db  | 2024-07-26 10:41:40.739 UTC [42] LOG:  database system is ready to accept connections
postgres_multi_db  |  done
postgres_multi_db  | server started
postgres_multi_db  | CREATE DATABASE
postgres_multi_db  |
postgres_multi_db  |
postgres_multi_db  | /usr/local/bin/docker-entrypoint.sh: sourcing /docker-entrypoint-initdb.d/init-multiple-databases.sh
postgres_multi_db  | Multiple database creation requested: db1,db2,db3
postgres_multi_db  |   Creating user and database &apos;db1&apos;
postgres_multi_db  | CREATE ROLE
postgres_multi_db  | CREATE DATABASE
postgres_multi_db  | GRANT
postgres_multi_db  |   Creating user and database &apos;db2&apos;
postgres_multi_db  | CREATE ROLE
postgres_multi_db  | CREATE DATABASE
postgres_multi_db  | GRANT
postgres_multi_db  |   Creating user and database &apos;db3&apos;
postgres_multi_db  | CREATE ROLE
postgres_multi_db  | CREATE DATABASE
postgres_multi_db  | GRANT
postgres_multi_db  | Multiple databases created
postgres_multi_db  |
postgres_multi_db  | waiting for server to shut down....2024-07-26 10:41:41.202 UTC [42] LOG:  received fast shutdown request
postgres_multi_db  | 2024-07-26 10:41:41.204 UTC [42] LOG:  aborting any active transactions
postgres_multi_db  | 2024-07-26 10:41:41.214 UTC [42] LOG:  background worker &quot;logical replication launcher&quot; (PID 48) exited with exit code 1
postgres_multi_db  | 2024-07-26 10:41:41.214 UTC [43] LOG:  shutting down
postgres_multi_db  | 2024-07-26 10:41:41.215 UTC [43] LOG:  checkpoint starting: shutdown immediate
postgres_multi_db  | 2024-07-26 10:41:41.465 UTC [43] LOG:  checkpoint complete: wrote 3687 buffers (22.5%); 0 WAL file(s) added, 0 removed, 1 recycled; write=0.088 s, sync=0.153 s, total=0.251 s; sync files=1196, longest=0.005 s, average=0.001 s; distance=17073 kB, estimate=17073 kB; lsn=0/259C0B8, redo lsn=0/259C0B8
postgres_multi_db  | 2024-07-26 10:41:41.497 UTC [42] LOG:  database system is shut down
postgres_multi_db  |  done
postgres_multi_db  | server stopped
postgres_multi_db  |
postgres_multi_db  | PostgreSQL init process complete; ready for start up.
postgres_multi_db  |
postgres_multi_db  | 2024-07-26 10:41:41.555 UTC [1] LOG:  starting PostgreSQL 16.3 on x86_64-pc-linux-musl, compiled by gcc (Alpine 13.2.1_git20240309) 13.2.1 20240309, 64-bit
postgres_multi_db  | 2024-07-26 10:41:41.555 UTC [1] LOG:  listening on IPv4 address &quot;0.0.0.0&quot;, port 5432
postgres_multi_db  | 2024-07-26 10:41:41.555 UTC [1] LOG:  listening on IPv6 address &quot;::&quot;, port 5432
postgres_multi_db  | 2024-07-26 10:41:41.559 UTC [1] LOG:  listening on Unix socket &quot;/var/run/postgresql/.s.PGSQL.5432&quot;
postgres_multi_db  | 2024-07-26 10:41:41.568 UTC [67] LOG:  database system was shut down at 2024-07-26 10:41:41 UTC
postgres_multi_db  | 2024-07-26 10:41:41.582 UTC [1] LOG:  database system is ready to accept connections
```

## Step 5: Verify the Databases

To confirm that the databases were created successfully, you can connect to the PostgreSQL container and list the databases:

```sh
docker exec -it postgres_multi_db psql -U myuser -c &quot;\l&quot;
```

```sh
root@docker-cloud:/opt/stacks/multi-postgress# docker exec -it postgres_multi_db psql -U bitdoze -c &quot;\l&quot;
                                                     List of databases
   Name    |  Owner  | Encoding | Locale Provider |  Collate   |   Ctype    | ICU Locale | ICU Rules |  Access privileges
-----------+---------+----------+-----------------+------------+------------+------------+-----------+---------------------
 bitdoze   | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |
 db1       | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =Tc/bitdoze        +
           |         |          |                 |            |            |            |           | bitdoze=CTc/bitdoze+
           |         |          |                 |            |            |            |           | db1=CTc/bitdoze
 db2       | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =Tc/bitdoze        +
           |         |          |                 |            |            |            |           | bitdoze=CTc/bitdoze+
           |         |          |                 |            |            |            |           | db2=CTc/bitdoze
 db3       | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =Tc/bitdoze        +
           |         |          |                 |            |            |            |           | bitdoze=CTc/bitdoze+
           |         |          |                 |            |            |            |           | db3=CTc/bitdoze
 postgres  | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           |
 template0 | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =c/bitdoze         +
           |         |          |                 |            |            |            |           | bitdoze=CTc/bitdoze
 template1 | bitdoze | UTF8     | libc            | en_US.utf8 | en_US.utf8 |            |           | =c/bitdoze         +
           |         |          |                 |            |            |            |           | bitdoze=CTc/bitdoze
(7 rows)
```

## Different users and Passwords for Databases

To handle different user permissions for each database in Docker Compose when setting up multiple PostgreSQL databases, you can modify the initialization script and Docker Compose configuration. Here&apos;s how to achieve this:

1. Update the initialization script (init-multiple-databases.sh) to accept custom users and permissions:

```sh
#!/bin/bash

set -e
set -u

function create_user_and_database() {
    local database=$1
    local user=$2
    local password=$3
    echo &quot;Creating user &apos;$user&apos; and database &apos;$database&apos;&quot;
    psql -v ON_ERROR_STOP=1 --username &quot;$POSTGRES_USER&quot; --dbname &quot;postgres&quot; &lt;&lt;-EOSQL
        CREATE USER $user WITH PASSWORD &apos;$password&apos;;
        CREATE DATABASE $database;
        GRANT ALL PRIVILEGES ON DATABASE $database TO $user;
EOSQL
}

if [ -n &quot;${POSTGRES_MULTIPLE_DATABASES:-}&quot; ]; then
    echo &quot;Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES&quot;
    for db_config in $(echo $POSTGRES_MULTIPLE_DATABASES | tr &apos;,&apos; &apos; &apos;); do
        IFS=&apos;:&apos; read -r db user password &lt;&lt;&lt; &quot;$db_config&quot;
        create_user_and_database $db $user $password
    done
    echo &quot;Multiple databases created&quot;
fi
```

2. Modify your `docker-compose.yml` file to pass the database configurations:

```yml
services:
  postgres:
    image: postgres:17-alpine
    container_name: postgres_multi_db
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-postgres}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
      POSTGRES_MULTIPLE_DATABASES: db1:user1:pass1,db2:user2:pass2,db3:user3:pass3
    volumes:
      - ./init-multiple-databases.sh:/docker-entrypoint-initdb.d/init-multiple-databases.sh
      - ./postgres_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    restart: unless-stopped
```

Here `POSTGRES_MULTIPLE_DATABASES: db1:user1:pass1,db2:user2:pass2,db3:user3:pass3` will contain the DB,USER and PASS, you can add this into you `.env` if you want to make it secure.

This is how easy it is to have multiple databases created in docker compose for same postgres service.</content:encoded><category>self-hosting</category><category>docker</category><category>postgres</category></item><item><title>NiceGUI For Beginners: Build An UI to Python App in 5 Minutes</title><link>https://www.bitdoze.com/nicegui-get-started/</link><guid isPermaLink="true">https://www.bitdoze.com/nicegui-get-started/</guid><description>Master NiceGUI quickly! Learn to add a user interface to your Python app in just 5 minutes with our beginner-friendly guide.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

[NiceGUI](https://nicegui.io/) is a newer framework that aims to provide a simple and elegant way to create GUIs with Python. It uses Vue, Quasar, and Tailwind for its frontend, which allows you to create web-based GUIs with HTML, CSS, and JavaScript.

Over time I have checked a few Python frameworks like Streamlit or Taipy that can help me build a Web UI for my Python application and in the end, I have decided to stick with NiceGUI as it provides a better speed and more customizations for my Python app. I have created some comparison articles that will help you see the exact differences: [Streamlit vs. NiceGUI](https://www.bitdoze.com/streamlit-vs-nicegui/) or [Streamlit vs Taipy](https://www.bitdoze.com/streamlit-vs-taipy/). There is also a master article with all the [Python Web UI frameworks](https://www.bitdoze.com/best-python-web-frameworks/) that you can use and there are a lot, you should choose in function of your needs.

## NiceGUI Features

In the below section I would like to highlight the most important features of NiceGUI and why I think is one of the best if you want to build a UI for your Python apps:

- **Performance**: NiceGUI has a very good performance when it comes to interacting with the components, websites need to be fast otherwise visitors will not like it. NiceGUI uses Vue, Quasar, and Tailwind and makes things very fast, you will not even know that the website is using Python behind the hood.
- **Customizations**: I like to have the option to customize the app the way I like even if it takes longer, the Tailwind classes, Quasar props and direct CSS styles will help you customize the app in the way you like. Also is very easy to change the default things and add JavaScript if you need.
- **Easy to Use**: I am not a Python expert nor a CSS or HTML one I know some things so I need the framework to be easy to use. After a couple of days, I understood most of the things and NiceGUI documentation and examples will help you understand most of the things.
- **Components**: NiceGUI provides the components you need to build the app, easily. You have `row`, `columns`, `markdown`, `images`, `sliders`, `cards` and a lot of other things that you can easily integrate with your Python code.

These are some of the most important things for me, in function what you need to do you can check NiceGUI documentation and see if it has the things you need.

If you want to deploy NiceGUI or any Python App to Docker you can check: [How To Run Any Python App in Docker with Docker Compose](https://www.bitdoze.com/docker-run-python/)

## Getting Started With NiceGUI

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/tfBKRxbCsao&quot;
  label=&quot;NiceGUI For Beginners&quot;
/&gt;

In the below section we are going to see how you can start with NiceGUI and what are some of the components and customizations you can use [Bitdoze Tools](https://tools.bitdoze.com/) is build with NiceGUI so you can take a look.

### Install and Run NiceGUI

NiceGui is easy to install and you just need to run a pip command:

```python
pip install nicegui
```

After you just need to create an `.py` file and start it. NiceGUI is now at version 2.x with improved TypeScript support, better Tailwind CSS integration, and new components like `ui.editor` (rich text), `ui.image` (lazy loading), and `ui.aggrid` (advanced tables). The core API below still works the same way across versions.

```python
from nicegui import ui

ui.label(&apos;Hello NiceGUI!&apos;)

ui.run()
```

- first, you import the UI like most of the other frameworks `from nicegui import ui`
- after you add your code with elements, `ui.label(&apos;Hello NiceGUI!&apos;)` will just add a text on the page, we will see next some of the other things.
- after you add the `ui.run()` that will tell python to run it

At the end you just need to run your `.py` file and NiceGUI will start the app on `8080` port:

```python
python3 main.py
```

### Add Markdown

If you need to add text or other things that are formatted you can use the `ui.markdown` element:

```python
ui.markdown(&apos;# This is H1 Header&apos;)
ui.markdown(&apos;## This is H2 Header&apos;)
ui.markdown(&apos;#### This is H3 Header&apos;)
```

These lines demonstrate how to use markdown syntax to create headers of different levels (H1, H2, and H3) in the UI.

### Add Rows

```python
# row element
with ui.row():
  ui.label(&apos; First row item&apos;)
  ui.label(&apos; Second row item&apos;)
  ui.label(&apos; Third row item&apos;)
```

This block creates a row layout where labels (text elements) are arranged horizontally. You can add under it the elements you need.

### Add Columns

```python
#Column element
with ui.column():
  ui.label(&apos; First column item&apos;)
  ui.label(&apos; Second column item&apos;)
  ui.label(&apos; Third colum item&apos;)
```

This block creates a column layout where labels are stacked vertically.

### Styling Components with Clases

```python
with ui.column():
  ui.label(&apos; First column item&apos;).classes(&apos;font-bold&apos;)
  ui.label(&apos; Second column item&apos;).classes(&apos;text-2xl&apos;)
  ui.label(&apos; Third column item&apos;).classes(&apos;text-red-600 text-2xl&apos;)
```

This block demonstrates how to add CSS classes to style text elements, such as making text bold, changing its size, or altering its color. You can use Tailwind classes to style the components in the way you like.

### Inputs and Buttons

```python
with ui.row():
    ui.input(label=&apos;Type Something&apos;).props(&apos;square outlined dense&apos;).classes(&apos;shadow-lg&apos;)
    ui.button(&apos;Click Me&apos;)
```

This block adds an input field with a label and a button in a row layout. The input field has additional properties and classes for styling. You can use Tailwind classes in combination with Quasar props to style your components.

### Add Images

```python
ui.image(&apos;https://www.bitdoze.com/_astro/streamlit-vs-nicegui.CbrH4KaA_2qjgFm.webp&apos;).classes(&apos;h-auto max-w-lg rounded-lg flex justify-center&apos;)
```

This line adds an image to the UI with a source URL and applies CSS classes for styling, such as setting the height automatically, limiting the maximum width, rounding the corners, and centering the image.

### Add Header with Drawer Toggle

```python
with ui.header(elevated=True).style(&apos;background-color: #3874c8&apos;).classes(&apos;items-center justify-between&apos;):
        ui.label(&apos;HEADER&apos;)
        ui.button(on_click=lambda: right_drawer.toggle(), icon=&apos;menu&apos;).props(&apos;flat color=white&apos;)
```

This block creates a header with a specific background color and styles. It includes a label for the header title and a button that toggles the visibility of a right-side drawer.

### Add Right Sidebar/Drawer

```python
with ui.right_drawer(fixed=False).style(&apos;background-color: #ebf1fa&apos;).props(&apos;bordered&apos;) as right_drawer:
        ui.label(&apos;RIGHT DRAWER&apos;)
```

This block defines a right-side drawer with a background color and border properties. It contains a label indicating it&apos;s the right drawer.

### Add Footer

```python
with ui.footer().style(&apos;background-color: #3874c8&apos;):
        ui.label(&apos;FOOTER&apos;)
```

This block creates a footer with a specific background color and contains a label for the footer text. You can add the footer elements in here if you choose to, split them into columns,etc.

### Add Code Into Header:

```python
 ui.add_head_html(&apos;&apos;&apos;
     &lt;script&gt;&lt;/script&gt;
     &apos;&apos;&apos;)
```

This will make possible adding the code you like into header, you can add javascript or CSS code, you can add your analytics code in here.

At the end you will have a file with all the elements that will look like this:

```python
from nicegui import ui

#demonstrate h1 and others
ui.markdown(&apos;# This is H1 Header&apos;)
ui.markdown(&apos;## This is H2 Header&apos;)
ui.markdown(&apos;#### This is H3 Header&apos;)

ui.separator()

#row element
with ui.row():
  ui.label(&apos; First row item&apos;)
  ui.label(&apos; Second row item&apos;)
  ui.label(&apos; Third row item&apos;)


ui.separator()
#Column element
with ui.column():
  ui.label(&apos; First column item&apos;)
  ui.label(&apos; Second column item&apos;)
  ui.label(&apos; Third colum item&apos;)

#stile the text
ui.separator()

with ui.column():
  ui.label(&apos; First column item&apos;).classes(&apos;font-bold&apos;)
  ui.label(&apos; Second column item&apos;).classes(&apos;text-2xl&apos;)
  ui.label(&apos; Third column item&apos;).classes(&apos;text-red-600 text-2xl&apos;)

#other elements
ui.separator()
with ui.row():
    ui.input(label=&apos;Tipe Something&apos;).props(&apos;squere outlined dense&apos;).classes(&apos;shadow-lg&apos;)
    ui.button(&apos;Click Me&apos;)


#image add
ui.separator()
ui.image(&apos;https://www.bitdoze.com/_astro/streamlit-vs-nicegui.CbrH4KaA_2qjgFm.webp&apos;).classes(&apos;h-auto max-w-lg rounded-lg flex justify-center&apos;)


## Header with right drawer

with ui.header(elevated=True).style(&apos;background-color: #3874c8&apos;).classes(&apos;items-center justify-between&apos;):
        ui.label(&apos;HEADER&apos;)
        ui.button(on_click=lambda: right_drawer.toggle(), icon=&apos;menu&apos;).props(&apos;flat color=white&apos;)
with ui.right_drawer(fixed=False).style(&apos;background-color: #ebf1fa&apos;).props(&apos;bordered&apos;) as right_drawer:
        ui.label(&apos;RIGHT DRAWER&apos;)

# footer
with ui.footer().style(&apos;background-color: #3874c8&apos;):
        ui.label(&apos;FOOTER&apos;)


ui.run()
```

This is just scratching the surface of what NiceGUI can do but should be enough to understand the capabilities of NIceGUI. Next, you can add your Python functions and link them to buttons and input fields.

## Why Some Will Probably Not Like NiceGUI

NiceGUI offers some advanced options to create a Python UI that is fast and can be customized the way you like, for some this will not be OK as they need to use CSS code and customize the UI and if you are not used to this it can be scary in beginning. That&apos;s why they would prefer Streamlit as you just throw the code in there and STreamlit will take care of the rest. But if you want a performant Python app and options to customize it then NiceGUI is better.

## Conclusions

That&apos;s the introduction to NiceGUI and some of the things it has to offer. You can check [NiceGUI documentation](https://nicegui.io/documentation) if you want to understand all the options that NiceGUI has.</content:encoded><category>web-development</category><category>nicegui</category><category>python</category></item><item><title>Railpack vs. Nixpacks: Which Containerization Tool Wins in 2026?</title><link>https://www.bitdoze.com/nixpacks-vs-railpack/</link><guid isPermaLink="true">https://www.bitdoze.com/nixpacks-vs-railpack/</guid><description>See what to choose for Containerization in 2026 between Railpack and Nixpacks.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Deploying an app shouldn’t feel like wrestling a bear after creating the article [Deploying a Python uv Project with Git and Railpack in Dokploy](https://www.bitdoze.com/dokploy-python-railpack-uv/) I decided to do a deeper dive in what both Railpack and Nixpacks have to offer.

In 2026, tools like **Railpack** and **Nixpacks** promise to tame the chaos of building OCI-compliant Docker images from your source code. Both streamline containerization for Node.js, Python, and beyond—but they’re not twins.

Railpack, the shiny new kid from Railway, flexes smaller images and precise control. Nixpacks, the seasoned innovator, bets on zero-config simplicity. So, which should you choose? Let’s break it down with practical insights and examples to guide your decision.

## What Are Railpack and Nixpacks?

### Nixpacks: The Original Innovator

[Nixpacks](https://nixpacks.com/), introduced by Railway in 2022, was built in Rust as an alternative to Buildpacks. It uses the Nix package manager to fetch system and language dependencies, producing a reproducible Docker image with minimal user input. Its strength lies in zero-configuration builds for languages like Node.js and Python, detected via files such as `package.json` or `main.py`.

- **Key Features**: Zero-config builds, Nix-based dependencies, wide language support.
- **Goal**: Simplify deployment with auditable, repeatable builds.

### Railpack: The Next Evolution

[Railpack](https://railpack.com/), launched in beta by Railway on March 4, 2026, replaces Nixpacks with a Go-based solution using BuildKit. It moves away from Nix to address image size and caching limitations, offering smaller, faster, and more customizable builds. Railpack aims to scale Railway’s user base from 1 million to 100 million.

- **Key Features**: Smaller images, BuildKit integration, granular versioning, `railpack.json` customization.
- **Goal**: Optimize performance and control for modern deployments.



## How They Stack Up

### 1. Tech Under the Hood
- **Nixpacks**: Nix bundles everything into a chunky `/nix/store` layer. Reproducible? Yes. Slim? Not so much.
- **Railpack**: BuildKit splits layers smartly, while Mise handles versioning. Result: leaner, meaner builds.

**Takeaway**: Railpack wins on efficiency; Nixpacks on predictability.

### 2. Configuration Style
- **Nixpacks**: Defaults like `npm install` kick in automatically. Tweak it with an optional `nixpacks.toml` if you’re feeling fancy.
- **Railpack**: Demands a `railpack.json` to define `setup`, `install`, and `deploy` steps. Explicit is its middle name.

**Example**:
- Nixpacks (optional): `NIXPACKS_NODE_VERSION=18`
- Railpack:
  ```json
  {
    &quot;setup&quot;: [&quot;node@20.11.1&quot;],
    &quot;install&quot;: [&quot;npm ci&quot;],
    &quot;deploy&quot;: [&quot;npm start&quot;]
  }
  ```

**Takeaway**: Nixpacks for plug-and-play; Railpack for control freaks.

### 3. Size and Speed
- **Nixpacks**: A Node.js app might balloon to 1.3GB. Caching? Meh—Nix’s single layer trips over itself.
- **Railpack**: That same app shrinks to ~450MB (38% less), and Python drops 77%. BuildKit’s sharable layers turbocharge CI/CD.

**Takeaway**: Railpack’s a lightweight sprinter; Nixpacks lumbers behind.

### 4. Version Precision
- **Nixpacks**: Node.js 14–22 or Python 2.7–3.13 via `.node-version`. Patch-level? Good luck.
- **Railpack**: Locks Node.js to 20.11.1 or Python to 3.12.2 with `RAILPACK_NODE_VERSION` or config.

**Takeaway**: Railpack’s your stability buddy; Nixpacks might drift.

### 5. Language Reach
- **Nixpacks**: Mature support for Node.js, Python, Go, PHP, and more—think `npm`, `uv`, `yarn`.
- **Railpack**: Beta covers Node.js, Python, Go, PHP, and static HTML (e.g., Vite). More’s coming.

**Takeaway**: Nixpacks has the edge now; Railpack’s closing fast.




## Pros and Cons

### Nixpacks
- **Pros**:
  - Zero-config ease for Node.js and Python.
  - Mature, stable since 2022.
  - Broad language support.
- **Cons**:
  - Larger images (e.g., 1.2GB for Node.js).
  - Limited caching control.
  - Less precise versioning.

### Railpack
- **Pros**:
  - Smaller images (e.g., 450MB for Node.js).
  - Fine-grained control via `railpack.json`.
  - Enhanced caching and versioning.
- **Cons**:
  - Beta status (March 2026).
  - Requires more configuration.
  - Limited language support (expanding).

## Use Cases

- **Choose Nixpacks If**:
  - You need a fast setup for a simple Node.js or Python app.
  - Larger images are acceptable.
  - You’re on a platform with legacy Nixpacks support.

- **Choose Railpack If**:
  - You prioritize small, fast images for Node.js or Python (e.g., Express or FastHTML).
  - You need precise build control.
  - You’re on Railway and can opt into the beta.


## Why the Shift?

Railway’s move to Railpack (March 2026) addresses Nixpacks’ issues:
- **Image Size**: Nix’s single-layer approach bloated images.
- **Caching**: Limited layer control hurt efficiency.
- **Scalability**: Railpack targets 100 million users with BuildKit.


## Decision Time: Which Fits You?

| **Need**                | **Nixpacks**                       | **Railpack**                     |
|-------------------------|------------------------------------|-----------------------------------|
| **Quick Start**         | ✅ Zero-config magic             | ❌ Config required               |
| **Small Images**        | ❌ 1GB+ bloat                   | ✅ ~450MB for Node.js           |
| **Control**             | ❌ Limited tweaks               | ✅ Granular steps               |
| **Stability**           | ❌ Version drift                | ✅ Patch-level locks            |
| **Maturity**            | ✅ Since 2022                   | ❌ Beta (March 2026)            |

- **Pick Nixpacks** if you’re spinning up a simple Node.js API or Python script and don’t mind heftier images. It’s battle-tested and frictionless.
- **Pick Railpack** if you’re optimizing an Express or FastHTML app for production, crave tiny images, and can handle beta quirks.


## Final Verdict

In 2026, your choice hinges on priorities. **Nixpacks** is the easygoing friend—great for fast Node.js or Python deploys, even if it packs extra weight. **Railpack**, the ambitious upstart, delivers speed, size, and control for production-grade apps—though its beta badge means it’s still finding its feet.

Test both: Nixpacks for a low-stakes spin, Railpack for a peek at the future. As Railpack matures and its community grows, it might just redefine how we containerize. Ready to try? Grab Railpack’s beta on Railway today and see if it’s your deployment game-changer.</content:encoded><category>tools</category><category>railpack</category></item><item><title>OpenClaw Alternatives Worth Trying in 2026</title><link>https://www.bitdoze.com/openclaw-alternatives/</link><guid isPermaLink="true">https://www.bitdoze.com/openclaw-alternatives/</guid><description>A look at NanoClaw, nanobot, memU, bitdoze-bot, PicoClaw, IronClaw, ZeroClaw, NullClaw, and OpenFang as self-hosted alternatives to OpenClaw for running your own 24/7 AI assistant.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

OpenClaw (the project that went through Clawdbot and Moltbot name changes) made a lot of people realize they could run an AI assistant on their own server. Always on, always reachable through Telegram or Slack, and not dependent on anyone&apos;s SaaS. I&apos;ve been running it myself and wrote a [full setup guide](/clawdbot-setup-guide/) if you want to try the original.

But OpenClaw isn&apos;t the only option anymore. Several projects have appeared with different takes on the same idea. Some are smaller and more focused. Some try to do more. I&apos;ve been looking at four of them, and each one makes different tradeoffs worth knowing about.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
Nine self-hosted AI bot projects that work as OpenClaw alternatives. Each section includes what the project does, how it&apos;s different, and how to get it running.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;New additions&quot;&gt;
ZeroClaw was added to this roundup on February 16, 2026. It&apos;s a Rust-based assistant with a 3.4MB binary and under 5MB RAM usage. See section 7 or our [full ZeroClaw setup guide](/zeroclaw-setup-guide/).

NullClaw was added on February 24, 2026. It&apos;s a Zig-based assistant with a 678 KB binary and ~1 MB RAM usage. See section 8 or our [full NullClaw deploy guide](/nullclaw-deploy-guide/).

OpenFang was added on March 2, 2026. It&apos;s a Rust-based Agent OS with autonomous Hands, 40 channels, 16 security layers, and a single ~32MB binary. See section 9 or our [full OpenFang setup guide](/openfang-setup-guide/).

Hermes Agent was added on March 20, 2026. It&apos;s a self-improving Python-based agent from Nous Research with a learning loop, voice mode, and OpenClaw migration. See section 10 or our [full Hermes Agent setup guide](/hermes-agent-setup-guide/).
&lt;/Notice&gt;

## Quick comparison

Before getting into each project, here&apos;s how they stack up:

| Feature | NanoClaw | nanobot | memU | bitdoze-bot | PicoClaw | IronClaw | ZeroClaw | NullClaw | OpenFang | Hermes Agent |
|---|---|---|---|---|---|---|---|---|---|---|
| **GitHub stars** | 9.3k | 21.6k | 9.6k | 10 | 16k | 2.4k | 14.2k | 8.7k | New | New |
| **Language** | TypeScript | Python | Python + Rust | Python | Go | Rust | Rust | Zig | Rust | Python |
| **Codebase size** | ~35k tokens | ~3.5k lines | Larger (framework) | Medium | Small (single binary) | Medium-large | Medium (1,017 tests) | Medium (3,230+ tests) | Large (137K LOC, 1,767+ tests) | Large |
| **License** | MIT | MIT | Apache 2.0 | MIT | MIT | Apache 2.0 / MIT | MIT | MIT | MIT | MIT |
| **Chat channels** | WhatsApp, Telegram, Discord, Slack, Signal | Telegram, Discord, WhatsApp, Slack, Feishu, DingTalk, Email, QQ | Bot at memu.bot | Discord | Telegram, Discord | REPL, HTTP, Telegram, Slack (WASM) | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook | CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Signal, IRC, Line, Lark, QQ, Email, Webhook, and more (17 total) | 40 adapters: Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, Teams, LINE, IRC, and 30 more | 12: Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk, CLI |
| **Memory** | Per-group CLAUDE.md | Built-in | Hierarchical (main feature) | Agno memory + learning | File-based workspace | PostgreSQL + pgvector (hybrid search) | SQLite hybrid (FTS5 + vector cosine) | SQLite hybrid (FTS5 + vector cosine) | SQLite + vector embeddings | MEMORY.md + USER.md + FTS5 session search + Honcho user modeling |
| **Install method** | Claude Code `/setup` | pip (`nanobot-ai`) | pip (`memu-py`) | UV + manual | Single binary / source | cargo build | cargo build / Docker | `zig build` / Docker | `curl` one-liner / cargo build / Docker | `curl` one-liner / pip |
| **Local models** | Claude only (Agent SDK) | vLLM support | Via custom providers | OpenAI-compatible | Via OpenRouter | Via NEAR AI | Ollama + 22 providers | Ollama + 22+ providers | Ollama + vLLM + 27 providers | Ollama + vLLM + any OpenAI-compatible endpoint |
| **Security features** | Container isolation (Docker / Apple Container) | Basic | N/A (framework) | Tool permissions, audit | Basic | WASM sandbox, credential protection, prompt injection defense | Gateway pairing, sandbox, allowlists, encrypted secrets | Gateway pairing, multi-layer sandbox (Landlock, Firejail, Bubblewrap, Docker), encrypted secrets | 16 systems: WASM sandbox, Merkle audit trail, taint tracking, Ed25519 signing, SSRF protection, secret zeroization | Command approval, DM pairing, container isolation (Docker/SSH/Modal), secret redaction |
| **Multi-agent** | Agent Swarms | No | No (memory layer) | Yes (Agno teams) | No | Parallel jobs with isolated workers | No | No | 7 autonomous Hands + 30 agent templates | Subagent delegation + background sessions |

## 1. NanoClaw

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/qwibitai/nanoclaw&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

NanoClaw is a TypeScript-based AI assistant built on Claude&apos;s Agent SDK. The key difference from other bots on this list is real container isolation — agents run inside Docker containers (or Apple Container on macOS), not behind application-level permission checks. It&apos;s also the first personal assistant to support Agent Swarms, where teams of specialized agents collaborate on tasks. We wrote a [full NanoClaw deploy guide](/nanoclaw-deploy-guide/) covering WhatsApp setup, container configuration, skills, and scheduled tasks.

### What it does

- Agents execute inside Linux containers with filesystem isolation
- Supports WhatsApp (default), Telegram, Discord, Slack, Signal via skills
- Agent Swarms for multi-agent collaboration
- Per-group memory and context isolation (each group gets its own CLAUDE.md)
- Skills system where contributors add Claude Code skills instead of features
- Scheduled tasks with per-group context
- Setup and customization through Claude Code commands

### Security approach

NanoClaw&apos;s security model is OS-level rather than application-level:

- **Container isolation**: Every agent runs in its own Docker container with only explicitly mounted directories visible
- **No network by default**: Containers have no network access unless you enable it
- **Process isolation**: Container processes can&apos;t access host processes
- **Non-root execution**: Containers run as non-root user

This puts NanoClaw ahead of projects that rely on allowlists or file path restrictions. The agent physically cannot escape the container.

### Getting started

&lt;Tabs&gt;
&lt;Tab name=&quot;VPS (recommended)&quot;&gt;

```bash
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
# Then type: /setup
```

Claude Code handles dependencies, WhatsApp authentication, container setup, and service configuration.

&lt;/Tab&gt;
&lt;Tab name=&quot;macOS&quot;&gt;

```bash
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
# Then type: /setup
# Optionally: /convert-to-apple-container for lighter-weight native containers
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Who this is for

NanoClaw is the pick if container-level security matters to you, or if you want multi-agent swarms. The Claude Agent SDK gives you Claude Code capabilities in an always-on assistant. The tradeoff is that it only works with Claude (no multi-model support) and the codebase is customized through Claude Code rather than config files. If you need multi-provider support, look at [NullClaw](/nullclaw-deploy-guide/) or [nanobot](/nanobot-setup-guide/) instead.

## 2. nanobot

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/HKUDS/nanobot&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

nanobot comes from HKUDS (Hong Kong University) and has grown fast. 15,400 stars, 2,200 forks. The pitch is similar to NanoClaw but much more ambitious: a lightweight bot (~3,500 lines of code) that connects to basically every chat platform. We wrote a [full nanobot setup guide](/nanobot-setup-guide/) covering MiniMax M2.5, GLM-5, and Brave Search if you want to try it.

### What it does

- Connects to Telegram, Discord, WhatsApp, Slack, Feishu, DingTalk, Email, and QQ
- Installable via pip: `pip install nanobot-ai`
- Built-in memory system
- Supports local models through vLLM
- Works with OpenRouter, Anthropic, OpenAI, DeepSeek, Groq, Gemini, and others
- Docker deployment available

The channel coverage is what sets nanobot apart. If you need your bot on WhatsApp and Discord and Slack at the same time, most alternatives don&apos;t do that without significant extra work.

### Setup

```bash
pip install nanobot-ai
nanobot init
# Follow wizard to configure channels and API keys
nanobot start
```

That&apos;s really it for a basic setup. The `init` wizard walks you through picking a chat platform and connecting an LLM provider. You can add more channels later.

### Local model support

nanobot can connect to a local vLLM server, which means you can run the whole stack without any API costs after the initial hardware investment. If you already have an Ollama or vLLM setup, pointing nanobot at it is straightforward.

```bash
# Example: using a local vLLM endpoint
nanobot config set llm.base_url http://localhost:8000/v1
nanobot config set llm.model your-local-model
```

### Who this is for

nanobot is the pragmatic choice if you need multi-channel support or want something you can install with pip and have running in five minutes. The university backing and large community mean bugs get fixed and features get added regularly. The tradeoff is that with so many integrations, configuration can get dense.

## 3. memU

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/NevaMind-AI/MemU&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

memU is different from the other projects here. It&apos;s not really a chatbot. It&apos;s a memory framework built for 24/7 agents, and it happens to include a bot (at memu.bot) as a reference implementation.

The core idea: most chatbots forget everything between sessions, and even ones with memory just do basic retrieval. memU treats memory like a file system, with categories, items, and cross-references, and it tries to predict what you&apos;re about to need before you ask for it.

### How memory works

memU organizes everything into three layers:

| Layer | What it stores | Purpose |
|---|---|---|
| Resources | Raw conversations, documents, images | Original data |
| Items | Extracted facts, preferences, skills | Searchable knowledge |
| Categories | Auto-organized topics | Navigation and context |

The &quot;file system&quot; metaphor means your agent&apos;s memory looks like this:

```
memory/
├── preferences/
│   ├── communication_style.md
│   └── topic_interests.md
├── knowledge/
│   ├── domain_expertise/
│   └── learned_skills/
└── context/
    ├── recent_conversations/
    └── pending_tasks/
```

New memories get auto-categorized. Related memories link to each other. The system claims 92% accuracy on the Locomo benchmark, which tests how well memory systems retain and retrieve information over long conversations.

### Proactive behavior

This is where memU gets interesting. Instead of just answering when asked, it monitors conversations and tries to anticipate what you&apos;ll need next. The agent can:

- Pre-fetch relevant context before you explicitly ask
- Notice patterns in what you&apos;re working on and surface related memories
- Draft action items from conversation flow
- Learn your preferences over time and adjust responses

Whether this is useful or annoying probably depends on your tolerance for unsolicited suggestions. I can see it working well for someone who uses an AI assistant all day, less so for occasional use.

### Setup

&lt;Tabs&gt;
&lt;Tab name=&quot;Cloud&quot;&gt;

The hosted version at [memu.so](https://memu.so) runs continuously. If you want to try a memory system without self-hosting, this is the fastest path.

&lt;/Tab&gt;
&lt;Tab name=&quot;Self-hosted&quot;&gt;

```bash
pip install memu-py

# In-memory test (no database needed)
export OPENAI_API_KEY=your_key
cd tests
python test_inmemory.py

# With PostgreSQL for persistent storage
docker run -d \
  --name memu-postgres \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=memu \
  -p 5432:5432 \
  pgvector/pgvector:pg16

python test_postgres.py
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

memU also supports OpenRouter, so you can route through whatever model provider you prefer:

```python
from memu import MemoryService

service = MemoryService(
    llm_profiles={
        &quot;default&quot;: {
            &quot;provider&quot;: &quot;openrouter&quot;,
            &quot;base_url&quot;: &quot;https://openrouter.ai&quot;,
            &quot;api_key&quot;: &quot;your_openrouter_api_key&quot;,
            &quot;chat_model&quot;: &quot;anthropic/claude-3.5-sonnet&quot;,
        },
    },
)
```

### Who this is for

memU makes the most sense if you&apos;re building your own agent and want a proper memory layer underneath it. It&apos;s also worth looking at if you&apos;re frustrated with how shallow memory is in other bots. The project has 8,700 stars and an active community. The downside is complexity. This isn&apos;t a &quot;clone and run&quot; bot like NanoClaw. It&apos;s a framework, and you&apos;ll need to integrate it into something.

## 4. bitdoze-bot

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/bitdoze/bitdoze_bot&quot; variant=&quot;solid&quot; color=&quot;red&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

This is my project. I built it because I wanted a Discord bot that could handle multiple specialized agents working together, not just one model answering questions.

bitdoze-bot uses the [Agno framework](/agno-get-start/) for multi-agent orchestration. You define agent &quot;teams&quot; in workspace folders, each agent with its own tools and personality, and a coordinator routes incoming messages to the right specialist.

I wrote a detailed build guide for it: [Build your own AI Discord bot with Agno teams](/create-your-own-ai-agent/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/yoWGFO7tvpc&quot;
  label=&quot;I Built a Personal AI Assistant in 90 Minutes&quot;
/&gt;

### What it does

- Discord-first (responds on mention)
- Multi-agent teams: a coordinator + specialist agents (coding, research, ops, whatever you define)
- Workspace-based config: each agent gets its own folder with instructions, tools, and permissions
- Memory and learning: stores context across conversations and learns from corrections
- Heartbeat + cron: scheduled health checks and recurring tasks
- Tool permissions and audit logging
- Observability: structured logs for every agent run

### The team setup

The multi-agent approach is the main difference from everything else on this list. Instead of one model trying to do everything, you split responsibilities:

```
workspaces/
├── main/
│   └── agent.yaml          # Coordinator - routes to specialists
├── coding/
│   └── agent.yaml          # Coding specialist
├── research/
│   └── agent.yaml          # Web research specialist
└── ops/
    └── agent.yaml          # Server ops specialist
```

When a message comes in, the coordinator decides which specialist handles it. You can start with just the `main` agent and add specialists as you need them.

### Getting started

```bash
git clone https://github.com/bitdoze/bitdoze_bot.git
cd bitdoze_bot
cp .env.example .env
# Edit .env with Discord token and API keys

# Install with UV
uv sync
uv run python main.py
```

You need Python 3.12+, a Discord bot token, and an API key for your model provider.

### Who this is for

If you want to go beyond a single-agent chatbot and experiment with multi-agent coordination, this is the project to look at. The workspace-based configuration makes it easy to add new specialists without touching the core code. The tradeoff is that it&apos;s Discord-only and the smallest project here by star count. But it&apos;s one I use daily, and the multi-agent approach has been worth the extra setup.

## 5. PicoClaw

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/sipeed/picoclaw&quot; variant=&quot;solid&quot; color=&quot;gray&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

PicoClaw takes the opposite approach from everything else on this list. Instead of adding features, it strips them away. The project is a Go rewrite of nanobot that compiles to a single binary, uses less than 10MB of RAM, and boots in under a second. Sipeed (the RISC-V hardware company) built it to run on their $10 LicheeRV-Nano boards, which says a lot about the resource budget they were working with. We wrote a [full PicoClaw setup guide](/picoclaw-setup-guide/) covering MiniMax M2.5, GLM-5, and Discord if you want to try it.

The whole thing was reportedly written in a single day, with the AI agent itself driving most of the Go migration. That sounds like a gimmick, but the result actually works. The binary runs on RISC-V, ARM, and x86 without changes.

### What it does

- Single binary AI assistant, no runtime dependencies
- Telegram and Discord support
- Tool access: shell commands, file operations, web search (via Brave Search API)
- Works with OpenRouter, Zhipu, Anthropic, OpenAI, Gemini, Groq, and DeepSeek
- Workspace-based file storage for memory and logs
- CLI mode for local use, gateway mode for chat channels
- Voice message transcription through Groq&apos;s Whisper

### Resource comparison

The numbers here are hard to ignore:

| Metric | OpenClaw | nanobot | PicoClaw |
|---|---|---|---|
| **RAM** | &amp;gt;1GB | &amp;gt;100MB | under 10MB |
| **Startup (0.8GHz core)** | &amp;gt;500s | &amp;gt;30s | under 1s |
| **Minimum hardware cost** | Mac Mini $599 | ~$50 SBC | ~$10 board |

If you have a NanoKVM or MaixCAM sitting around, PicoClaw can turn it into an always-on assistant. That&apos;s a use case none of the other projects here can touch.

### Getting started

```bash
# Download prebuilt binary from releases, or build from source:
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make build

# Initialize config
picoclaw onboard

# Edit ~/.picoclaw/config.json with your API keys

# Chat directly
picoclaw agent -m &quot;What is 2+2?&quot;

# Or start as gateway for Telegram/Discord
picoclaw gateway
```

### Who this is for

PicoClaw is the pick if you care about resource efficiency above all else, or if you want to run a bot on hardware that would choke on Python. The Go codebase is small and readable, and the single-binary deployment means there&apos;s nothing to install. The tradeoff is that it&apos;s brand new (launched February 2026), so the feature set is more limited than nanobot, and the community is still forming. But 1,100 stars in a few days suggests people are paying attention.

## 6. IronClaw

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/nearai/ironclaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;
&lt;Button text=&quot;ZeroClaw Fork&quot; link=&quot;https://github.com/theonlyhennygod/zeroclaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

IronClaw comes from NEAR AI and takes a security-first approach that goes well beyond what the other projects attempt. It&apos;s a full Rust rewrite of the OpenClaw concept, and the main selling point is the WASM sandbox. Every untrusted tool runs inside an isolated WebAssembly container with explicit capability-based permissions. Your API keys never get exposed to tool code. HTTP requests only go to hosts you&apos;ve approved.

If you&apos;ve ever felt nervous about giving an AI agent shell access on a box with real data on it, IronClaw was built for that anxiety.

### What it does

- Rust-native AI assistant with REPL, HTTP webhook, and WASM-based channels (Telegram, Slack)
- WASM sandbox for all tool execution with capability-based permissions
- Credential protection: secrets get injected at the host boundary, tool code never sees them
- Prompt injection defense with pattern detection and content sanitization
- Endpoint allowlisting so the agent can only reach hosts you approve
- PostgreSQL with pgvector for memory (full-text + vector hybrid search)
- Heartbeat system for proactive background tasks
- Parallel job execution with isolated contexts
- Self-expanding: describe a tool you need, and IronClaw builds it as a WASM module
- MCP protocol support for connecting external tool servers

### The security model

This is where IronClaw stands apart. The security pipeline for tool execution looks like this:

1. Tool request hits the endpoint allowlist validator
2. Request gets scanned for credential leaks
3. Credentials get injected at the host boundary (tool code never holds them)
4. Request executes inside the WASM sandbox
5. Response gets scanned again for credential leaks
6. Result returns to the agent

There are also per-tool rate limits and resource caps (memory, CPU time, execution duration). Policy rules let you set severity levels for different situations: block, warn, review, or sanitize.

No other project on this list has anything close to this level of isolation. NanoClaw has its FileGuard and ShellSandbox, but those are Python-level guards. IronClaw&apos;s WASM containers are a fundamentally different approach.

### Getting started

```bash
git clone https://github.com/nearai/ironclaw.git
cd ironclaw

# Build
cargo build --release

# Set up PostgreSQL with pgvector
createdb ironclaw
psql ironclaw -c &quot;CREATE EXTENSION IF NOT EXISTS vector;&quot;

# Run the setup wizard (handles DB connection, NEAR AI auth, encryption)
ironclaw onboard

# Start as REPL
cargo run
```

You&apos;ll need Rust 1.85+, PostgreSQL 15+ with pgvector, and a NEAR AI account (the setup wizard handles the OAuth flow through your browser).

### Who this is for

IronClaw is for people who want the strongest security guarantees available in this space. The WASM sandbox and credential isolation make it the safest option for running an AI agent with real tool access on a production machine. The Rust codebase gives native performance and memory safety. The downsides: it&apos;s newer (368 stars), requires PostgreSQL infrastructure, and requires a NEAR AI auth requirement that may not sit well with everyone who wants a fully independent self-hosted setup.

## 7. ZeroClaw

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/zeroclaw-labs/zeroclaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

ZeroClaw is a Rust-based assistant that pushes resource efficiency further than PicoClaw. The release binary is 3.4MB, it uses under 5MB of RAM, and it boots in under 10ms. The project comes from zeroclaw-labs and has 22+ built-in providers, 8+ chat channels, and a SQLite memory system with hybrid search (FTS5 keyword + vector cosine similarity). We wrote a [full ZeroClaw setup guide](/zeroclaw-setup-guide/) covering MiniMax M2.5, GLM-5, and Discord if you want to try it.

### What it does

- Single Rust binary, no runtime dependencies beyond the binary itself
- 8+ channels: CLI, Telegram, Discord, Slack, iMessage, Matrix, WhatsApp, Webhook
- 22+ LLM providers built in (OpenRouter, Anthropic, OpenAI, Ollama, Gemini, Groq, Mistral, xAI, DeepSeek, and more)
- SQLite hybrid memory: FTS5 keyword search + vector embeddings with cosine similarity
- Gateway pairing with 6-digit one-time codes and bearer token auth
- Workspace sandboxing, command allowlists, and forbidden path protection
- Encrypted secrets storage (ChaCha20-Poly1305)
- Docker support with distroless production images
- Built-in `zeroclaw migrate openclaw` command for switching from OpenClaw
- TOML configuration instead of JSON

### Security approach

ZeroClaw&apos;s security defaults are stricter than most projects on this list. The gateway binds to `127.0.0.1` and refuses to go public without a tunnel. Empty channel allowlists deny all messages by default (opposite of most bots). There&apos;s a 6-digit pairing code flow before the gateway accepts webhook requests.

```toml
[autonomy]
workspace_only = true
allowed_commands = [&quot;git&quot;, &quot;npm&quot;, &quot;cargo&quot;, &quot;ls&quot;, &quot;cat&quot;, &quot;grep&quot;]
forbidden_paths = [&quot;/etc&quot;, &quot;/root&quot;, &quot;/proc&quot;, &quot;/sys&quot;, &quot;~/.ssh&quot;, &quot;~/.gnupg&quot;, &quot;~/.aws&quot;]

[secrets]
encrypt = true
```

14 system directories and 4 sensitive dotfiles are blocked by default. Symlink escape attempts get caught through path canonicalization.

### Getting started

```bash
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release --locked
cargo install --path . --force --locked

# Interactive setup
zeroclaw onboard --interactive

# Chat
zeroclaw agent -m &quot;Hello!&quot;

# Start all channels
zeroclaw daemon
```

On a Raspberry Pi with 1GB RAM, use `CARGO_BUILD_JOBS=1 cargo build --release` to avoid the kernel killing rustc.

### Who this is for

ZeroClaw is the pick if you want the lowest possible resource footprint with serious security defaults and a wide range of built-in providers. The 22+ provider support means you can point it at nearly any LLM API without custom configuration. The SQLite hybrid memory is more capable than what PicoClaw or nanobot offer. The tradeoff is compile time (Rust builds take a few minutes on a VPS) and a newer, smaller community. If you&apos;re migrating from OpenClaw, the built-in migration command makes the switch straightforward.

## 8. NullClaw

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/nullclaw/nullclaw&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

NullClaw pushes resource efficiency to the absolute limit. It&apos;s a static Zig binary — 678 KB, ~1 MB RAM at runtime, boots in under 2 milliseconds on Apple Silicon. The project ships with 22+ LLM providers, 17 chat channels, hybrid memory (FTS5 + vector), and multi-layer sandboxing. We wrote a [full NullClaw deploy guide](/nullclaw-deploy-guide/) covering provider setup, channels, memory, sandboxing, and edge hardware deployment.

The &quot;null overhead, null compromise&quot; philosophy means zero runtime dependencies beyond libc. Drop the binary on any hardware with a CPU and it runs. That includes $5 ARM boards and RISC-V SBCs.

### What it does

- 678 KB static binary with no runtime dependencies
- 17 channels: CLI, Telegram, Signal, Discord, Slack, WhatsApp, iMessage, Matrix, IRC, Line, Lark, QQ, OneBot, Email, DingTalk, MaixCam, Webhook
- 22+ LLM providers via OpenAI-compatible interface (OpenRouter, Anthropic, OpenAI, Ollama, Groq, Mistral, xAI, DeepSeek, and more)
- SQLite hybrid memory: FTS5 keyword search + vector embeddings with cosine similarity
- Multi-layer sandboxing: Landlock, Firejail, Bubblewrap, Docker (auto-detected)
- Gateway pairing with 6-digit codes and bearer token auth
- Encrypted secrets (ChaCha20-Poly1305)
- MCP server support
- Built-in `nullclaw migrate openclaw` for switching from OpenClaw
- Cross-compilation for ARM, x86, and RISC-V from any platform

### Resource footprint

The numbers speak for themselves:

| Metric | OpenClaw | nanobot | PicoClaw | ZeroClaw | **NullClaw** |
|---|---|---|---|---|---|
| **RAM** | &amp;gt;1GB | &amp;gt;100MB | &amp;lt;10MB | &amp;lt;5MB | **~1 MB** |
| **Startup (0.8 GHz)** | &amp;gt;500s | &amp;gt;30s | &amp;lt;1s | &amp;lt;10ms | **&amp;lt;8 ms** |
| **Binary** | ~28MB | N/A | ~8MB | 3.4MB | **678 KB** |
| **Tests** | — | — | — | 1,017 | **3,230+** |
| **Min hardware** | Mac Mini $599 | ~$50 SBC | ~$10 board | ~$10 | **$5 board** |

### Getting started

```bash
# Install Zig 0.15.2
curl -L https://ziglang.org/download/0.15.2/zig-linux-x86_64-0.15.2.tar.xz | tar -xJ
sudo mv zig-linux-x86_64-0.15.2 /usr/local/zig
sudo ln -s /usr/local/zig/zig /usr/local/bin/zig

# Clone and build
git clone https://github.com/nullclaw/nullclaw.git
cd nullclaw
zig build -Doptimize=ReleaseSmall

# Setup
nullclaw onboard --interactive

# Start
nullclaw daemon
```

For model recommendations, [MiniMax M2.5 and GLM-5](/best-opensource-models-for-openclaw/) work well with NullClaw through OpenRouter or direct API endpoints.

### Who this is for

NullClaw is for anyone who wants the smallest possible footprint with the widest feature set. If you have a Raspberry Pi Zero, a cheap ARM SBC, or any edge device sitting around, NullClaw turns it into a full AI assistant. The 17-channel support and 22+ providers mean you&apos;re unlikely to hit a wall with what it connects to. The tradeoff is that Zig is less familiar than Rust or Python, and the community is newer. But the 3,230+ test suite and active development suggest the project is solid.

## 9. OpenFang

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/RightNow-AI/openfang&quot; variant=&quot;solid&quot; color=&quot;red&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

OpenFang calls itself an &quot;Agent Operating System,&quot; and after running it for two weeks I think that&apos;s fair. Built in Rust (137K lines of code, 14 crates, 1,767+ tests), it compiles to a single ~32MB binary. The thing that got my attention is Hands — autonomous agents that run on schedules, build knowledge graphs, and report to your dashboard without you prompting them. We wrote a [full OpenFang setup guide](/openfang-setup-guide/) covering GLM-5, MiniMax M2.5, Discord, the Hands system, and security configuration.

### What it does

- Single ~32MB Rust binary with 40 channel adapters (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Teams, LINE, IRC, and 31 more)
- 7 autonomous Hands: Clip (video), Lead (sales), Collector (OSINT), Predictor (forecasting), Researcher (deep research), Twitter (social), Browser (web automation)
- 27 LLM providers including MiniMax, Zhipu, Anthropic, OpenAI, Ollama, vLLM, and OpenRouter
- 16 discrete security systems: WASM dual-metered sandbox, Merkle audit trail, taint tracking, Ed25519 signed manifests, SSRF protection
- 53 built-in tools + MCP + Agent-to-Agent (A2A) protocol support
- SQLite + vector embedding memory with canonical sessions and compaction
- Built-in dashboard at localhost:4200 (Tauri 2.0 desktop app also available)
- Migration from OpenClaw: `openfang migrate --from openclaw`

### The Hands system

Hands are what separates OpenFang from chatbots. Each Hand is a pre-built autonomous agent that bundles a HAND.toml manifest, a multi-phase system prompt, domain expertise (SKILL.md), and guardrails. You activate one and it runs on a schedule — the Researcher Hand cross-references sources and generates cited reports, the Lead Hand discovers and scores prospects daily, the Collector Hand monitors targets with change detection and sentiment analysis.

```bash
openfang hand activate researcher
openfang hand status researcher
```

You can also build your own Hands and publish them to FangHub.

### Security approach

OpenFang has the deepest security model of any project on this list. The 16 systems include WASM sandboxing (tool code physically cannot escape), Merkle hash-chain audit trails (tamper with one entry and the chain breaks), information flow taint tracking, Ed25519 signed agent manifests, SSRF protection, and automatic secret zeroization. Every layer operates independently.

### Getting started

```bash
# Install
curl -fsSL https://openfang.sh/install | sh

# Initialize
openfang init

# Start the daemon
openfang start

# Dashboard is live at http://localhost:4200
```

For model recommendations, [GLM-5 and MiniMax M2.5](/best-opensource-models-for-openclaw/) both work well. GLM-5 pairs nicely with the Researcher Hand given its BrowseComp scores.

### Who this is for

OpenFang is the pick if you want autonomous agents that work without prompting, 40 channel adapters, or the most security layers of anything on this list. No other project here runs agents on schedules that build knowledge and report results on their own. The tradeoff is that it&apos;s v0.1.0 (first public release), the binary is larger than ZeroClaw or NullClaw, and breaking changes may happen before v1.0. Pin to a specific commit for production use.

## 10. Hermes Agent

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/NousResearch/hermes-agent&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

Hermes Agent is a self-improving AI assistant from Nous Research. The headline feature is a learning loop: the agent creates skills from complex tasks, improves those skills on subsequent runs, and remembers who you are across sessions through MEMORY.md, USER.md, and FTS5 session search. It ships with a built-in OpenClaw migration tool (`hermes claw migrate`) that imports your persona, memories, skills, and API keys. We wrote a [full Hermes Agent setup guide](/hermes-agent-setup-guide/) covering OpenRouter free tier configuration, messaging platforms, voice mode, and terminal backend isolation.

### What it does

Hermes runs as a single Python process with a TUI for the CLI and a gateway for 12 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, SMS, Email, Home Assistant, Mattermost, Matrix, DingTalk). It has 40+ built-in tools, six terminal backends (local, Docker, SSH, Daytona, Singularity, Modal), voice mode across CLI and messaging, a natural-language cron scheduler, and a SOUL.md personality system with 14 built-in presets.

### Why it&apos;s different

The learning loop is what sets it apart. After a complex task, Hermes extracts the workflow into a reusable skill and refines it on later runs. FTS5 session search lets it recall details from past conversations. Voice mode in Discord voice channels (join, listen, speak) is something none of the other projects on this list offer. And the `hermes claw migrate` command makes switching from OpenClaw a one-line operation.

### Getting started

```bash
# Install
curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
source ~/.bashrc

# Configure with OpenRouter free models
hermes setup

# Start chatting
hermes
```

For model provider options, Hermes supports OpenRouter (200+ models including free tier), Nous Portal, OpenAI, Anthropic, Ollama, and any OpenAI-compatible endpoint.

### Who this is for

Hermes Agent is the pick if you want an assistant that gets better over time through skill creation and session search. Voice mode and terminal backend isolation (Docker, SSH, Modal) are bonuses you won&apos;t find elsewhere. The tradeoff is that it&apos;s Python-based with a larger footprint than the Rust/Go/Zig options, and the community is newer than OpenClaw&apos;s.

## Which one should you pick?

It depends on what you actually need:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Want container-level security with multi-agent swarms? NanoClaw isolates agents in Docker containers and supports Agent Swarms. See the [deploy guide](/nanoclaw-deploy-guide/).&lt;/li&gt;
&lt;li&gt;Need to be on Telegram, Discord, WhatsApp, and Slack at once? nanobot handles that.&lt;/li&gt;
&lt;li&gt;Building your own agent and need a real memory system? memU is the memory framework to look at.&lt;/li&gt;
&lt;li&gt;Want multi-agent teams on Discord? bitdoze-bot does that with Agno.&lt;/li&gt;
&lt;li&gt;Running on extremely limited hardware or want a single Go binary with no dependencies? PicoClaw.&lt;/li&gt;
&lt;li&gt;Need serious security isolation with WASM-sandboxed tools? IronClaw is the hardened option.&lt;/li&gt;
&lt;li&gt;Want ultra-low resource usage (under 5MB RAM) with 22+ providers and SQLite hybrid memory? ZeroClaw.&lt;/li&gt;
&lt;li&gt;Want the absolute smallest binary (678 KB, ~1 MB RAM) with 17 channels and edge hardware support? [NullClaw](/nullclaw-deploy-guide/).&lt;/li&gt;
&lt;li&gt;Want autonomous agents that work on schedules, 40 channels, and 16 security layers? [OpenFang](/openfang-setup-guide/).&lt;/li&gt;
&lt;li&gt;Want a self-improving assistant with voice mode, session search, and built-in OpenClaw migration? [Hermes Agent](/hermes-agent-setup-guide/).&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

All of them run on a basic VPS. A Hetzner CX22 ($5.50/month) is enough for any of them. API costs depend on which model you pick and how much you chat, but $15-50/month covers most people. For model recommendations, see our [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) guide covering MiniMax M2.5 and GLM-5.

If you haven&apos;t tried any self-hosted AI bot yet, I&apos;d actually recommend starting with [OpenClaw itself](/clawdbot-setup-guide/). It&apos;s the most documented, has the largest community, and the setup wizard makes the first run pretty painless. Once you know what you want to change about it, these alternatives start making more sense.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Can I switch from OpenClaw to one of these without losing my conversations?**

Not directly for most projects. Each one stores memory differently. You&apos;d need to export from OpenClaw and manually import, or just start fresh. memU has the most flexible import options since it&apos;s designed as a memory framework. The exception is Hermes Agent, which has a built-in `hermes claw migrate` command that imports your persona, memories, skills, API keys, and platform configs from OpenClaw automatically.

**Do any of these work on a Raspberry Pi?**

NanoClaw and nanobot can run on a Pi 4 with 4GB+ RAM. Performance will be limited. memU with PostgreSQL needs more resources. bitdoze-bot depends on how many agents you run. PicoClaw is the clear winner here, running on boards as cheap as $10 with under 10MB of RAM. ZeroClaw is a close second with under 5MB RAM at runtime, though it needs more RAM during compilation. NullClaw takes it further — the 678 KB binary uses only ~1 MB RAM and runs on $5 boards including Raspberry Pi Zero 2 W. OpenFang&apos;s ~32MB binary and 40MB idle RAM run comfortably on a Pi 4 with remote API providers. IronClaw needs PostgreSQL, so a Pi 4 with 4GB is the minimum.

**Can I use local models instead of API providers?**

nanobot has native vLLM support. NanoClaw only uses Claude&apos;s Agent SDK (no other model support). bitdoze-bot works with any OpenAI-compatible endpoint, so you can point it at Ollama or vLLM. memU supports custom LLM providers. PicoClaw works with OpenRouter and several direct providers. ZeroClaw has a built-in Ollama provider and supports any OpenAI-compatible endpoint via the `custom:` provider. NullClaw supports 22+ providers including Ollama and any OpenAI-compatible endpoint. OpenFang supports 27 providers including Ollama, vLLM, and LM Studio for local models. IronClaw routes through NEAR AI. See our [running OpenClaw with Ollama](/openclaw-ollama-local-models/) guide for hardware tiers, model picks, and full configuration, or the [Ollama Docker guide](/ollama-docker-install/) for the Docker setup.

**How much coding do these require?**

NanoClaw requires Claude Code and is customized through Claude Code commands rather than config files. nanobot and PicoClaw are close to zero-code for basic setups. ZeroClaw is similar, edit a TOML config and the interactive onboard wizard handles the rest. NullClaw uses a JSON config file and an interactive onboard wizard — straightforward once Zig is installed. OpenFang has a `config.toml` file and an `openfang init` wizard — the one-line install script and single binary keep setup quick. Hermes Agent uses `hermes setup` for a guided wizard and stores config in `~/.hermes/config.yaml` — one of the easier setups on this list. bitdoze-bot needs some YAML configuration for agents. memU requires Python integration work since it&apos;s a framework, not a standalone bot. IronClaw requires Rust tooling and PostgreSQL setup, but the onboard wizard handles most of the configuration.

&lt;/Accordion&gt;

If you&apos;re settled on OpenClaw and want a proper UI for it, our [best OpenClaw dashboards](/best-openclaw-dashboards/) guide covers nine community-built options — from full multi-agent orchestration platforms like Mission Control down to lightweight single-file monitors. For getting started with OpenClaw itself, the [setup guide](/clawdbot-setup-guide/) has the full installation walkthrough. And before installing skills from ClawHub, read the [OpenClaw security guide](/openclaw-security-guide/) — 12% of skills were infected in a supply chain attack (CVE-2026-25253). For OpenClaw, Hermes, coding agents, and the rest of the stack in one place, see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category></item><item><title>Text Case Transformations with Sed: Master Advanced Techniques</title><link>https://www.bitdoze.com/sed-change-case/</link><guid isPermaLink="true">https://www.bitdoze.com/sed-change-case/</guid><description>Master sed command for text case transformations - convert text to uppercase, lowercase, and more with practical examples.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Need to convert text to uppercase or lowercase quickly? Sed handles this in one line. Useful for cleaning data, formatting logs, or standardizing config files.

## What Is Sed?

**sed** (Stream Editor) is a command-line tool for text manipulation. It processes input line by line, which makes it fast for large files.

Sed is great for case conversion because:
- Works non-interactively (no prompts)
- Handles large files efficiently
- Can target specific patterns
- Works with pipes and other commands

Other sed guides:
- [Delete lines](https://www.bitdoze.com/sed-delete-lines/)
- [Insert or append text](https://www.bitdoze.com/sed-insert-append-text/)
- [Search and replace](https://www.bitdoze.com/sed-search-replace/)

## When You Need Case Conversion

Common situations:

- **Data cleanup** - Standardize names or fields
- **Log formatting** - Make error levels consistent (ERROR vs error)
- **Config files** - Match required case for certain apps
- **Batch processing** - Convert file extensions or headers

Manual editing is tedious. Sed automates it.

## Basic Case Transformation Commands

sed offers several methods for changing text case. Here are the most effective approaches:

### Method 1: Using the Translate Command (`y`)

**Convert to lowercase:**
```sh
echo &quot;HELLO WORLD&quot; | sed &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos;
# Output: hello world
```

**Convert to uppercase:**
```sh
echo &quot;hello world&quot; | sed &apos;y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/&apos;
# Output: HELLO WORLD
```

### Method 2: Using Substitution with Case Modifiers

**Convert to lowercase (GNU sed):**
```sh
echo &quot;HELLO WORLD&quot; | sed &apos;s/.*/\L&amp;/&apos;
# Output: hello world
```

**Convert to uppercase (GNU sed):**
```sh
echo &quot;hello world&quot; | sed &apos;s/.*/\U&amp;/&apos;
# Output: HELLO WORLD
```

### Method 3: Pattern-Specific Case Changes

**Change specific words:**
```sh
sed &apos;s/linux/LINUX/g&apos; filename.txt
```

**Change words matching a pattern:**
```sh
sed &apos;s/\b[a-z]\+/\U&amp;/g&apos; filename.txt  # Capitalize all words
```

### Quick Reference

| Command | Function |
|---------|----------|
| `y/A-Z/a-z/` | Convert uppercase to lowercase |
| `y/a-z/A-Z/` | Convert lowercase to uppercase |
| `s/.*/\L&amp;/` | Convert entire line to lowercase (GNU sed) |
| `s/.*/\U&amp;/` | Convert entire line to uppercase (GNU sed) |

**Note**: The `\L` and `\U` modifiers work with GNU sed. For broader compatibility, use the `y` command.

## Converting Case in Files

Working with files requires different approaches depending on whether you want to modify the original file or create a new one.

### Convert Entire File to Lowercase

**Using translate command (portable):**
```sh
sed &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; filename.txt &gt; output.txt
```

**Using case modifiers (GNU sed):**
```sh
sed &apos;s/.*/\L&amp;/&apos; filename.txt &gt; output.txt
```

### Convert Entire File to Uppercase

**Using translate command:**
```sh
sed &apos;y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/&apos; filename.txt &gt; output.txt
```

**Using case modifiers:**
```sh
sed &apos;s/.*/\U&amp;/&apos; filename.txt &gt; output.txt
```

### In-Place File Editing

**Modify original file directly:**
```sh
sed -i &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; filename.txt
```

**Create backup before modifying:**
```sh
sed -i.bak &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; filename.txt
```

### Target Specific Patterns

**Convert only lines matching a pattern:**
```sh
sed &apos;/^ERROR/s/.*/\U&amp;/&apos; logfile.txt
```

**Convert specific words:**
```sh
sed &apos;s/\blinux\b/\U&amp;/g&apos; filename.txt
```

### Practical Examples

**Capitalize first letter of each line:**
```sh
sed &apos;s/^./\U&amp;/&apos; filename.txt
```

**Convert file extensions to lowercase:**
```sh
sed &apos;s/\.[A-Z]*$/\L&amp;/&apos; filelist.txt
```

**Safety Tips:**
- Always test commands without `-i` first
- Use `-i.bak` to create backups
- Test on sample files before processing important data

## Batch Processing Multiple Files

Processing multiple files efficiently requires combining sed with shell utilities and loops.

### Using For Loops

**Convert all .txt files to lowercase:**
```sh
for file in *.txt; do
    sed -i &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; &quot;$file&quot;
done
```

**Convert with backup:**
```sh
for file in *.txt; do
    sed -i.bak &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; &quot;$file&quot;
done
```

### Using Find and Xargs

**Process files recursively:**
```sh
find . -name &quot;*.txt&quot; -type f | xargs sed -i &apos;y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/&apos;
```

**Process with null delimiters (handles spaces in filenames):**
```sh
find . -name &quot;*.txt&quot; -type f -print0 | xargs -0 sed -i &apos;y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/&apos;
```

### Using Find with -exec

**More reliable for complex filenames:**
```sh
find . -name &quot;*.txt&quot; -type f -exec sed -i &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; {} \;
```

**Process multiple files at once (faster):**
```sh
find . -name &quot;*.txt&quot; -type f -exec sed -i &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; {} +
```

### Conditional Processing

**Convert case only in files containing specific keywords:**
```sh
grep -l &quot;ERROR&quot; *.log | xargs sed -i &apos;s/error/ERROR/g&apos;
```

**Convert only specific lines:**
```sh
find . -name &quot;*.conf&quot; -exec sed -i &apos;/^#/!s/.*/\L&amp;/&apos; {} \;
```

### Practical Examples

**Convert all PHP files to lowercase:**
```sh
find ./src -name &quot;*.php&quot; -exec sed -i &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; {} +
```

**Convert log levels to uppercase:**
```sh
find ./logs -name &quot;*.log&quot; -exec sed -i &apos;s/\b\(info\|warn\|error\|debug\)\b/\U&amp;/g&apos; {} +
```

### Safety Best Practices

1. **Always backup first:**
   ```sh
   find . -name &quot;*.txt&quot; -exec cp {} {}.backup \;
   ```

2. **Test on sample files:**
   ```sh
   find . -name &quot;sample*.txt&quot; -exec sed &apos;y/A-Z/a-z/&apos; {} \;
   ```

3. **Use version control or create archives before batch operations**

4. **Check results with diff:**
   ```sh
   diff original.txt modified.txt
   ```

## Summary

Sed handles case conversion with two main approaches:
- `y` command - portable, works everywhere
- `\L` and `\U` - GNU sed only, shorter syntax

Key tips:
1. Test without `-i` first
2. Use `-i.bak` when editing in place
3. `find` + `xargs` for batch processing

For complex field processing use `awk`. For simple character translation use `tr`. For Unicode use `perl`.

## Quick Reference Guide

### Common Case Conversion Commands

```sh
# Convert to lowercase (portable)
sed &apos;y/ABCDEFGHIJKLMNOPQRSTUVWXYZ/abcdefghijklmnopqrstuvwxyz/&apos; file.txt

# Convert to uppercase (portable)
sed &apos;y/abcdefghijklmnopqrstuvwxyz/ABCDEFGHIJKLMNOPQRSTUVWXYZ/&apos; file.txt

# Convert to lowercase (GNU sed)
sed &apos;s/.*/\L&amp;/&apos; file.txt

# Convert to uppercase (GNU sed)
sed &apos;s/.*/\U&amp;/&apos; file.txt

# In-place editing with backup
sed -i.bak &apos;y/A-Z/a-z/&apos; file.txt

# Process multiple files
find . -name &quot;*.txt&quot; -exec sed -i &apos;y/A-Z/a-z/&apos; {} +
```

### Quick FAQ

**`y` or `\L`/ `\U`?**
Use `y` for portability. Use `\L`/`\U` on GNU sed if you prefer shorter syntax.

**How to target specific patterns?**
`sed &apos;/pattern/s/.*/\U&amp;/&apos; file.txt` - converts lines containing &quot;pattern&quot; to uppercase.

**Can I undo sed changes?**
No. Use `-i.bak` to create backups, or work on copies.

**Files with spaces in names?**
`find . -name &quot;*.txt&quot; -print0 | xargs -0 sed -i &apos;command&apos;`

**Unicode characters?**
Sed&apos;s `y` only handles ASCII. Use `tr`, `awk`, or `perl` for Unicode.</content:encoded><category>linux</category><category>sed</category></item><item><title>Insert or Append Text with Sed: Master Advanced Techniques</title><link>https://www.bitdoze.com/sed-insert-append-text/</link><guid isPermaLink="true">https://www.bitdoze.com/sed-insert-append-text/</guid><description>Master sed&apos;s insert and append commands to add text precisely at any line position with practical examples and best practices, including GNU vs BSD sed notes.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Need to add text at specific positions in files? `sed` handles inserting and appending text well. Whether you&apos;re adding configuration lines, inserting headers, or appending data, the `i` (insert) and `a` (append) commands are useful for text editing.

&lt;Notice type=&quot;info&quot; title=&quot;GNU sed vs BSD sed (macOS)&quot;&gt;
Most Linux distributions use GNU sed, while macOS ships BSD sed. The main difference for this article is in-place editing.
- GNU sed: `sed -i &apos;...&apos; file`
- BSD sed (macOS): `sed -i &apos;&apos; &apos;...&apos; file` (you need the empty backup extension)
If you want a portable approach that works everywhere, write to a temp file and move it into place (examples included below).
&lt;/Notice&gt;

## What Is sed?

sed (Stream Editor) is a command-line utility for filtering and transforming text in Unix-like systems. It processes files line by line without user interaction, which works well for scripts and batch operations.

Key advantages for text insertion and appending:
- Non-interactive: Works automatically after you run the command
- Precise positioning: Insert or append at specific line numbers or pattern matches
- Fast: Handles large files without loading everything into memory
- Scriptable: Good for automation and repetitive tasks

### Why Use sed for Text Insertion?

- Fast processing, even with large datasets
- Target exact locations using line numbers or patterns
- Works well in scripts that modify multiple files
- Compatible with pipes, redirects, and other Unix tools

Other sed guides for text manipulation:
- [Delete lines](https://www.bitdoze.com/sed-delete-lines/) using the `d` command
- Insert or append text with `i` and `a` commands (this guide)
- [Transform text case](https://www.bitdoze.com/sed-change-case/) for standardization
- [Search and replace text](https://www.bitdoze.com/sed-search-replace/) with pattern matching

## Inserting Text with sed

### Print-only vs in-place editing
By default, sed prints the modified output to stdout and doesn&apos;t change the file. This is good for testing:

```sh
sed &apos;4i Inserted line&apos; example.txt
```

When you&apos;re ready to edit files in-place, see the &quot;Safe in-place editing&quot; section further down (GNU vs BSD differences matter here).

The `i` command inserts text before a specified line. This works for adding headers, comments, or configuration entries at specific locations.

### Basic Insert Syntax

```sh
sed &apos;LINE_NUMBER i TEXT&apos; filename
```

### Insert by Line Number

**Insert before specific line:**
```sh
sed &apos;4i This is the inserted line.&apos; example.txt
```
This inserts text before line 4.

**Insert at beginning of file:**
```sh
sed &apos;1i Header line goes here&apos; filename
```

**Insert multiple lines (portable style):**
```sh
sed &apos;4i\
First inserted line\
Second inserted line\
Third inserted line&apos; filename
```

Notes:
- The newline right after `i\` matters for portability between GNU sed and BSD sed
- Keep the closing quote at the end of the last inserted line

### Insert by Pattern Matching

**Insert before lines matching a pattern:**
```sh
sed &apos;/PATTERN/i TEXT&apos; filename
```

**Practical examples:**
```sh
sed &apos;/function main/i # Main function starts here&apos; script.py
sed &apos;/^server {/i # Nginx server configuration&apos; nginx.conf
sed &apos;/export PATH/i # Adding to PATH variable&apos; .bashrc
```

### Advanced Insert Examples

**Insert with variables (quote safely):**
```sh
header=&quot;Generated on $(date)&quot;
sed &quot;1i\\
$header&quot; datafile.txt
```

Why: if your variable contains characters that sed treats specially (leading dashes, backslashes, etc.), using the `i\` + newline style tends to behave more consistently across platforms.

**Insert configuration blocks:**
```sh
sed &apos;/^# Database settings/i \
# Redis configuration\
redis.host=localhost\
redis.port=6379&apos; config.ini
```

**Insert after finding specific content:**
```sh
sed &apos;/TODO:/i # FIXME: Review this section&apos; code.py
```

### Key Points

- Text is inserted before the specified line or pattern
- Original line numbers shift down after insertion
- Use `\` at line end for multi-line insertions
- sed displays output to stdout by default (use `-i` for in-place editing)

## Appending Text with sed

The `a` command appends text after a specified line. This is useful for adding footers, closing tags, or data following specific content.

### Basic Append Syntax

```sh
sed &apos;LINE_NUMBER a TEXT&apos; filename
```

### Append by Line Number

**Append after specific line:**
```sh
sed &apos;2a Don&apos;\&apos;&apos;t forget to subscribe!&apos; filename
```
This appends text after line 2.

**Append at end of file:**
```sh
sed &apos;$a Footer text goes here&apos; filename
```

**Append multiple lines (portable style):**
```sh
sed &apos;4a\
First appended line\
Second appended line\
Third appended line&apos; filename
```

Note: just like `i`, the newline after `a\` is the portable multi-line form.

### Append by Pattern Matching

**Append after lines matching a pattern:**
```sh
sed &apos;/PATTERN/a TEXT&apos; filename
```

**Practical examples:**
```sh
sed &apos;/^}$/a # End of function block&apos; script.js
sed &apos;/^server {/a     # Server configuration continues&apos; nginx.conf
sed &apos;/export PATH/a # PATH modified above&apos; .bashrc
```

### Advanced Append Examples

**Append configuration sections:**
```sh
sed &apos;/^# Database settings/a \
host=localhost\
port=5432\
database=myapp&apos; config.ini
```

**Append with variables:**
```sh
timestamp=&quot;Last modified: $(date)&quot;
sed &quot;\$a $timestamp&quot; datafile.txt
```

**Append after specific markers:**
```sh
sed &apos;/&lt;!-- INSERT HERE --&gt;/a &lt;div&gt;New content&lt;/div&gt;&apos; template.html
```

### Append vs Insert Comparison

| Command | Position | Example |
|---------|----------|---------|
| `i` | Before line/pattern | `sed &apos;5i text&apos;` → inserts before line 5 |
| `a` | After line/pattern | `sed &apos;5a text&apos;` → appends after line 5 |

### Key Points

- Text is appended after the specified line or pattern
- Use `$` to append at end of file
- Use `\` at line end for multi-line appends
- Original line numbers remain unchanged (new lines added below)

## Targeting Specific Positions

sed&apos;s strength is targeting where text should be inserted or appended using different addressing methods.

### Position-Based Targeting

**Line numbers:**
```sh
sed &apos;1i Header text&apos; filename        # Insert at beginning
sed &apos;5a Middle text&apos; filename        # Append after line 5
sed &apos;$a Footer text&apos; filename        # Append at end
```

**Line ranges:**
```sh
sed &apos;10,15i # Section start&apos; filename    # Insert before lines 10-15
sed &apos;20,$a # End section&apos; filename       # Append after line 20 to end
```

### Pattern-Based Targeting

**Simple patterns:**
```sh
sed &apos;/TODO/i # FIXME: Address this&apos; filename
sed &apos;/^function/a # Function definition ends&apos; filename
sed &apos;/^#/a # Comment continues&apos; filename
```

**Complex patterns with regular expressions:**
```sh
sed &apos;/^[0-9]/i # Numbered item:&apos; filename         # Before lines starting with digits
sed &apos;/\.log$/a # Log entry processed&apos; filename    # After lines ending with .log
sed &apos;/^[[:space:]]*$/i # Empty line above&apos; filename  # Before blank lines
```

### Practical Use Cases

**Configuration files:**
```sh
# Add database configuration
sed &apos;/^# Database/a \
host=localhost\
port=5432\
user=admin&apos; config.ini

# Insert security headers
sed &apos;/^server {/a \
    add_header X-Frame-Options SAMEORIGIN;\
    add_header X-Content-Type-Options nosniff;&apos; nginx.conf
```

**Code files:**
```sh
# Add function documentation
sed &apos;/^def /i # Function: Performs calculation&apos; script.py

# Insert debugging statements
sed &apos;/^if /a print(&quot;Debug: Condition checked&quot;)&apos; debug.py
```

**Data processing:**
```sh
# Add CSV headers
sed &apos;1i Name,Age,Email&apos; data.csv

# Insert separators
sed &apos;/^---/i # Section divider&apos; document.txt
```

### Advanced Targeting Techniques

**Conditional insertion (block form):**
```sh
# Insert only if pattern exists
sed &apos;/config_section/{
i\
# Configuration starts here
}&apos; filename
```

Tip: prefer the `i\` + newline form inside blocks for consistent behavior between GNU sed and BSD sed.

**Multiple operations (insert + append around the same match):**
```sh
sed &apos;/important_line/{
i\
# Important section begins
a\
# Important section ends
}&apos; filename
```

**Using variables for dynamic content:**
```sh
section_name=&quot;Database Configuration&quot;
sed &quot;/^# $section_name/a host=localhost&quot; config.ini
```

### Safety and Testing

**Preview changes (recommended first step):**
```sh
sed &apos;/pattern/i\
NEW TEXT&apos; filename
sed &apos;/pattern/i\
NEW TEXT&apos; filename | head
```

**Show line numbers for context:**
```sh
nl -ba filename | sed &apos;/pattern/i\
NEW TEXT&apos;
```

### Safe in-place editing (GNU vs BSD sed)

**GNU sed (most Linux):**
```sh
sed -i.bak &apos;/pattern/i\
NEW TEXT&apos; filename
```

**BSD sed (macOS):**
```sh
sed -i &apos;.bak&apos; &apos;/pattern/i\
NEW TEXT&apos; filename
```

Both variants create `filename.bak` so you can revert if needed.

**Portable approach (works everywhere, avoids `-i` differences):**
```sh
tmp=&quot;$(mktemp)&quot;
sed &apos;/pattern/i\
NEW TEXT&apos; filename &gt; &quot;$tmp&quot; &amp;&amp; mv &quot;$tmp&quot; filename
```

## Advanced Insert and Append Techniques

Once you know the basics, sed&apos;s features help handle more complex scenarios.

### Conditional Text Addition

**Insert only when specific conditions are met:**
```sh
# Insert warning before error lines
sed &apos;/ERROR/i *** WARNING: Critical error detected ***&apos; logfile.txt

# Append configuration after specific sections
sed &apos;/^# Network settings/a \
interface=eth0\
dhcp=true&apos; config.txt
```

**Multiple condition matching:**
```sh
# Insert before lines starting with specific patterns
sed &apos;/^[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}/i Date entry:&apos; dates.txt

# Append after lines containing both keywords
sed &apos;/database.*config/a # Database configuration processed&apos; app.conf
```

### Multi-Line Text Blocks

**Insert complex configuration blocks:**
```sh
sed &apos;/^# SSL Configuration/i \
# SSL Certificate Setup\
ssl_certificate /path/to/cert.pem;\
ssl_certificate_key /path/to/key.pem;\
ssl_protocols TLSv1.2 TLSv1.3;&apos; nginx.conf
```

**Append structured data:**
```sh
sed &apos;/^{$/a \
    &quot;name&quot;: &quot;default&quot;,\
    &quot;version&quot;: &quot;1.0&quot;,\
    &quot;active&quot;: true&apos; config.json
```

### Dynamic Content Insertion

**Using variables and command substitution:**
```sh
# Insert timestamp
current_time=$(date)
sed &quot;1i Generated on: $current_time&quot; report.txt

# Append system information
sed &quot;\$a System: $(uname -s), User: $(whoami)&quot; logfile.txt
```

**Insert file contents:**
```sh
# Insert entire file content
sed &apos;/INCLUDE_POINT/r include.txt&apos; main.txt

# Combine with text insertion
sed &apos;/HEADER/{ i # Configuration file begins
r config_template.txt
a # Configuration file ends
}&apos; main.conf
```

### Pattern Range Operations

**Insert/append within specific ranges:**
```sh
# Insert text before each line in a range
sed &apos;10,20i # Line in middle section&apos; filename

# Append after pattern range
sed &apos;/START/,/END/a # Block processed&apos; filename
```

### Advanced Scripting Techniques

**Multiple operations in sequence:**
```sh
sed -e &apos;/function/i # Function definition&apos; \
    -e &apos;/function/a # Function body starts&apos; \
    -e &apos;/return/i # Function ends&apos; \
    -e &apos;/return/a # Return statement processed&apos; script.py
```

**Using sed scripts for complex operations:**
Create `modify.sed` (portable multi-line insert/append style):
```
/^# Database/i\
# Database Configuration Section
/^# Database/a\
host=localhost
/^# Database/a\
port=5432
/^# Security/i\
# Security Settings Section
/^# Security/a\
enable_ssl=true
```

Why: the `i\`/`a\` + newline style avoids edge cases that vary between sed implementations.

Run with:
```sh
sed -f modify.sed config.ini
```

### Practical Advanced Examples

**Log file processing:**
```sh
now=&quot;$(date)&quot;
sed -e &apos;/ERROR/i\
=== ERROR DETECTED ===&apos; \
    -e &quot;/ERROR/a\
Timestamp: $now&quot; \
    -e &apos;/WARN/i\
--- Warning ---&apos; application.log
```

**HTML/XML processing:**
```sh
# Insert DOCTYPE and meta tags
sed -e &apos;1i &lt;!DOCTYPE html&gt;&apos; \
    -e &apos;/&lt;head&gt;/a &lt;meta charset=&quot;UTF-8&quot;&gt;&apos; \
    -e &apos;/&lt;head&gt;/a &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;&apos; index.html
```

**Code documentation:**
```sh
# Add function documentation
sed &apos;/^def /i \
# Function documentation\
# TODO: Add parameter descriptions\
# TODO: Add return value description&apos; script.py
```

### Performance and Safety

**Test complex operations step by step:**
```sh
# Test first operation
sed &apos;/pattern/i TEXT1&apos; file.txt | head -10

# Add second operation
sed -e &apos;/pattern/i TEXT1&apos; -e &apos;/other/a TEXT2&apos; file.txt | head -10
```

**Use intermediate files for complex workflows:**
```sh
sed &apos;/pattern/i TEXT&apos; original.txt &gt; temp1.txt
sed &apos;/other/a MORE_TEXT&apos; temp1.txt &gt; temp2.txt
sed &apos;/final/i FINAL_TEXT&apos; temp2.txt &gt; result.txt
```

**Backup strategy for advanced operations:**
```sh
cp original.txt original.txt.$(date +%Y%m%d_%H%M%S)
sed -i -f complex_script.sed original.txt
```

### Regular Expression Integration

**Advanced pattern matching:**
```sh
# Insert before lines matching complex patterns
sed &apos;/^[A-Z][a-z]*[0-9]\{2,4\}/i # Code identifier found&apos; data.txt

# Append after email patterns
sed &apos;/[a-zA-Z0-9._%+-]\+@[a-zA-Z0-9.-]\+\.[a-zA-Z]\{2,\}/a # Email processed&apos; contacts.txt
```

**Pro tip**: For very complex text manipulation, consider combining sed with other tools like awk, or writing dedicated scripts for better maintainability.

## Conclusion

Learning sed&apos;s insert and append commands helps you edit text files efficiently. You now know how to:

- Insert text before specific lines or patterns using the `i` command
- Append text after target locations using the `a` command
- Handle complex scenarios with pattern matching and multi-line text
- Modify files safely with testing and backups

### Key Points

1. Use `i` to insert text before lines/patterns
2. Use `a` to append text after lines/patterns
3. Test commands first without the `-i` flag
4. Create backups when editing files in-place
5. Use patterns for flexible text placement

### Best Practices

- Preview changes before applying permanently
- Use backups with `-i.bak` for safety
- Test on samples before processing important files
- Escape special characters properly in patterns
- Document complex commands for future reference

### When to Use Each Command

| Scenario | Command | Example |
|----------|---------|---------|
| Add header/title | `i` | `sed &apos;1i # File Header&apos; file.txt` |
| Add footer/signature | `a` | `sed &apos;$a # End of file&apos; file.txt` |
| Insert before errors | `i` | `sed &apos;/ERROR/i *** ALERT ***&apos; log.txt` |
| Append after config | `a` | `sed &apos;/^server/a port=8080&apos; config.txt` |

### More sed Commands

Expand your sed expertise with related techniques:
- [Delete lines](https://www.bitdoze.com/sed-delete-lines/) - Remove unwanted content
- [Transform text case](https://www.bitdoze.com/sed-change-case/) - Standardize capitalization
- [Search and replace](https://www.bitdoze.com/sed-search-replace/) - Pattern-based substitution

With these insert and append techniques, you can add content to files precisely, automate file modifications, and maintain text files across Unix-like systems.

## Quick Reference Guide

### Common Insert Commands
```sh
# Insert at specific positions
sed &apos;1i Header text&apos; file.txt        # Before first line
sed &apos;5i Middle text&apos; file.txt        # Before line 5
sed &apos;/pattern/i Before text&apos; file.txt # Before matching lines

# Multi-line insert
sed &apos;1i\
Line 1\
Line 2\
Line 3&apos; file.txt
```

### Common Append Commands
```sh
# Append at specific positions
sed &apos;5a After text&apos; file.txt         # After line 5
sed &apos;$a Footer text&apos; file.txt        # After last line
sed &apos;/pattern/a After text&apos; file.txt # After matching lines

# Multi-line append
sed &apos;$a\
Footer line 1\
Footer line 2\
Footer line 3&apos; file.txt
```

### Frequently Asked Questions

**Q: What&apos;s the difference between insert and append?**
A: Insert (`i`) adds text before the target line/pattern. Append (`a`) adds text after the target line/pattern.

**Q: How do I add multi-line text?**
A: Use backslashes at the end of each line: `sed &apos;1i\
Line 1\
Line 2&apos; file.txt`

**Q: Can I insert variables in the text?**
A: Yes, use double quotes: `sed &quot;1i Current date: $(date)&quot; file.txt`

**Q: How do I make changes permanent?**
A: Use in-place editing with a backup.
- GNU sed (Linux): `sed -i.bak &apos;1i\&apos;$&apos;\n&apos;&apos;Header&apos; file.txt` (or use the portable multi-line style shown above)
- BSD sed (macOS): `sed -i &apos;.bak&apos; &apos;1i\&apos;$&apos;\n&apos;&apos;Header&apos; file.txt`

If you don&apos;t want to deal with `-i` differences, use the portable temp-file approach from the &quot;Safe in-place editing&quot; section.

**Q: What happens to line numbers after insertion?**
A: Insert: Line numbers shift down. Append: Line numbers stay the same, new lines added below.

**Q: Can I insert/append to multiple files at once?**
A: Yes: `sed -i &apos;1i Header&apos; *.txt` or use find: `find . -name &quot;*.txt&quot; -exec sed -i &apos;1i Header&apos; {} \;`</content:encoded><category>linux</category><category>sed</category></item><item><title>Streamlit vs. NiceGUI: Choose the Best Python Web Framework</title><link>https://www.bitdoze.com/streamlit-vs-nicegui/</link><guid isPermaLink="true">https://www.bitdoze.com/streamlit-vs-nicegui/</guid><description>Compare Streamlit vs NiceGUI for Python web apps: architecture, customization, real-time behavior, and which framework fits your use case.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Two Python frameworks make building web apps straightforward: [Streamlit](https://streamlit.io/) and [NiceGUI](https://nicegui.io/). Both let you create interactive web apps with mostly Python code, but they work better for different use cases.

[Streamlit](https://streamlit.io/) targets data applications: dashboards, data exploration, and ML demos. You write a Python script and Streamlit renders it as a web app with built-in components for charts and tables.

[NiceGUI](https://nicegui.io/) works better for application-style UIs like forms, admin panels, and monitoring tools. It&apos;s built on FastAPI with a web UI layer, giving you more control over events, state, and backend features.

## Quick Comparison Overview

| Feature | Streamlit | NiceGUI |
|---------|-----------|---------|
| Best For | Data apps, dashboards | General web apps, desktop-like UIs |
| Learning Curve | Very easy | Easy |
| Customization | Limited | High |
| Backend | Built-in | FastAPI |
| Real-time Updates | Automatic reruns | Event-driven |

&gt; For more Python web frameworks, see: [Best Python Web Frameworks](https://www.bitdoze.com/best-python-web-frameworks/)

## Key Differences Between Streamlit and NiceGUI

### 1. Primary Purpose
- Streamlit: Built specifically for data science, ML, and analytics dashboards
- NiceGUI: Designed for general-purpose web applications with desktop-like interfaces

### 2. Development Approach
- Streamlit: Script-based - runs from top to bottom on each interaction
- NiceGUI: Event-driven - responds to specific user actions

### 3. Customization Level
- Streamlit: Limited customization, focuses on rapid prototyping
- NiceGUI: Highly customizable with direct access to HTML/CSS/JS when needed

### 4. Backend Architecture
- Streamlit: Built-in server runtime with session/state patterns
- NiceGUI: FastAPI-based backend with more explicit control over routing and integration

### 5. Real-time Features
- Streamlit: Interaction triggers a script rerun; you manage state/caching to keep it responsive
- NiceGUI: Event-driven updates; good fit for live UI patterns (monitoring/control panels)

### 6. Learning Curve
- Streamlit: Extremely beginner-friendly, no web dev knowledge needed
- NiceGUI: Slightly steeper but still accessible, benefits from web dev basics

### 7. Community &amp; Resources
- Streamlit: Large community, extensive documentation, Streamlit Cloud hosting
- NiceGUI: Growing community, good documentation, self-hosting focused

### 8. License &amp; Cost
- Streamlit: Apache 2.0, free with Streamlit Community Cloud (with limitations)
- NiceGUI: MIT License, completely free and open source

## When to Choose Each Framework

### Choose Streamlit if you need:
- Data dashboards with charts, tables, and visualizations
- ML model interfaces for demos and prototypes
- Rapid development with minimal coding
- Built-in data handling (CSV, JSON, databases)
- Easy deployment on Streamlit Cloud

Example use cases:
- Sales analytics dashboard
- Machine learning model demo
- Financial data explorer
- Research data visualization

### Choose NiceGUI if you need:
- Desktop-like applications in the browser
- Real-time interactions without page reloads
- Custom UI components and layouts
- Advanced backend features (authentication, APIs)
- Full control over user experience

Example use cases:
- Project management tools
- IoT device controllers
- Interactive forms and surveys
- Real-time monitoring systems

## Getting Started: Code Examples

Both frameworks are beginner-friendly, but Streamlit has a slight edge for absolute beginners.

### Streamlit Example: Data Dashboard

```python
import streamlit as st
import pandas as pd

st.title(&apos;Sales Dashboard&apos;)

data = pd.DataFrame({
    &apos;Month&apos;: [&apos;Jan&apos;, &apos;Feb&apos;, &apos;Mar&apos;, &apos;Apr&apos;],
    &apos;Sales&apos;: [100, 150, 120, 200],
})

month_filter = st.selectbox(&apos;Select Month&apos;, data[&apos;Month&apos;])
filtered = data[data[&apos;Month&apos;] == month_filter]

st.bar_chart(data.set_index(&apos;Month&apos;))
st.write(f&apos;Sales for {month_filter}: ${int(filtered[&quot;Sales&quot;].iloc[0])}&apos;)
```

### NiceGUI Example: Interactive Form

```python
from nicegui import ui

def handle_submit():
    ui.notify(f&apos;Hello {name.value}! You are {age.value} years old.&apos;)

ui.label(&apos;User Information Form&apos;)

with ui.row():
    name = ui.input(&apos;Name&apos;, placeholder=&apos;Enter your name&apos;)
    age = ui.number(&apos;Age&apos;, value=25, min=0, max=120)

ui.button(&apos;Submit&apos;, on_click=handle_submit)

ui.run()
```

### Key Differences in the Code

- Streamlit: Linear, script-like flow - perfect for data workflows
- NiceGUI: Component-based with explicit event handling - better for interactive apps

## NiceGUI Advantages

### Real-Time Interactions
- Event/callback-driven UI updates
- Good fit for monitoring, controls, and app-like UIs

### FastAPI Integration
- You can use FastAPI concepts (routing, dependencies, middleware) as your app grows
- Useful when you need APIs alongside the UI, or want more control over auth and request handling

Note: Built-in authentication and user management isn&apos;t automatic in FastAPI itself. You typically implement auth using FastAPI patterns and libraries, then integrate it with your app.

### Advanced Customization
- More layout and component control than Streamlit
- Can integrate with web concepts when needed (styling and custom behavior)

### Development Experience
- Good local dev workflow and straightforward self-hosting
- Works well in containers when you need repeatable deployment

### Flexibility
- Natural fit for event-driven apps
- Can integrate with existing Python services and libraries

## Streamlit Advantages

### Data Apps First
- Excellent defaults for pandas DataFrames, charts, and common analytics workflows
- Great for ML demos and internal dashboards

### Ultra-Simple Development Model
- Very low web framework overhead: write Python top-to-bottom
- Easy to iterate quickly and share prototypes with teammates

### Deployment Options
- Streamlit Community Cloud is convenient for quick publishing
- Also deployable to your own infrastructure (VPS, containers, etc.)

### Data-Focused Widgets
- Strong widget set for filters, inputs, and interactive exploration
- Built-in caching and session state patterns help keep apps responsive

### Strong Ecosystem
- Large community, lots of examples and integrations

## Making Your Choice: Decision Guide

### Choose Streamlit if:
- You&apos;re building data dashboards or ML demos
- You want rapid prototyping with minimal code
- You&apos;re new to web development
- You need built-in data visualization
- You want easy deployment and sharing

### Choose NiceGUI if:
- You need real-time interactions without page reloads
- You&apos;re building general-purpose web apps
- You want desktop-like UI in the browser
- You need advanced backend features
- You want full customization control

## Quick Start Resources

Streamlit:
- [Official Documentation](https://docs.streamlit.io/)
- [30 Days of Streamlit Challenge](https://30days.streamlit.app/)
- [Deploy Streamlit on VPS](https://www.bitdoze.com/streamlit-deploy-vps-cloudflare/)

NiceGUI:
- [Official Documentation](https://nicegui.io/)
- [NiceGUI for Beginners](https://www.bitdoze.com/nicegui-get-started/)
- [GitHub Examples](https://github.com/zauberzeug/nicegui/tree/main/examples)

## What&apos;s New in 2026

Both frameworks have evolved since this comparison was first written:

**Streamlit** has continued to refine its data app workflow. Recent releases improved multi-page app support, added better theming controls, and enhanced performance for large DataFrames. Streamlit Community Cloud remains the easiest way to deploy a data app.

**NiceGUI** shipped its 2.0 release with updated dependencies and has been steadily adding features — better TypeScript support, improved Tailwind CSS integration, and more native components. The community grew significantly through 2025, and self-hosting remains the primary deployment model.

**New alternatives worth watching:**
- **Mesop** — Google&apos;s Python UI framework that compiles to web components. Similar idea to Streamlit but with a different rendering model.
- **Reflex** (formerly Pynecone) — full-stack Python framework that compiles to Next.js. More like building a traditional web app, just in Python.
- **Gradio 5** — refreshed UI with better customization, competing more directly with Streamlit for ML demos.

The landscape is more crowded now, but Streamlit and NiceGUI still hold their positions well — Streamlit for data, NiceGUI for applications.

## Final Verdict

Both frameworks excel in their domains. Streamlit dominates data science applications with its simplicity and built-in data tools. NiceGUI shines for interactive web applications that need desktop-like functionality.

Pick the framework that matches your project requirements, not which one is objectively &quot;better.&quot; Start with what fits your immediate needs - you can explore the other later as your projects evolve.</content:encoded><category>web-development</category><category>streamlit</category><category>nicegui</category><category>python</category></item><item><title>How to Use Claude Sonnet 4.6, Opus 4.7, GPT-5.5, Gemini 3 for FREE</title><link>https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/</link><guid isPermaLink="true">https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/</guid><description>Discover 5 legitimate ways to access Claude Sonnet 4.6, GPT-5.5, Gemini 3, and Opus 4.7 for free. Get free API credits, tokens, and premium AI access for development.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>Want to use Claude Sonnet 4.6, GPT-5.5, Gemini 3, or Opus 4.7 without paying? Here are five ways to access these AI models for free. I use these tools daily for coding, automation, and content creation.

&lt;Notice type=&quot;success&quot; title=&quot;What You&apos;ll Get&quot;&gt;

- **$200 in free API credits** to test premium models
- **20 million free tokens first month** for coding and automation
- **25 free prompts per month** in an AI IDE
- **Google Antigravity** with free Gemini 3 Pro, Claude Opus 4.7, and multi-model access
- **GitHub Copilot 30-day free trial** with ongoing free tier

&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/JoeInjyhMo8&quot;
  label=&quot;How to Use Claude Sonnet 4.6 and GPT-5 for FREE&quot;
/&gt;

## Why Use Claude Sonnet 4.6, GPT-5.5, and Gemini 3?

&lt;ListCheck&gt;

- **Claude Sonnet 4.6**: Best coding model in the world with strong performance on SWE-bench Verified, capable of maintaining focus for 30+ hours on complex tasks
- **Claude Opus 4.7**: Anthropic&apos;s most powerful model for complex reasoning and agentic tasks
- **GPT-5.5**: OpenAI&apos;s latest flagship model with enhanced reasoning, extended thinking capabilities, and superior performance across multiple domains
- **Gemini 3**: Google&apos;s most intelligent model representing a step-change for agentic coding
- **Premium Performance**: All models excel at coding, reasoning, content creation, and agentic tasks
- **Production Ready**: Proven reliability for professional development and business applications

&lt;/ListCheck&gt;

Claude Sonnet 4.6 costs $3 per million input tokens and $15 per million output tokens, while GPT-5.5 costs $2.50/$10 per million tokens. These free alternatives help avoid those costs.

## Method 1: Droid CLI - 20 Million Free Tokens First Month

**[Droid CLI by Factory AI](https://go.bitdoze.com/droid-cli)** offers 20 million free tokens for the first month.

### What is Droid CLI?

Droid CLI is a command-line interface that provides access to multiple AI models including Claude Sonnet 4.6, GPT-5, Claude Opus 4.7, and more. It&apos;s designed for developers who prefer working in the terminal or need to integrate AI into their workflows.

### Key Features

&lt;ListCheck&gt;

- **20 Million Free Tokens**: First month allowance for testing and development
- **Multiple Model Support**: Access Claude Sonnet 4.6, Claude Opus 4.7, GPT-5, and more
- **CLI Integration**: Works in your terminal
- **IDE Support**: Integrates with development environments
- **Reasoning Mode**: Extended thinking for complex problem-solving
- **Real-Time Usage Tracking**: Monitor your token consumption

&lt;/ListCheck&gt;

### How to Get Started with Droid CLI

**Step 1: Sign Up and Install**

```bash
# Visit the website and create an account
# https://go.bitdoze.com/droid-cli

# Install the CLI tool (instructions provided after signup)
curl -fsSL https://app.factory.ai/cli | sh
```

**Step 2: Launch and Configure**

```bash
# Start Droid CLI
droid

# The interface will prompt you to choose your model
```

**Step 3: Select Your Model**

When you launch Droid, you&apos;ll see options to choose between:

- Claude Sonnet 4.6
- Claude Opus 4.7
- GPT-5
- Other supported models

**Step 4: Enable Reasoning (Optional)**

For complex tasks requiring deep thinking, you can enable reasoning mode which allows the model to think through problems step-by-step before responding.

### Real-World Usage Example

Here&apos;s how I use Droid CLI in my daily workflow:

```bash
# Launch Droid
droid

# Select Claude Sonnet 4.6 or Opus 4.7
# Enable reasoning mode for complex tasks

# Example prompt
&quot;Create a Python web scraper that extracts product prices from e-commerce sites,
handles pagination, and exports data to CSV with error handling&quot;
```

The model will process your request and provide comprehensive code with explanations. Each interaction consumes tokens from your 20 million first month allowance.

### Monitoring Your Usage

&lt;Notice type=&quot;info&quot; title=&quot;Token Tracking&quot;&gt;

Droid CLI shows usage statistics. Refresh your dashboard to see how many tokens you&apos;ve consumed. A typical complex prompt uses 20,000-30,000 tokens, leaving room for development work.

&lt;/Notice&gt;

### Best Use Cases for Droid CLI

- **Backend Development**: API creation, database design, server configuration
- **Automation Scripts**: Task automation, data processing, workflow optimization
- **Code Review**: Analyzing and improving existing codebases
- **Learning**: Exploring new programming concepts and frameworks
- **Prototyping**: Rapid development of proof-of-concept applications

&lt;Button
  text=&quot;Get 20M Free Tokens with Droid CLI&quot;
  url=&quot;https://go.bitdoze.com/droid-cli&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## Method 2: Windsurf IDE - 25 Free Prompts Monthly

**[Windsurf](https://go.bitdoze.com/windsurf)** is an AI-integrated development environment that offers 25 free prompts per month with access to premium models including Claude Sonnet 4.6 and GPT-5.

### What Makes Windsurf Special?

Windsurf is a development environment with AI integrated into the workflow. Unlike traditional IDEs with AI plugins, Windsurf is built with AI assistance as a core feature.

### Key Features

&lt;ListCheck&gt;

- **25 Free Prompts Monthly**: Allowance for regular development work
- **Premium Model Access**: Claude Sonnet 4.6, GPT-5, and their Supernova model
- **Dual Modes**: Chat mode for questions and Code mode for direct file editing
- **Cascade Projects**: AI can plan and execute multi-file projects
- **Free Supernova Access**: Unlimited access to their proprietary model
- **Credit System**: Different models cost different amounts per prompt

&lt;/ListCheck&gt;

### Understanding the Credit System

Windsurf uses a credit-based system where different models have different costs:

| Model                 | Credits per Prompt | Best For                             |
| --------------------- | ------------------ | ------------------------------------ |
| **Supernova**         | Free (unlimited)   | General coding, quick questions      |
| **Claude Sonnet 4.6** | 1 credit           | Complex coding, reasoning tasks      |
| **GPT-5**             | 1 credit           | Advanced reasoning, content creation |
| **GPT-4o**            | 0.5 credits        | Cost-effective premium performance   |

With 25 free credits monthly, use premium models for complex tasks while leveraging Supernova for routine work.

### How to Get Started with Windsurf

**Step 1: Download and Install**

Visit [Windsurf](https://go.bitdoze.com/windsurf) and download the IDE for your operating system (Windows, macOS, or Linux).

**Step 2: Create Your Account**

Sign up for a free account to access your monthly credit allowance.

**Step 3: Choose Your Mode**

Windsurf offers two primary interaction modes:

- **Chat Mode**: Ask questions, get explanations, discuss architecture
- **Code Mode**: Direct file editing and project-wide changes

**Step 4: Select Your Model**

Open the model selector and choose based on your needs:

- Use Supernova for general tasks (free, unlimited)
- Reserve Claude Sonnet 4.6 or GPT-5 for complex problems (1 credit each)

### Practical Example: Building a Snake Game

Here&apos;s a real-world example of using Windsurf:

```
Prompt: &quot;Build a snake game in Python with pygame&quot;

The AI will:
1. Plan the project structure
2. Create necessary files
3. Implement game logic
4. Add controls and scoring
5. Provide instructions for running the game
```

Windsurf&apos;s Cascade feature allows the AI to plan multi-step projects and execute them systematically, creating multiple files and organizing your project structure automatically.

### Monitoring Your Usage

&lt;Notice type=&quot;warning&quot; title=&quot;Credit Management&quot;&gt;

Track your credit usage in the Windsurf dashboard. The interface shows remaining credits and usage history to help you plan your monthly allocation.

&lt;/Notice&gt;

### Best Use Cases for Windsurf

- **Full-Stack Development**: Complete web applications with frontend and backend
- **Code Refactoring**: Improving existing codebases with AI assistance
- **Learning New Frameworks**: Exploring React, Vue, Angular, etc.
- **UI/UX Development**: Creating beautiful, responsive interfaces
- **Documentation**: Generating comprehensive project documentation

&lt;Button
  text=&quot;Get 25 Free Prompts with Windsurf&quot;
  url=&quot;https://go.bitdoze.com/windsurf&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## Method 3: AgentRouter - $200 Free API Credits

**[AgentRouter](https://go.bitdoze.com/agentrouter)** offers $200 in free API credits to test premium models including GPT-5, Claude Sonnet 4.6, and GLM-4.5.

### What is AgentRouter?

AgentRouter is an API routing service that provides access to multiple AI models through a unified interface. The $200 free credit makes it useful for testing and development.

### Key Features

&lt;ListCheck&gt;

- **$200 Free Credits**: Substantial allowance for extensive testing
- **Multiple Premium Models**: GPT-5, Claude Sonnet 4.6, GLM-4.5
- **OpenAI-Compatible API**: Easy integration with existing tools
- **Dashboard Interface**: Monitor usage and manage API keys
- **IDE Integration**: Works with Roo Code, Claude Code, Cline, Kilo Code, and more

&lt;/ListCheck&gt;

### Available Models

| Model                 | Use Case                             |
| --------------------- | ------------------------------------ |
| **GPT-5**             | Advanced reasoning, content creation |
| **Claude Sonnet 4.6** | Complex coding, long-context tasks   |
| **GLM-4.5**           | Agentic tasks, tool integration      |

### How to Get Started with AgentRouter

**Step 1: Sign Up**

Visit [AgentRouter](https://go.bitdoze.com/agentrouter) and create an account. You&apos;ll immediately receive $200 in free credits.

**Step 2: Create an API Key**

1. Navigate to the API Keys section in your dashboard
2. Click &quot;Create New Key&quot;
3. Set a name (e.g., &quot;Development Key&quot;)
4. Choose default grouping
5. Set a quota (e.g., $10 per key for safety)
6. Optionally restrict to specific models
7. Click Submit to generate your key

**Step 3: Configure Your IDE**

AgentRouter works with multiple development tools. Here&apos;s how to set it up:

#### For Roo Code (VS Code Extension)

```json
// Create a new profile in Roo Code settings
{
  &quot;profile_name&quot;: &quot;AgentRouter Free&quot;,
  &quot;provider&quot;: &quot;OpenAI Compatible&quot;,
  &quot;base_url&quot;: &quot;https://agentrouter.org/v1&quot;,
  &quot;api_key&quot;: &quot;your_api_key_here&quot;,
  &quot;model&quot;: &quot;gpt-5&quot;,
  &quot;enable_streaming&quot;: true
}
```

#### For Kilo Code

1. Open Kilo Code settings
2. Select &quot;Use my own key&quot;
3. Choose &quot;OpenAI Compatible&quot; provider
4. Enter base URL: `https://agentrouter.org/v1`
5. Paste your API key
6. Select your preferred model

### Important Considerations

&lt;Notice type=&quot;warning&quot; title=&quot;Reliability Notice&quot;&gt;

AgentRouter is a newer service. Use it for:

- Testing and experimentation
- Non-critical projects
- Learning and development
- Open-source contributions

Avoid using it for production applications or sensitive business projects.

&lt;/Notice&gt;

### Best Use Cases for AgentRouter

- **API Development**: Building applications that use AI models
- **Batch Processing**: Processing large amounts of data
- **Experimentation**: Testing different models and approaches
- **Cost Comparison**: Evaluating which model works best for your needs

&lt;Button
  text=&quot;Get $200 Free Credits with AgentRouter&quot;
  url=&quot;https://go.bitdoze.com/agentrouter&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## Method 4: Google Antigravity IDE - Free Gemini 3 Pro Access

**[Google Antigravity](https://antigravity.google/)** is Google&apos;s agentic development platform available in public preview with rate limits on Gemini 3 Pro usage. It also includes access to Claude Sonnet 4.6, Claude Opus 4.7, and GPT-OSS models.

### What is Google Antigravity?

Google Antigravity is an agent-first platform where AI agents can autonomously plan and execute software tasks. It provides an IDE experience with browser control capabilities, asynchronous interaction patterns, and multi-agent orchestration.

### Key Features

&lt;ListCheck&gt;

- **Free Gemini 3 Pro Access**: Rate limits during public preview
- **Multi-Model Support**: Access Gemini 3, Claude Sonnet 4.6, Claude Opus 4.7, and GPT-OSS
- **Browser Control**: Agents can autonomously control browser for testing
- **Asynchronous Workflows**: Spawn and orchestrate multiple agents in parallel
- **Dual Interface**: Editor view for synchronous work, Manager view for agent orchestration
- **Knowledge Management**: Agent learns from past work and feedback
- **Cross-Platform**: Compatible with MacOS, Linux, and Windows

&lt;/ListCheck&gt;

### Core Tenets of Antigravity

Google built Antigravity around four key principles:

| Tenet               | Description                                                                         |
| ------------------- | ----------------------------------------------------------------------------------- |
| **Trust**           | Task-level abstractions with artifacts and verification results for user validation |
| **Autonomy**        | Agents operate across code editor, terminal, and browser simultaneously             |
| **Feedback**        | Async feedback through Google-doc-style comments on text and visual artifacts       |
| **Self-improvement**| Knowledge management allows agents to learn from past work                          |

### How Antigravity Works

**Editor View (Synchronous):**
- AI-powered IDE experience
- Tab completions and inline commands
- Fully functioning agent in the side panel
- Focused, hands-on development

**Manager View (Asynchronous):**
- Mission control for spawning multiple agents
- Orchestrate agents across multiple workspaces in parallel
- Inbox notifications for agent progress
- Running background research while focusing on other tasks

### Artifacts and Verification

Unlike tools that show every single tool call or only the final code change, Antigravity provides context at a natural task-level abstraction through Artifacts:

&lt;ListCheck&gt;

- **Task Lists**: Clear breakdown of what the agent will accomplish
- **Implementation Plans**: Review before implementation begins
- **Walkthroughs**: Understand what was done at completion
- **Screenshots**: Visual verification of browser-based testing
- **Browser Recordings**: Full recordings of automated testing sessions

&lt;/ListCheck&gt;

### How to Get Started with Antigravity

**Step 1: Visit the Platform**

Go to [antigravity.google](https://antigravity.google/) and sign up for the public preview.

**Step 2: Download and Install**

Download the Antigravity IDE for your operating system (macOS, Linux, or Windows).

**Step 3: Choose Your Model**

Select from available models:

- **Gemini 3 Pro** - Google&apos;s most intelligent model (generous free limits)
- **Claude Sonnet 4.6** - Anthropic&apos;s top coding model
- **Claude Opus 4.7** - Anthropic&apos;s most powerful model for complex reasoning
- **GPT-OSS** - OpenAI&apos;s model offering

**Step 4: Start Building**

Use either the Editor view for hands-on coding or the Manager view to spawn autonomous agents for complex tasks.

### Real-World Use Case Example

```
Task: &quot;Build a new frontend feature and verify it works&quot;

The Antigravity Agent will:
1. Write code for the new frontend feature
2. Use the terminal to launch localhost
3. Actuate the browser to test the feature works
4. Provide screenshots and recordings as verification
5. All without manual intervention
```

### Best Use Cases for Antigravity

- **Full-Stack Development**: Leverage browser control for end-to-end testing
- **Complex Multi-File Projects**: Let agents plan and execute across your codebase
- **Parallel Development**: Run multiple agents on different tasks simultaneously
- **Automated Testing**: Agents verify their own work through browser automation
- **Research Tasks**: Spawn background agents for research while you focus elsewhere

&lt;Button
  text=&quot;Try Google Antigravity Free&quot;
  url=&quot;https://antigravity.google/&quot;
  size=&quot;lg&quot;
  color=&quot;red&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## Method 5: GitHub Copilot - 30-Day Free Trial + Free Tier

**[GitHub Copilot](https://github.com/features/copilot/plans)** offers a 30-day free trial of their Pro plan, plus an ongoing free tier. This gives you access to GPT-5 mini (unlimited), Claude Sonnet 4.6, Claude Opus 4.7, and other premium models through a production-ready platform.

### What is GitHub Copilot?

GitHub Copilot is an AI coding assistant integrated with GitHub&apos;s ecosystem. It offers code completion, chat assistance, CLI tools, and autonomous coding agents.

### Free Access Options

| Plan                  | What You Get                                 |
| --------------------- | -------------------------------------------- |
| **30-Day Free Trial** | Full Pro features, no credit card required   |
| **Free Tier**         | 50 premium requests/month ongoing            |
| **Pro ($10/month)**   | Unlimited GPT-5 mini + 300 premium requests  |

### Key Features

&lt;ListCheck&gt;

- **30-Day Free Trial**: Full access to Pro features without credit card
- **Ongoing Free Tier**: 50 premium requests monthly forever
- **Unlimited GPT-5 Mini**: Fast, capable model for daily coding (Pro plan)
- **Multi-Model Access**: Claude Sonnet 4.6, Claude Opus 4.7, GPT-5, Gemini 2.0 Flash
- **Multi-IDE Support**: VS Code, Zed, JetBrains, Vim, and more
- **Copilot CLI**: AI assistance directly in your terminal
- **Coding Agents**: Autonomous PR creation from issues

&lt;/ListCheck&gt;

### Available Models in GitHub Copilot

| Model             | Premium Requests | Best For                        |
|-------------------|-----------------|----------------------------------|
| GPT-5 mini        | Unlimited (Pro) | Daily coding tasks               |
| Claude Sonnet 4.6 | 1×              | Complex algorithms, architecture |
| Claude Opus 4.7   | 3×             | Most complex reasoning tasks     |
| GPT-5             | 1×              | Advanced reasoning               |
| Gemini 2.0 Flash  | 0.25×           | Quick questions (efficient!)     |
| Claude Haiku 4.5  | 0.33×           | Fast responses                   |

### How to Get Started with GitHub Copilot

**Step 1: Sign Up for Free Trial**

Visit [GitHub Copilot Plans](https://github.com/features/copilot/plans) and start your 30-day free trial. No credit card required.

**Step 2: Install in Your IDE**

```bash
# For Zed (recommended for speed)
brew install zed
# Enable Copilot in Settings → Extensions

# For VS Code
# Extensions → Search &quot;GitHub Copilot&quot; → Install
```

**Step 3: Install CLI (Optional)**

```bash
# Install GitHub CLI
brew install gh

# Install Copilot CLI extension
gh extension install github/gh-copilot

# Use it
copilot -p &quot;Create a Next.js app with auth&quot;
```

**Step 4: Enable Latest Models**

Visit https://github.com/settings/copilot/features and enable:
- Model choice
- Preview features
- Latest models

### Copilot Features Overview

&lt;Tabs&gt;
&lt;Tab name=&quot;Code Completion&quot;&gt;

**Real-time suggestions as you type:**
- Ghost text suggestions appear inline
- Tab to accept completions
- Context-aware across your project
- Works in all supported IDEs

&lt;/Tab&gt;
&lt;Tab name=&quot;Chat&quot;&gt;

**Your AI pair programmer:**
- Ask coding questions
- Generate code from descriptions
- Refactor and improve existing code
- Debug issues with explanations

&lt;/Tab&gt;
&lt;Tab name=&quot;CLI&quot;&gt;

**AI in your terminal:**
- Create files and features
- Git operations with natural language
- DevOps automation
- Create PRs and issues

&lt;/Tab&gt;
&lt;Tab name=&quot;Agents&quot;&gt;

**Autonomous development:**
- Assign issues to @copilot
- Copilot analyzes and codes
- Creates pull requests automatically
- Review and merge

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Best Use Cases for GitHub Copilot

- **Daily Development**: Unlimited GPT-5 mini handles 90% of coding tasks
- **Complex Problems**: Switch to Claude Sonnet 4.6 or Opus 4.7 for architecture decisions
- **Terminal Workflows**: Use CLI for DevOps and automation
- **Team Collaboration**: Deep GitHub integration for PRs and issues
- **Learning**: Great for exploring new languages and frameworks

&lt;Notice type=&quot;info&quot; title=&quot;For More Details&quot;&gt;

Check out the complete [GitHub Copilot Pro Guide](https://www.bitdoze.com/github-copilot-complete-guide/) for in-depth setup instructions, tips, and advanced workflows.

&lt;/Notice&gt;

&lt;Button
  text=&quot;Start 30-Day Free Trial - GitHub Copilot&quot;
  url=&quot;https://github.com/features/copilot/plans&quot;
  size=&quot;lg&quot;
  color=&quot;gray&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

## Comparison: Which Free Method is Best?

Here&apos;s a comparison to help you choose the right option:

| Feature            | Droid CLI                       | Windsurf                       | AgentRouter                   | Antigravity                          | GitHub Copilot                  |
| ------------------ | ------------------------------- | ------------------------------ | ----------------------------- | ------------------------------------ | ------------------------------- |
| **Free Allowance** | 20M tokens                | 25 prompts/month               | $200 credits                  | Generous limits                      | 30-day trial + 50/month         |
| **Models**         | Claude 4.6, Opus 4.7, GPT-5.5     | Claude 4.6, GPT-5.5, Supernova   | GPT-5.5, Claude 4.6, GLM-4.5    | Gemini 3, Claude 4.6, Opus 4.7, GPT-OSS | Claude 4.6, Opus 4.7, GPT-5.5     |
| **Best For**       | CLI users, automation           | IDE development                | API integration               | Agentic workflows                    | Professional dev                |
| **Reliability**    | High                            | High                           | Moderate                      | High                                 | Very High                       |
| **Learning Curve** | Medium                          | Low                            | Medium                        | Medium                               | Low                             |
| **IDE Integration**| Yes                             | Built-in                       | Yes (multiple)                | Built-in                             | Multi-IDE                       |
| **Browser Control**| No                              | No                             | No                            | Yes                                  | No                              |
| **Renewal**        | First Month                     | Monthly                        | One-time                      | Ongoing preview                      | Monthly                         |

### My Recommendations

**Choose Droid CLI if you:**

- Prefer working in the terminal
- Need extensive token allowance
- Want access to Claude Opus 4.7 for complex tasks
- Focus on backend development and automation

**Choose Windsurf if you:**

- Want a complete IDE experience
- Prefer visual development tools
- Need both chat and code modes
- Work on full-stack projects

**Choose AgentRouter if you:**

- Need API access for custom applications
- Want to test multiple models extensively
- Are building integrations
- Don&apos;t mind occasional reliability issues

**Choose Google Antigravity if you:**

- Want autonomous agents that control browser for testing
- Need to run multiple agents in parallel
- Prefer an agent-first development approach
- Want access to Gemini 3 Pro and Claude Opus 4.7

**Choose GitHub Copilot if you:**

- Want the most production-ready experience
- Use multiple IDEs and need consistent experience
- Value deep GitHub integration
- Need CLI tools for DevOps workflows
- Want access to Claude Opus 4.7 for complex reasoning

&lt;Notice type=&quot;success&quot; title=&quot;Pro Tip: Use All Five!&quot;&gt;

Use all five methods strategically:

- **Droid CLI** for daily coding and automation tasks
- **Windsurf** for complex projects requiring visual development
- **AgentRouter** for API testing and experimentation
- **Google Antigravity** for autonomous agent workflows and Gemini 3 access
- **GitHub Copilot** for professional development with GitHub integration

This gives you maximum flexibility and access to premium AI models without spending a cent!

&lt;/Notice&gt;

## Conclusion

Access to Claude Sonnet 4.6, GPT-5, Gemini 3, and Opus 4.7 doesn&apos;t have to cost money. With these five free methods, you can:

✅ **Get 20 million tokens monthly** with Droid CLI for development work

✅ **Use 25 premium prompts** in Windsurf&apos;s IDE environment

✅ **Experiment with $200 in credits** through AgentRouter&apos;s API

✅ **Access Gemini 3 Pro and Opus 4.7 free** with Google Antigravity&apos;s platform

✅ **Try GitHub Copilot free** for 30 days plus ongoing free tier access

Start with all five services to maximize your free access and find what works best for your workflow. Whether you&apos;re learning to code, building side projects, or exploring AI capabilities, these tools help you get started without spending money.

&lt;Notice type=&quot;success&quot; title=&quot;Ready to Get Started?&quot;&gt;

Click the buttons below to access each free service and start building with premium AI models today!

&lt;/Notice&gt;

&lt;Button
  text=&quot;Get 20M Free Tokens - Droid CLI&quot;
  url=&quot;https://go.bitdoze.com/droid-cli&quot;
  size=&quot;lg&quot;
  color=&quot;blue&quot;
  variant=&quot;solid&quot;
/&gt;

&lt;Button
  text=&quot;Get 25 Free Prompts - Windsurf&quot;
  url=&quot;https://go.bitdoze.com/windsurf&quot;
  size=&quot;lg&quot;
  color=&quot;green&quot;
  variant=&quot;solid&quot;
/&gt;

&lt;Button
  text=&quot;Get $200 Credits - AgentRouter&quot;
  url=&quot;https://go.bitdoze.com/agentrouter&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
/&gt;

&lt;Button
  text=&quot;Try Antigravity Free - Gemini 3&quot;
  url=&quot;https://antigravity.google/&quot;
  size=&quot;lg&quot;
  color=&quot;red&quot;
  variant=&quot;solid&quot;
/&gt;

&lt;Button
  text=&quot;GitHub Copilot - 30 Day Trial&quot;
  url=&quot;https://github.com/features/copilot/plans&quot;
  size=&quot;lg&quot;
  color=&quot;gray&quot;
  variant=&quot;solid&quot;
/&gt;

Remember to monitor your usage and take advantage of all five platforms to maximize your free access to AI models. Happy coding!</content:encoded><category>ai</category><category>claude</category><category>gpt-5</category></item><item><title>VibeProxy: Use Your Claude, Codex &amp; Gemini Subscriptions with Any AI Coding Tool</title><link>https://www.bitdoze.com/vibeproxy-ai-subscriptions-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/vibeproxy-ai-subscriptions-guide/</guid><description>Learn how to use VibeProxy to leverage your existing Claude Code, OpenAI Codex, Gemini, and Antigravity subscriptions with Factory AI Droids, Amp Code, Zed, and other coding platforms.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

You already pay for Claude Code, ChatGPT Plus, or other AI subscriptions. Why pay again for API access? VibeProxy is a macOS menu bar app that routes your existing subscriptions to coding tools like Factory AI Droids, Amp Code, Zed, and other OpenAI-compatible applications.

&lt;Notice type=&quot;success&quot; title=&quot;What You&apos;ll Learn&quot;&gt;

- **Stop paying twice** for AI by reusing your existing subscriptions
- **Set up VibeProxy** in minutes on your Mac (Apple Silicon)
- **Configure Factory Droids, Amp Code, and other tools** to use your subscriptions
- **Access premium models** like Claude Opus 4.5, GPT-5.1, and Gemini 3 Pro
- **Use extended thinking** for complex reasoning tasks

&lt;/Notice&gt;

If you&apos;re new to AI coding tools, check out our [AI Programming Beginners Guide](https://www.bitdoze.com/ai-programming-beginners-guide/) for a comprehensive introduction.

## What is VibeProxy?

[VibeProxy](https://github.com/automazeio/vibeproxy) is a free, open-source macOS app from Automaze that runs in the menu bar. It creates a local proxy server on port 8317 and routes API requests from your coding tools to your existing AI subscriptions.

```
Your Coding Tool (Factory, Amp, Zed, etc.)
    ↓
VibeProxy (Local Proxy on port 8317)
    ↓
Your Existing Subscriptions (Claude Code, ChatGPT Plus, Gemini, etc.)
```

### Why Use VibeProxy?

&lt;ListCheck&gt;

- **Save Money**: Use your $20/month Claude Code subscription instead of paying API fees
- **Multiple AI Models**: Access Claude Opus 4.5, Sonnet 4.5, GPT-5.1, Gemini 3 Pro, and more
- **Native macOS Experience**: SwiftUI menu bar app
- **OAuth Authentication**: Browser-based login, no API keys to manage
- **Auto Updates**: Keeps itself and CLIProxyAPI up to date automatically
- **Universal Compatibility**: Works with OpenAI-compatible coding tools

&lt;/ListCheck&gt;

VibeProxy uses [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) to handle OAuth authentication, token management, and API routing.

## Supported AI Subscriptions

VibeProxy currently supports these AI subscriptions:

| Subscription | Models Available | Requirements |
|-------------|------------------|--------------|
| **Claude Code Pro/Max** | Claude Sonnet 4.5, Claude Opus 4.5, Extended Thinking | Active subscription |
| **ChatGPT Plus/Pro** | GPT-5, GPT-5.1, GPT-5.1 Codex, GPT-5.1 Codex Max | Active subscription |
| **Google Antigravity** | Gemini 3 Pro, Gemini 3 Pro Image | Google account |
| **Gemini CLI** | Gemini 2.5 Pro, Gemini 2.5 Flash | Google Cloud account |
| **Qwen** | Qwen3 Coder Plus, Qwen3 Coder Flash | Qwen account |

&lt;Notice type=&quot;info&quot; title=&quot;Already Have These Subscriptions?&quot;&gt;

If you&apos;re using [Claude Sonnet 4.6 and GPT-5.5 for free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/) or have paid subscriptions, VibeProxy lets you use them across your coding tools.

&lt;/Notice&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/AeBIWsSOcJ0&quot;
  label=&quot;Claude, ChatGPT, Gemini in ONE Place? Here&apos;s How VibeProxy Makes It Happen&quot;
/&gt;

## Installing VibeProxy

&lt;Notice type=&quot;warning&quot; title=&quot;System Requirements&quot;&gt;

VibeProxy requires:
- **macOS 13.0 (Ventura)** or later
- **Apple Silicon** (M1/M2/M3/M4) only

&lt;/Notice&gt;

### Step 1: Download VibeProxy

1. Go to the [VibeProxy Releases](https://github.com/automazeio/vibeproxy/releases) page
2. Download the latest `VibeProxy.zip`
3. Extract the ZIP file

### Step 2: Install the App

```bash
# Move to Applications folder
mv VibeProxy.app /Applications/

# Or double-click to open from Downloads
```

The app is code signed and notarized by Apple, so Gatekeeper won&apos;t block installation.

### Step 3: Launch VibeProxy

1. Open VibeProxy from `/Applications`
2. A menu bar icon will appear
3. Click the icon and select **&quot;Open Settings&quot;**
4. The server starts automatically on port **8317**

## Connecting Your AI Subscriptions

Once VibeProxy is running, authenticate with each AI provider you want to use.

### Connecting Claude Code

1. Click the VibeProxy menu bar icon
2. Select **&quot;Open Settings&quot;**
3. Click **&quot;Connect&quot;** next to Claude Code
4. Your browser opens for OAuth authentication
5. Log in with your Claude Code account
6. VibeProxy detects completion automatically

### Connecting OpenAI Codex

1. In VibeProxy settings, click **&quot;Connect&quot;** next to Codex
2. Complete the browser authentication
3. Wait for VibeProxy to confirm the connection

### Connecting Antigravity (Gemini 3 Pro)

&lt;Notice type=&quot;info&quot; title=&quot;Gemini 3 vs Gemini 2.x&quot;&gt;

- **Antigravity** authentication provides access to **Gemini 3 Pro** models
- **Gemini CLI** authentication provides access to **Gemini 2.x** models
- Connect both to access all Gemini models

&lt;/Notice&gt;

1. Click **&quot;Connect&quot;** next to Antigravity
2. Sign in with your Google account
3. Grant permissions for AI model access
4. Restart VibeProxy to activate Gemini 3 Pro access

### Connecting Gemini CLI

1. Click **&quot;Connect&quot;** next to Gemini
2. Sign in with your Google account
3. Select a Google Cloud project (or accept the default)
4. VibeProxy saves your credentials automatically

## Setting Up Factory AI Droids

[Factory CLI (Droid)](https://app.factory.ai/r/FM8BJHFQ) is an AI coding agent. Here&apos;s how to configure it to use VibeProxy.

### Install Factory CLI

```bash
# Install Factory CLI
curl -fsSL https://app.factory.ai/cli | sh
```

### Configure Custom Models

Create or edit the Factory configuration file at `~/.factory/config.json`:

```json
{
  &quot;custom_models&quot;: [
    {
      &quot;model_display_name&quot;: &quot;CC: Opus 4.5 (High)&quot;,
      &quot;model&quot;: &quot;claude-opus-4-5-20251101-thinking-32000&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;CC: Sonnet 4.5&quot;,
      &quot;model&quot;: &quot;claude-sonnet-4-5-20250929&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;GPT-5.1 Codex&quot;,
      &quot;model&quot;: &quot;gpt-5.1-codex&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;Gemini 3 Pro&quot;,
      &quot;model&quot;: &quot;gemini-3-pro-preview&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    }
  ]
}
```

### Using Factory with VibeProxy

1. Launch Factory CLI:
   ```bash
   droid
   ```

2. Select your model with `/model` and choose from your configured options

3. Start coding! Factory routes all requests through VibeProxy automatically.

&lt;Accordion label=&quot;Full Factory Configuration with All Models&quot; group=&quot;config&quot;&gt;

Here&apos;s a comprehensive configuration with all available models:

```json
{
  &quot;custom_models&quot;: [
    {
      &quot;model_display_name&quot;: &quot;CC: Opus 4.5 (High)&quot;,
      &quot;model&quot;: &quot;claude-opus-4-5-20251101-thinking-32000&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;CC: Opus 4.5 (Medium)&quot;,
      &quot;model&quot;: &quot;claude-opus-4-5-20251101-thinking-10000&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;CC: Opus 4.5 (Low)&quot;,
      &quot;model&quot;: &quot;claude-opus-4-5-20251101-thinking-4000&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;CC: Sonnet 4.5 (High)&quot;,
      &quot;model&quot;: &quot;claude-sonnet-4-5-20250929-thinking-32000&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;CC: Sonnet 4.5&quot;,
      &quot;model&quot;: &quot;claude-sonnet-4-5-20250929&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;GPT-5.1 Codex Max&quot;,
      &quot;model&quot;: &quot;gpt-5.1-codex-max&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;GPT-5.1 Codex&quot;,
      &quot;model&quot;: &quot;gpt-5.1-codex&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;GPT-5.1&quot;,
      &quot;model&quot;: &quot;gpt-5.1&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;Gemini 3 Pro&quot;,
      &quot;model&quot;: &quot;gemini-3-pro-preview&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;Gemini 2.5 Pro&quot;,
      &quot;model&quot;: &quot;gemini-2.5-pro&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    },
    {
      &quot;model_display_name&quot;: &quot;Qwen3 Coder Plus&quot;,
      &quot;model&quot;: &quot;qwen3-coder-plus&quot;,
      &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
      &quot;api_key&quot;: &quot;dummy-not-used&quot;,
      &quot;provider&quot;: &quot;openai&quot;
    }
  ]
}
```

&lt;/Accordion&gt;

## Setting Up Amp Code

[Amp Code](https://www.bitdoze.com/amp-code-free-ai-coding-agent/) by Sourcegraph is an AI coding agent. VibeProxy works with Amp CLI.

### Configure Amp URL

Create or edit `~/.config/amp/settings.json`:

```json
{
  &quot;amp.url&quot;: &quot;http://localhost:8317&quot;
}
```

### Login to Amp Through VibeProxy

```bash
amp login
```

This will:
1. Open your browser to `http://localhost:8317/api/auth/cli-login`
2. VibeProxy forwards the request to ampcode.com
3. Complete the login in your browser
4. Amp CLI saves your credentials

### Fix the Secrets File

After login, you need to add a simple `apiKey` field. Edit `~/.local/share/amp/secrets.json`:

```bash
# View current contents
cat ~/.local/share/amp/secrets.json
```

Add the `apiKey` field (copy value from existing keys):

```json
{
  &quot;apiKey@https://ampcode.com/&quot;: &quot;sgamp_user_01XXXXX...&quot;,
  &quot;apiKey@http://localhost:8317&quot;: &quot;sgamp_user_01XXXXX...&quot;,
  &quot;apiKey&quot;: &quot;sgamp_user_01XXXXX...&quot;
}
```

Or use this Python one-liner to do it automatically:

```bash
python3 &lt;&lt; &apos;EOF&apos;
import json
import os

secrets_file = os.path.expanduser(&apos;~/.local/share/amp/secrets.json&apos;)

with open(secrets_file, &apos;r&apos;) as f:
    data = json.load(f)

api_key = data.get(&apos;apiKey@https://ampcode.com/&apos;, data.get(&apos;apiKey@http://localhost:8317&apos;, &apos;&apos;))

if api_key and &apos;apiKey&apos; not in data:
    data[&apos;apiKey&apos;] = api_key
    with open(secrets_file, &apos;w&apos;) as f:
        json.dump(data, f, indent=2)
    print(&apos;✅ Added apiKey field to secrets.json&apos;)
EOF
```

### Use Amp with VibeProxy

```bash
# Interactive mode
amp

# Direct prompt
amp &quot;Write a hello world in Python&quot;
```

&lt;Notice type=&quot;info&quot; title=&quot;Smart Fallback&quot;&gt;

When Amp requests a model, it checks for local VibeProxy authentication first. If authenticated, it uses your subscription. Otherwise, it uses Amp credits.

&lt;/Notice&gt;

## Using with Other Coding Platforms

VibeProxy works with **any OpenAI-compatible tool**. Here&apos;s how to configure popular options.

### Zed Editor

[Zed](https://zed.dev) is a fast code editor with AI features. Configure it to use VibeProxy:

1. Open Zed Settings (`Cmd+,`)
2. Navigate to **AI** settings
3. Set the API endpoint to `http://localhost:8317/v1`
4. Use `dummy` as the API key

### OpenCode

For [OpenCode](https://github.com/0x0950/opencode) or similar CLI tools, set the environment variable:

```bash
export OPENAI_API_BASE=&quot;http://localhost:8317/v1&quot;
export OPENAI_API_KEY=&quot;dummy&quot;
```

### VS Code Extensions (Roo Code, Continue, etc.)

Many VS Code extensions support custom endpoints. Configure them with:

```json
{
  &quot;provider&quot;: &quot;OpenAI Compatible&quot;,
  &quot;base_url&quot;: &quot;http://localhost:8317/v1&quot;,
  &quot;api_key&quot;: &quot;dummy-not-used&quot;,
  &quot;model&quot;: &quot;claude-sonnet-4-5-20250929&quot;
}
```

### Any OpenAI-Compatible Application

The general pattern for any tool is:

| Setting | Value |
|---------|-------|
| **Base URL** | `http://localhost:8317/v1` (or `/` for Anthropic) |
| **API Key** | `dummy` (or any placeholder) |
| **Model** | See available models below |

## Available Models Reference

### Claude Models (via Claude Code subscription)

| Model ID | Description |
|----------|-------------|
| `claude-opus-4-5-20251101` | Claude Opus 4.5 (Most powerful) |
| `claude-sonnet-4-5-20250929` | Claude Sonnet 4.5 (Best for coding) |
| `claude-opus-4-5-20251101-thinking-32000` | Opus 4.5 with Extended Thinking (32K tokens) |
| `claude-sonnet-4-5-20250929-thinking-10000` | Sonnet 4.5 with Extended Thinking |

### OpenAI Models (via ChatGPT subscription)

| Model ID | Description |
|----------|-------------|
| `gpt-5.1-codex-max` | GPT-5.1 Codex Max (Best for agentic coding) |
| `gpt-5.1-codex` | GPT-5.1 Codex |
| `gpt-5.1` | GPT-5.1 Base |
| `gpt-5-codex` | GPT-5 Codex |

### Gemini Models

| Model ID | Description | Auth Required |
|----------|-------------|---------------|
| `gemini-3-pro-preview` | Gemini 3 Pro | Antigravity |
| `gemini-3-pro-image-preview` | Gemini 3 Pro with Vision | Antigravity |
| `gemini-2.5-pro` | Gemini 2.5 Pro | Gemini CLI |
| `gemini-2.5-flash` | Gemini 2.5 Flash | Gemini CLI |

### Qwen Models

| Model ID | Description |
|----------|-------------|
| `qwen3-coder-plus` | Qwen3 Coder Plus |
| `qwen3-coder-flash` | Qwen3 Coder Flash |

## Extended Thinking Mode

VibeProxy supports Claude&apos;s Extended Thinking feature. The model reasons step-by-step before answering.

### How to Use Extended Thinking

Append a thinking suffix to any Claude model name:

```
{model-name}-thinking-{NUMBER}
```

**Presets:**
- `-thinking-4000` — ~4K tokens
- `-thinking-10000` — ~10K tokens  
- `-thinking-32000` — ~32K tokens

### Examples

```json
{
  &quot;model&quot;: &quot;claude-sonnet-4-5-20250929-thinking-10000&quot;
}
```

VibeProxy:
1. Removes the `-thinking-10000` suffix
2. Adds the thinking parameter with a 10,000 token budget
3. Forwards the request to Claude

The response shows Claude&apos;s reasoning steps.

## Troubleshooting

&lt;Accordion label=&quot;VibeProxy Menu Bar Status&quot; group=&quot;troubleshoot&quot;&gt;

Check the menu bar icon:
- **Green dot**: Server is running correctly
- **Red dot**: Server is stopped
- **Click the status** to toggle on/off

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Connection Issues&quot; group=&quot;troubleshoot&quot;&gt;

| Problem | Solution |
|---------|----------|
| Can&apos;t connect to Claude/Codex | Re-click &quot;Connect&quot; in VibeProxy settings |
| Factory shows 404 errors | Ensure VibeProxy server is running (check menu bar) |
| Authentication expired | Disconnect and reconnect the service |
| Port 8317 already in use | Quit other VibeProxy or CLIProxyAPI instances |
| Gemini returns 401 | Verify Google Cloud has Gemini API enabled |

&lt;/Accordion&gt;

&lt;Accordion label=&quot;&apos;App is damaged&apos; Error&quot; group=&quot;troubleshoot&quot;&gt;

If macOS shows this error, remove quarantine attributes:

```bash
xattr -cr /Applications/VibeProxy.app
```

Then try opening again.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Verification Checklist&quot; group=&quot;troubleshoot&quot;&gt;

1. ✅ VibeProxy is running (menu bar icon shows green)
2. ✅ Services show as &quot;Connected&quot; in settings
3. ✅ Your tool is configured with `localhost:8317`
4. ✅ API key is set (any placeholder works)
5. ✅ Test with a simple prompt: &quot;what day is it?&quot;

&lt;/Accordion&gt;

## Security Considerations

&lt;Notice type=&quot;warning&quot; title=&quot;Important Security Information&quot;&gt;

VibeProxy handles your AI credentials securely:

- **Local Storage**: All tokens stored in `~/.cli-proxy-api/` with 0600 permissions
- **Localhost Only**: Server binds only to 127.0.0.1 (not accessible from network)
- **HTTPS Upstream**: All traffic to AI providers uses HTTPS encryption
- **Auto-Refresh**: Tokens refresh automatically before expiration

**Terms of Service Note**: Using your subscriptions this way may violate provider TOS. Use at your own risk and discretion.

&lt;/Notice&gt;

## Use Cases and Benefits

### For Professional Developers

&lt;ListCheck&gt;

- **Cost Savings**: Your $20/month Claude subscription works across tools
- **Model Flexibility**: Switch between Claude, GPT, and Gemini without separate API keys
- **Unified Workflow**: Use the same models in Factory, Amp, Zed, and other tools
- **Extended Thinking**: Access reasoning capabilities for complex architecture decisions

&lt;/ListCheck&gt;

### For Hobbyists and Learners

&lt;ListCheck&gt;

- **Maximize Value**: Get more from your subscriptions
- **Experiment Freely**: Try different coding tools without additional costs
- **Learn AI Development**: Understand how AI APIs work through the proxy
- **Build Projects**: Use premium models for side projects

&lt;/ListCheck&gt;

For more AI programming guidance, check out:
- [AI Programming Beginners Guide](https://www.bitdoze.com/ai-programming-beginners-guide/)
- [Best Open-Source LLMs as Claude Alternatives](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)
- [MCP Introduction for Beginners](https://www.bitdoze.com/mcp-introduction-beginners/)

## Related Tools and Resources

- [GitHub Copilot Complete Guide](https://www.bitdoze.com/github-copilot-complete-guide/)
- [Amp Code Free AI Coding Agent](https://www.bitdoze.com/amp-code-free-ai-coding-agent/)
- [Docker &amp; Podman for AI CLI Tools](https://www.bitdoze.com/docker-podman-ai-cli-tools-safe-environment/)
- [BrightData MCP Guide](https://www.bitdoze.com/brightdata-mcp-guide/)

## Frequently Asked Questions

&lt;Accordion label=&quot;Is VibeProxy free?&quot; group=&quot;faq&quot;&gt;

Yes! VibeProxy is completely free and open-source under the MIT license. You can download it from GitHub, use it indefinitely, and even contribute to its development.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does it work on Intel Macs?&quot; group=&quot;faq&quot;&gt;

No, VibeProxy requires **Apple Silicon** (M1/M2/M3/M4). Intel Macs are not supported due to the underlying binary architecture.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will this get my account banned?&quot; group=&quot;faq&quot;&gt;

There&apos;s a risk. Using subscriptions through a proxy may violate terms of service for some AI providers. The VibeProxy documentation acknowledges this—use at your own risk and discretion.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use it with Linux or Windows?&quot; group=&quot;faq&quot;&gt;

VibeProxy itself is macOS-only. However, the underlying CLIProxyAPI can be run manually on other platforms. Check the [CLIProxyAPI repository](https://github.com/router-for-me/CLIProxyAPI) for details.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I update VibeProxy?&quot; group=&quot;faq&quot;&gt;

Starting with v1.6, VibeProxy checks for updates daily and installs them automatically via Sparkle. You can also manually download new versions from the releases page.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What if a model isn&apos;t working?&quot; group=&quot;faq&quot;&gt;

1. Ensure you&apos;re authenticated for that provider in VibeProxy settings
2. Check that the model ID is correct (see Available Models section)
3. Restart VibeProxy after making changes
4. Check the application logs in Console.app (search for &quot;VibeProxy&quot;)

&lt;/Accordion&gt;

## Conclusion

VibeProxy lets you use your existing AI subscriptions with coding tools instead of paying separately for API access.

**Key Takeaways:**

✅ **Stop Paying Twice** — Use Claude Code, ChatGPT Plus, and Gemini subscriptions in any tool

✅ **Universal Compatibility** — Works with Factory AI, Amp Code, Zed, and OpenAI-compatible apps

✅ **Premium Models** — Access Claude Opus 4.5, GPT-5.1, Gemini 3 Pro, and more

✅ **Extended Thinking** — Enable deep reasoning for complex problems

✅ **Native macOS App** — Menu bar integration with automatic updates

Whether you&apos;re using [GitHub Copilot](https://www.bitdoze.com/github-copilot-complete-guide/), [Amp Code](https://www.bitdoze.com/amp-code-free-ai-coding-agent/), Factory Droids, or other AI coding tools, VibeProxy extends your existing AI subscriptions.

&lt;Button
  text=&quot;Download VibeProxy&quot;
  url=&quot;https://github.com/automazeio/vibeproxy/releases&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Continue Learning&quot;&gt;

- [How to Use Claude Sonnet 4.6 and GPT-5.5 for Free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/)
- [AI Programming Beginners Guide](https://www.bitdoze.com/ai-programming-beginners-guide/)
- [Best Open-Source LLMs as Claude Alternatives](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)
- [GitHub Copilot Complete Guide](https://www.bitdoze.com/github-copilot-complete-guide/)
- [Amp Code Free AI Coding Agent](https://www.bitdoze.com/amp-code-free-ai-coding-agent/)

&lt;/Notice&gt;

Download VibeProxy, connect your subscriptions, and use your AI models with any coding tool.</content:encoded><category>ai</category><category>ai-tools</category><category>devops</category></item><item><title>Zerobyte Restic GUI: Self-Hosted Backup Automation</title><link>https://www.bitdoze.com/zerobyte-restic-gui/</link><guid isPermaLink="true">https://www.bitdoze.com/zerobyte-restic-gui/</guid><description>Deploy Zerobyte, a Restic-powered backup dashboard, with Dokploy or Docker Compose. Create repositories, schedule jobs, and restore data safely.</description><pubDate>Mon, 04 May 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you run a VPS, homelab, or any Linux server, backups are the only insurance that matters. Disks fail, updates break, and mistakes happen. Zerobyte gives you the reliability of [Restic](https://restic.net/) with a clean web UI, so you can automate backups without living in the terminal.

## What is Zerobyte?

[Zerobyte](https://github.com/nicotsx/zerobyte) is an open-source backup automation platform that wraps the Restic engine with a modern dashboard. It lets you create repositories, schedule jobs, and monitor snapshots from a web interface instead of managing everything by hand.

### Why Restic plus Zerobyte works

| Layer | Responsibility | What you get |
| --- | --- | --- |
| Restic | Encryption, deduplication, compression, repo format | Secure and efficient backups |
| Zerobyte | Web UI, scheduling, retention, logs | A usable control plane |

Together they deliver fast, encrypted backups with a workflow you can revisit months later and still understand.

### Key features

&lt;ListCheck&gt;
- Automated backups with retention policies powered by Restic
- End-to-end encryption by default
- Flexible schedules for daily, weekly, or custom jobs
- Multi-backend repositories: local, S3, GCS, Azure, rclone remotes
- Source volumes from local directories, NFS, SMB, or WebDAV
- Clear job logs and snapshot visibility in the UI
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Early-stage project&quot;&gt;
Zerobyte is still in the 0.x series. Expect breaking changes between versions and keep a close eye on release notes.
&lt;/Notice&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/menR-JtmgLI&quot;
  label=&quot;ZeroByte: The Most Powerful Open-Source Backup UI&quot;
/&gt;


## Zerobyte UI tour

The UI is straightforward and task-focused. Here&apos;s what the main sections look like and how to use them.

### Volumes: connect data sources

![Zerobyte volumes](../../assets/images/25/12/zerobyte-volumes.webp)

Volumes define the data you want to back up. Add local directories, NFS, SMB, or WebDAV sources, then reference them when creating backup jobs.

### Repositories: choose storage backends

![Zerobyte repositories](../../assets/images/25/12/zerobyte-repositories.webp)

Repositories are the encrypted destinations for your snapshots. Configure local paths, S3-compatible storage, or rclone remotes depending on where you want backups to live.

### Backups: monitor jobs and snapshots

![Zerobyte backups](../../assets/images/25/12/zerobyte-backups.webp)

This is your operational view. Track job status, verify recent runs, and confirm snapshots are being created on schedule.

### Notifications: avoid silent failures

![Zerobyte notifications](../../assets/images/25/12/zerobyte-notifications.webp)

Notifications let you control how Zerobyte reports job events. Set them up early so failed backups never go unnoticed.

## Prerequisites

&lt;ListCheck&gt;
- Linux server or VPS with Docker and Docker Compose installed
- At least 2 GB RAM recommended for smooth UI and backup jobs
- Local disk space for `/var/lib/zerobyte` (avoid network shares)
- Optional: rclone if you want cloud storage backends
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## Option 1: Deploy with Dokploy

Dokploy is a straightforward way to deploy Docker apps with managed domains and SSL. If you don&apos;t have it yet, start here: [Dokploy install guide](https://www.bitdoze.com/dokploy-install/).

### Step 1: Create the service

1. Open your Dokploy project
2. Click Add Service and choose Compose
3. Name it `zerobyte`

### Step 2: Paste the compose file

```yaml
services:
  zerobyte:
    image: ghcr.io/nicotsx/zerobyte:v0.19
    networks:
      - dokploy-network
    restart: unless-stopped
    cap_add:
      - SYS_ADMIN
    devices:
      - /dev/fuse:/dev/fuse
    environment:
      - TZ=UTC
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - zerobyte-data:/var/lib/zerobyte
      - /srv/backups:/backups
      - /srv/data:/data:ro
      - /etc/rclone:/root/.config/rclone:ro

networks:
  dokploy-network:
    external: true

volumes:
  zerobyte-data:
```

### Step 3: Domain and port

Create a domain in Dokploy and map it to port 4096. After deploy, open `https://your-domain.com` to access the UI.

**Notes about the compose file**

- `/srv/backups` is a sample local repository path. Change it to your storage location.
- `/srv/data` is the source data to back up. Mount it read-only when possible.
- `/etc/rclone` is optional. Remove it if you don&apos;t use rclone remotes.
- The UI listens on port `4096` by default.

&lt;Notice type=&quot;info&quot; title=&quot;Storage warning&quot;&gt;
Keep `/var/lib/zerobyte` on local disk. Pointing it to a network share often causes permission issues and poor performance.
&lt;/Notice&gt;

## Option 2: Docker Compose (standalone)

### Full setup (remote mounts enabled)

Use this if you need Zerobyte to mount NFS, SMB, or WebDAV shares inside the container.

```yaml
services:
  zerobyte:
    image: ghcr.io/nicotsx/zerobyte:v0.19
    container_name: zerobyte
    restart: unless-stopped
    cap_add:
      - SYS_ADMIN
    ports:
      - &quot;4096:4096&quot;
    devices:
      - /dev/fuse:/dev/fuse
    environment:
      - TZ=UTC
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/lib/zerobyte:/var/lib/zerobyte
```

Start it:

```bash
docker compose up -d
```

### Simplified setup (local folders only)

If you only back up local directories, drop the FUSE device and extra capabilities:

```yaml
services:
  zerobyte:
    image: ghcr.io/nicotsx/zerobyte:v0.19
    container_name: zerobyte
    restart: unless-stopped
    ports:
      - &quot;4096:4096&quot;
    environment:
      - TZ=UTC
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - /var/lib/zerobyte:/var/lib/zerobyte
      - /srv/data:/data:ro
```

**Trade-offs**

- Better security with fewer privileges
- Works for local directories
- No direct NFS, SMB, or WebDAV mounts

## Add your first volume

Zerobyte backs up data from volumes (source locations). To back up a local directory, mount it into the container and then pick it from the UI.

Example host path:

```diff
services:
  zerobyte:
    volumes:
      - /var/lib/zerobyte:/var/lib/zerobyte
+     - /srv/projects:/projects:ro
```

Then go to Volumes in Zerobyte and create a new &quot;Directory&quot; volume that points to `/projects`.

## Create a repository

Repositories are where your encrypted backups live. Zerobyte supports multiple backends:

| Repository type | Example |
| --- | --- |
| Local directory | `/var/lib/zerobyte/repositories/myrepo` |
| S3 compatible | AWS S3, MinIO, Wasabi, Backblaze |
| Google Cloud Storage | GCS buckets |
| Azure Blob Storage | Azure containers |
| rclone remotes | Google Drive, Dropbox, OneDrive, etc |

### Using rclone for cloud storage

1. Install rclone on your host: `curl https://rclone.org/install.sh | sudo bash`
2. Configure a remote: `rclone config`
3. Mount the config into the container:

```diff
services:
  zerobyte:
    volumes:
      - /var/lib/zerobyte:/var/lib/zerobyte
+     - ~/.config/rclone:/root/.config/rclone
```

After restarting the container, choose rclone as the repository type and select your remote.

## Create your first backup job

1. Pick a source volume and a repository
2. Set a schedule (daily, weekly, or custom)
3. Add include and exclude paths
4. Configure retention (keep daily, weekly, monthly, yearly)
5. Run the job once manually to verify credentials and performance

## Restore data

Open Backups, select a snapshot, and restore files back to their original paths. For partial restores, pick only the files you need and Zerobyte will pull them from Restic.

## Post-deploy checklist

&lt;ListCheck&gt;
- Create at least one repository and test a manual backup
- Validate restores with a small file set
- Schedule jobs during low-usage windows for the first run
- Keep Zerobyte updated and review logs for failures
&lt;/ListCheck&gt;

## Restic CLI commands you should know

```bash
# initialize a local repo
export RESTIC_REPOSITORY=/srv/backups/restic-repo
export RESTIC_PASSWORD=&quot;strong-password&quot;
restic init

# create a backup
restic -r $RESTIC_REPOSITORY backup /home /etc

# list snapshots
restic -r $RESTIC_REPOSITORY snapshots

# forget and prune with a retention policy
restic -r $RESTIC_REPOSITORY forget --prune \
  --keep-daily 7 --keep-weekly 4 --keep-monthly 12 --keep-yearly 3
```

## Practical retention tips

- Test restores regularly. A backup you cannot restore is useless.
- Keep Zerobyte metadata on local disk for performance and permissions.
- Expect the first backup to be heavy on I/O and bandwidth.
- Keep at least one independent offline copy for critical data.
- Use `restic check` and inspect logs when a job fails.

## FAQ

&lt;Accordion label=&quot;Do I need SYS_ADMIN and /dev/fuse?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Only if you want Zerobyte to mount NFS, SMB, or WebDAV shares inside the container. For local folders, remove the capability and device for a safer setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Where does Zerobyte store repositories?&quot; group=&quot;faq&quot;&gt;
Local repositories live under `/var/lib/zerobyte/repositories` by default. Cloud repositories are stored in the backend you configure.
&lt;/Accordion&gt;

## Final thoughts

Restic gives you trustworthy, encrypted backups. Zerobyte adds the convenience of a modern dashboard and scheduling layer. If you want a low-maintenance, self-hosted backup system, this combo is hard to beat.

&lt;Button text=&quot;View Zerobyte on GitHub&quot; link=&quot;https://github.com/nicotsx/zerobyte&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>backup</category><category>restic</category></item><item><title>GitHub Copilot Usage-Based Pricing: What Changes and 5 Cheaper Alternatives</title><link>https://www.bitdoze.com/github-copilot-alternatives-2026/</link><guid isPermaLink="true">https://www.bitdoze.com/github-copilot-alternatives-2026/</guid><description>GitHub Copilot moves to usage-based billing June 1, 2026. What changes, how much it actually costs, and five alternatives that give you more control over your AI coding budget.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

GitHub announced on April 27, 2026 that all Copilot plans move to usage-based billing on June 1. Premium request units are gone. Instead, you get GitHub AI Credits, consumed based on actual token usage at published API rates. The base plan prices stay the same ($10/month for Pro, $39/month for Pro+, $19/user/month for Business), but what you can do within those prices changes a lot.

I spent the last few days reading the announcement, the docs, the pricing tables, and the community reaction. Here is what is actually changing, what it costs, and what I would switch to if the math does not work out.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;What changes on June 1 and what stays the same&lt;/li&gt;
&lt;li&gt;Actual cost breakdown per model and task type&lt;/li&gt;
&lt;li&gt;Five alternatives that give you more control over spend&lt;/li&gt;
&lt;li&gt;My recommendation for what to do before June 1&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## What is changing on June 1

The old system used Premium Request Units (PRUs). You had a monthly allotment, and each model had a fixed cost in PRUs per request. If you ran out, you fell back to a lower-cost model.

The new system uses GitHub AI Credits. Each plan includes a monthly credit amount equal to the plan price:

| Plan | Monthly Price | Monthly Credits |
|------|--------------|-----------------|
| Copilot Pro | $10 | $10 |
| Copilot Pro+ | $39 | $39 |
| Copilot Business | $19/user | $19/user |
| Copilot Enterprise | $39/user | $39/user |

Credits are consumed based on token usage at the [published API rates](https://docs.github.com/en/copilot/reference/copilot-billing/models-and-pricing) for each model. Heavier usage burns credits faster. When credits run out, the service stops working until you buy more or your cycle resets.

### What stays the same

- **Base plan prices** are not changing.
- **Code completions and Next Edit suggestions** remain included in all plans and do not consume AI Credits.
- **Model selection** — you can still pick between different models in Copilot.

### What is actually different

- **No more fallback.** Under the old system, if you exhausted your PRUs, Copilot would drop to a cheaper model and keep working. Under the new system, when credits run out, you are done. You have to buy more or wait.
- **Usage varies wildly by model.** A quick code completion in GPT-4o-mini is cheap. A multi-hour agentic session with Claude Opus 4.6 can burn through your entire monthly credit in a single session.
- **Copilot code review now also consumes GitHub Actions minutes.** That is on top of the AI Credits.
- **Annual plan holders:** you stay on PRU-based pricing until your plan expires, but model multipliers increase on June 1. When the annual plan expires, you move to Copilot Free with an option to upgrade to monthly.

### The business/enterprise promo

GitHub is offering promotional credits for the transition period (June through August):

- **Business:** $30 in monthly AI Credits (up from $19)
- **Enterprise:** $70 in monthly AI Credits (up from $39)

After August, those drop back to the standard amounts.

## How much will it actually cost?

This is the part GitHub&apos;s announcement glosses over. The published API rates for each model determine how fast you burn credits. Here is what the math looks like for common Copilot tasks:

| Task | Model | Approximate Token Usage | Credit Cost |
|------|-------|------------------------|-------------|
| Code completion | GPT-4o-mini | ~500 tokens | fractions of a cent |
| Chat question | Claude Sonnet 4.6 | ~2K input, 500 output | ~$0.02 |
| Multi-file refactor | Claude Opus 4.6 | ~20K input, 5K output | ~$0.50 |
| Agentic session (1 hour) | Claude Opus 4.6 | ~200K input, 50K output | ~$5-8 |
| Agentic session (4 hours) | Claude Opus 4.6 | ~1M input, 250K output | ~$25-40 |

If you do one long agentic session with Opus per month, that eats most of a $10 Pro credit allotment. If you do two or three, you&apos;re buying extra credits or switching models.

The key takeaway: **light Copilot users (completions, occasional chat) will barely notice. Heavy agentic users will pay more, possibly much more.**

## Five alternatives that save you money

### 1. OpenCode (best overall alternative)

OpenCode is the open-source terminal coding agent with 120K+ GitHub stars. You pick the model and the provider. No monthly subscription required — just pay for API tokens at cost.

&lt;Button text=&quot;OpenCode Setup Guide&quot; link=&quot;/opencode-setup-guide/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

**Cost example:** MiniMax M2.7 at $0.30/M input, $1.20/M output. A typical coding session uses 50K input and 10K output tokens — that is about $0.03 per session. Run 100 sessions a month for $3.

**Why it works:** Model choice is the killer feature. Use MiniMax M3 for routine edits, GLM 5.2 for complex refactors, DeepSeek V4 Pro for big repos. You control the cost per task.

### 2. OpenAI Codex CLI (free with ChatGPT plan)

Codex CLI is OpenAI&apos;s open-source terminal agent. If you already pay for ChatGPT Plus ($20/month) or ChatGPT Pro ($200/month), Codex is included.

**Cost:** Included with your existing ChatGPT subscription. No additional API costs for the default model.

**Why it works:** If you&apos;re already paying for ChatGPT, Codex CLI is a free add-on. It uses GPT-5.4 by default and supports MCP servers.

**Limitation:** OpenAI only. You can&apos;t use Anthropic, Google, or open source models.

### 3. Aider (lightweight and flexible)

Aider is a minimal terminal coding agent that works with any LLM provider. It is less flashy than OpenCode but has stronger git integration and supports more obscure providers.

**Cost:** Pay for API tokens at cost. Same pricing as using the models directly.

**Why it works:** Aider is the most provider-agnostic option. If you need to connect to a niche provider or a local Ollama instance, Aider handles it.

**Limitation:** No plan mode, no image support, minimal TUI.

### 4. OpenCode Go ($10/month flat)

If you want one subscription that covers everything, the [OpenCode Go $10/month plan](/opencode-go-plan/) gives you access to 16 models (Grok 4.5, Kimi K3, GLM-5.2, DeepSeek V4, and more). For a detailed breakdown with limits and benchmarks, see the [OpenCode Go review](/opencode-go-plan/).

&lt;Button text=&quot;OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

**Cost:** $10/month. Usage limits cap at $60/month in equivalent API spend.

**Why it works:** Predictable cost, no per-token surprises. 16 models including Grok 4.5, Kimi K3, MiniMax M3, Qwen3.7, GLM-5.2, and DeepSeek V4 Pro.

**Limitation:** Usage limits exist ($12 per 5 hours, $30 per week, $60 per month). Heavy users might hit these.

### 5. Local models with Ollama (zero API cost)

If you have decent hardware (16GB+ RAM or a GPU), you can run coding models locally through Ollama and point OpenCode or Aider at them.

**Cost:** Zero per-token cost. Electricity and hardware only.

**Why it works:** Complete privacy, no API keys, no subscriptions, no rate limits.

**Limitation:** Local models are weaker than API models for complex coding tasks. A 32B model handles simple edits and refactoring, but for multi-file changes across a large codebase, you want an API model. See our [Ollama Docker guide](/ollama-docker-install/) for setup.

## What I would do before June 1

If you&apos;re on a monthly Copilot plan, you get automatically migrated on June 1. There is nothing you need to do, but there are a few things worth considering:

**Check your current usage.** GitHub is launching a &quot;preview bill&quot; experience in early May on the Billing Overview page. See how much your current usage would cost under the new system.

**If you only use completions:** Stay on Copilot. Completions are still included and do not consume credits. The new pricing does not affect you.

**If you use Copilot Chat occasionally:** The $10/month Pro plan with $10 in credits probably covers you. Stick with it and see.

**If you use agentic features heavily:** This is where it gets expensive. Switch to OpenCode with a cheap model for daily work, keep Copilot for completions only, and use Claude Code or Codex CLI for the occasional complex task.

**If you&apos;re on an annual plan:** You stay on the old PRU system until your plan expires. Model multipliers go up on June 1 though. When the annual plan expires, you move to Copilot Free and need to decide whether to switch to monthly or go elsewhere.

## The bigger picture

GitHub&apos;s move to usage-based billing is not a surprise. Every AI provider is heading this way. Anthropic, Google, and OpenAI have all adjusted pricing recently. The flat-rate era for AI coding tools is ending because agentic usage (long sessions, many tool calls, large contexts) costs far more to serve than simple completions.

The developers who will be fine are the ones who use the right model for the right task. A $0.30/M model for routine edits. A $1.00/M model for complex refactors. A $3.50/M model only when you need maximum accuracy. OpenCode makes this easy because you switch models with a keystroke. Copilot does not give you that control.

&lt;Notice type=&quot;info&quot; title=&quot;Related guides&quot;&gt;
- [OpenCode setup guide](/opencode-setup-guide/) — full installation and configuration walkthrough
- [Best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) — pricing and benchmarks for all the major open source models
- [Hermes Agent setup guide](/hermes-agent-setup-guide/) — self-hosted AI agent for server tasks
- [Best open source models for OpenClaw](/best-opensource-models-for-openclaw/) — model recommendations that apply to any coding agent
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Will Copilot completions still work the same?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Code completions and Next Edit suggestions remain included in all plans and do not consume AI Credits. If that&apos;s all you use Copilot for, nothing changes for you.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I buy additional credits if I run out?&quot; group=&quot;faq&quot;&gt;
Yes. Paid plan users can purchase additional AI Credits. GitHub has not published the exact pricing for extra credits yet, but they will be available at the published API rates.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens to annual plan subscribers?&quot; group=&quot;faq&quot;&gt;
Annual plan holders stay on PRU-based pricing until their plan expires. Model multipliers increase on June 1 for annual subscribers only. At expiration, you move to Copilot Free with the option to upgrade to a monthly plan. You can also convert to monthly before expiration and get prorated credits.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is OpenCode really free?&quot; group=&quot;faq&quot;&gt;
The software is free and open source. You pay for LLM API usage. With cheap models like MiniMax M2.7 at $0.30/M input, a month of heavy coding costs $5-15. [OpenCode Go](/opencode-go-plan/) bundles 16 models for $10/month.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use multiple tools at once?&quot; group=&quot;faq&quot;&gt;
Yes. I use Copilot for in-editor completions, OpenCode for terminal coding, and Hermes Agent for server tasks. They don&apos;t conflict with each other. The best setup is often a combination: cheap completions from Copilot plus a model-agnostic terminal agent like OpenCode for the heavy lifting.
&lt;/Accordion&gt;

For more on AI coding tools, model pricing, and self-hosted agent setups, check out our [AI coding tools comparison](/ai-coading-tools/), the [OpenClaw alternatives](/openclaw-alternatives/) roundup, and the [top AI GitHub repos](/top-ai-github-repos/) catalog (OpenCode, Pi, Hermes, skills).</content:encoded><category>ai</category><category>ai-tools</category><category>vps</category></item><item><title>OpenCode Setup Guide: Open-Source Claude Code Alternative on Your VPS</title><link>https://www.bitdoze.com/opencode-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/opencode-setup-guide/</guid><description>Install and configure OpenCode on a Linux VPS. Connect it to cheap open source models like MiniMax M2.7, Qwen 3.6, and DeepSeek V4. The open-source alternative to Claude Code that costs a fraction of the price.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Claude Code is great. I use it. But it costs $20/month minimum, burns through your rate limits fast, and if you&apos;re on the annual plan, you&apos;re locked into Anthropic&apos;s pricing. With GitHub Copilot [switching to usage-based billing on June 1](/github-copilot-alternatives-2026/), a lot of developers are looking for something they can control.

[OpenCode](https://opencode.ai/) is the open-source alternative that keeps getting better. 120K+ GitHub stars, 75+ model providers, and you can run it on a $6/month VPS with open source models that cost pennies per request. I have been using it alongside [Hermes Agent](/hermes-agent-setup-guide/) and [OpenClaw](/clawdbot-setup-guide/) for the past few weeks and it has become my default terminal coding agent.

This guide covers installation on a Linux VPS, connecting to cheap models, and the workflow I actually use day to day.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing OpenCode on a Linux VPS in under a minute&lt;/li&gt;
&lt;li&gt;Connecting to cheap open source models via OpenRouter and direct providers&lt;/li&gt;
&lt;li&gt;Setting up OpenCode Go for $10/month with access to 16 models&lt;/li&gt;
&lt;li&gt;Plan mode vs build mode, the /init workflow, and real usage tips&lt;/li&gt;
&lt;li&gt;How OpenCode compares to Claude Code, Codex CLI, and Aider&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## What OpenCode actually is

OpenCode is a terminal-based AI coding agent. It reads your codebase, plans changes, edits files, runs commands, and iterates on failures — the same loop Claude Code runs, but open source and provider-agnostic. It works with Anthropic, OpenAI, Google, Alibaba, MiniMax, DeepSeek, Xiaomi, Moonshot, and 70+ other providers. You can also use local models through Ollama.

The key difference from Claude Code: you pick the model and the provider. If Claude Sonnet is too expensive for a task, switch to Qwen 3.6 Plus at $0.33/M input tokens. If you need maximum reasoning, switch to GLM 5.2. You control the cost.

&lt;Button text=&quot;OpenCode GitHub&quot; link=&quot;https://github.com/anomalyco/opencode&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

## Installing OpenCode on a VPS

The install script handles everything. On a Linux VPS:

```bash
curl -fsSL https://opencode.ai/install | bash
```

This installs the `opencode` binary. Verify it works:

```bash
opencode --version
```

Other install methods if the script does not work for your setup:

&lt;Tabs&gt;
&lt;Tab name=&quot;npm&quot;&gt;
```bash
npm install -g opencode-ai
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Bun&quot;&gt;
```bash
bun install -g opencode-ai
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Homebrew&quot;&gt;
```bash
brew install anomalyco/tap/opencode
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;
```bash
docker run -it --rm ghcr.io/anomalyco/opencode
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

### What you need

- Node.js 18+ (the install script handles this on most systems)
- An API key from at least one provider
- A terminal emulator that supports images if you want drag-and-drop screenshots (WezTerm, Ghostty, Kitty, or Alacritty)

On a headless VPS, you don&apos;t need the image support. OpenCode works fine over plain SSH.

## Configuring a model provider

OpenCode supports three ways to connect models, from easiest to most flexible.

### Option 1: OpenCode Zen (simplest)

OpenCode Zen is the built-in provider. You sign in at [opencode.ai/auth](https://opencode.ai/auth), get an API key, and paste it into OpenCode. The Zen provider has a curated list of models tested and verified by the OpenCode team.

Run `/connect` in the TUI, select `opencode`, and paste your key.

### Option 2: OpenRouter (most models)

OpenRouter gives you access to 200+ models through a single API key. This is what I use for most of my work.

```bash
# Add your OpenRouter key
echo &quot;OPENROUTER_API_KEY=your-key-here&quot; &gt;&gt; ~/.config/opencode/.env
```

Then in OpenCode, run `/connect` and select OpenRouter.

### Option 3: Direct provider keys

For specific providers you can set environment variables directly:

```bash
# MiniMax
echo &quot;MINIMAX_API_KEY=your-key&quot; &gt;&gt; ~/.config/opencode/.env

# Z.AI (GLM)
echo &quot;ZAI_API_KEY=your-key&quot; &gt;&gt; ~/.config/opencode/.env

# Xiaomi MiMo
echo &quot;MIMO_API_KEY=your-key&quot; &gt;&gt; ~/.config/opencode/.env

# DeepSeek
echo &quot;DEEPSEEK_API_KEY=your-key&quot; &gt;&gt; ~/.config/opencode/.env

# Moonshot (Kimi)
echo &quot;MOONSHOT_API_KEY=your-key&quot; &gt;&gt; ~/.config/opencode/.env
```

## OpenCode Go: $10/month, all models included

If you don&apos;t want to manage multiple API keys, the [OpenCode Go $10/month subscription](/opencode-go-plan/) bundles 16 models (Grok 4.5, Kimi K3, GLM-5.2, DeepSeek V4, MiniMax M3, and more) into a single API key. For a detailed breakdown of limits, model quality, and real-world usage, see the full [OpenCode Go review](/opencode-go-plan/).

&lt;Button text=&quot;OpenCode Go&quot; link=&quot;https://go.bitdoze.com/opencode-go&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### What you get

- $10/month
- MiniMax M3, M2.7, MiMo V2.5 Pro, GLM 5.2, Kimi K2.5, K2.6, DeepSeek V4 Pro, V4 Flash, Qwen 3.5 Plus, Qwen 3.6 Plus, and more
- Models hosted in the US, EU, and Singapore
- Zero-retention policy

### Usage limits

| Limit | Cap |
|-------|-----|
| Per 5 hours | $12 |
| Per week | $30 |
| Per month | $60 |

Cheaper models stretch further. MiniMax M3 gives you an estimated 3,400 requests per 5 hours. GLM 5.2 gives you 620.

### Setting it up

Subscribe at [opencode.ai/auth](https://go.bitdoze.com/opencode-go), copy your API key, then in OpenCode:

```
/connect
```

Select `OpenCode Go` and paste your key. Switch models with `/models`.

The Go endpoint is also accessible via API at `https://opencode.ai/zen/go/v1/chat/completions`, so you can use it with [Hermes Agent](/hermes-agent-setup-guide/) or [OpenClaw](/clawdbot-setup-guide/) too.

## Working with OpenCode day to day

### Initialize a project

The first time you open OpenCode in a project:

```bash
cd /path/to/project
opencode
```

Then run:

```
/init
```

OpenCode analyzes your codebase and creates an `AGENTS.md` file in the project root. This file tells OpenCode about your project structure, coding patterns, and conventions. You can edit it manually to add custom instructions.

### Plan mode vs build mode

This is the workflow I keep coming back to. Hit **Tab** to switch between modes:

**Plan mode** (read-only): OpenCode reads your codebase and describes how it would implement a feature. No changes are made. You review the plan, give feedback, iterate.

**Build mode** (read-write): OpenCode makes the actual changes to your files.

The pattern:

1. Switch to plan mode with Tab
2. Describe what you want
3. Review the plan, add constraints or images
4. Switch to build mode with Tab
5. Tell it to go ahead

This is safer than letting the agent loose immediately, especially on production code.

### Referencing files

Use `@` to point OpenCode at specific files:

```
Look at @src/api/auth.ts and add the same pattern to @src/api/settings.ts
```

### Undo and redo

If OpenCode makes a change you don&apos;t like:

```
/undo
```

It reverts the changes and shows your original prompt again so you can rephrase.

```
/redo
```

Brings the changes back if you changed your mind.

### Sharing conversations

```
/share
```

Creates a link to your current conversation. Useful for showing teammates what the agent did or getting a second opinion on the plan.

## Choosing the right model for the job

Not every task needs the most expensive model. Here is what I use:

| Task | Model | Why |
|------|-------|-----|
| Quick edits, file refactoring | MiniMax M2.7 | Cheap and fast, handles most edits fine |
| Frontend work, UI generation | Qwen 3.6 Plus | Strong at &quot;vibe coding&quot; and design |
| Complex multi-file changes | GLM 5.2 | Best coding accuracy, worth the cost |
| Long context, big repos | DeepSeek V4 Pro | 1M context, low hallucination rate |
| Default everyday use | MiniMax M2.7 or Qwen 3.6 Plus | Balance of cost and quality |

Switch models in OpenCode with `/models`. No restart needed.

## OpenCode vs Claude Code vs Codex CLI

| Feature | OpenCode | Claude Code | Codex CLI |
|---------|----------|-------------|-----------|
| **Price** | Free (pay for API) | $20/month + API | Included with ChatGPT |
| **Model choice** | 75+ providers | Anthropic only | OpenAI only |
| **Open source** | Yes | No | Yes |
| **Plan mode** | Yes (Tab key) | No (use /plan) | No |
| **Image support** | Drag and drop | Paste images | No |
| **MCP servers** | Yes | Yes | Yes |
| **Undo/redo** | /undo, /redo | Git-based | No |
| **VPS friendly** | Yes | Yes | Yes |

The biggest advantage OpenCode has over Claude Code is model choice. You&apos;re not locked into Anthropic&apos;s pricing. When Claude Sonnet gets expensive, switch to a cheaper model for routine tasks and save Claude for the hard stuff.

The biggest advantage over Codex CLI is the plan mode and the ability to work with non-OpenAI models.

&lt;Notice type=&quot;warning&quot; title=&quot;Subscription risk&quot;&gt;
Using your Claude Code or ChatGPT subscription OAuth tokens with automated coding agents can get your account banned. These providers monitor for automated usage. Use API keys from the models listed above instead. See our [models guide](/best-opensource-models-for-openclaw/) for the full breakdown.
&lt;/Notice&gt;

## Add persistent memory to OpenCode

OpenCode supports MCP servers, which means you can give it long-term memory with [Hindsight](/hindsight-docker-deploy/). There&apos;s a [community plugin](https://hindsight.vectorize.io/integrations) that auto-retains your conversations and recalls relevant context on session start. It adds retain, recall, and reflect tools directly into OpenCode&apos;s tool palette.

If you&apos;d rather use the MCP server approach:

```bash
# After deploying Hindsight (see the guide linked above)
# Add it as an MCP server in your opencode config
```

This is useful if you work on the same project across multiple sessions and want OpenCode to remember decisions, conventions, and past debugging sessions.

## Running OpenCode on a VPS via SSH

OpenCode works over SSH. I run it on a $6/month Hetzner VPS and connect from my laptop. A few tips:

Use **tmux** so your session survives if the SSH connection drops:

```bash
tmux new -s code
cd /path/to/project
opencode
```

If you detach (`Ctrl+B, D`) and reconnect later, `tmux attach -t code` puts you back where you left off.

For a better terminal experience over SSH, use **mosh** instead of plain SSH. It handles network drops and roaming better:

```bash
mosh user@your-vps-ip
```

## FAQ

&lt;Accordion label=&quot;Is OpenCode really free?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
The software is free and open source. You pay for the LLM API usage. If you use OpenCode Go, that&apos;s $5 for the first month then $10/month. If you use direct provider keys, you pay per token. MiniMax M2.7 through OpenRouter costs $0.30/M input tokens, which is roughly $3-7/month for typical coding use.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use OpenCode with local models via Ollama?&quot; group=&quot;faq&quot;&gt;
Yes. Set `OPENAI_BASE_URL=http://localhost:11434/v1` and `OPENAI_API_KEY=ollama` in your OpenCode config. Then select the local model in OpenCode. See our [Ollama Docker guide](/ollama-docker-install/) for setting up Ollama. Performance depends on your hardware — a 7B model handles simple edits, but complex multi-file tasks need at least a 32B model.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does OpenCode compare to Aider?&quot; group=&quot;faq&quot;&gt;
Both are terminal coding agents. OpenCode has a richer TUI with plan mode, image support, and sharing. Aider is more minimal and has stronger git integration. Aider supports more obscure model providers. For most developers, OpenCode is the better daily driver.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use OpenCode alongside Hermes Agent?&quot; group=&quot;faq&quot;&gt;
Yes, they complement each other. OpenCode is for coding tasks — editing files, running builds, fixing bugs. Hermes Agent is for broader tasks — web searches, scheduled jobs, messaging, server management. Run both on the same VPS.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What VPS do I need?&quot; group=&quot;faq&quot;&gt;
Any Linux VPS with 1GB+ RAM works. OpenCode itself is lightweight — the heavy lifting happens at the API provider. A $6/month Hetzner CX22 or equivalent is more than enough. See our [VPS hosting comparison](/vps-ai-coding-setup/) for options.
&lt;/Accordion&gt;

If you are also running AI agents on your server, check out our [Hermes Agent setup guide](/hermes-agent-setup-guide/) and [OpenClaw alternatives](/openclaw-alternatives/) roundup. For the cheapest models to use with any of these tools, the [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide covers pricing and benchmarks. For Alibaba&apos;s latest Qwen 3.6 models that work great with OpenCode, see the [Qwen 3.6 for AI coding agents](/qwen36-ai-coding-agents/) guide. If you want a more minimal terminal coding agent with a TypeScript extension system, our [Pi coding agent setup guide](/pi-coding-agent-setup-guide/) covers installation, model configuration, and the best extensions. For a detailed comparison of these two agents, see the [OpenCode vs Pi Agent comparison](/opencode-vs-pi-agent/). For a deep dive into OpenCode Go&apos;s limits, models (including Grok 4.5 and Kimi K3), and whether the subscription is worth it, see the [OpenCode Go $10/month plan review](/opencode-go-plan/). Full map of AI GitHub projects next to OpenCode: [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>OpenCode vs Pi Agent: Which Terminal Coding Agent Should You Use?</title><link>https://www.bitdoze.com/opencode-vs-pi-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/opencode-vs-pi-agent/</guid><description>A hands-on comparison of OpenCode and Pi terminal coding agents. Covers installation, model support, extensions, workflow differences, pricing, and which one fits your style of work.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

I have been running [OpenCode](/opencode-setup-guide/) and [Pi](/pi-coding-agent-setup-guide/) side by side for the past few weeks. Both are open-source terminal coding agents, both connect to cheap models, and both run on a $6/month VPS. But they work differently enough that picking one over the other actually matters.

OpenCode gives you everything upfront. You install it, connect a model, and you have plan mode, image support, MCP servers, undo/redo, and a rich TUI. Pi gives you four tools and a TypeScript extension system. You add memory, planning, MCP support, and sub-agents through extensions you pick yourself.

I ended up using both for different things. Here is what I found.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;How the two agents differ in philosophy and default behavior&lt;/li&gt;
&lt;li&gt;Installation and model configuration side by side&lt;/li&gt;
&lt;li&gt;Workflow differences: plan mode, extensions, memory, sessions&lt;/li&gt;
&lt;li&gt;Pricing with cheap models through OpenRouter and OpenCode Go&lt;/li&gt;
&lt;li&gt;When to pick one over the other (or both)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

For full setup instructions, see the [OpenCode setup guide](/opencode-setup-guide/) and the [Pi coding agent setup guide](/pi-coding-agent-setup-guide/). If you want to compare these agents to Claude Code too, both guides include comparison tables.

## Philosophy: batteries-included vs build-your-own

This is the core difference and everything else flows from it.

OpenCode is batteries-included. You get a polished TUI with plan mode, build mode, image drag-and-drop, MCP server support, undo/redo, conversation sharing, and a `/init` command that bootstraps your project. The moment you finish installing, you have a fully functional coding agent. You pick a model and start working.

Pi starts with four tools: read, write, edit, and bash. No plan mode. No MCP. No memory. No sub-agents. You add what you need through TypeScript extensions. That sounds like a disadvantage until you realize you can build exactly the agent you want, with no feature you will not use sitting in the way.

Neither approach is better. It depends on how you work. If you want to sit down and start coding immediately, OpenCode gets you there faster. If you want to craft a specific workflow and are willing to spend an hour setting up extensions, Pi gives you more control over what the agent can do.

## Installation

Both install in under a minute on any Linux VPS with Node.js 18+.

### OpenCode

```bash
curl -fsSL https://opencode.ai/install | bash
```

Also available through npm (`npm install -g opencode-ai`), Bun, Homebrew, and Docker.

### Pi

```bash
npm install -g @mariozechner/pi-coding-agent
```

Run it with `pi` in any project directory.

OpenCode has its own install script and a binary distribution. Pi is a standard npm package. For Docker, both work fine:

```bash
# OpenCode
docker run -it --rm ghcr.io/anomalyco/opencode

# Pi
docker run -it --rm -v $(pwd):/workspace -w /workspace node:22 \
  npx @mariozechner/pi-coding-agent
```

## Model support

Both agents connect to the same cheap models. The difference is how you configure them.

### OpenCode

OpenCode has 75+ built-in providers. You set environment variables or use the `/connect` command in the TUI. OpenRouter, Anthropic, OpenAI, Google, MiniMax, DeepSeek, ZAI (GLM), Xiaomi MiMo, Moonshot (Kimi), and dozens more are ready to go.

### Pi

Pi has 20+ built-in providers with the same env variable approach. For anything not built-in, you create `~/.pi/agent/models.json` to add custom providers, Ollama, or any OpenAI-compatible API. The `models.json` file reloads when you open `/model`, so you can edit it mid-session.

Both support OpenRouter, which is the easiest way to access all the cheap models with one key:

```bash
export OPENROUTER_API_KEY=sk-or-...
```

Both also support [OpenCode Go](https://go.bitdoze.com/opencode-go) as a built-in provider. At $10/month, the [OpenCode Go subscription](/opencode-go-plan/) includes 16 models: Grok 4.5, Kimi K3, GLM-5.2, DeepSeek V4 Pro, MiniMax M3, and more. For a breakdown of cheap models and their benchmarks, see the [best cheap models guide](/best-cheap-models-hermes-agent/). For limits and real-world usage, see the full [OpenCode Go review](/opencode-go-plan/).

### Models I use with both agents

| Task | Model | Cost |
|------|-------|------|
| Everyday edits | MiniMax M2.7 | $0.30/M input |
| Frontend / UI work | Qwen 3.6 Plus | $0.33/M input |
| Multi-file refactoring | GLM 5.2 | $1.40/M input |
| Big repos, long context | DeepSeek V4 Pro | $0.435/M input |

## Workflow comparison

### Plan mode

OpenCode has plan mode built in. Press Tab to switch between plan mode (read-only, the agent describes what it would do) and build mode (the agent makes changes). This is the workflow pattern I keep coming back to in OpenCode.

Pi has plan mode through the `pi-plan` extension. You install it, and Pi gets the same read-only planning with approval-based execution. It works the same way conceptually, but you have to install it first.

### MCP support

OpenCode supports MCP servers out of the box. Configure them in the OpenCode config and they are available immediately.

Pi needs the `pi-mcp-adapter` extension. Once installed, it connects to any MCP-compatible tool server — GitHub, Playwright, Brave Search, Postgres, Notion, Slack. If you already have MCP servers configured for other agents like [Hermes Agent](/hermes-agent-setup-guide/), you can reuse them.

### Memory

OpenCode does not have persistent memory. Each session starts fresh.

Pi gets memory through the `pi-memory-md` extension. The agent stores what it learns about your project in Markdown files and loads them on the next session. This is the first extension I install when setting up Pi. After a few sessions, the agent already knows your project structure, conventions, and quirks.

### Sub-agents

OpenCode does not support sub-agents.

Pi has `pi-subagents`. When a task has independent parts, sub-agents tackle them in parallel. This matters for complex refactors or multi-file changes where different parts of the work do not depend on each other.

### Image support

OpenCode supports drag-and-drop images in terminals that support it (WezTerm, Ghostty, Kitty, Alacritty). Over SSH with tmux, you can paste images instead.

Pi supports both paste and drag. The experience is similar across SSH.

### Project initialization

OpenCode has `/init`. It reads your codebase and creates an `AGENTS.md` file with project conventions, structure, and coding patterns. You can edit it manually.

Pi does not have a built-in init command. You create `AGENTS.md` yourself. Pi loads it from `~/.pi/agent/AGENTS.md` (global) and from your project root. With the `pi-config` community repository, you can copy curated extension and skill configs to get started faster.

### Sessions

Both agents save sessions automatically.

OpenCode: `/undo`, `/redo`, `/share`, and conversation history.

Pi: `/resume`, `/new`, `/tree`, `/fork`, `/clone`. Pi also supports non-interactive mode with `pi -p &quot;prompt&quot;` for one-shot commands in scripts or CI. The `--mode json` and `--mode rpc` flags give you structured output for process integration.

## Extensions and customization

This is where the two agents diverge the most.

### OpenCode

OpenCode is configured through files and environment variables. You set model providers in `.env`, configure MCP servers in the config, and use `AGENTS.md` for project instructions. There is no plugin system or extension API. What you see is what you get.

### Pi

Pi has a full TypeScript extension system. Extensions are `.ts` files you drop into `~/.pi/agent/extensions/`. They can:

- Register custom tools the LLM can call
- Intercept tool calls before execution (to block dangerous commands, for example)
- Add slash commands
- Modify the system prompt
- Build custom TUI components
- Hot-reload with `/reload` without restarting

The community around this extension system is growing. [LazyPi](https://lazypi.org/) bundles 60+ skills, 76 themes, MCP support, sub-agents, memory, and planning mode into one command. The [pi-config](https://github.com/amosblomqvist/pi-config) repository has curated extensions for web search, bash guards, Reddit, PDF reading, and more.

If you want to build a specific workflow, the extension system gives you control that OpenCode does not offer. But it takes time to set up, and you need to be comfortable writing or at least configuring TypeScript.

## Pricing

Both agents are free and open source. You pay for LLM API usage.

| Scenario | Cost |
|----------|------|
| MiniMax M2.7 via OpenRouter | $3-10/month typical coding use |
| Qwen 3.6 Plus via OpenRouter | $5-15/month |
| OpenCode Go subscription | $10/month |
| Claude Sonnet via Anthropic | $15-50/month depending on use |

OpenCode Go works with both agents. Subscribe once and use the key in OpenCode and Pi. The usage limits are shared: $12 per 5 hours, $30 per week, $60 per month.

For the full pricing breakdown, see the [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide.

## Side-by-side summary

| Feature | OpenCode | Pi |
|---------|----------|-----|
| Install | Binary/npm/Homebrew/Docker | npm package |
| Default tools | Full set (read, write, edit, bash, grep, find, ls) | 4 (read, write, edit, bash) |
| Plan mode | Built-in (Tab key) | Via extension |
| MCP support | Built-in | Via extension |
| Memory | No | Via extension |
| Sub-agents | No | Via extension |
| Image support | Drag and drop + paste | Paste + drag |
| Model providers | 75+ built-in | 20+ built-in + models.json for custom |
| /init project setup | Yes | No (manual AGENTS.md) |
| Extension system | None | TypeScript extensions |
| Themes | Built-in | 2 built-in + 76 community |
| Session features | Undo, redo, share | Tree, fork, clone, non-interactive mode |
| Pricing | Free (pay API) | Free (pay API) |
| OpenCode Go support | Yes | Yes |

## When to pick OpenCode

- You want a working agent immediately after install, no configuration needed
- Plan mode and build mode are central to your workflow
- You want 75+ model providers without touching config files
- Image drag-and-drop matters (mockups, screenshots, UI feedback)
- You want `/init` to bootstrap project conventions
- You like a polished TUI with keyboard shortcuts

## When to pick Pi

- You want to build a custom agent workflow from parts
- Persistent memory across sessions matters to you
- You need sub-agents for parallel work on complex tasks
- You want to write TypeScript extensions for custom tools
- You care about 76 terminal themes
- You want non-interactive mode (`pi -p`) for CI or scripting
- You want LazyPi to set up 60+ skills in one command

## When to use both

This is what I do. OpenCode for quick coding sessions where I know what I want and just need to get it done. Pi for longer projects where memory, sub-agents, and a tailored workflow pay off over time. Both agents connect to the same OpenRouter key, so the cost is the same either way.

Run them on the same VPS if you want. They do not conflict. Use tmux or mosh to connect from your laptop.

&lt;Accordion label=&quot;Can I use the same API key for both?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Both agents read `OPENROUTER_API_KEY` from environment variables. If you use OpenCode Go, set `OPENCODE_API_KEY` and both agents can access the same subscription. Usage limits are shared.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run both on the same VPS?&quot; group=&quot;faq&quot;&gt;
Yes. They are separate binaries that work in separate directories. No port conflicts, no shared state. Use different tmux sessions: `tmux new -s opencode` and `tmux new -s pi`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which one is better for Ollama / local models?&quot; group=&quot;faq&quot;&gt;
Both support Ollama. OpenCode uses environment variables (`OPENAI_BASE_URL`). Pi uses `models.json` with compatibility flags for models that do not support the developer role. Pi&apos;s approach gives you more control over compatibility settings for reasoning models. See our [Ollama Docker guide](/ollama-docker-install/) for setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do either support Claude Code or ChatGPT subscriptions?&quot; group=&quot;faq&quot;&gt;
Pi supports subscription login for Claude Pro/Max, ChatGPT Plus/Pro, GitHub Copilot, and Google Gemini CLI through the `/login` command. OpenCode relies on API keys. Warning: using subscription OAuth tokens with automated agents can get your account banned. Use API keys instead.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Which has better community extensions?&quot; group=&quot;faq&quot;&gt;
Pi, by a wide margin. The TypeScript extension system, LazyPi (60+ skills), pi-config (curated community extensions), and 76 themes give Pi a much larger ecosystem of add-ons. OpenCode has no extension system.
&lt;/Accordion&gt;

For more agent guides, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/), the [GitHub Copilot alternatives](/github-copilot-alternatives-2026/) article, and the [Qwen 3.6 for AI coding agents](/qwen36-ai-coding-agents/) guide. For the wider tooling catalog (assistants, skills, memory, gateways), see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>llm</category></item><item><title>Qwen 3.6 Models for AI Coding Agents: Setup, Pricing, and Benchmarks</title><link>https://www.bitdoze.com/qwen36-ai-coding-agents/</link><guid isPermaLink="true">https://www.bitdoze.com/qwen36-ai-coding-agents/</guid><description>Alibaba&apos;s Qwen 3.6 series includes Plus, 27B, 35B-A3B, and Max Preview models. Pricing from $0.33/M input tokens, 1M context, and strong coding benchmarks. How to set them up with Hermes, OpenClaw, and OpenCode.</description><pubDate>Thu, 30 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Alibaba shipped the Qwen 3.6 series in April 2026 and the developer community noticed fast. The r/LocalLLaMA thread announcing Qwen 3.6 hit 760 upvotes with comments like &quot;the performance jump is real&quot; and people reporting it handled tasks they normally only trust Opus and Codex with. I have been testing Qwen 3.6 Plus and Qwen 3.6-27B with [Hermes Agent](/hermes-agent-setup-guide/), [OpenCode](/opencode-setup-guide/), and [OpenClaw](/clawdbot-setup-guide/) for the past two weeks.

What caught my attention: Qwen 3.6 Plus scores 78.8 on SWE-bench Verified, costs $0.33/M input tokens, and has a 1M token context window. For reference, that puts it in the same performance bracket as models that cost three to ten times as much. The open-weight models (27B and 35B-A3B) run on modest hardware and still pull strong numbers.

This guide covers the full Qwen 3.6 lineup, what each model is good at, pricing through different providers, and how to connect them to your coding agents.

&lt;Notice type=&quot;info&quot; title=&quot;What this covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Four Qwen 3.6 models: Plus, 27B, 35B-A3B, and Max Preview&lt;/li&gt;
&lt;li&gt;Pricing through Alibaba direct, OpenRouter, and OpenCode Go&lt;/li&gt;
&lt;li&gt;Benchmarks: SWE-bench, Terminal-Bench, GPQA, and agentic tests&lt;/li&gt;
&lt;li&gt;Setup with Hermes Agent, OpenClaw, OpenCode, and Ollama&lt;/li&gt;
&lt;li&gt;Which Qwen 3.6 model to pick for different tasks&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you are still deciding between AI coding agents, our [OpenCode setup guide](/opencode-setup-guide/) covers the open-source Claude Code alternative, and the [GitHub Copilot alternatives](/github-copilot-alternatives-2026/) article breaks down the options after the June 1 pricing change.

## The Qwen 3.6 lineup

Alibaba released four models in the 3.6 series. Each targets a different use case.

| Model | Parameters | Context | Input $/M | Output $/M | License | Best For |
|-------|-----------|---------|-----------|------------|---------|----------|
| **Qwen 3.6 Plus** | Proprietary MoE | 1M | $0.33 | $1.95 | Closed | Daily coding, agents |
| **Qwen 3.6 27B** | 27B dense | 262K | ~$0.15 | ~$0.60 | Apache 2.0 | Self-hosted coding |
| **Qwen 3.6 35B-A3B** | 35B total, 3B active | 262K | ~$0.08 | ~$0.30 | Apache 2.0 | Budget self-hosting |
| **Qwen 3.6 Max Preview** | ~1T MoE | 262K | Varies | Varies | Closed | Maximum performance |

### Qwen 3.6 Plus — The one I use most

This is the workhorse. $0.33/M input tokens with a 1M context window. It builds on a hybrid architecture that combines linear attention with sparse MoE routing. Alibaba tuned it specifically for agentic coding and front-end development.

On SWE-bench Verified it scores 78.8. On the Design Arena benchmark for front-end work, it places in the top 11% for 3D scenes, top 14% for games, and top 16% for UI components. That &quot;vibe coding&quot; experience people talk about — generating usable React components and full-stack apps from a description — this model does it well.

The 1M context window matters for agent work. When Hermes or OpenCode is processing a large repo, the model needs to hold the full file structure, multiple related files, and the conversation history without dropping pieces. 1M tokens handles that.

&lt;Button text=&quot;Qwen 3.6 Plus on OpenRouter&quot; link=&quot;https://openrouter.ai/qwen/qwen3.6-plus&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

### Qwen 3.6 27B — Self-hosted coding

A dense 27B parameter model released under Apache 2.0. If you have a GPU with 24GB+ VRAM (or 64GB+ RAM for CPU inference), you can run this locally through Ollama and pay zero per-token costs.

It accepts text, image, and video input, has a 262K context window, and includes a built-in thinking mode for extended reasoning. The r/LocalLLaMA community reports it handles repository-level code comprehension, front-end workflows, and multi-step problem solving at a level comparable to much larger models.

### Qwen 3.6 35B-A3B — The budget self-hosted option

This is a MoE model with 35B total parameters but only 3B active per token. That means it runs fast on much less hardware than the 27B dense model while delivering comparable performance for many tasks. Apache 2.0 license, 262K native context (extensible to 1M via YaRN).

If you want to self-host a coding model on a VPS without a GPU, this is the one to try. A 3B active model can run on CPU-only hardware at usable speeds.

### Qwen 3.6 Max Preview — Maximum performance

Alibaba&apos;s proprietary frontier model. It hit number one on six coding benchmarks on April 20, 2026: SWE-bench Pro, Terminal-Bench 2.0, and SkillsBench among them. About 1 trillion total parameters, 262K context.

This is closed-weights and available only through Alibaba Cloud and Qwen Studio APIs. It is the strongest Qwen model but costs more than the Plus variant. For most coding agent use cases, Plus is the better value.

## Pricing comparison

Qwen 3.6 models are available through multiple providers. Prices vary.

### Direct from Alibaba (Qwen API)

| Model | Input $/M | Output $/M |
|-------|-----------|------------|
| Qwen 3.6 Plus (up to 256K) | $0.50 | $3.00 |
| Qwen 3.6 Plus (over 256K) | $2.00 | $6.00 |

### Through OpenRouter

OpenRouter adds a small markup but gives you automatic fallback across providers.

| Model | Input $/M | Output $/M | Cache Read |
|-------|-----------|------------|------------|
| Qwen 3.6 Plus | $0.33 | $1.95 | $0.033 |
| Qwen 3.6 35B-A3B | ~$0.08 | ~$0.30 | Varies |
| Qwen 3.6 27B | ~$0.15 | ~$0.60 | Varies |
| Qwen 3.6 Max Preview | Varies | Varies | Varies |

The effective weighted average price on OpenRouter for Qwen 3.6 Plus is about $0.40/M input and $2.05/M output. The cache read price of $0.033/M is very low, which benefits agent workflows where the model repeatedly reads the same project files.

### Through OpenCode Go

Qwen 3.6 Plus and Qwen 3.5 Plus are both included in [OpenCode Go](https://go.bitdoze.com/opencode-go) at $10/month. At that price, Qwen 3.6 Plus gives you an estimated 3,300 requests per 5 hours and 16,300 requests per month.

## Benchmarks

### Coding performance

| Benchmark | Qwen 3.6 Plus | Qwen 3.6 Max Preview |
|-----------|--------------|---------------------|
| SWE-bench Verified | 78.8% | #1 (multiple benchmarks) |
| SWE-bench Pro | — | #1 |
| Terminal-Bench 2.0 | — | #1 |
| SkillsBench | — | #1 |

### Design Arena (front-end)

| Category | Qwen 3.6 Plus Elo | Ranking |
|----------|-------------------|---------|
| 3D | 1321 | Top 11% |
| Code Categories | 1292 | Top 14% |
| Game Development | 1293 | Top 14% |
| UI Component | 1301 | Top 16% |
| Website | 1274 | Top 19% |
| SVG | 1249 | Top 16% |
| Data Visualization | 1270 | Top 18% |

### Who uses Qwen 3.6?

On OpenRouter, the top apps using Qwen 3.6 Plus this month are Hermes Agent (153B tokens), OpenClaw (147B tokens), Claude Code (56.3B tokens), Roo Code (18.3B tokens), and Cline (17.6B tokens). That tells you the agent ecosystem is already adopting these models at scale.

## Setting up Qwen 3.6 with your agents

### Hermes Agent

```bash
# Via OpenRouter (recommended)
hermes config set model qwen/qwen3.6-plus

# Or set OpenRouter key if not already configured
echo &quot;OPENROUTER_API_KEY=your-key&quot; &gt;&gt; ~/.hermes/.env
```

### OpenCode

```
/connect
# Select OpenRouter or OpenCode Go
```

Then `/models` to pick Qwen 3.6 Plus.

### OpenClaw

Edit your config:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;qwen/qwen3.6-plus&quot;,
        &quot;fallback&quot;: [&quot;minimax/minimax-m2.7&quot;]
      }
    }
  }
}
```

Restart the gateway:

```bash
openclaw gateway restart
```

### Ollama (for self-hosted 27B or 35B-A3B)

```bash
# Pull the model
ollama pull qwen3.6:27b

# Or the MoE variant
ollama pull qwen3.6:35b-a3b
```

Then configure your agent to use the local Ollama endpoint:

```bash
# Hermes
echo &quot;OPENAI_BASE_URL=http://localhost:11434/v1&quot; &gt;&gt; ~/.hermes/.env
echo &quot;OPENAI_API_KEY=ollama&quot; &gt;&gt; ~/.hermes/.env
hermes config set model ollama/qwen3.6:27b
```

See our [Ollama Docker guide](/ollama-docker-install/) for setting up Ollama on your server.

## Which Qwen 3.6 model should you pick?

**Everyday coding agent work:** Qwen 3.6 Plus. The $0.33/M input price, 1M context, and strong SWE-bench score make it the default choice. It handles most coding tasks without needing to switch to a more expensive model.

**Self-hosting with a GPU:** Qwen 3.6 27B. Apache 2.0 license, 262K context, strong performance. Runs on a single 24GB GPU.

**Self-hosting on a budget:** Qwen 3.6 35B-A3B. Only 3B active parameters means it runs on modest hardware, including CPU-only VPS setups. Apache 2.0 license.

**Maximum accuracy regardless of cost:** Qwen 3.6 Max Preview. Number one on six coding benchmarks. Use it for the hard stuff and fall back to Plus for everything else.

**Do not want to choose:** [OpenCode Go](https://go.bitdoze.com/opencode-go) includes both Qwen 3.6 Plus and Qwen 3.5 Plus at $10/month. See the [OpenCode Go guide](/opencode-go-plan/) for limits and benchmarks. Switch between them and 10 other models based on the task.

## Qwen 3.6 vs the competition

| Feature | Qwen 3.6 Plus | MiniMax M3 | GLM 5.2 | DeepSeek V4 Pro |
|---------|--------------|-------------|---------|----------------|
| **Input $/M** | $0.33 | $0.30 | $1.05 | $0.435 |
| **Output $/M** | $1.95 | $1.20 | $3.50 | $0.87 |
| **Context** | 1M | 196K | 200K | 1M |
| **SWE-bench Verified** | 78.8% | — | — | — |
| **Design/front-end** | Strong | Average | Average | Average |
| **Hallucination rate** | Not published | 65.6% | Near-zero | 6.0% |
| **License** | Closed | Open weights | Open source | MIT |

Qwen 3.6 Plus sits between MiniMax M3 and GLM 5.2 in price. Its 1M context matches DeepSeek V4 Pro. Where it stands out is front-end and UI work — the Design Arena rankings are significantly stronger than any other model at this price point.

For backend and systems coding, GLM 5.2 still has the edge with its 62.1% SWE-bench Pro score. For the absolute cheapest option, MiniMax M3 at $0.30/M input is hard to beat.

A practical setup: use Qwen 3.6 Plus as your default model for everything. Switch to DeepSeek V4 Pro when you need the absolute lowest hallucination rate on server commands. Switch to GLM 5.2 for the hardest coding problems.

&lt;Notice type=&quot;info&quot; title=&quot;Related guides&quot;&gt;
- [Best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) — full pricing comparison across all five major open source models
- [OpenCode setup guide](/opencode-setup-guide/) — terminal coding agent that works with any Qwen model
- [Best open source models for OpenClaw](/best-opensource-models-for-openclaw/) — model recommendations for self-hosted AI agents
- [Hermes Agent setup guide](/hermes-agent-setup-guide/) — self-improving AI assistant with Qwen support
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Is Qwen 3.6 Plus free anywhere?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
OpenRouter offers a free tier for Qwen 3.6 Plus with rate limits. OpenCode Go ($10/month) includes it without per-token charges up to the monthly usage cap. Direct from Alibaba, there is no free tier but the per-token pricing is competitive.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run Qwen 3.6 locally?&quot; group=&quot;faq&quot;&gt;
Yes. Qwen 3.6 27B and Qwen 3.6 35B-A3B are both open-weight models under Apache 2.0. The 27B dense model needs a 24GB GPU or 64GB+ RAM. The 35B-A3B MoE model has only 3B active parameters and runs on much less — even CPU-only at usable speeds for simple tasks. Pull them with Ollama: `ollama pull qwen3.6:27b` or `ollama pull qwen3.6:35b-a3b`.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Qwen 3.6 Plus compare to Claude Sonnet?&quot; group=&quot;faq&quot;&gt;
Qwen 3.6 Plus costs $0.33/M input versus Claude Sonnet at roughly $3/M input. That is about 9x cheaper. On coding benchmarks, Qwen 3.6 Plus scores 78.8% on SWE-bench Verified. Claude Sonnet scores higher on some benchmarks, but for the price difference, Qwen 3.6 Plus is the better value for most coding tasks. Use Claude for the hardest problems, Qwen for everything else.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about Qwen 3.6 Max Preview?&quot; group=&quot;faq&quot;&gt;
Qwen 3.6 Max Preview is Alibaba&apos;s strongest model, hitting number one on six coding benchmarks. It is closed-weights and only available through Alibaba Cloud and Qwen Studio APIs. It costs more than Plus. For most developers, Plus is the better daily driver. Use Max Preview when you need maximum accuracy on a specific hard problem.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Qwen 3.6 work with MCP servers?&quot; group=&quot;faq&quot;&gt;
Yes. Qwen 3.6 Plus supports function calling and structured output, which is what MCP servers use under the hood. When you connect MCP servers through OpenCode, Hermes Agent, or OpenClaw, Qwen 3.6 Plus handles the tool calls like any other compatible model.
&lt;/Accordion&gt;

For more model comparisons and AI agent setup guides, check out our [AI tools category](/category/ai/) and the [OpenClaw alternatives](/openclaw-alternatives/) roundup.</content:encoded><category>ai</category><category>ai-tools</category><category>hermes</category><category>llm</category></item><item><title>iVANKY FusionDock Ultra Review: Two Weeks With a Thunderbolt 5 Beast</title><link>https://www.bitdoze.com/ivanky-fusiondock-ultra-review/</link><guid isPermaLink="true">https://www.bitdoze.com/ivanky-fusiondock-ultra-review/</guid><description>After two weeks of daily use with an M1 Pro MacBook and M4 Pro Mac Mini, here is my honest take on the iVANKY FusionDock Ultra — 26 ports, 10Gb Ethernet, quad display support, and dual-chip Thunderbolt 5 architecture.</description><pubDate>Tue, 28 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import AmazonProduct from &quot;@components/widgets/AmazonProduct.astro&quot;;

The [iVANKY FusionDock Ultra](https://ivanky.com/products/fusiondock-ultra) is a Thunderbolt 5 dock with 26 ports, 10Gb Ethernet, and a dual-chip architecture that can drive up to four external displays. I have been using it daily for two weeks with an M1 Pro MacBook and an M4 Pro Mac Mini, and this review covers what that experience has been like.

&lt;Button text=&quot;Check FusionDock Ultra&quot; link=&quot;https://ivanky.com/products/fusiondock-ultra&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

## iVANKY FusionDock Ultra Video Review

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ypA4gBUj_6w&quot;
  label=&quot;iVANKY FusionDock Ultra Review - Two Weeks Later&quot;
/&gt;

## What You Get in the Box

The FusionDock Ultra arrives with everything you need to get started:

- The dock itself
- A dual USB-C magnetic cable (1.2m) that clicks together for MacBook Pro or splits apart for Mac Mini/Studio
- A separate Thunderbolt 5 / USB-C 80Gbps cable (1.2m)
- A 240W power adapter with power cord
- Quick start guide and FAQs booklet

The magnetic cable is a nice touch. On a MacBook Pro, the two connectors snap together magnetically so you plug them in as one unit. On a Mac Mini or Mac Studio, you peel them apart to reach the spaced-out Thunderbolt ports. Simple and it works well.

## Design and Build

The dock has a solid aluminum chassis. iVANKY says they use a 2,000-ton extrusion press to form the body, and it feels like it — the thing is dense and does not slide around on the desk. There is a copper-alloy midframe inside that helps pull heat away from the chips, and the chassis floats slightly to let air pass through.

On the front: two USB-A ports, seven USB-C ports (one delivers 45W PD), a 3.5mm combo audio jack, and UHS-II SD and microSD card slots. There is also a small iVANKY logo that lights up when the dock is powered on. No power button, which means it turns on and off with your Mac.

On the back: two USB-C host ports (80/120Gbps) for connecting to your Mac, four USB-C downstream ports (80/120Gbps), one DisplayPort 2.1, one HDMI 2.0, one 10Gb Ethernet, optical audio in/out, two more USB-A ports, and a Kensington lock slot.

&lt;Notice type=&quot;info&quot; title=&quot;Apple Silicon Only&quot;&gt;
The FusionDock Ultra is designed for Apple Silicon Macs only. It will not work with Intel Macs, Windows PCs, or Chromebooks. You need macOS 15.1 or later.
&lt;/Notice&gt;

## Using It With One Cable vs. Two

One thing that confused me at first: the dock has two host ports, and you can use it with just one of them if you do not need all 26 ports. When you plug into the bottom host port only, you get access to the lower chip&apos;s ports, which still gives you two USB-C monitor outputs, the Ethernet port, the USB-A ports, and everything on the front. That is how I have been running it most days — one cable to my M4 Pro Mac Mini, two monitors, an SSD, and Ethernet. No issues.

If you want the full bandwidth and all display outputs, connect both host ports with the included magnetic cable. That activates the second chip and unlocks the upper row of display-capable ports for quad display support.

## Dual-Chip Architecture: Why It Matters

Most Thunderbolt docks use a single chip to handle everything — displays, ports, power delivery, and data. When you push that chip hard (four monitors plus an SSD plus Ethernet), things can slow down or get unstable.

The FusionDock Ultra has two chips inside. Each handles its own set of ports and display outputs, so the workload is split. In practice, I did not notice any speed drops when running two 4K monitors (one OLED, one at 144Hz) while also connected to an external SSD over the 10Gbps ports. Read and write speeds on the SSD hovered around 6 GB/s, which is about what I expect from the drive itself. The monitors did not cause any bandwidth dip.

## Display Support

Here is where your specific Mac matters. The dock can output to four displays, but it cannot override Apple&apos;s built-in limits. What you actually get depends on your chip:

&lt;Tabs&gt;
&lt;Tab name=&quot;MacBook Pro Max&quot;&gt;

| Chip | External Displays |
|------|------------------|
| M5 Max | Up to 4 displays (any 4 of 6 display ports) |
| M4 Max | Up to 4 displays (split: 2 upper row, 2 lower row) |
| M3 Max | Up to 4 displays (split: 2 upper row, 2 lower row) |
| M2 Max | Up to 4 displays (split: 2 upper row, 2 lower row) |
| M1 Max | Up to 4 displays (split: 2 upper row, 2 lower row) |

&lt;/Tab&gt;
&lt;Tab name=&quot;MacBook Pro&quot;&gt;

| Chip | External Displays |
|------|------------------|
| M5 Pro | Up to 3 displays |
| M4 Pro | Up to 2 displays |
| M3 Pro | Up to 2 displays (depends on config) |
| M2 Pro | Up to 2 displays |
| M1 Pro | Up to 2 displays |

&lt;/Tab&gt;
&lt;Tab name=&quot;Mac Desktops&quot;&gt;

| Model | External Displays |
|-------|------------------|
| Mac Studio (Max) | Up to 4 displays |
| Mac Studio (Ultra) | Up to 4 displays |
| Mac Mini (M4 Pro) | Up to 2 displays |
| Mac Mini (M4) | Up to 2 displays |
| Mac Mini (M2 Pro) | Up to 2 displays |

&lt;/Tab&gt;
&lt;/Tabs&gt;

I tested with two 4K monitors — one OLED and one running at 144Hz — connected to an M1 Pro and separately to an M4 Pro Mac Mini. Both worked without flickering or handshake issues. Plug and play, no DisplayLink drivers needed.

&lt;Notice type=&quot;warning&quot; title=&quot;5K Monitor Note&quot;&gt;
If you use an LG UltraFine 5K or Samsung ViewFinity S9 5K, these monitors consume a full Thunderbolt bus each. You can connect up to two of them, but they must be split between the upper and lower rows of ports. Check the iVANKY compatibility guide for your specific monitor arrangement.
&lt;/Notice&gt;

## 10Gb Ethernet: A Real Differentiator

Most Thunderbolt docks max out at 1Gb or 2.5Gb Ethernet. The FusionDock Ultra has a 10Gb Ethernet port. I do not have a 10Gb connection at home to fully push it, but the built-in port means you are not sacrificing one of your Thunderbolt ports for a separate Ethernet adapter — it is already there. For anyone working with NAS drives, large file transfers over the network, or anything bandwidth-heavy, this alone makes the dock worth considering.

## Cooling and Noise

The dock has two active fans: one pulls air in, the other pushes it out through the aluminum chassis. I have been sitting next to it for two weeks and I can say with certainty that it is quiet. It is noticeably quieter than my previous Thunderbolt dock from ASUS, which sounded like a small jet engine whenever I pushed it. The FusionDock Ultra&apos;s fan noise is comparable to my M4 Pro Mac Mini — sometimes the Mini is louder under load.

Under light use, the fans can turn off entirely. Under sustained heavy loads (SSD transfers plus two monitors), they ramp up but stay below what I consider distracting in a home office. The chassis gets warm to the touch but never hot.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Two active fans with push-pull airflow design&lt;/li&gt;
&lt;li&gt;Aluminum chassis acts as a heat spreader&lt;/li&gt;
&lt;li&gt;Comparable noise level to an M4 Mac Mini&lt;/li&gt;
&lt;li&gt;Quieter than most competing Thunderbolt 5 docks&lt;/li&gt;
&lt;li&gt;Fans turn off under light workloads&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Power Delivery

The 240W power adapter is substantial. It provides:

- **140W** to your Mac via the upstream port (enough for a 16-inch MacBook Pro at full charge speed)
- **45W** from the front USB-C PD port for an iPad, phone, or other device
- **15W** per downstream USB-C port
- **7.5W** for remaining USB-C and USB-A ports

In daily use, my Mac Mini stayed powered without any issues, and I could charge my phone from the front port while everything else was connected. No power drops.

## What I Like After Two Weeks

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;The dual-chip setup handles multiple monitors and peripherals without bandwidth throttling&lt;/li&gt;
&lt;li&gt;10Gb Ethernet is built in — no need to sacrifice a port for a dongle&lt;/li&gt;
&lt;li&gt;Very quiet cooling, especially compared to other Thunderbolt 5 docks I have used&lt;/li&gt;
&lt;li&gt;The magnetic dual-cable connector is well designed for switching between MacBook and Mac Mini&lt;/li&gt;
&lt;li&gt;Solid build quality — heavy enough to stay put, no flex&lt;/li&gt;
&lt;li&gt;Can run with a single cable if you do not need all ports active&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## What Could Be Better

No product is perfect. A few things I noticed:

- **No built-in NVMe SSD slot.** You can plug in an external enclosure, but some competing docks have a slot right on the board. If you want internal storage, you need an external enclosure.
- **Apple Silicon only.** If you have a work-issued Windows laptop or an older Intel Mac, this dock will not work for you. The dual-chip design is specifically built for Apple&apos;s display architecture.
- **No power button.** The dock powers on when your Mac wakes up and off when it sleeps. Some people prefer manual control.
- **The price.** At €649.99 (current sale from €749.99), this is not a casual purchase. You need to actually use most of these ports to justify it.

&lt;AmazonProduct
  productName=&quot;iVANKY FusionDock Ultra&quot;
  productDescription=&quot;Thunderbolt 5 dock with dual-chip architecture, 26 ports, 10Gb Ethernet, quad display support, 140W PD charging, and dual-fan cooling for Apple Silicon Macs.&quot;
  productFeatures={[&quot;Dual-chip Thunderbolt 5 architecture&quot;, &quot;26 pro-grade ports including 10Gb Ethernet&quot;, &quot;Quad 6K display support (Mac dependent)&quot;, &quot;140W Power Delivery + 45W front PD&quot;, &quot;Intelligent dual-fan cooling&quot;, &quot;Magnetic dual USB-C cable included&quot;]}
  productLink=&quot;https://ivanky.com/products/fusiondock-ultra&quot;
  productImage=&quot;https://cdn.shopify.com/s/files/1/0576/6833/7827/files/2x-ecomstack_e08f58c0-cdf3-4150-91d6-364eb9f13b67_1024x1024.webp?v=1770113902&quot;
  productRating={4.5}
  importantConsiderations={[&quot;Apple Silicon Macs only — not compatible with Intel Macs, Windows, or ChromeOS&quot;, &quot;Display count depends on your Mac chip (M1/M2/M3 base models limited to 1 external display)&quot;, &quot;No built-in NVMe SSD slot&quot;, &quot;No power button — powers on/off with your Mac&quot;]}
  pros={[&quot;Excellent dual-chip performance with no bandwidth throttling&quot;, &quot;10Gb Ethernet built in&quot;, &quot;Very quiet dual-fan cooling&quot;, &quot;26 ports cover nearly any setup&quot;, &quot;Solid build quality with aluminum chassis&quot;, &quot;Works with a single cable for basic setups&quot;]}
  cons={[&quot;Expensive at full retail price&quot;, &quot;Apple Silicon only — no Windows or Intel Mac support&quot;, &quot;No built-in SSD slot&quot;, &quot;No power button&quot;]}
/&gt;

## Port Layout Reference

For quick reference, here is the full port breakdown:

### Front Panel
| Port | Spec |
|------|------|
| USB-C (PD) | 10Gbps, 45W Power Delivery |
| USB-C x6 | 10Gbps, 7.5W each |
| USB-A x2 | 10Gbps |
| SD 4.0 | UHS-II |
| microSD 4.0 | UHS-II |
| 3.5mm Audio | Combo in/out |

### Rear Panel
| Port | Spec |
|------|------|
| USB-C Host x2 | 80/120Gbps (connect to Mac) |
| USB-C Downstream x4 | 80/120Gbps |
| USB-C | 10Gbps |
| USB-A x2 | 10Gbps |
| DisplayPort 2.1 | — |
| HDMI 2.0 | — |
| 10Gb Ethernet | RJ45 |
| Optical Audio | Toslink in/out |
| 3.5mm Audio | In and Out |
| DC In | 240W adapter |

## Who Should Buy This

The FusionDock Ultra is built for people who are running multi-monitor setups with Apple Silicon Macs and want everything connected through a single dock. If you have three or four external displays, a NAS on 10Gb, and a bunch of peripherals, this dock handles it all without choking.

If you are a single-monitor user with just a keyboard and mouse to connect, this is overkill. There are cheaper docks that will do that job fine.

For developers with multiple screens, video editors working with external SSDs and capture cards, or anyone who has been frustrated by docks that throttle under load — this one holds up.

&lt;Button text=&quot;Check FusionDock Ultra&quot; link=&quot;https://ivanky.com/products/fusiondock-ultra&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

## Frequently Asked Questions

&lt;Accordion label=&quot;Is the FusionDock Ultra compatible with Windows PCs?&quot; group=&quot;fusiondock-faq&quot;&gt;
No. The FusionDock Ultra is designed exclusively for Apple Silicon Macs (M1 and later). It will not work with Windows PCs, Chromebooks, or Intel-based Macs. The dual-chip architecture relies on Apple&apos;s USB-C display signaling.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use the dock with just one cable?&quot; group=&quot;fusiondock-faq&quot;&gt;
Yes. Plugging into the lower host port gives you access to the bottom chip&apos;s ports, which includes two USB-C display outputs, Ethernet, USB-A, and all front-facing ports. You only need the second cable if you want more displays or full quad-display bandwidth.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does the dock work with Mac Mini and Mac Studio?&quot; group=&quot;fusiondock-faq&quot;&gt;
Yes. The included magnetic cable can split apart to reach the spaced-out Thunderbolt ports on Mac Mini and Mac Studio. For the M4 Mac Mini (three Thunderbolt ports), use any two. For Mac Studio (four rear Thunderbolt ports), use any two.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How loud are the fans?&quot; group=&quot;fusiondock-faq&quot;&gt;
In my testing, the fans are comparable to an M4 Mac Mini under load. Under light use, they turn off. Under heavy load (multiple monitors, SSD transfers), they ramp up but remain quieter than competing docks like the ASUS Thunderbolt 5 dock. The aluminum chassis helps dissipate heat passively.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use an NVMe SSD with this dock?&quot; group=&quot;fusiondock-faq&quot;&gt;
The dock does not have a built-in NVMe slot. You can connect an external NVMe enclosure via USB-C and get read/write speeds around 6 GB/s, which I confirmed during testing. It is not as clean as an internal slot, but the performance is there.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What is in the box?&quot; group=&quot;fusiondock-faq&quot;&gt;
You get the FusionDock Ultra dock, a dual USB-C magnetic cable (1.2m), a Thunderbolt 5 USB-C cable (1.2m), a 240W power adapter with power cord, a user manual, and a quick start guide. No DisplayPort or HDMI cables are included.
&lt;/Accordion&gt;</content:encoded><category>gadgets</category><category>thunderbolt-dock</category><category>mac</category></item><item><title>VPS Setup for AI Coding Agents: Secure, Accessible, Ready to Code</title><link>https://www.bitdoze.com/vps-ai-coding-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/vps-ai-coding-setup/</guid><description>Set up a VPS to run AI coding agents like opencode, lock it down with CrowdSec, and access it from your Mac through Zed, VS Code, or a browser terminal.</description><pubDate>Mon, 20 Apr 2026 00:00:00 GMT</pubDate><content:encoded>Running AI coding agents on your laptop is fine until your fan sounds like a jet engine and your battery drops 30% in an hour. A VPS fixes all of that. You get a machine that runs 24/7, handles the compute load, and lets you connect from anywhere — your desk, a café, or your phone.

This guide walks through the full setup: creating SSH keys, adding a locked-down user, securing the server with CrowdSec, setting up a nice terminal with Starship, installing opencode, and connecting from your Mac through Zed, VS Code, or a browser terminal with Termix.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free Credit&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

&gt; A VPS with 2 vCPUs and 4 GB RAM is enough to get started. opencode itself is lightweight — the heavy lifting happens in the LLM APIs, not on your server.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/0gAZEha59-8&quot;
  label=&quot;Stop Running AI Agents Locally! (Do THIS Instead)&quot;
/&gt;

## What you need before starting

- A VPS running Ubuntu 22.04 or 24.04 (any provider works)
- A Mac as your local machine
- An API key for at least one LLM provider (Anthropic, OpenAI, Google, etc.)
- Basic comfort with the terminal

## Step 1: Create SSH keys on your Mac

Before touching the server, generate a key pair on your local machine. You&apos;ll use this key to log in without passwords.

Open Terminal on your Mac and run:

```sh
ssh-keygen -t ed25519 -C &quot;your-email@example.com&quot;
```

Press Enter to accept the default path (`~/.ssh/id_ed25519`), then set a passphrase when asked. You can leave it blank, but a passphrase adds a useful layer of protection if someone gets your laptop.

Check that the keys were created:

```sh
ls ~/.ssh/
# id_ed25519  id_ed25519.pub
```

The `.pub` file is your public key. That goes on the server. The other file never leaves your machine.

### Add the key to your SSH agent

So you don&apos;t have to type the passphrase every time:

```sh
eval &quot;$(ssh-agent -s)&quot;
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
```

## Step 2: First login and server prep

Log into your VPS as root using the temporary password or key your provider gave you:

```sh
ssh root@your_server_ip
```

Update the system first:

```sh
apt update &amp;&amp; apt upgrade -y
```

### Add swap space

Most budget VPS plans have limited RAM. Swap prevents out-of-memory crashes:

```sh
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | tee -a /etc/fstab
```

## Step 3: Create a user and add them to sudo

Running everything as root is asking for trouble. Create a regular user for daily work:

```sh
adduser aidev
```

Follow the prompts — set a password, skip the rest of the fields.

Add the user to the sudo group:

```sh
usermod -aG sudo aidev
```

Allow sudo without a password (since you&apos;ll be using SSH key auth, not passwords):

```sh
echo &quot;aidev ALL=(ALL) NOPASSWD:ALL&quot; | tee /etc/sudoers.d/aidev
chmod 0440 /etc/sudoers.d/aidev
```

### Copy your SSH public key to the new user

```sh
mkdir -p /home/aidev/.ssh
cp /root/.ssh/authorized_keys /home/aidev/.ssh/
chown -R aidev:aidev /home/aidev/.ssh
chmod 700 /home/aidev/.ssh
chmod 600 /home/aidev/.ssh/authorized_keys
```

If your root account didn&apos;t have an `authorized_keys` file (you logged in with a password), paste the contents of your local `~/.ssh/id_ed25519.pub` into `/home/aidev/.ssh/authorized_keys` manually.

### Test the new user before doing anything else

Open a second terminal on your Mac and verify the connection works:

```sh
ssh aidev@your_server_ip
```

Once inside, confirm sudo works:

```sh
sudo whoami
# root
```

Keep both terminal windows open until you&apos;ve finished locking things down.

## Step 4: Harden SSH access

Switch to the `aidev` session for the rest of this guide. Now tighten SSH so it only accepts keys and ignores passwords.

```sh
sudo nano /etc/ssh/sshd_config
```

Find and set these lines:

```
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
```

Also add a 15-minute idle timeout so abandoned sessions don&apos;t pile up:

```
ClientAliveInterval 900
ClientAliveCountMax 0
```

Save the file, then restart SSH:

```sh
sudo systemctl restart ssh
```

Try connecting again from your Mac to confirm it still works. If it does, your key auth is solid.

## Step 5: Secure the server with CrowdSec

CrowdSec watches your logs for brute-force attempts, port scans, and other bad behavior. When it spots something suspicious, it blocks the source IP. It&apos;s free and surprisingly effective.

### Install CrowdSec

```sh
curl -s https://install.crowdsec.net | sudo sh
sudo apt update &amp;&amp; sudo apt install crowdsec -y
```

### Install the firewall bouncer

The bouncer is what actually drops traffic from blocked IPs:

```sh
sudo apt install crowdsec-firewall-bouncer-iptables -y
```

### Set up firewall rules

Allow only what you need:

```sh
# Keep existing connections alive
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT

# Allow SSH
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Allow HTTP and HTTPS (if you later add a web service)
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Drop everything else
sudo iptables -P INPUT DROP
```

### Make firewall rules survive reboots

```sh
sudo apt install iptables-persistent -y
# Select Yes when asked to save IPv4 and IPv6 rules
sudo netfilter-persistent save
```

### Verify CrowdSec is running

```sh
sudo systemctl status crowdsec
sudo cscli collections list   # should show crowdsecurity/sshd
sudo cscli bouncers list       # should show the firewall bouncer
```

CrowdSec automatically monitors SSH login attempts. Anything that looks like a brute-force attack gets banned without you lifting a finger.

Useful commands to check on it later:

```sh
sudo cscli decisions list   # active bans
sudo cscli alerts list      # recent events
```

## Step 6: Set up Starship with Catppuccin theme

A good prompt makes a big difference when you&apos;re spending hours in a terminal. [Starship](https://www.bitdoze.com/starship-ghostty-terminal/) is fast, informative, and easy to customize.

Install Starship as the `aidev` user:

```sh
curl -sS https://starship.rs/install.sh | sh
```

Add it to your shell&apos;s config. For bash:

```sh
echo &apos;eval &quot;$(starship init bash)&quot;&apos; &gt;&gt; ~/.bashrc
```

### Apply the Catppuccin Mocha theme

Download the theme palette directly into your Starship config:

```sh
mkdir -p ~/.config
curl -o ~/.config/starship.toml \
  https://raw.githubusercontent.com/catppuccin/starship/refs/heads/main/themes/mocha.toml
```

Reload your shell:

```sh
source ~/.bashrc
```

Your prompt now shows Git status, the current directory, language versions, and exit codes — all in Catppuccin Mocha colors. The full setup walkthrough, including [Ghostty terminal configuration, is in this guide](https://www.bitdoze.com/starship-ghostty-terminal/).

## Step 7: Install opencode

opencode is an open-source AI coding agent that runs in your terminal. It supports Anthropic, OpenAI, Google, and 70+ other LLM providers.

```sh
curl -fsSL https://opencode.ai/install | bash
```

Once installed, set your API key. For Anthropic:

```sh
export ANTHROPIC_API_KEY=&quot;your-key-here&quot;
```

To make it permanent:

```sh
echo &apos;export ANTHROPIC_API_KEY=&quot;your-key-here&quot;&apos; &gt;&gt; ~/.bashrc
source ~/.bashrc
```

Start opencode in any directory:

```sh
opencode
```

It opens an interactive terminal UI where you can ask it to write code, explain errors, refactor files, or anything else you&apos;d use an AI coding assistant for.

### Run opencode in a persistent session with tmux

If you want opencode to keep running after you disconnect, use tmux:

```sh
sudo apt install tmux -y
```

Start a named session:

```sh
tmux new -s coding
```

Run opencode inside it, then detach with `Ctrl+B, D`. When you reconnect later, reattach with:

```sh
tmux attach -t coding
```

Your session and whatever opencode was doing will be exactly where you left it.

## Step 8: Configure SSH on your Mac

Now make connecting to the server as easy as typing its name. Edit your local SSH config:

```sh
nano ~/.ssh/config
```

Add this block:

```
Host ai-server
    HostName your_server_ip
    User aidev
    IdentityFile ~/.ssh/id_ed25519
    AddKeysToAgent yes
    UseKeychain yes
```

Now you can connect with just:

```sh
ssh ai-server
```

No IP address, no username, no key path to remember.

## Step 9: Connect with Zed IDE

Zed has built-in remote development support. Your Mac runs the editor UI, while the actual files and processes live on the server. This means the editor is fast and responsive even if the server is across the world.

1. [Download and install Zed](https://zed.dev) on your Mac
2. Open Zed and press `Cmd+Shift+P`, then search for &quot;Remote Projects&quot;
3. Click **Connect New Server**
4. Enter your SSH host: `ssh://ai-server` (using the alias you set in `~/.ssh/config`)
5. Zed installs a small headless server on your VPS automatically
6. Once connected, choose a folder to open (e.g., `/home/aidev/projects`)

You get a full editor experience — file tree, terminal, AI features — but all the heavy work happens on the server. You can also open it from the terminal:

```sh
zed ssh://ai-server/home/aidev/projects/my-project
```

The terminal inside Zed connects directly to your server, so you can run opencode right there alongside your editor.

## Step 10: Connect with VS Code

If you prefer VS Code, the Remote SSH extension gives you a similar experience.

1. Install the [Remote - SSH extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) from the extensions panel
2. Press `Cmd+Shift+P` and search for **Remote-SSH: Connect to Host**
3. Select `ai-server` (it reads your `~/.ssh/config` automatically)
4. VS Code installs its server component on the VPS and opens a remote window
5. Open a folder on the server and you&apos;re in

The integrated terminal connects to the server, so you can run opencode, git, or anything else right alongside your editor. Extensions run on the server side, so Python linting, formatters, and language servers work exactly as they would locally.

## Step 11: Access from anywhere with Termix

Zed and VS Code are great when you&apos;re at your computer, but sometimes you want to jump into a terminal from a browser or another device without installing anything. [Termix](https://www.bitdoze.com/termix-self-host/) is a self-hosted web terminal you can run on your VPS.

The full setup is covered in the [Termix self-hosting guide](https://www.bitdoze.com/termix-self-host/). The short version: deploy Termix on your server, put it behind a reverse proxy with HTTPS, and you get a full terminal in any browser.

Once it&apos;s running, you can:
- SSH into sessions from your iPad or phone
- Share a terminal link with a collaborator
- Access your opencode sessions without installing anything on the client

&lt;Notice type=&quot;info&quot; title=&quot;Security note&quot;&gt;
Put Termix behind authentication. The guide covers this, but worth emphasizing: a web terminal with no auth is a wide-open door. Use HTTP basic auth, an auth proxy, or Termix&apos;s built-in token system.
&lt;/Notice&gt;

## What to do next

With the server running, here are a few things worth setting up:

**Nerd Fonts for Starship icons** — Starship uses icons that only render correctly with a Nerd Font. Install one like [FiraCode Nerd Font](https://www.nerdfonts.com/) in your terminal and configure it in Zed or VS Code.

**Node.js or other runtimes** — If you&apos;re working on JavaScript projects, install Node.js via `nvm` so you can switch versions easily:

```sh
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install --lts
```

**Git configuration** — Set your name and email on the server so commits look right:

```sh
git config --global user.name &quot;Your Name&quot;
git config --global user.email &quot;you@example.com&quot;
```

**Automatic security updates** — Keep the server patched without thinking about it:

```sh
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure --priority=low unattended-upgrades
```

## Wrapping up

What you have now:

- A VPS with a locked-down user, key-only SSH, and CrowdSec watching for threats
- A clean terminal with Starship and Catppuccin Mocha
- opencode ready to run AI coding tasks with any LLM provider
- SSH config on your Mac so connecting takes one command
- Zed and VS Code wired up for full remote development
- Termix for browser-based access when you&apos;re away from your machine

The whole setup takes about 30 minutes. After that, your laptop stays cool, the AI agent keeps running whether you&apos;re connected or not, and you can pick up where you left off from any device.</content:encoded><category>ai</category><category>vps</category><category>ai-tools</category><category>self-hosted</category></item><item><title>How to Deploy Your Static Astro Website to Bunny.net</title><link>https://www.bitdoze.com/deploy-astro-bunny-net/</link><guid isPermaLink="true">https://www.bitdoze.com/deploy-astro-bunny-net/</guid><description>Learn how to deploy your Astro static website to Bunny.net CDN with edge storage. Includes a deploy script, GitHub Actions CI/CD, caching setup, and Bunny Shield WAF configuration.</description><pubDate>Thu, 16 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

You built an Astro site. It&apos;s fast, it&apos;s static, and now you need somewhere to host it that won&apos;t cost a fortune or require you to maintain a server.

I run my own Astro site on Bunny.net, and after months of using it in production I can say the setup is straightforward once you know the steps. Bunny gives you edge storage with global replication and a CDN with 119+ points of presence. You upload your built files, Bunny serves them from the nearest edge location to every visitor. No VPS to patch, no Nginx config to debug, no Docker container to babysit.

I wrote a full [Bunny.net review after 1 year](/bunny-net-review/) covering CDN pricing, Stream, and how it compares to Cloudflare. This guide does one thing: walk you through getting your Astro static site deployed and live on Bunny.net.

If you don&apos;t have an Astro site yet, I covered how to [build a free blog with Astro](/build-astro-blog-free/) using the [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme). The same setup applies here, just swap Cloudflare Pages for Bunny.net hosting.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You can follow along with this guide without spending anything. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card required and get a full 14-day free trial.
&lt;/Notice&gt;

## What you need before starting


- An Astro site configured for static output (or fork the [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) to follow along)
- A [Bunny.net account](https://go.bitdoze.com/bunny) (free 14-day trial, no credit card)
- Node.js installed on your machine
- A domain name (optional, but recommended for production)

## Why Bunny.net for static sites


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/dXW9uA-BAIg&quot;
  label=&quot;Astro on Bunny CDN&quot;
/&gt;

A quick aside on why this is worth doing before we get into the setup.

&lt;ListCheck&gt;
- No server maintenance. Upload files, Bunny serves them. No OS updates, no security patches.
- Edge storage replication. Your site files live in multiple regions worldwide, not a single data center.
- Perma-Cache stores content permanently on edge servers so visitors almost always get a cache hit.
- Storage starts at $0.01/GB, bandwidth at $0.01/GB in EU/NA. Most blogs run for under $1/month.
- Let&apos;s Encrypt certificates are provisioned automatically. No certbot, no renewal cron jobs.
- Custom domains with full DNS control. Bring your own domain and Bunny handles the rest.
&lt;/ListCheck&gt;

Here&apos;s how Bunny.net stacks up against other popular static hosting options:

| Feature | Bunny.net | Cloudflare Pages | Netlify | Vercel |
|---------|-----------|-----------------|---------|--------|
| Bandwidth price (EU/NA) | $0.01/GB | Free (limited) | $0.10/GB | $0.15/GB |
| Storage price | $0.01/GB | Free (limited) | Included | Included |
| Edge locations | 119+ | 300+ | ~100 | ~100 |
| Custom domain | Free | Free | Free | Free |
| Free tier | 14-day trial | Yes (generous) | 100GB/mo | 100GB/mo |
| WAF / DDoS protection | Bunny Shield (free tier) | Included | Add-on | Add-on |
| Min. monthly cost | $1 | $0 | $0 | $0 |

Cloudflare Pages has the better free tier if cost is your only concern. But if you want edge storage replication, predictable pricing at scale, or you already use Bunny for CDN or video, hosting your Astro site there keeps everything in one dashboard. I switched from Cloudflare Pages to Bunny for exactly that reason.

## Step 1: Build your Astro site

Make sure your Astro project is configured for static output. Open `astro.config.mjs` and confirm it looks something like this:

```javascript
import { defineConfig } from &apos;astro/config&apos;;

export default defineConfig({
  site: &apos;https://yourdomain.com&apos;,
  // output: &apos;static&apos; is the default, so you may not see this line at all
});
```

Astro uses static output by default, so unless you explicitly changed it to `server` or `hybrid`, you&apos;re already set.

Build the site:

```bash
npm run build
```

This generates a `dist/` folder containing your complete static site — HTML files, CSS, JavaScript, images, everything. You can preview it locally to verify everything looks right:

```bash
npm run preview
```

The `dist/` folder is what we&apos;ll deploy to Bunny.net.

## Step 2: Set up Bunny.net infrastructure

You need two things on the Bunny.net side: a storage zone (where your files live) and a pull zone (the CDN layer that serves them to visitors). Neither takes more than a couple of minutes.

### Create a storage zone

![Bunny.net add storage zone interface](../../assets/images/26/04/bunny-add-storage-zone.webp)

1. Log into your [Bunny.net dashboard](https://go.bitdoze.com/bunny)
2. Go to **Storage** in the left sidebar
3. Click **Add Storage Zone**
4. Fill in the details:
   - **Name**: Something like `my-astro-site` (this becomes part of the storage URL)
   - **Main Region**: Pick the region closest to your primary audience (e.g., `DE` for Europe, `NY` for US East)
   - **Replication Regions**: Add regions where you want copies of your files (e.g., add `NY` and `SYD` if you have visitors in the US and Australia)
   - **Tier**: Edge tier (SSD storage) is worth the tiny price difference for better performance

&lt;Notice type=&quot;warning&quot; title=&quot;Choose regions carefully&quot;&gt;
  You cannot change the main region or remove replication regions after creating a storage zone. You can add replication regions later, but start with your primary audience&apos;s region and add more as needed.
&lt;/Notice&gt;

### Create a pull zone

![Bunny.net add pull zone interface](../../assets/images/26/04/bunny-add-pull-zone.webp)

The pull zone is the CDN configuration that sits in front of your storage zone and serves files to visitors.

1. In your newly created storage zone, go to **Connected Pull Zones**
2. Click **Connect Pull Zone**
3. Configure it:
   - **Name**: Something like `my-astro-site` (this becomes `my-astro-site.b-cdn.net`)
   - **Zones**: Select the geographic zones you want to serve traffic from (EU, NA, Asia, etc.)

That&apos;s the basic infrastructure. Your pull zone hostname (`my-astro-site.b-cdn.net`) is already live and will serve whatever is in your storage zone. Right now that&apos;s nothing, so let&apos;s fix that.

## Step 3: Deploy your site

The deployment uploads files from your `dist/` folder to Bunny Storage via the Bunny API, then purges the CDN cache. You can do this from your local machine or automate it with GitHub Actions.

### Get your credentials

You need four values from the Bunny dashboard:

1. **Storage Zone Name**: The name you chose when creating the storage zone (e.g., `my-astro-site`)
2. **Storage Password**: Go to **Storage** → your storage zone → **FTP &amp; API Access** → copy the Password
3. **Pull Zone ID**: Go to **Pull Zones** → your pull zone → check the URL, it contains the pull zone ID (or find it in the pull zone settings)
4. **Account API Key**: Go to **Account Settings** → copy the API Key

![Bunny.net API key location](../../assets/images/26/04/bunny-api-key.webp)

&lt;Tabs&gt;
&lt;Tab name=&quot;Deploy Script (Local)&quot;&gt;

Create a `.env` file in your project root (add it to `.gitignore` so you don&apos;t commit secrets):

```bash
# .env
BUNNY_STORAGE_ZONE=your-storage-zone-name
BUNNY_STORAGE_PASSWORD=your-storage-zone-password
BUNNY_PULL_ZONE_ID=your-pull-zone-id
BUNNY_API_KEY=your-account-api-key
```

Then create the deploy script. Save it as `deploy.sh` in your project root:

```bash
#!/bin/bash
set -euo pipefail

# Load environment variables from .env
if [ ! -f .env ]; then
  echo &quot;Error: .env file not found. Copy .env.example to .env and fill in your credentials.&quot;
  exit 1
fi

set -a
source .env
set +a

# Validate required variables
for var in BUNNY_STORAGE_ZONE BUNNY_STORAGE_PASSWORD BUNNY_PULL_ZONE_ID BUNNY_API_KEY; do
  if [ -z &quot;${!var:-}&quot; ]; then
    echo &quot;Error: $var is not set in .env&quot;
    exit 1
  fi
done

DIST_DIR=&quot;dist&quot;

# Build the site
echo &quot;Building Astro site...&quot;
npm run build

# Collect file list into a temp file to avoid pipe subshell issues
FILE_LIST=$(mktemp)
trap &apos;rm -f &quot;$FILE_LIST&quot;&apos; EXIT
find &quot;$DIST_DIR&quot; -type f &gt; &quot;$FILE_LIST&quot;

total=$(wc -l &lt; &quot;$FILE_LIST&quot;)
echo &quot;Uploading $total files to Bunny Storage...&quot;

failed=0
count=0
while IFS= read -r file; do
  count=$((count + 1))
  remote_path=&quot;${file#$DIST_DIR/}&quot;
  encoded_path=$(python3 -c &quot;import urllib.parse; print(urllib.parse.quote(&apos;$remote_path&apos;))&quot;)
  printf &quot;  [%d/%d] Uploading: %s&quot; &quot;$count&quot; &quot;$total&quot; &quot;$remote_path&quot;

  http_code=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; -X PUT \
    &quot;https://storage.bunnycdn.com/$BUNNY_STORAGE_ZONE/$encoded_path&quot; \
    -H &quot;AccessKey: $BUNNY_STORAGE_PASSWORD&quot; \
    --data-binary &quot;@$file&quot; || true)

  if [ &quot;$http_code&quot; -eq 201 ] || [ &quot;$http_code&quot; -eq 200 ]; then
    echo &quot; -&gt; OK&quot;
  else
    echo &quot; -&gt; FAILED (HTTP $http_code)&quot;
    failed=$((failed + 1))
  fi
done &lt; &quot;$FILE_LIST&quot;

echo &quot;Upload complete: $((count - failed))/$count succeeded.&quot;

if [ &quot;$failed&quot; -gt 0 ]; then
  echo &quot;Warning: $failed file(s) failed to upload.&quot;
fi

# Purge CDN cache
echo &quot;Purging CDN cache for pull zone $BUNNY_PULL_ZONE_ID...&quot;
http_code=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; -X POST \
  &quot;https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache&quot; \
  -H &quot;AccessKey: $BUNNY_API_KEY&quot; || true)
if [ &quot;$http_code&quot; -eq 200 ] || [ &quot;$http_code&quot; -eq 204 ]; then
  echo &quot;Cache purged successfully.&quot;
else
  echo &quot;Warning: cache purge returned HTTP $http_code&quot;
fi

echo &quot;Deployment complete!&quot;
```

Make it executable and run it:

```bash
chmod +x deploy.sh
./deploy.sh
```

The script builds your Astro site, uploads every file from `dist/` to Bunny Storage using the storage API, then purges the CDN cache so visitors see the fresh version immediately.

&lt;/Tab&gt;
&lt;Tab name=&quot;GitHub Actions (Automated)&quot;&gt;

To deploy automatically on every push to `main`, use the same approach in a GitHub Actions workflow.

### Set up secrets

Go to your GitHub repository → **Settings** → **Secrets and variables** → **Actions** and add these secrets:

| Secret name | Value |
|-------------|-------|
| `BUNNY_STORAGE_ZONE` | Your storage zone name |
| `BUNNY_STORAGE_PASSWORD` | Your storage zone password (from FTP &amp; API Access) |
| `BUNNY_PULL_ZONE_ID` | Your pull zone ID |
| `BUNNY_API_KEY` | Your account API key |

### Create the workflow

Create `.github/workflows/deploy.yml`:

```yaml
name: Deploy to Bunny.net

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: lts

      - name: Install dependencies
        run: npm ci

      - name: Build site
        run: npm run build

      - name: Upload to Bunny Storage
        env:
          BUNNY_STORAGE_ZONE: ${{ secrets.BUNNY_STORAGE_ZONE }}
          BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
        run: |
          DIST_DIR=&quot;dist&quot;
          total=$(find &quot;$DIST_DIR&quot; -type f | wc -l)
          echo &quot;Uploading $total files to Bunny Storage...&quot;
          count=0
          failed=0
          while IFS= read -r file; do
            count=$((count + 1))
            remote_path=&quot;${file#$DIST_DIR/}&quot;
            encoded_path=$(python3 -c &quot;import urllib.parse; print(urllib.parse.quote(&apos;$remote_path&apos;))&quot;)
            printf &quot;  [%d/%d] Uploading: %s\n&quot; &quot;$count&quot; &quot;$total&quot; &quot;$remote_path&quot;
            http_code=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; -X PUT \
              &quot;https://storage.bunnycdn.com/$BUNNY_STORAGE_ZONE/$encoded_path&quot; \
              -H &quot;AccessKey: $BUNNY_STORAGE_PASSWORD&quot; \
              --data-binary &quot;@$file&quot; || true)
            if [ &quot;$http_code&quot; -ne 201 ] &amp;&amp; [ &quot;$http_code&quot; -ne 200 ]; then
              echo &quot;  -&gt; FAILED (HTTP $http_code)&quot;
              failed=$((failed + 1))
            fi
          done &lt; &lt;(find &quot;$DIST_DIR&quot; -type f)
          echo &quot;Upload complete: $((count - failed))/$count succeeded.&quot;
          if [ &quot;$failed&quot; -gt 0 ]; then
            echo &quot;Error: $failed file(s) failed to upload.&quot;
            exit 1
          fi

      - name: Purge CDN cache
        env:
          BUNNY_PULL_ZONE_ID: ${{ secrets.BUNNY_PULL_ZONE_ID }}
          BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
        run: |
          http_code=$(curl -s -o /dev/null -w &quot;%{http_code}&quot; -X POST \
            &quot;https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache&quot; \
            -H &quot;AccessKey: $BUNNY_API_KEY&quot;)
          if [ &quot;$http_code&quot; -eq 200 ] || [ &quot;$http_code&quot; -eq 204 ]; then
            echo &quot;Cache purged successfully.&quot;
          else
            echo &quot;Warning: cache purge returned HTTP $http_code&quot;
          fi
```

Every push to `main` now triggers: install dependencies, build the Astro site, upload all files to Bunny Storage, purge the CDN cache. Fully automated.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Step 4: Configure your custom domain

Your site is already accessible at `your-pull-zone.b-cdn.net`, but you probably want your own domain. Here&apos;s how.

### Add the hostname in Bunny

1. Go to **Pull Zones** → your pull zone → **General**
2. Scroll to **Hostnames**
3. Click **Add Custom Hostname**
4. Enter your domain (e.g., `www.yourdomain.com` or `yourdomain.com`)
5. Click **Add**

Bunny generates an SSL certificate automatically (usually within a few minutes).

### Configure DNS at your registrar

How you configure DNS depends on whether you&apos;re using a subdomain or the root domain.

**For a subdomain (e.g., `www.yourdomain.com`):**

Add a CNAME record:

| Type | Name | Value |
|------|------|-------|
| CNAME | www | your-pull-zone.b-cdn.net |

CNAME records are the standard approach. They route traffic through Bunny&apos;s Anycast network efficiently.

**For a root domain (e.g., `yourdomain.com`):**

Add an ANAME (also called ALIAS) record:

| Type | Name | Value |
|------|------|-------|
| ANAME / ALIAS | @ | your-pull-zone.b-cdn.net |

Not all DNS providers support ANAME records. Cloudflare, DNSMadeEasy, and Bunny&apos;s own DNS do. If yours doesn&apos;t, use a subdomain like `www` with a CNAME instead, then set up a redirect from the root domain to `www`.

&lt;Notice type=&quot;info&quot; title=&quot;CNAME vs ANAME for root domains&quot;&gt;
  ANAME records resolve to an IP address at the DNS level, which can slightly reduce routing optimization compared to CNAME. If performance matters more than a clean root domain URL, use `www` with a CNAME record instead. Bunny has a [detailed writeup on how ANAME records affect CDN routing](https://bunny.net/blog/how-aname-dns-records-affect-cdn-routing/) if you want the technical details.
&lt;/Notice&gt;

### Verify and enforce SSL

1. Wait for the SSL certificate status to change to **Active** (check the Hostnames section in your pull zone)
2. Toggle **Force SSL** to redirect all HTTP traffic to HTTPS
3. Verify by visiting `https://yourdomain.com` in a browser

### DNS propagation

DNS changes can take anywhere from a few minutes to 48 hours to propagate globally, though most updates show up within 15-30 minutes. You can check propagation status with tools like `dig` or online DNS checkers.

## Step 5: Configure caching

Bunny has three caching features worth understanding for a static Astro site. They work together in layers.

### Smart Cache

Smart Cache is enabled by default on pull zones accelerated by Bunny DNS. It decides what gets cached and what passes through to the origin on every request.

For static sites, Smart Cache caches everything with a recognized static file extension (images, fonts, CSS, JS, PDFs, etc.) and never caches `text/html`, `application/json`, or `application/xml` MIME types. This is the right behavior for most Astro sites since your HTML pages will be fetched fresh while assets get cached.

If you need to cache HTML pages (Bunny excludes them by default), create an Edge Rule with the **Override Cache Time** action targeting your HTML files.

### Vary Cache

Vary Cache lets Bunny store different versions of the same URL based on factors like browser capabilities, device type, or location. The relevant options for a static Astro site:

| Setting | What it does |
|---------|-------------|
| **WebP support** | Serves WebP images to browsers that support them, original format to the rest |
| **AVIF support** | Same idea, but for the AVIF format |
| **URL Query String** | Treats different query strings as separate cached files |

&lt;Notice type=&quot;warning&quot; title=&quot;Watch your cache cardinality&quot;&gt;
  Each Vary setting multiplies the number of cached versions per URL. WebP + AVIF + Mobile/Desktop creates `2 x 2 x 2 = 8` cached versions of each file. Only enable settings you actually need, or your cache hit rate drops.
&lt;/Notice&gt;

### Perma-Cache

Perma-Cache is a secondary permanent cache layer between the CDN and your origin. When a cache miss occurs on the CDN edge, Bunny checks Perma-Cache storage first before hitting your origin. Files that get fetched from the origin are stored permanently in Perma-Cache in the background.

This is different from regular CDN caching, which expires based on time or available space. Perma-Cache files stick around indefinitely.

&lt;Notice type=&quot;info&quot; title=&quot;Perma-Cache vs Storage Zone origin&quot;&gt;
  If your pull zone is directly connected to a storage zone as its origin (which is the setup in this guide), Perma-Cache is not available because your content is already hosted on Bunny storage. Perma-Cache is useful when your origin is an external server (a VPS, for example) and you want Bunny to cache origin responses permanently.
&lt;/Notice&gt;

To enable Perma-Cache:

1. Create a separate storage zone (different from the one holding your site files)
2. Go to **Pull Zones** → your pull zone → **Caching** → **Perma-Cache**
3. Select the storage zone from the dropdown
4. Save configuration

## Step 6: Add security with Bunny Shield

Bunny Shield is Bunny&apos;s WAF (Web Application Firewall) and DDoS protection service. It sits in front of your pull zone and filters malicious traffic before it reaches your site. There&apos;s a free tier that covers the basics.

### What the free tier gives you

The free Shield tier includes 71 built-in WAF rules and DDoS protection. It blocks common attacks automatically:

- SQL injection attempts in query parameters and forms
- Cross-site scripting (XSS) payloads
- Remote file inclusion (RFI) attacks
- Other OWASP Top 10 threats

For a static Astro site, Shield is mostly defense against DDoS and bot traffic since there&apos;s no server-side code to exploit. But it still reduces noise in your logs and can block scrapers or abuse patterns.

### Enable Shield

1. Go to **Shield** in the Bunny dashboard
2. Create a new Shield Zone
3. Connect it to your pull zone
4. The free tier activates automatically

### Optional: rate limiting and custom WAF rules

The paid tiers ($9.50/month for Advanced, $99/month for Business) add:

- Custom WAF rules (10 on Advanced, 25 on Business)
- Advanced bot detection
- Rate limiting per IP or path

For a static blog, the free tier is usually enough. Rate limiting becomes relevant if you notice specific IPs hammering your site or if you want to block traffic from certain countries.

### Set up Edge Rules for security headers

Edge Rules let you add custom headers to responses. Adding security headers is a quick win regardless of whether you use Shield:

1. Go to **Pull Zones** → your pull zone → **Edge Rules**
2. Create rules to add headers:

| Header | Value |
|--------|-------|
| X-Content-Type-Options | nosniff |
| X-Frame-Options | SAMEORIGIN |
| X-XSS-Protection | 1; mode=block |
| Referrer-Policy | strict-origin-when-cross-origin |

## Cost breakdown

What does this actually cost? Here&apos;s a realistic scenario for a typical Astro blog:

| Resource | Monthly usage | Cost |
|----------|--------------|------|
| Edge storage | 500 MB | ~$0.01 |
| CDN bandwidth (EU/NA) | 25 GB | ~$0.25 |
| Shield (free tier) | - | $0.00 |
| **Total** | | **~$0.26/month** |

Under $4/year for a blog with moderate traffic, including DDoS protection and WAF. Even at 100 GB of monthly bandwidth you&apos;re looking at roughly $1/month.

Bunny has a $1 monthly minimum, so very low-traffic sites still pay $1. But that $1 covers up to 100 GB of EU/NA bandwidth, which is more than most blogs use in a month.

## Troubleshooting

&lt;Accordion label=&quot;Site returns 404 errors&quot; group=&quot;troubleshoot&quot;&gt;

Check that your files are uploaded to the root of your storage zone, not inside a subfolder. The `dist/` folder contents (not the folder itself) should be at the top level. With the deploy script, this is handled by the path stripping logic (`remote_path=&quot;${file#$DIST_DIR/}&quot;`). If you uploaded manually, make sure you uploaded the contents of `dist/`, not the `dist/` directory itself.

Also verify your pull zone is connected to the correct storage zone (Pull Zones → your zone → General → Origin Type should show &quot;StorageZone&quot; with the right zone selected).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;CSS and JavaScript not loading&quot; group=&quot;troubleshoot&quot;&gt;

This is usually a MIME type issue. Bunny.net should detect and serve correct MIME types automatically, but if something is off:

1. Check the pull zone&apos;s **Routing** settings
2. Make sure the **Enable Static File Processing** option is enabled if available
3. Verify that your `dist/` folder contains the referenced assets with correct file extensions

If you&apos;re using Astro&apos;s default build output, CSS and JS files are placed in `dist/_astro/` with hashed filenames. As long as the full `dist/` folder was uploaded, these should work.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Changes not visible after deployment&quot; group=&quot;troubleshoot&quot;&gt;

The CDN is serving cached content. Purge the cache:

- From the dashboard: Pull Zones → your zone → Purge Cache → Purge All Files
- The deploy script handles this automatically after each upload

Cache purges propagate within seconds on Bunny&apos;s network. If you&apos;re using Perma-Cache, note that a full pull zone purge doesn&apos;t delete Perma-Cache files. It switches to a new directory structure instead.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;SSL certificate not provisioning&quot; group=&quot;troubleshoot&quot;&gt;

SSL certificates are provisioned automatically when you add a custom hostname, but they require the DNS record to be pointing to Bunny first. Verify:

1. Your CNAME or ANAME record is correctly configured at your DNS provider
2. DNS has propagated (use `dig yourdomain.com` to check)
3. Wait a few minutes after DNS propagation for the certificate to be issued

If it&apos;s been over 30 minutes and the certificate is still pending, remove the hostname from Bunny and re-add it.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Deploy script fails with authentication errors&quot; group=&quot;troubleshoot&quot;&gt;

Double-check your credentials:

- **Storage Password**: This is the password from the storage zone&apos;s FTP &amp; API Access page, not your account password
- **API Key**: This is the key from Account Settings, not the storage password
- **Pull Zone ID**: This is a numeric ID, not the pull zone name. You can find it in the pull zone settings or the dashboard URL

Also make sure your `.env` file doesn&apos;t have trailing whitespace or quotes around the values.

&lt;/Accordion&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Can I use this with other static site generators?&quot; group=&quot;faq&quot;&gt;

Yes. The same setup works with Hugo, Next.js (static export), Gatsby, 11ty, Jekyll, or any tool that outputs a folder of static files. The only Astro-specific step is the build command (`npm run build` producing a `dist/` folder). Replace that with your generator&apos;s build command and output directory, and everything else stays the same.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why use the deploy script instead of Bunny Launcher?&quot; group=&quot;faq&quot;&gt;

Bunny Launcher is a third-party tool that abstracts away the Bunny API. The deploy script in this guide calls the Bunny Storage and CDN APIs directly with `curl`, so you can see exactly what&apos;s happening and adapt it to any environment. It works in GitHub Actions, GitLab CI, or a local terminal without installing extra npm packages.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about server-side rendering (SSR)?&quot; group=&quot;faq&quot;&gt;

This guide covers static sites only. Bunny.net&apos;s edge storage and CDN serve pre-built files with no server-side runtime. If your Astro site uses SSR mode (`output: &apos;server&apos;`), you need a hosting platform that runs Node.js (Vercel, Netlify, or a VPS). You can still put Bunny CDN in front of your SSR server for caching, but that&apos;s a different setup.

If you need a database for dynamic content, you can also pair your Astro site with [Astro DB on Bunny Database](/astro-db-bunny-database/), Bunny&apos;s managed libSQL, so static pages come from the CDN while dynamic data lives in the same Bunny dashboard.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need Bunny Optimizer?&quot; group=&quot;faq&quot;&gt;

Not necessarily. Astro handles image optimization, CSS minification, and JavaScript bundling during the build process. Your `dist/` folder is already optimized. Bunny Optimizer is worth considering if you serve images that weren&apos;t processed during build (user uploads, dynamically referenced assets) or want on-the-fly WebP/AVIF conversion. For a standard Astro blog, it&apos;s redundant.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I handle redirects?&quot; group=&quot;faq&quot;&gt;

Use Bunny&apos;s Edge Rules to set up redirects. Go to Pull Zones → your zone → Edge Rules and create a redirect rule. Common patterns include redirecting `yourdomain.com` to `www.yourdomain.com`, redirecting old URLs after a site migration, or enforcing trailing slash consistency.

For Astro&apos;s built-in redirects (configured in `astro.config.mjs`), those generate a `_redirects` file in the `dist/` folder during build. Bunny doesn&apos;t process this file, so you&apos;d need to replicate those rules as Edge Rules in the Bunny dashboard.

&lt;/Accordion&gt;

## Wrapping up

Your Astro site is now running on Bunny.net&apos;s global CDN with edge storage, caching, and DDoS protection via Shield. Storage, CDN, SSL, WAF, and global delivery for under $1/month for most blogs.

If you run into issues, the troubleshooting section above covers the common ones. The GitHub Actions workflow from Step 3 is the logical next step if you want automatic deploys on every push to main.

&lt;Button text=&quot;Get Started with Bunny.net&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;</content:encoded><category>web-development</category><category>astro</category><category>bunny-net</category><category>deployment</category></item><item><title>How to Sell Digital Products Online With Payhip (Step-by-Step)</title><link>https://www.bitdoze.com/sell-digital-products-payhip/</link><guid isPermaLink="true">https://www.bitdoze.com/sell-digital-products-payhip/</guid><description>A practical, step-by-step guide to launching your digital product business using Payhip. From account setup to your first sale, everything you need to get started.</description><pubDate>Tue, 14 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

import imgAccount from &quot;../../assets/images/26/04/payhip-account.webp&quot;;
import imgDash from &quot;../../assets/images/26/04/payhip-dash.webp&quot;;
import imgAddProduct from &quot;../../assets/images/26/04/payhip-add-product.webp&quot;;
import imgStore from &quot;../../assets/images/26/04/payhip-store.webp&quot;;
import imgMarketing from &quot;../../assets/images/26/04/payhip-marketing.webp&quot;;
import imgAnalytics from &quot;../../assets/images/26/04/payhip-analytics.webp&quot;;

I spent way too long overthinking my first digital product launch. Weeks of comparing platforms, reading feature lists, watching YouTube reviews. When I finally picked [Payhip](https://go.bitdoze.com/payhip) and uploaded my first ebook, the whole process took about 20 minutes. I kind of wanted those weeks back.

If you&apos;re sitting on knowledge that could be an ebook, a course, a template pack, or any other downloadable file, this guide walks you through every step. No fluff, no theory-heavy &quot;mindset&quot; sections. Just the practical stuff.

## What is Payhip?


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/lZopf8HZyOc&quot;
  label=&quot;How to Sell Digital Products Online for FREE (Payhip Tutorial)&quot;
/&gt;


Payhip is an ecommerce platform built specifically for creators who sell digital products. It&apos;s based in London and has been around for over 10 years, with more than 130,000 sellers using it. The short version: you upload your product, connect a payment method, and start selling. Payhip handles the checkout, file delivery, tax compliance, and customer management.

What made me choose it over the alternatives was the pricing model. Every feature is available on every plan, including the free one. No &quot;upgrade to unlock affiliates&quot; or &quot;pay $99/month to remove the watermark.&quot; The free tier charges 5% per sale, and that&apos;s the only cost until you decide to upgrade.

Payhip isn&apos;t just for ebooks, though. You can sell online courses, coaching sessions, memberships, physical products, and pretty much any file type you can think of. I&apos;ll get into each of those below.

## Why digital products?

Physical products need inventory, shipping labels, and returns management. Digital products need a file and a way to collect payment. That&apos;s about it.

The margins are hard to ignore. You create the product once and sell it indefinitely. No per-unit manufacturing cost, no warehouse, no tracking numbers. A $15 ebook costs you the same whether you sell 10 copies or 10,000.

Some of the most common digital products people sell:

- Ebooks and PDF guides
- Online courses and video tutorials
- Design templates (Canva, Figma, Photoshop)
- Lightroom presets and LUTs
- Software tools and plugins
- Printable planners, worksheets, and checklists
- Music, sound effects, stock photos

You don&apos;t need a massive audience to start. Plenty of sellers make their first sales with a small email list or a social media following under 1,000.

## Step-by-step: launching your digital product business with Payhip

Here&apos;s the process I followed, from signing up to getting my first sale.

### Step 1: Decide what you&apos;re selling

Before touching any platform, figure out what you&apos;re actually going to sell. The best digital products solve a specific problem or save someone time.

Ask yourself: What do people ask me for help with? What do I know that took me years to learn? Is there a shortcut I can package?

If you&apos;re a photographer, that might be presets. If you&apos;re a developer, maybe code snippets or boilerplates. If you&apos;re a teacher, a course makes obvious sense. Don&apos;t overthink it. Your first product doesn&apos;t have to be perfect. It needs to exist.

### Step 2: Create your Payhip account

Head to [Payhip](https://go.bitdoze.com/payhip) and sign up. The free plan gives you access to every feature with no product or revenue limits. The only cost is a 5% transaction fee per sale, which is quite reasonable when you&apos;re starting out and have zero upfront costs.

&lt;Picture src={imgAccount} alt=&quot;Payhip account registration page&quot; /&gt;

The signup takes about two minutes. Email, password, store name. That&apos;s it.

### Step 3: Connect Stripe and PayPal

This part matters because it determines how you get paid and what your customers can use at checkout.

**Stripe** is where most of your sales will come through. Once connected, your store accepts Visa, MasterCard, American Express, JCB, Discover, Diners Club, and several other card types. Stripe also enables Apple Pay and Google Pay automatically, which speeds up mobile checkouts. Stripe operates in 40+ countries, so even if you&apos;re not based in the US, you can likely use it. Their standard processing fee is around 2.9% + $0.30 per transaction.

**PayPal** is the other option, and I&apos;d recommend connecting both. Some buyers have PayPal balances they prefer to spend, and others just trust the PayPal checkout flow more than entering card details on a site they haven&apos;t bought from before. PayPal&apos;s fees are similar to Stripe&apos;s.

You connect both from your Payhip dashboard in a few clicks. Once linked, customers choose their preferred payment method at checkout.

Payhip deposits money to your connected account right after each transaction. No waiting around for weekly or monthly payouts.

&lt;Picture src={imgDash} alt=&quot;Payhip dashboard overview&quot; /&gt;

This is the part that sold me. Payhip doesn&apos;t hold your money or batch payouts. The funds land in your Stripe or PayPal account as soon as the transaction clears. If you&apos;ve dealt with platforms that sit on your money for 7 or 14 days, you know why this matters, especially when you&apos;re reinvesting in ads or new products.

### Step 4: Add your first product

Click &quot;Add Product&quot; and choose your product type. Payhip lets you sell five different kinds of products, and they all work under the same account:

**Digital downloads** are the bread and butter. Ebooks, templates, presets, software, music, fonts, stock photos, anything that&apos;s a file. You upload it, set a price, and Payhip handles delivery after purchase. Buyers get instant access to a download page, plus an email with the link. There&apos;s a 5GB limit per file, but no cap on total storage or bandwidth.

**Online courses** are where things get interesting if you&apos;re a teacher or expert in something. Payhip hosts full courses with video lessons, text content, quizzes, assignments, surveys, and downloadable files. You can drip content over time (so students unlock new lessons on a schedule) and issue completion certificates. Students get their own accounts to track progress. You can self-host videos through YouTube/Vimeo embeds for free, or use Payhip&apos;s own video hosting for $5/month.

**Coaching** products let you sell one-on-one sessions. You connect a calendar tool (Zoom, Calendly, Skype, Google Meet) and Payhip handles the booking and payment. Clients buy a session, pick a time slot, and show up. If you&apos;re a consultant, therapist, tutor, or any kind of freelancer selling your time, this removes the back-and-forth scheduling headache.

**Memberships** let you charge customers on a recurring basis, either monthly or yearly. Members get access to exclusive files, content, or a membership group that you manage. It&apos;s recurring revenue without building a separate membership site. You control what members see and can add or remove content over time.

**Physical products** are also supported if you want to mix digital and tangible goods. Payhip handles inventory tracking, order fulfillment, and shipping information. I personally stick to digital, but some sellers bundle a physical item (like a printed workbook) alongside their digital course.

&lt;Picture src={imgAddProduct} alt=&quot;Adding a new product on Payhip&quot; /&gt;

For digital downloads specifically, you can set up pay-what-you-want pricing with a minimum amount. This works well for resource bundles where buyers feel good paying more. Payhip also generates software license keys automatically if you&apos;re selling software. And for PDF files, there&apos;s a PDF stamping feature that prints the buyer&apos;s purchase details on every page, discouraging unauthorized sharing.

You can limit download attempts too. By default, buyers get 3 download attempts per file, which you can adjust.

### Step 5: Customize your store and connect your domain

Your Payhip store comes with a drag-and-drop builder, and you can pick from premade themes to get started quickly. You can customize colors, fonts, layout sections, and your logo without writing any code. There&apos;s also a built-in blog CMS if you want to publish content alongside your products.

&lt;Picture src={imgStore} alt=&quot;Payhip store builder interface&quot; /&gt;

Here&apos;s something that surprised me: you can connect your own custom domain to your Payhip store for free on any plan. So instead of sending customers to `payhip.com/YourStore`, you point your domain (like `shop.yourdomain.com` or even your root domain) to Payhip and your store runs under your own brand. For anyone who cares about looking professional, and you should, this is a real win. Most competing platforms either charge extra for custom domains or lock it behind a paid tier.

If you already have a website on WordPress, Squarespace, or anything else, there&apos;s another option. You can embed Payhip&apos;s checkout buttons and product cards directly on your existing site using a short code snippet. Customers see the buy button on your site, click it, and the Payhip checkout overlay handles the rest. No redirects, no clunky integrations.

The store builder is where Payhip pulls ahead of simpler platforms. You can build actual product landing pages within the platform instead of needing a separate website builder. For people who want to [sell digital products](https://payhip.com/features/sell-digital-downloads) without dealing with WordPress or Shopify, that&apos;s one less thing to worry about.

### Step 6: Set up your marketing tools

Payhip includes built-in marketing tools that would cost you $50-100/month if you bought them separately.

&lt;Picture src={imgMarketing} alt=&quot;Payhip marketing tools&quot; /&gt;

Here&apos;s what you can use right from the dashboard:

- **Coupon codes** with usage limits and expiration dates, great for launch promotions
- **Affiliate program** where others promote your products for a commission you set
- **Referral discounts** that reward customers for sharing with friends
- **Email marketing** to send updates, new product announcements, and promotions to your buyer list
- **Cross-selling and upselling** to suggest related products during checkout
- **Mailing list integrations** with MailChimp, ConvertKit, and others

The affiliate program alone is worth calling out. You set a commission percentage, and anyone can sign up to promote your products. When they generate a sale through their unique link, they get paid automatically. It&apos;s marketing that only costs you when it actually produces a sale. I&apos;ve seen sellers grow their revenue 30-40% just by turning on affiliates and letting niche bloggers do the promotion.

The email marketing tool deserves a mention too. You can send broadcasts to all your past buyers directly from Payhip. No need for Mailchimp or ConvertKit unless you want more advanced automation. For most solo sellers, the built-in option covers what you need.

### Step 7: Launch and track results

Once your store is live, share your store URL everywhere: social media profiles, email signatures, blog posts, YouTube descriptions, wherever your audience hangs out.

&lt;Picture src={imgAnalytics} alt=&quot;Payhip analytics dashboard&quot; /&gt;

Payhip&apos;s analytics show your sales, revenue, and traffic. You can see which products sell best, where your traffic comes from, and how coupons and affiliates perform. It won&apos;t replace Google Analytics for deep traffic analysis, but for tracking what&apos;s making you money, it does the job.

## Payhip pricing

Payhip&apos;s pricing is refreshingly simple. Every plan includes every feature. No feature-gating, no &quot;premium-only&quot; tools. The only variable is the transaction fee:

| Plan | Monthly cost | Transaction fee |
|------|-------------|-----------------|
| Free Forever | $0/mo | 5% per sale |
| Plus | $29/mo | 2% per sale |
| Pro | $99/mo | 0% per sale |

All plans include unlimited products, unlimited revenue, unlimited storage, custom domain support, the full store builder, marketing tools, affiliate system, and email marketing. Everything.

Keep in mind that PayPal and Stripe charge their own processing fees (around 2.9% + $0.30 per transaction) regardless of which Payhip plan you&apos;re on. That&apos;s standard across every platform and not something Payhip controls.

The free plan is the obvious starting point. You pay nothing until you actually make a sale, and 5% is a very reasonable cut. Once you&apos;re doing consistent revenue, here&apos;s a rough breakpoint: if you sell more than about $970/month, the Plus plan ($29/mo at 2%) saves you money over the free plan. And at around $3,300/month in sales, the Pro plan at $99/month with zero fees becomes the better deal.

&lt;Notice type=&quot;info&quot; title=&quot;EU and UK sellers&quot;&gt;
  Payhip automatically calculates, collects, and remits EU and UK VAT on digital products. You don&apos;t need to register for VAT in each EU country or file returns yourself. This alone saves hours of compliance headaches and potential fines if you&apos;re selling internationally.
&lt;/Notice&gt;

## Tips from my own experience

**Start with one product.** I&apos;ve seen people spend months building a catalog of 10 items before launching. Ship one product, learn what sells, then expand.

**Price higher than you think.** First-time sellers almost always underprice. A well-made ebook that saves someone 20 hours of work is worth more than $5. Test $19 or $29 and see what happens.

**Use the affiliate program early.** Even with a small audience, giving bloggers and content creators a reason to talk about your product generates sales you wouldn&apos;t get on your own.

**Email your buyers.** Payhip&apos;s built-in email tool lets you stay in touch with past customers. When you release product two, those buyers are your warmest leads.

&lt;Button text=&quot;Start Selling on Payhip for Free&quot; link=&quot;https://go.bitdoze.com/payhip&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

## Frequently asked questions

&lt;Accordion label=&quot;Can I use Payhip without a website?&quot; group=&quot;faq&quot;&gt;
Yes. Payhip gives you a hosted store with its own URL. You can also connect a custom domain for free if you want it on your own brand.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Payhip handle taxes?&quot; group=&quot;faq&quot;&gt;
Payhip automatically handles EU and UK VAT for digital products. It calculates the correct rate, charges the customer, and remits the tax on your behalf. You can also configure additional tax rules for other regions.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What payment methods can my customers use?&quot; group=&quot;faq&quot;&gt;
Customers can pay with PayPal or credit/debit cards including Visa, MasterCard, American Express, JCB, Discover, and Diners Club through Stripe.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a limit on file size?&quot; group=&quot;faq&quot;&gt;
Individual files can be up to 5GB. There&apos;s no overall storage limit or bandwidth cap.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I sell courses on Payhip?&quot; group=&quot;faq&quot;&gt;
Yes. Payhip supports full online courses with video lessons, quizzes, assignments, drip content, student accounts, and completion certificates. You can use their video hosting ($5/month) or embed from YouTube, Vimeo, or Wistia.
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>ecommerce</category><category>digital-products</category></item><item><title>Bunny Storage vs S3 vs Backblaze: Cheapest Cloud Storage in 2026?</title><link>https://www.bitdoze.com/bunny-storage-vs-s3-vs-backblaze/</link><guid isPermaLink="true">https://www.bitdoze.com/bunny-storage-vs-s3-vs-backblaze/</guid><description>Real pricing breakdowns for Bunny Storage, AWS S3, and Backblaze B2. I compare storage costs, egress fees, and hidden charges so you can pick the cheapest option for your workload.</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

Cloud storage pricing should be simple. You store files, you pay per gigabyte, done. But that&apos;s not how most providers work. AWS S3 has storage classes, request fees, lifecycle policies, and egress charges that need a spreadsheet to calculate. Even &quot;simple&quot; providers bury costs in API call fees or bandwidth surcharges.

I&apos;ve been running sites and services through [Bunny.net](https://go.bitdoze.com/bunny) for a while now (you can read my [full Bunny.net review here](/bunny-net-review/)), and their storage is one of the pieces I find most interesting. But is it actually cheaper than S3 or Backblaze B2? That depends on what you&apos;re storing, how often you access it, and where your users are.

I went through the pricing pages for all three, ran the numbers for common scenarios, and put together this comparison. No affiliate math tricks where I conveniently ignore egress fees to make one option look better.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny.net free for 14 days&quot;&gt;
  You can test Bunny Storage yourself with no credit card. [Sign up at Bunny.net](https://go.bitdoze.com/bunny) and get a full 14-day trial with $1 free credit.
&lt;/Notice&gt;

## The quick comparison

Before we get into details, here&apos;s what you&apos;re looking at:

| | **Bunny Storage** | **AWS S3** | **Backblaze B2** |
|---|---|---|---|
| **Storage cost** | $0.01/GB (single region) | $0.023/GB (Standard) | $0.006/GB |
| **Egress cost** | Free to Bunny CDN | $0.09/GB (first 10 TB) | Free up to 3x storage |
| **API request fees** | None | $0.005/1K PUT, $0.0004/1K GET | $0.004/10K downloads |
| **Free tier** | 14-day trial | 5 GB for 12 months | 10 GB free forever |
| **Global replication** | Up to 15 regions | Cross-region copy (extra cost) | 2 regions |
| **S3 compatible** | Yes | Yes (it IS S3) | Yes |
| **CDN integration** | Built-in (Bunny CDN) | CloudFront (separate config) | Partner CDNs (free egress) |
| **Minimum storage duration** | None | Varies by class | None |
| **Pricing complexity** | Low | High | Low |

Backblaze B2 has the cheapest raw storage. Bunny Storage has the simplest pricing and built-in CDN. S3 has the most features but costs the most after you factor in egress and request fees.

## Bunny Storage: what you get

[Bunny Storage](https://go.bitdoze.com/bunny) is built into the Bunny.net platform, which means it connects directly to their CDN without extra configuration. Files you store get served through 119+ edge locations automatically.

![Bunny Storage file manager interface](../../assets/images/26/04/bunny-storage-interface.webp)

### Pricing breakdown

- **Single region**: $0.01/GB/month
- **Two regions**: $0.02/GB/month
- **Three regions**: $0.025/GB/month
- **Each additional region**: +$0.005/GB/month
- **API requests**: Free
- **Egress to Bunny CDN**: Free
- **Egress to internet**: Billed through CDN pricing ($0.01/GB EU/NA)

The pricing model is flat. You pick how many regions you want your data replicated to, and you pay that rate. No storage classes to pick between, no request fees, no minimum storage durations.

### What I like about it

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;Zero API call fees (S3 charges for PUT, GET, LIST, everything)&lt;/li&gt;
    &lt;li&gt;Free egress to Bunny CDN, no separate transfer costs&lt;/li&gt;
    &lt;li&gt;Built-in CDN with 119+ PoPs, no need to configure a separate service&lt;/li&gt;
    &lt;li&gt;Global replication across up to 15 regions with a single toggle&lt;/li&gt;
    &lt;li&gt;S3-compatible API, so tools like rclone and s3cmd work out of the box&lt;/li&gt;
    &lt;li&gt;Simple dashboard, you can browse files through the web UI&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

### Where it falls short

The raw per-GB storage cost ($0.01) is higher than Backblaze ($0.006) and comparable to S3 Standard ($0.023 looks higher, but S3 has cheaper tiers like Glacier at fractions of a penny). If you&apos;re storing terabytes of archive data you rarely access, Bunny Storage isn&apos;t the cheapest bucket to throw files into.

There&apos;s also no lifecycle policy system. S3 lets you automatically transition objects from Standard to Infrequent Access to Glacier after set time periods. Bunny Storage keeps everything hot, always. That&apos;s either a feature or a limitation depending on what you need.

If you&apos;re already using Bunny CDN to serve your sites (like I do), the storage integrates perfectly. I covered the CDN side in depth in my [Bunny.net review](/bunny-net-review/) and the [video streaming setup in this guide](/bunny-stream-guide/).

## AWS S3: the feature king with complex pricing

S3 is the default choice for most companies. It&apos;s been around since 2006, it&apos;s battle-tested, and it integrates with every AWS service and almost every third-party tool. But the pricing is a maze.

### Pricing breakdown

**Storage (US East, per GB/month):**
- S3 Standard: $0.023 (first 50 TB), $0.022 (next 450 TB), $0.021 (over 500 TB)
- S3 Infrequent Access: $0.0125
- S3 Glacier Instant: $0.004
- S3 Glacier Flexible: $0.0036
- S3 Glacier Deep Archive: $0.00099

**Requests:**
- PUT/COPY/POST/LIST: $0.005 per 1,000 requests
- GET/SELECT: $0.0004 per 1,000 requests

**Data transfer out:**
- First 10 TB/month: $0.09/GB
- Next 40 TB: $0.085/GB
- Next 100 TB: $0.07/GB
- Over 150 TB: $0.05/GB

That egress pricing is where S3 gets expensive fast. Store 1 TB and serve it once? You&apos;re paying $0.023 for storage plus $90 for egress. The storage cost is almost a rounding error compared to the bandwidth bill.

### When S3 makes sense

S3 wins when you need specific features that nobody else has:

- Lifecycle rules that automatically move cold data to cheaper storage classes
- Object versioning, locking, and compliance retention
- Event triggers through Lambda for processing uploads
- Fine-grained IAM access controls
- Cross-region replication with granular rules
- Integration with hundreds of AWS services

If your infrastructure already lives on AWS, adding S3 is the path of least resistance. The pricing gets better with AWS Data Transfer Out discounts or if you&apos;re using CloudFront (which has its own pricing model that can reduce egress costs).

For everyone else, S3&apos;s complexity is hard to justify. I&apos;ve talked to people who were surprised by their S3 bill because they didn&apos;t realize GET requests cost money, or that listing bucket contents is a billable operation.

## Backblaze B2: cheapest raw storage

Backblaze started as a consumer backup company and eventually launched B2, their S3-compatible object storage. Their pitch is simple: dirt-cheap storage with reasonable egress policies.

### Pricing breakdown

- **Storage**: $0.006/GB/month ($6/TB)
- **Downloads**: Free up to 3x your average monthly storage
- **Downloads beyond 3x**: $0.01/GB
- **CDN partner egress**: Free (Cloudflare, Bunny.net, Fastly, Vultr, and others)
- **Class B transactions** (downloads, metadata): 2,500 free/day, then $0.004/10K
- **Class C transactions** (uploads, list, delete): 2,500 free/day, then $0.004/1K
- **Minimum file size**: None
- **Minimum storage duration**: None

The 3x free egress rule is generous. If you store 1 TB, you can download up to 3 TB per month for free. And if you&apos;re serving files through a partner CDN like Bunny.net or Cloudflare, egress is completely free regardless of volume.

### The Backblaze + Bunny combo

One setup I find particularly interesting: store files on Backblaze B2 at $0.006/GB, then serve them through Bunny CDN. Backblaze doesn&apos;t charge egress to partner CDNs, and you get Bunny&apos;s 119+ PoPs for delivery. Total cost per GB stored and served: $0.006 for storage + Bunny CDN delivery fees ($0.01/GB EU/NA for bandwidth used).

This combo undercuts both pure Bunny Storage ($0.01/GB) and S3 + CloudFront by a wide margin for read-heavy workloads.

The trade-off? Two accounts to manage, two dashboards, and some configuration to link them. If you&apos;re setting up [Dokploy backups with S3-compatible storage](/dokploy-backups-cloudflare-r2/) or running a [self-hosted file manager like Cloudreve](/cloudreve-docker-setup/), Backblaze B2 works as a drop-in S3 alternative.

### Where B2 falls short

Backblaze only has two data center regions (US West and EU Central). If you need data physically stored in Asia-Pacific or South America, B2 isn&apos;t an option. S3 has 30+ regions and Bunny has 15 storage locations.

The API is S3-compatible but not identical. Some S3 features like object lifecycle policies, versioning, and fine-grained access controls are either limited or missing. For backup and archive workloads this doesn&apos;t matter. For complex application storage, it might.

## Real cost comparisons

Numbers on a pricing page are meaningless without context. Here&apos;s what you&apos;d actually pay for three common scenarios:

&lt;Tabs&gt;
  &lt;Tab name=&quot;1 TB static site&quot;&gt;
    **Scenario: 1 TB of static assets (images, CSS, JS) served globally, 5 TB egress/month**

    | Provider | Storage | Egress | Requests (est.) | Total/month |
    |----------|---------|--------|-----------------|-------------|
    | **Bunny Storage** | $10 | $0 (CDN included) | $0 | **~$60** (CDN delivery) |
    | **S3 + CloudFront** | $23 | ~$425 (varies) | ~$5 | **~$453** |
    | **S3 direct** | $23 | $450 | ~$5 | **~$478** |
    | **Backblaze B2** | $6 | $20 (2 TB over 3x) | ~$2 | **~$28** |
    | **B2 + Bunny CDN** | $6 | $0 (partner) | ~$2 | **~$58** (CDN delivery) |

    For serving static assets, Backblaze B2 alone is cheapest if your users can tolerate fetching from two data centers. Add Bunny CDN on top and you get global delivery at a similar price to pure Bunny Storage. S3 is the most expensive option by far, mostly because of egress.

    Note: Bunny Storage&apos;s $60 includes CDN delivery for the 5 TB. The B2 + Bunny CDN combo is similar because you pay Bunny&apos;s CDN rates for the actual delivery bandwidth.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;10 TB backup archive&quot;&gt;
    **Scenario: 10 TB of backups, minimal egress (restore once a quarter, ~500 GB)**

    | Provider | Storage | Egress | Requests | Total/month |
    |----------|---------|--------|----------|-------------|
    | **Bunny Storage** | $100 | ~$5 (occasional) | $0 | **~$105** |
    | **S3 Standard** | $230 | ~$11 (avg) | ~$2 | **~$243** |
    | **S3 Glacier Instant** | $40 | ~$11 + retrieval | ~$2 | **~$55** |
    | **S3 Glacier Deep** | $9.90 | ~$11 + retrieval | ~$2 | **~$25+** |
    | **Backblaze B2** | $60 | $0 (within 3x) | ~$1 | **~$61** |

    For pure archival, S3 Glacier Deep Archive wins on storage cost ($0.00099/GB). But you pay retrieval fees and wait hours for access. Backblaze at $60/month with free egress up to 30 TB is the best no-surprises option. Bunny Storage costs more because it keeps everything hot and replicated.

    If you&apos;re setting up [WordPress backups](/best-free-wordpress-backup-plugins/) or [CloudPanel remote backups](/cloudpanel-remote-backups/), B2 is the sweet spot between cheap and accessible.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;500 GB video hosting&quot;&gt;
    **Scenario: 500 GB of video files, 3 TB delivery/month**

    | Provider | Storage | Egress | Requests | Total/month |
    |----------|---------|--------|----------|-------------|
    | **Bunny Storage + CDN** | $5 | $30 (CDN) | $0 | **~$35** |
    | **Bunny Stream** | $5 | $30 (delivery) | $0 | **~$35** |
    | **S3 + CloudFront** | $11.50 | ~$255 | ~$3 | **~$270** |
    | **B2 + Bunny CDN** | $3 | $30 (CDN) | ~$1 | **~$34** |

    For video delivery, the S3 + CloudFront path is painful. The B2 + Bunny CDN combo edges out on pure cost, but if you want transcoding, adaptive bitrate, and a built-in player, [Bunny Stream](/bunny-stream-guide/) handles all of that without extra setup. I use Bunny Stream for the video courses on [bitbuddies.me](https://bitbuddies.me) and it just works.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Performance comparison

Price isn&apos;t everything. If your files take twice as long to deliver, users leave.

| Metric | **Bunny Storage** | **AWS S3** | **Backblaze B2** |
|--------|-------------------|-----------|-----------------|
| **Global avg. latency** | 41ms | 131ms | ~80ms (2 regions) |
| **Storage regions** | 15 | 30+ | 2 |
| **Built-in CDN** | Yes (119+ PoPs) | No (need CloudFront) | No (need partner CDN) |
| **Avg. CDN latency** | 24ms | ~30ms (CloudFront) | Depends on CDN used |
| **Upload speed** | Fast (edge ingest) | Fast (regional) | Moderate (2 locations) |

Bunny Storage&apos;s 41ms average comes from their multi-region replication. When you replicate to 5+ regions, the nearest copy is usually close to any user worldwide. S3 stores in one region by default (cross-region replication costs extra and needs configuration). Backblaze with just two data centers depends entirely on CDN caching for global performance.

For most web applications, all three are fast enough once you put a CDN in front. The difference shows up for uncached requests or API-heavy workloads where you&apos;re hitting origin storage directly.

## Feature comparison

&lt;Tabs&gt;
  &lt;Tab name=&quot;Storage features&quot;&gt;
    | Feature | **Bunny** | **S3** | **B2** |
    |---------|----------|--------|--------|
    | S3-compatible API | Yes | Yes | Yes |
    | Web file browser | Yes | Yes (console) | Yes |
    | Object versioning | No | Yes | Yes (limited) |
    | Lifecycle policies | No | Yes (extensive) | Yes (basic) |
    | Object lock/retention | No | Yes | Yes |
    | Server-side encryption | Yes | Yes (multiple options) | Yes |
    | Custom metadata | Yes | Yes | Yes (limited) |
    | Multipart uploads | Yes | Yes | Yes |
    | Pre-signed URLs | Yes | Yes | Yes |
    | Event notifications | No | Yes (Lambda, SNS, SQS) | Yes (webhooks) |
    | Access logging | Yes | Yes | Yes |
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Developer experience&quot;&gt;
    | Feature | **Bunny** | **S3** | **B2** |
    |---------|----------|--------|--------|
    | Official SDKs | Limited | Every language | Python, Java, CLI |
    | API documentation | Good | Extensive | Good |
    | CLI tool | No (use s3cmd/rclone) | aws-cli | b2-cli |
    | Terraform provider | Limited | Full | Community |
    | Dashboard UX | Simple, clean | Complex but powerful | Simple |
    | Setup time | Minutes | Minutes to hours | Minutes |
    | Billing clarity | Very clear | Confusing | Clear |
  &lt;/Tab&gt;
&lt;/Tabs&gt;

S3 has features that Bunny Storage and B2 simply don&apos;t offer. If you need object lifecycle transitions, Lambda triggers on upload, or compliance-level retention locks, S3 is still the only real option among these three.

But most people storing website assets, backups, or media files don&apos;t need any of that. They need a bucket, an API key, and predictable pricing.

## When to use each

&lt;Accordion label=&quot;Bunny Storage: best for websites and CDN-delivered content&quot; group=&quot;when&quot; expanded=&quot;true&quot;&gt;
  Pick Bunny Storage when:

  - You&apos;re already using or plan to use Bunny CDN
  - You want storage and delivery in one bill with no surprises
  - You need multi-region replication without managing cross-region sync yourself
  - You&apos;re hosting a static site or serving assets that need global delivery
  - You want S3 compatibility with simpler pricing

  If you&apos;re [deploying an Astro site](/deploy-astro-on-vps/) or any static site, Bunny Storage + CDN gives you hosting and delivery without touching a web server.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;AWS S3: best for complex applications and the AWS ecosystem&quot; group=&quot;when&quot;&gt;
  Pick S3 when:

  - Your infrastructure already runs on AWS
  - You need lifecycle policies to automatically archive old data
  - You need event-driven processing (Lambda triggers on upload)
  - Compliance requirements demand object lock or retention policies
  - You&apos;re storing data that needs to stay in a specific geographic region for legal reasons
  - You need the deepest integration ecosystem available

  S3 is overkill for simple file hosting, but nothing else matches its feature depth. The cost premium is the price of that flexibility.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Backblaze B2: best for backups and archival storage&quot; group=&quot;when&quot;&gt;
  Pick Backblaze B2 when:

  - Cost per GB stored is your primary concern
  - You&apos;re doing backups from [WordPress](/best-free-wordpress-backup-plugins/), [CloudPanel](/cloudpanel-remote-backups/), or [Dokploy](/dokploy-backups-cloudflare-r2/)
  - You want generous free egress (3x storage or unlimited to CDN partners)
  - You don&apos;t need more than two storage regions
  - You plan to serve files through a CDN like Bunny.net or Cloudflare anyway

  The B2 + partner CDN combo is hard to beat on price for read-heavy workloads.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;B2 + Bunny CDN: the budget combo&quot; group=&quot;when&quot;&gt;
  This deserves its own mention. Store on B2 at $0.006/GB, serve through Bunny CDN for free egress from B2 plus fast global delivery. You manage two services instead of one, but the savings add up quickly at scale.

  Best for: media sites, file distribution, any workload where you store a lot and serve a lot.
&lt;/Accordion&gt;

## FAQ

&lt;Accordion label=&quot;Is Bunny Storage S3-compatible?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
  Yes. You can use any S3-compatible tool (rclone, s3cmd, Cyberduck, backup plugins) with Bunny Storage. The API endpoint and authentication work the same way. Not every S3 feature is supported (no lifecycle policies, no object lock), but standard upload/download/list operations work fine.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Backblaze B2 with Bunny CDN?&quot; group=&quot;faq&quot;&gt;
  Yes, and it&apos;s a popular combination. Backblaze has a CDN alliance program that includes Bunny.net, so egress from B2 to Bunny CDN is completely free. You set up a Bunny pull zone pointed at your B2 bucket and files get cached and served globally.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why is S3 egress so expensive?&quot; group=&quot;faq&quot;&gt;
  AWS makes most of its margin on data transfer. They want you to bring data into AWS (free ingress) but charge a premium to get it out. This is a well-documented strategy called &quot;data gravity.&quot; Once your data is in S3, the egress cost creates friction to leave. Other providers like Bunny and Backblaze use free or cheap egress as a competitive advantage.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about Cloudflare R2?&quot; group=&quot;faq&quot;&gt;
  R2 is another strong option with zero egress fees. I covered a related setup in the [Dokploy backups with Cloudflare R2 guide](/dokploy-backups-cloudflare-r2/). R2 pricing is $0.015/GB/month for storage, which sits between Bunny ($0.01) and S3 ($0.023). The main advantage is free egress everywhere, not just to a specific CDN. Worth considering if you&apos;re already in the Cloudflare ecosystem.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the cheapest option for storing 1 TB?&quot; group=&quot;faq&quot;&gt;
  Just storage with minimal access: Backblaze B2 at $6/month. Storage plus global CDN delivery: Backblaze B2 + Bunny CDN at $6/month plus CDN bandwidth. Storage with simplest setup: Bunny Storage at $10/month with CDN included. S3 Standard at $23/month is the most expensive for simple storage.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny Storage work for backups?&quot; group=&quot;faq&quot;&gt;
  It can, but it&apos;s not the cheapest option for pure backup storage. At $0.01/GB, a 5 TB backup costs $50/month vs $30 on B2 or under $5 on S3 Glacier Deep Archive. Bunny Storage makes more sense when you also need fast retrieval and global delivery through their CDN. For cold backups, B2 or Glacier is cheaper.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I self-host something instead?&quot; group=&quot;faq&quot;&gt;
  You can run MinIO or SeaweedFS on your own servers for S3-compatible storage with zero per-GB fees. You pay for the hardware and bandwidth instead. If you&apos;re curious about self-hosted file management, check out the [Cloudreve Docker setup guide](/cloudreve-docker-setup/) for a Google Drive-like interface that supports S3 backends. The trade-off is maintenance and reliability: cloud storage providers handle replication, backups, and uptime for you.
&lt;/Accordion&gt;

## The verdict

There&apos;s no single cheapest option because it depends on your access patterns:

- **Backblaze B2** wins on raw storage cost. At $0.006/GB with generous free egress, it&apos;s the default choice for backups and archives.
- **Bunny Storage** wins on simplicity and integrated delivery. One platform for storage and CDN with no hidden fees. Best when you need fast global delivery and want one bill.
- **AWS S3** wins on features. Lifecycle policies, event triggers, compliance tools, and the deepest ecosystem. You pay for that breadth.

For my own projects, I use Bunny.net because the CDN and storage work together without any glue code or extra accounts. The B2 + Bunny CDN combo is something I&apos;d recommend if cost is your top priority and you don&apos;t mind managing two services.

If you want the full picture on what Bunny.net offers beyond storage, read my [Bunny.net review](/bunny-net-review/) or the [Bunny Stream guide](/bunny-stream-guide/) if you&apos;re looking at video hosting.

&lt;Button text=&quot;Try Bunny.net Storage Free&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;</content:encoded><category>hosting</category><category>cdn</category><category>self-hosted</category></item><item><title>Bunny Stream: How to Host and Deliver Videos Without Vimeo or YouTube</title><link>https://www.bitdoze.com/bunny-stream-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/bunny-stream-guide/</guid><description>A step-by-step guide to hosting your own videos with Bunny Stream. I use it for my free courses on bitbuddies.me and it costs a fraction of what Vimeo charges.</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

If you&apos;re hosting video content on your website, whether that&apos;s courses, tutorials, product demos, or marketing material, you&apos;ve probably looked at YouTube and Vimeo as your two main options. YouTube is free but plasters ads over your content and doesn&apos;t let you control who watches. Vimeo gives you control but charges a premium for it, especially once your library grows.

I ran into this exact problem with [BitBuddies](https://bitbuddies.me/), where I host free courses on topics like Dokploy and CloudPanel setup. I needed a video platform that would let me embed clean, ad-free videos in my course pages without paying Vimeo&apos;s subscription fees. Bunny Stream turned out to be the answer, and I&apos;ve been running all my course videos through it since.

This guide walks through the full setup: creating a library, uploading videos, embedding the player, customizing it, and locking things down with security features. I&apos;ll also cover the API if you want to automate uploads.

If you want a broader look at the Bunny.net platform beyond just video, check out my [Bunny.net review](/bunny-net-review/) (honest take after 1 year — cheaper than Cloudflare on bandwidth) where I cover CDN, storage, DNS, and the rest of the stack.

&lt;Notice type=&quot;success&quot; title=&quot;Try Bunny Stream free for 14 days&quot;&gt;
  [Sign up at Bunny.net](https://go.bitdoze.com/bunny) with no credit card required. You get full access to Stream and every other service during the trial.
&lt;/Notice&gt;

## Why not just use YouTube?

YouTube is free and the obvious choice for public videos. But it comes with tradeoffs that matter if you&apos;re embedding videos on your own site:

- **Ads on your content**: Unless viewers pay for YouTube Premium, they see ads before or during your videos. On a course page, that means your students watch an ad for something unrelated before every lesson.
- **Recommendations pull viewers away**: YouTube&apos;s sidebar and end-screen suggestions actively try to send your viewers somewhere else. You&apos;re competing with cat videos for attention.
- **No real access control**: You can make videos unlisted, but anyone with the link can share it. There&apos;s no token authentication or DRM.
- **YouTube owns the relationship**: Your viewers are YouTube&apos;s users first, yours second. Analytics are limited, and you can&apos;t customize the player to match your brand.
- **SEO leakage**: Embedded YouTube videos send authority to youtube.com, not your domain.

YouTube makes sense for public marketing content where you want maximum reach. But for course content, membership sites, or anything where you want to control the experience, it falls short.

## Why not Vimeo?

Vimeo solves the ad and branding problems, but the pricing is where it gets painful:

| Feature | Vimeo Starter | Vimeo Standard | Vimeo Advanced |
|---------|--------------|----------------|----------------|
| Price | $12/mo | $33/mo | $65/mo |
| Storage | 100 GB | 5 TB | 5 TB |
| Bandwidth | Limited | Higher | Highest |
| Privacy controls | Basic | Yes | Yes |
| Player customization | Limited | Yes | Yes |

Those prices are per-user, billed annually. And once you hit bandwidth limits, you either upgrade or your videos stop loading. For a course platform with a growing library, the costs scale in a way that doesn&apos;t make much sense.

## What Bunny Stream gives you instead

Bunny Stream is pay-as-you-go. No monthly subscription tiers, no per-video limits, no bandwidth caps. You pay for storage and delivery, that&apos;s it.

![Bunny Stream video library interface](../../assets/images/26/04/bunny-stream-interface.webp)

Here&apos;s what it includes at no extra cost:

&lt;ListCheck&gt;
- **Free transcoding**: Upload once, Bunny creates 240p through 1080p versions automatically
- **Built-in player**: Customizable, responsive, no third-party embed needed
- **DRM protection**: Block downloads, screen recording, and screenshots
- **Token authentication**: Control exactly who can view each video
- **Hotlink protection**: Prevent unauthorized embedding on other domains
- **Adaptive bitrate streaming**: HLS delivery adjusts quality to viewer&apos;s connection
- **TUS resumable uploads**: Large file uploads that survive connection interruptions
- **Webhook support**: Get notified when transcoding finishes, videos get viewed, etc.
- **AI content tagging**: Automatic categorization of your video library
- **Watermarking**: Add your logo or text overlay to videos
&lt;/ListCheck&gt;

For what I run on [BitBuddies](https://bitbuddies.me/), my monthly Bunny Stream bill stays under $5. That&apos;s for all the course videos stored and delivered. On Vimeo, the same setup would cost me $33/month at minimum, probably more.

## Setting up Bunny Stream step by step

Here&apos;s the full walkthrough from zero to embedded videos on your site.

### 1. Create a Bunny.net account

Head to [bunny.net](https://go.bitdoze.com/bunny) and sign up. No credit card needed for the 14-day trial. You&apos;ll land on the dashboard after confirming your email.

### 2. Create a video library

In the Bunny dashboard, go to **Stream** in the left sidebar, then click **Add Video Library**.

You&apos;ll need to pick:

- **Library name**: Something descriptive. I use &quot;bitbuddies-courses&quot; for my course content.
- **Primary storage region**: Pick the one closest to where most of your viewers are. For a global audience, Frankfurt or New York are solid defaults.
- **Replication regions** (optional): Add more regions if you want faster delivery worldwide. Each additional region adds $0.005/GB to storage costs, but delivery gets faster for viewers in those areas.

Hit create and your library is ready.

### 3. Upload your videos

You have three options for getting videos into Bunny Stream:

&lt;Tabs&gt;
&lt;Tab name=&quot;Dashboard upload&quot;&gt;

The simplest approach. Go to your video library, click **Upload**, and drag your files in. Bunny handles everything from there.

The dashboard uses TUS resumable uploads under the hood, so if your connection drops halfway through a 2 GB file, it picks up where it left off. You&apos;ll see a progress bar and transcoding status for each video.

I use this for one-off uploads when I&apos;m adding a new lesson to a course. Quick and painless.

&lt;/Tab&gt;
&lt;Tab name=&quot;API upload&quot;&gt;

For automated workflows, Bunny&apos;s API lets you create video objects and upload via TUS protocol. Here&apos;s the basic flow:

```bash
# Step 1: Create a video object
curl -X POST &quot;https://video.bunnycdn.com/library/{libraryId}/videos&quot; \
  -H &quot;AccessKey: {your-api-key}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;title&quot;: &quot;Lesson 1 - Getting Started&quot;}&apos;
```

This returns a video GUID. Then upload the file:

```bash
# Step 2: Upload the video file via PUT
curl -X PUT &quot;https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}&quot; \
  -H &quot;AccessKey: {your-api-key}&quot; \
  -H &quot;Content-Type: application/octet-stream&quot; \
  --data-binary @lesson-1.mp4
```

For larger files, use the TUS protocol endpoint instead of the PUT method. Libraries like `tus-js-client` handle chunked uploads with automatic resumption.

&lt;/Tab&gt;
&lt;Tab name=&quot;Direct URL fetch&quot;&gt;

If your videos are already hosted somewhere (S3, another CDN, a direct URL), Bunny can fetch them for you:

```bash
curl -X POST &quot;https://video.bunnycdn.com/library/{libraryId}/videos/fetch&quot; \
  -H &quot;AccessKey: {your-api-key}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;url&quot;: &quot;https://example.com/video.mp4&quot;, &quot;title&quot;: &quot;Lesson 1&quot;}&apos;
```

This is handy when migrating from another platform. Point Bunny at your existing video URLs and it pulls them in, transcodes them, and replicates them across your storage regions.

&lt;/Tab&gt;
&lt;/Tabs&gt;

After upload, Bunny automatically transcodes each video into multiple resolutions. You can watch the progress in the dashboard. Transcoding time depends on video length and resolution, but a 10-minute 1080p video usually finishes in a couple of minutes.

### 4. Embed videos on your site

Once a video is transcoded, click on it in the dashboard and you&apos;ll find the embed code. It looks like this:

```html
&lt;iframe
  src=&quot;https://iframe.mediadelivery.net/embed/{libraryId}/{videoId}&quot;
  loading=&quot;lazy&quot;
  style=&quot;border:none;position:absolute;top:0;height:100%;width:100%;&quot;
  allow=&quot;accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;&quot;
  allowfullscreen=&quot;true&quot;
&gt;&lt;/iframe&gt;
```

Wrap it in a responsive container for proper sizing:

```html
&lt;div style=&quot;position:relative;padding-top:56.25%;&quot;&gt;
  &lt;iframe
    src=&quot;https://iframe.mediadelivery.net/embed/{libraryId}/{videoId}&quot;
    loading=&quot;lazy&quot;
    style=&quot;border:none;position:absolute;top:0;height:100%;width:100%;&quot;
    allow=&quot;accelerometer;gyroscope;autoplay;encrypted-media;picture-in-picture;&quot;
    allowfullscreen=&quot;true&quot;
  &gt;&lt;/iframe&gt;
&lt;/div&gt;
```

The `padding-top: 56.25%` gives you a 16:9 aspect ratio. Adjust for other ratios if needed.

On [BitBuddies](https://bitbuddies.me/), each course lesson page has one of these embeds. The player loads fast, there are no ads, and students can pick their quality level. That&apos;s exactly the experience I wanted.

### 5. Customize the player

Go to your video library settings to configure the player appearance:

- **Player color**: Match your brand. I use the BitBuddies accent color so the player feels native to the site.
- **Controls**: Show or hide specific buttons (speed control, quality selector, PiP, fullscreen).
- **Captions**: Upload SRT/VTT files for subtitles. Multiple languages supported.
- **Thumbnail**: Bunny auto-generates one, or you upload your own.
- **Watermark**: Overlay your logo on the video. Position, size, and opacity are all adjustable.
- **Playback speed**: Enable speed controls so viewers can watch at 1.5x or 2x (course students love this).

You can also skip the built-in player entirely and use the raw HLS manifest URL with your own player (Video.js, Plyr, whatever you prefer). The HLS URL is available in each video&apos;s settings.

### 6. Set up security

This is where Bunny Stream earns its keep over YouTube for course content.

&lt;Accordion label=&quot;Token authentication&quot; group=&quot;security&quot;&gt;

Token auth ensures that only your website can generate valid playback URLs. Enable it in your library security settings, set a token key, and then sign your embed URLs server-side.

Here&apos;s how the signed URL works:

```javascript
const crypto = require(&apos;crypto&apos;);

function signBunnyUrl(libraryId, videoId, tokenKey, expirationTime) {
  const expires = Math.floor(Date.now() / 1000) + expirationTime;
  const hashableBase = tokenKey + videoId + expires;
  const token = crypto
    .createHash(&apos;sha256&apos;)
    .update(hashableBase)
    .digest(&apos;hex&apos;);
  return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?token=${token}&amp;expires=${expires}`;
}
```

The URL expires after your set time window, so even if someone shares the link, it stops working. I set mine to expire after 4 hours, which gives students plenty of time to watch a lesson without the link becoming permanently shareable.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;DRM protection&quot; group=&quot;security&quot;&gt;

If you&apos;re selling video content and downloads are a concern, Bunny&apos;s DRM integration blocks:

- Video downloads (right-click save, browser extensions)
- Screen recording on supported platforms
- Screenshots on supported devices

DRM uses Widevine (Chrome, Android) and FairPlay (Safari, iOS) under the hood. You enable it per-library in the settings. No additional cost.

Worth noting: DRM isn&apos;t bulletproof. Someone can always point a camera at their screen. But it stops 95% of casual piracy, which is usually enough.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Hotlink protection and allowed domains&quot; group=&quot;security&quot;&gt;

Restrict which domains can embed your videos. In your library settings, add your allowed domains (e.g., `bitbuddies.me`, `www.bitbuddies.me`) and enable hotlink protection. Anyone trying to embed your video on an unauthorized domain sees nothing.

This prevents people from copying your embed code onto their own site and serving your content at your expense.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Geo-blocking&quot; group=&quot;security&quot;&gt;

Block or allow specific countries from accessing your videos. Useful for licensing restrictions or if your content is region-specific.

&lt;/Accordion&gt;

## Working with the API

If you&apos;re building a course platform or any app that manages video programmatically, the Bunny Stream API covers everything you need.

### Common API operations

```bash
# List all videos in a library
curl &quot;https://video.bunnycdn.com/library/{libraryId}/videos?page=1&amp;itemsPerPage=100&quot; \
  -H &quot;AccessKey: {your-api-key}&quot;

# Get video details (including transcoding status)
curl &quot;https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}&quot; \
  -H &quot;AccessKey: {your-api-key}&quot;

# Update video metadata
curl -X POST &quot;https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}&quot; \
  -H &quot;AccessKey: {your-api-key}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;title&quot;: &quot;Updated Title&quot;, &quot;chapters&quot;: [{&quot;title&quot;: &quot;Intro&quot;, &quot;start&quot;: 0, &quot;end&quot;: 60}]}&apos;

# Delete a video
curl -X DELETE &quot;https://video.bunnycdn.com/library/{libraryId}/videos/{videoId}&quot; \
  -H &quot;AccessKey: {your-api-key}&quot;
```

### Webhooks

Set up webhook URLs in your library settings to get notified when:

- A video finishes transcoding (`video_encoded`)
- A video gets a caption generated (`caption_generated`)

This is useful if you want to automatically publish a course lesson once its video is ready, or trigger a notification to students.

## How much it actually costs

This is the part that sold me. Bunny Stream pricing breaks down into two components:

| Component | Cost |
|-----------|------|
| Storage | $0.01/GB per month |
| Delivery (Standard network) | $0.01/GB (EU/NA) |
| Delivery (Volume network) | $0.005/GB |
| Transcoding | Free |
| Player | Free |
| DRM | Free |
| API access | Free |

### Real-world cost examples

&lt;Tabs&gt;
&lt;Tab name=&quot;Small course (my setup)&quot;&gt;

What I run on BitBuddies:

| Item | Amount | Monthly cost |
|------|--------|-------------|
| Stored video | ~50 GB | $0.50 |
| Monthly delivery | ~100 GB (EU/NA) | $1.00 |
| Replication | 2 regions | $0.50 |
| **Total** | | **~$2.00/mo** |

For comparison, Vimeo Starter costs $12/month with less storage and limited bandwidth.

&lt;/Tab&gt;
&lt;Tab name=&quot;Medium course platform&quot;&gt;

A platform with a larger library:

| Item | Amount | Monthly cost |
|------|--------|-------------|
| Stored video | 500 GB | $5.00 |
| Monthly delivery | 1 TB (EU/NA) | $10.00 |
| Replication | 3 regions | $3.75 |
| **Total** | | **~$18.75/mo** |

The same setup on Vimeo Standard would cost $33/month, and you&apos;d still hit bandwidth ceilings.

&lt;/Tab&gt;
&lt;Tab name=&quot;Large video library&quot;&gt;

A production video platform:

| Item | Amount | Monthly cost |
|------|--------|-------------|
| Stored video | 5 TB | $50.00 |
| Monthly delivery | 20 TB (Volume network) | $100.00 |
| Replication | 5 regions | $50.00 |
| **Total** | | **~$200/mo** |

At this scale, traditional cloud video (AWS MediaConvert + S3 + CloudFront) would cost significantly more. Transcoding alone on AWS runs about $0.02/minute.

&lt;/Tab&gt;
&lt;/Tabs&gt;

## My setup on BitBuddies

Here&apos;s how I actually use Bunny Stream for [BitBuddies](https://bitbuddies.me/) in practice:

I have one video library called &quot;bitbuddies-courses&quot; with Frankfurt as the primary storage region and a second replication point in New York. Most of my students are in Europe and North America, so this covers the vast majority of viewers with low latency.

Each course (like the [Dokploy Setup](https://bitbuddies.me/courses/dokploy-setup) and [CloudPanel Setup](https://bitbuddies.me/courses/cloudpanel-setup) courses) has its own collection within the library. When I record a new lesson, I upload it through the dashboard, wait a few minutes for transcoding, and drop the embed code into the course page.

I&apos;ve enabled token authentication so the video URLs expire after a few hours. The courses are free, so I&apos;m not trying to lock things down aggressively, but it prevents people from hot-linking the videos elsewhere and racking up my bandwidth bill.

The player matches the BitBuddies brand colors, and I have playback speed controls enabled because students watching setup tutorials often want to skip through the parts they already know.

Total monthly cost for all of this: around $2. I spend more on coffee in a day.

## When you should still use YouTube or Vimeo

Bunny Stream isn&apos;t the right pick for every situation. Be honest about your use case:

**Stick with YouTube when:**

- You want maximum organic discoverability (search, recommendations, algorithm)
- Your content is public and you benefit from YouTube&apos;s built-in audience
- You don&apos;t mind ads on your videos
- You need free hosting with unlimited storage

**Consider Vimeo when:**

- You need OTT (over-the-top) live streaming with monetization
- Your workflow depends on Vimeo&apos;s specific integrations (certain LMS platforms, marketing tools)
- You need Vimeo&apos;s built-in video creation and editing tools

**Use Bunny Stream when:**

- You&apos;re embedding videos on your own site and want a clean, branded experience
- You&apos;re building a course platform, membership site, or knowledge base
- Cost predictability matters and you don&apos;t want per-tier subscription pricing
- You need DRM, token auth, or hotlink protection
- You want API control over your video library

## Frequently asked questions

&lt;Accordion label=&quot;Can I migrate my existing videos from Vimeo to Bunny Stream?&quot; group=&quot;faq&quot;&gt;

Yes. If you can get direct download URLs for your Vimeo videos (available on paid plans), you can use Bunny&apos;s URL fetch API to pull them in directly. Bunny downloads, transcodes, and stores them. You&apos;ll need to update your embed codes afterward, but the actual migration is straightforward.

If you can&apos;t get direct URLs from Vimeo, download the files locally first and re-upload them to Bunny through the dashboard or API.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Bunny Stream support live streaming?&quot; group=&quot;faq&quot;&gt;

Not currently. Bunny Stream is for video-on-demand (pre-recorded content). If you need live streaming, you&apos;d need to pair it with a separate service like OBS + a streaming platform, then upload the recordings to Bunny afterward.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What video formats can I upload?&quot; group=&quot;faq&quot;&gt;

Bunny accepts most common formats: MP4, MKV, MOV, AVI, WEBM, and others. The recommended upload format is MP4 with H.264 encoding, as it gives the fastest transcoding times. But you can throw pretty much any format at it and Bunny figures it out.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How long does transcoding take?&quot; group=&quot;faq&quot;&gt;

Depends on the video length and resolution. A 10-minute 1080p video usually transcodes in 2-3 minutes. Longer or higher-resolution videos take proportionally more time. 4K content takes noticeably longer. You can monitor progress in the dashboard or via webhooks.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use my own video player instead of Bunny&apos;s?&quot; group=&quot;faq&quot;&gt;

Yes. Every video gets an HLS manifest URL that you can feed into any HLS-compatible player: Video.js, Plyr, hls.js, or whatever you prefer. The built-in player is convenient but entirely optional.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Bunny Stream good enough for paid courses?&quot; group=&quot;faq&quot;&gt;

For most course creators, yes. With DRM enabled, token authentication, and hotlink protection, you have enough security to prevent casual piracy. No video platform is piracy-proof (screen recording always exists), but Bunny&apos;s protections are on par with what Vimeo and Wistia offer at higher price points.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Bunny Stream handle mobile playback?&quot; group=&quot;faq&quot;&gt;

The built-in player is responsive and works well on mobile browsers. HLS adaptive streaming automatically adjusts quality based on the viewer&apos;s connection speed, so mobile users on cellular data get a lower resolution that loads quickly, while wifi users get full quality.

&lt;/Accordion&gt;

## Wrapping up

Bunny Stream fills a gap that YouTube and Vimeo leave open. YouTube is free but takes control away from you. Vimeo gives control back but charges a subscription that scales awkwardly. Bunny charges for what you actually use, includes features that cost extra elsewhere, and gets out of your way.

I&apos;ve been running my [BitBuddies](https://bitbuddies.me/) course videos on it for a while now and I have no plans to switch. The setup took less than 30 minutes, the player looks clean on every device, and my monthly bill is pocket change.

If you want to learn more about the full Bunny.net platform (CDN, storage, DNS, security, and more), I wrote a detailed [Bunny.net review](/bunny-net-review/) that covers everything. And if you&apos;re looking at storage options for your video files or other assets, check out my [Bunny Storage vs S3 vs Backblaze comparison](/bunny-storage-vs-s3-vs-backblaze/).

&lt;Button text=&quot;Try Bunny Stream Free for 14 Days&quot; link=&quot;https://go.bitdoze.com/bunny&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;</content:encoded><category>hosting</category><category>video-streaming</category><category>cdn</category></item><item><title>How to Use Hermes Agent with Free Models on the Nous Portal</title><link>https://www.bitdoze.com/hermes-agent-mimo-v2-pro/</link><guid isPermaLink="true">https://www.bitdoze.com/hermes-agent-mimo-v2-pro/</guid><description>Learn how to connect your Hermes agent to free models on the Nous Research Portal — currently Step 3.5 Flash (10 days free). Step-by-step setup, Discord integration, and Hermes Workspace dashboard walkthrough. Originally covering MIMO V2 Pro.</description><pubDate>Wed, 08 Apr 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Nous Research has been running partnerships that give Hermes users free access to different models. The original promotion was MIMO V2 Pro for two weeks — that one has ended. The current offer is free access to Step 3.5 Flash for ten days. I have been running [Hermes Agent](/hermes-agent-setup-guide/) on my VPS for a while now, so I wanted to see how these models compare to what I normally use.

I recorded the whole process — account creation, model configuration, Discord hookup, and a quick tour of the Hermes Workspace dashboard.

&lt;YouTubeEmbed url=&quot;https://www.youtube.com/watch?v=t39ph8knPX0&quot; label=&quot;How to Use Hermes Agent with Free Models on the Nous Portal&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What you will learn&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Creating a free billing account on the Nous Research portal&lt;/li&gt;
&lt;li&gt;Connecting Hermes Agent to free models on your VPS&lt;/li&gt;
&lt;li&gt;Selecting models through the Nous Portal provider&lt;/li&gt;
&lt;li&gt;Linking Hermes to a Discord channel for external chat&lt;/li&gt;
&lt;li&gt;Navigating the Hermes Workspace dashboard for sessions, memory, and token tracking&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## Current free offer: Step 3.5 Flash

The active promotion right now is Step 3.5 Flash, free for ten days through the Nous Portal. It&apos;s fast and handles most tasks well — code generation, web searches, file editing, the usual Hermes workload. Ten days is enough to put it through real work and see if you want to stick with the Nous Portal after the trial ends.

The Nous Portal also has other free options like Neatron and Trinity that don&apos;t have a time limit, just rate limits. Step 3.5 Flash felt noticeably quicker in my chats than those.

&lt;Notice type=&quot;info&quot; title=&quot;Previous promotion: MIMO V2 Pro&quot;&gt;
The original promotion covered in this article was Xiaomi&apos;s MIMO V2 Pro, free for two weeks. That promotion has ended. If you still have the Nous Portal configured from that, the same steps below apply — just pick Step 3.5 Flash instead of MIMO V2 Pro when selecting a model.
&lt;/Notice&gt;

## Prerequisites

You need Hermes Agent installed on a VPS before any of this works. If you haven&apos;t done that yet, the [Hermes Agent setup guide](/hermes-agent-setup-guide/) walks through installation, OpenRouter config, and messaging platform setup.

&lt;Button text=&quot;Hermes Setup / Workspace&quot; link=&quot;https://hermes-workspace.com/&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Step 1: Create a free account on the Nous Research portal

Go to the [Nous Research Portal](https://portal.nousresearch.com/) and sign up. After logging in:

1. Open the **Billing** section
2. Pick the **Free** subscription
3. Fill in your details

The billing dashboard shows your token usage once you start chatting. I burned through about 11 million input tokens in my first few days of testing — all free.

&lt;Button text=&quot;Nous Research Portal&quot; link=&quot;https://portal.nousresearch.com/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Step 2: Configure the Nous Portal provider in Hermes

On your VPS where Hermes is installed, run:

```bash
hermes model
```

Pick **Nous Portal** from the list. The first time, Hermes spits out a URL. Paste it into your browser, authorize the device, and you are in. Every model on the portal becomes available after that one-time auth step.

## Step 3: Select Step 3.5 Flash

Hermes shows you the full model list after authorization. Scroll to **Step 3.5 Flash** and confirm. Hermes saves the selection.

To verify, just start a chat:

```bash
hermes
```

The model name appears in the response, so you can tell right away which model you&apos;re on.

## Step 4: Link Hermes to Discord (optional)

I linked my Hermes agent to a Discord channel so I can message it without SSH-ing into the server. The [Hermes Agent setup guide](/hermes-agent-setup-guide/) has the full Discord walkthrough. Short version:

1. Create a bot in the Discord Developer Portal
2. Add the bot token to your Hermes config
3. Invite the bot to your server

After that, messages in the linked channel go through Hermes and get answered by whichever model you selected. You can see the model name in the replies.

## Hermes Workspace dashboard

Hermes Workspace is a Docker-based web UI for managing your agent. I installed it on the same VPS where Hermes runs. It is basically a control panel where you can view chat sessions and start new ones from the browser, check what the agent has stored in memory, monitor scheduled jobs, open a terminal, and browse files the agent can access. There is also a dashboard page that shows session counts and how many tokens you have used.

If your workspace is exposed to the internet, you can set a password to lock it down.

&lt;Button text=&quot;Hermes Workspace&quot; link=&quot;https://hermes-workspace.com/&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Free for ten days&quot;&gt;
You get ten full days of Step 3.5 Flash at no cost. After the trial, switch to another free model on the portal (Neatron, Trinity) or hook up a paid provider.
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Do I need to pay anything to use Step 3.5 Flash with Hermes?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. Create a free account on the Nous Research Portal, pick the free tier, and you get ten days of Step 3.5 Flash access. After that, Neatron and Trinity remain free on the portal with rate limits.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I still use MIMO V2 Pro?&quot; group=&quot;faq&quot;&gt;
The free MIMO V2 Pro promotion has ended. If you want to use MIMO V2 Pro now, you&apos;d need a paid Nous Research subscription. The current free promotion is Step 3.5 Flash for ten days.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use other models alongside Step 3.5 Flash?&quot; group=&quot;faq&quot;&gt;
Yes. Run `hermes model` any time to switch between Step 3.5 Flash, Neatron, Trinity, and whatever else is on the portal.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens after the ten-day free trial?&quot; group=&quot;faq&quot;&gt;
Switch to another free model on the portal (Neatron, Trinity), connect a provider like OpenRouter, or upgrade to a paid Nous Research tier.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need a VPS to run Hermes?&quot; group=&quot;faq&quot;&gt;
Yes. Hermes runs on a Linux server. A basic VPS is enough. The [Hermes Agent setup guide](/hermes-agent-setup-guide/) covers the full install.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Hermes Workspace required?&quot; group=&quot;faq&quot;&gt;
No. It is a nice-to-have web dashboard for sessions, memory, and jobs. Hermes itself works fine from the terminal or through Discord and Telegram without it. For other dashboard options beyond Hermes Workspace, see the [best Hermes dashboards](/best-hermes-dashboards/) roundup.
&lt;/Accordion&gt;

For affordable models to pair with your Hermes Agent, see the [best cheap models for Hermes Agent](/best-cheap-models-hermes-agent/) guide. It covers MiniMax M3, MiMo V2.5 Pro, GLM 5.2, Kimi K2.6, and DeepSeek V4 Pro with pricing comparisons and setup instructions. For the full setup chain, start with the [Hermes Agent setup guide](/hermes-agent-setup-guide/), and for the built-in web dashboard, see the [Hermes dashboard guide](/hermes-dashboard-guide/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category></item><item><title>cmux Terminal: A Practical Guide for AI Coding Agents on macOS</title><link>https://www.bitdoze.com/cmux-terminal/</link><guid isPermaLink="true">https://www.bitdoze.com/cmux-terminal/</guid><description>Learn what cmux is, how it relates to Ghostty and tmux, and why its browser, notifications, and automation CLI make it interesting for AI-agent workflows.</description><pubDate>Fri, 13 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you like [Ghostty](/ghostty-terminal/) but keep ending up with too many agent sessions, too many tabs, and no clue which one needs you, **cmux** is worth a look.

It is a native macOS terminal built for a very current problem: running AI coding agents and still feeling in control of the mess. You get a Ghostty-powered terminal, vertical workspaces, split panes, notifications that actually point to the session asking for help, and a built-in browser that can be driven from the CLI.

That last part matters more than it sounds. A lot of AI tooling feels like it wants to replace your workflow. cmux feels closer to &quot;here are the primitives, now build your own setup.&quot;

&lt;Notice type=&quot;info&quot; title=&quot;What cmux is, in one sentence&quot;&gt;
cmux is a native macOS terminal app built on `libghostty`, with workspaces, panes, notifications, browser automation, and a CLI/socket API for controlling the whole thing.
&lt;/Notice&gt;

## What cmux actually is


![CMUX terminal](../../assets/images/26/03/cmux-ui.webp)


cmux is **not** a Ghostty fork. According to the official site, it uses `libghostty` for terminal rendering, the same way an app might use WebKit for web content. So you still get Ghostty-style rendering and config compatibility, but the app itself is doing something different.

The idea is simple:

- Open several coding agents in parallel
- Keep them separated by workspace, pane, or browser surface
- Get a clear visual signal when one needs input
- Automate the terminal and browser from scripts or hooks

If that sounds niche, it kind of is. But it is a very real niche now.

## Why cmux is getting attention

The selling point is not &quot;yet another terminal.&quot; The interesting part is how it handles multi-agent work without forcing you into a closed orchestration product.

From the official README and docs, cmux currently gives you:

- A **native macOS app** built with Swift and AppKit
- A **Ghostty-powered terminal** that reads your existing Ghostty config for themes, fonts, and colors
- **Vertical tabs and split panes** for juggling multiple workspaces
- **Notification rings, unread badges, and a notification panel**
- A **built-in browser** with a scriptable command surface
- A **CLI and socket API** for sending text, reading screens, opening panes, moving surfaces, and more
- **tmux compatibility commands** for people who still want tmux-style behavior in parts of their workflow

It is also free and open source, with the repo licensed under `AGPL-3.0-or-later`.

## The short version: what cmux can do

Here is the practical list.

### 1. Run multiple agents without losing track of them

This is the big one. cmux uses windows, workspaces, panes, and surfaces so you can split work in a way that makes sense.

Roughly speaking:

- **Window**: the app window
- **Workspace**: the main vertical tab in the sidebar
- **Pane**: a split area inside a workspace
- **Surface**: the actual terminal or browser tab inside a pane

That model sounds more complex than a normal terminal at first. In practice, it is how cmux can show multiple agents, multiple splits, and even browser tabs without everything collapsing into a pile of tabs.

### 2. Tell you which agent needs attention

This is where cmux feels genuinely thoughtful.

Instead of a generic desktop alert, cmux can show:

- a notification ring around the pane
- an unread badge on the workspace
- a notification panel with pending items
- a desktop notification on macOS

It supports standard terminal notification escape sequences like `OSC 9`, `OSC 99`, and `OSC 777`, and it also exposes `cmux notify` for scripts and hooks.

That means you can wire Claude Code, Codex, OpenCode, or your own shell scripts into the same notification flow.

### 3. Open a browser beside the terminal and automate it

This is the feature that makes cmux feel different from Ghostty and tmux.

You can open a browser surface next to a terminal, then control it with commands like:

```bash
cmux browser open https://localhost:4321
cmux browser snapshot --interactive
cmux browser click &quot;button[type=&apos;submit&apos;]&quot;
cmux browser fill &quot;input[name=&apos;email&apos;]&quot; &quot;me@example.com&quot;
```

If you are building web apps with an agent, that is pretty compelling. The browser is no longer a separate thing you alt-tab to. It becomes part of the same workspace as the terminal session doing the work.

### 4. Control the workspace from the CLI

The CLI is a big part of cmux, and your `cmux help` output makes that clear.

You can:

- create workspaces and windows
- split panes in all directions
- create terminal or browser surfaces
- send text or keys into a terminal
- read the visible screen or scrollback
- move or reorder tabs and surfaces
- set sidebar status, progress, and logs
- script Claude Code hooks

That makes cmux more than a GUI terminal. It is closer to a programmable local control plane for agent workflows.

## Useful cmux commands to know first

The full help is broad, but these are the commands I would learn first.

| Task | Command |
|------|---------|
| Open a new workspace | `cmux new-workspace` |
| Split the current layout | `cmux new-split right` or `cmux new-split down` |
| Add a new terminal/browser surface | `cmux new-surface --type terminal` or `cmux new-surface --type browser` |
| Inspect the current layout | `cmux tree --all` |
| Send input to a terminal | `cmux send &quot;npm run dev&quot;` |
| Read terminal output | `cmux read-screen --scrollback --lines 200` |
| Fire a notification | `cmux notify --title &quot;Build Complete&quot; --body &quot;All tests passed&quot;` |
| Open a browser page | `cmux browser open https://localhost:4321` |
| Snapshot the browser state | `cmux browser snapshot --interactive` |

If you are already comfortable with terminal scripting, commands like `send`, `send-key`, `read-screen`, `notify`, and `browser snapshot` are where cmux gets interesting fast.

## How cmux compares to Ghostty and tmux

This is the question most terminal users will ask first.

| Tool | Best for | What it does well | What to watch for |
|------|----------|-------------------|-------------------|
| **Ghostty** | Fast everyday terminal use | Native feel, GPU rendering, simple config, built-in multiplexing | Less opinionated around agent workflows |
| **tmux** | Portable sessions and remote work | Detach/attach, SSH-friendly, huge ecosystem | More setup, more keybindings, no built-in browser or GUI notifications |
| **cmux** | Local multi-agent development on macOS | Workspaces, pane notifications, browser automation, CLI control | macOS-only, and app restart does not restore live process state yet |

My take: if you mostly want a better terminal, start with [Ghostty](/ghostty-terminal/). If you live on remote servers, tmux still earns its keep. If your day is turning into &quot;three Claude sessions, one Codex session, a dev server, and a browser you want to script,&quot; cmux starts to make a lot of sense.

## Getting started with cmux

Install it with Homebrew:

```bash
brew tap manaflow-ai/cmux
brew install --cask cmux
```

Or download the DMG from [cmux.dev](https://www.cmux.dev/) and drag it into Applications.

cmux is macOS-only right now, and the docs list these requirements:

- macOS 14 or later
- Apple Silicon or Intel Mac

### Optional: expose the CLI outside cmux

Inside cmux terminals, the CLI works automatically because cmux sets environment variables such as `CMUX_WORKSPACE_ID` and `CMUX_SURFACE_ID`.

If you want to call `cmux` from outside the app, the docs suggest creating a symlink:

```bash
sudo ln -sf &quot;/Applications/cmux.app/Contents/Resources/bin/cmux&quot; /usr/local/bin/cmux
```

After that, commands like these become available from any shell:

```bash
cmux list-workspaces
cmux current-workspace
cmux notify --title &quot;Build Complete&quot; --body &quot;Your build finished&quot;
```

## A practical setup that makes sense

If I were setting up cmux for real work, I would keep it simple:

1. One workspace for the main coding agent
2. One split for logs or tests
3. One browser surface for the app you are building
4. Notifications wired into agent hooks

That already gives you something useful without inventing a whole &quot;AI operating system&quot; for yourself.

If you already use Ghostty as your daily terminal, read my [Ghostty setup guide](/ghostty-terminal/) first for the fonts, config basics, and general terminal workflow. If you want a cleaner prompt inside Ghostty or cmux, the [Starship + Ghostty guide](/starship-ghostty-terminal/) is a good companion.

## Example workflow: Claude Code + browser + notifications

Here is the kind of setup cmux seems designed for:

```bash
# Create a new workspace
cmux new-workspace --cwd ~/projects/my-app

# Split the workspace
cmux new-split right

# Open the app in a browser surface
cmux browser open https://localhost:4321

# Notify yourself when a task is done
cmux notify --title &quot;Claude Code&quot; --body &quot;Agent finished the refactor&quot;
```

The official docs also show a simple Claude Code hook script that triggers `cmux notify` on stop events or after matching tool runs. That is a small detail, but it is exactly the sort of thing that turns the app from &quot;interesting demo&quot; into &quot;actually useful on a Tuesday.&quot;

## What I like about cmux

Three things stand out.

First, it does not try to hide the terminal. A lot of agent tools rush to build a whole new environment around the LLM. cmux still feels like a terminal person made it.

Second, the browser being part of the same workspace is a bigger deal than it looks on paper. When the terminal, browser, and notifications all live together, the workflow feels tighter.

Third, the project is honest about being a set of primitives. The &quot;Zen of cmux&quot; blog post says the same thing directly: cmux is not prescriptive, and the workflow is yours to shape. I like that framing.

## What to keep in mind before switching

cmux is promising, but it is not magic.

- It is **macOS-only** for now
- It restores **layout and metadata**, not live terminal process state
- If you mainly work over SSH, **tmux** is still the safer default
- If you do not need browser automation or agent notifications, **Ghostty alone may be enough**

That is not a knock against cmux. It just means you should use it for the job it is clearly trying to solve.

## FAQ

&lt;Accordion label=&quot;Is cmux just Ghostty with tabs?&quot; group=&quot;cmux-faq&quot; expanded=&quot;true&quot;&gt;
No. cmux uses `libghostty` for rendering, but it is a separate native macOS app focused on workspaces, notifications, browser surfaces, and automation for agent workflows.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do you still need tmux if you use cmux?&quot; group=&quot;cmux-faq&quot;&gt;
Sometimes yes. If you need detach/attach sessions on remote machines, tmux still solves a different problem. cmux is strongest for local macOS workflows where you want GUI workspaces, browser automation, and visible notifications.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can cmux work with agents besides Claude Code?&quot; group=&quot;cmux-faq&quot;&gt;
Yes. The official FAQ says any agent that runs in a terminal should work, including Claude Code, Codex, OpenCode, Gemini CLI, Aider, Goose, Cline, and others.
&lt;/Accordion&gt;

## Final thoughts

cmux feels like a tool built by someone who got tired of juggling AI agents in generic terminals and decided to fix the problem properly.

I would not recommend it to every terminal user. But if your local workflow already includes multiple agent sessions, app previews, and constant context switching, cmux is one of the more interesting things happening in terminals right now.

That is probably the best way to think about it: not as a replacement for every terminal, but as a sharper tool for a very specific kind of modern development.</content:encoded><category>ai</category><category>cmux</category><category>ghostty</category></item><item><title>CoPaw Setup Guide: Multi-Channel AI Assistant You Can Self-Host</title><link>https://www.bitdoze.com/copaw-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/copaw-setup-guide/</guid><description>Step-by-step guide to installing CoPaw on a VPS or locally. Covers pip install, Docker, model providers, channels like DingTalk, Discord and Telegram, skills, memory, and scheduled tasks.</description><pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

CoPaw is an open-source personal AI assistant from the AgentScope team at Alibaba. It runs on your own machine or server and connects to DingTalk, Feishu, QQ, Discord, Telegram, and iMessage. I&apos;ve been setting it up alongside my [OpenClaw](/clawdbot-setup-guide/) and [nanobot](/nanobot-setup-guide/) instances. What got my attention was the built-in web console for configuration and the fact that it ships with three local model backends out of the box.

&lt;Button text=&quot;CoPaw GitHub&quot; link=&quot;https://github.com/agentscope-ai/CoPaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing CoPaw via pip, one-line script, or Docker&lt;/li&gt;
&lt;li&gt;Configuring cloud LLM providers (DashScope, OpenAI, Azure OpenAI)&lt;/li&gt;
&lt;li&gt;Running local models with llama.cpp, MLX, and Ollama&lt;/li&gt;
&lt;li&gt;Setting up channels — DingTalk, Feishu, QQ, Discord, Telegram, iMessage&lt;/li&gt;
&lt;li&gt;Built-in skills, custom skills, and importing from skill hubs&lt;/li&gt;
&lt;li&gt;Memory system with hybrid semantic + full-text search&lt;/li&gt;
&lt;li&gt;Scheduled tasks and heartbeat check-ins&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted AI assistant options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup covers several projects including nanobot, NanoClaw, PicoClaw, and more. For a lighter Go-based option, see the [PicoClaw setup guide](/picoclaw-setup-guide/).

## What CoPaw actually does

CoPaw stands for &quot;Co Personal Agent Workstation.&quot; The AgentScope team built it on top of [AgentScope](https://github.com/agentscope-ai/agentscope) and [ReMe](https://github.com/agentscope-ai/ReMe) for memory management. The pitch: one assistant that connects to your messaging apps and runs tasks on a schedule without you having to ask.

The architecture:

```
You (DingTalk / Feishu / QQ / Discord / Telegram / iMessage)
    ↓
CoPaw Server (running on your machine or VPS)
    ↓
LLM Provider (DashScope, OpenAI, Azure, Ollama, llama.cpp, MLX)
    ↓
Skills (cron, PDF, Word/Excel/PPT, news, file reader, browser, custom)
```

Messages come in from whatever chat app you use, CoPaw routes them to whatever LLM you picked, and the model can use built-in skills to handle files, run scheduled jobs, browse the web, or deal with documents. There&apos;s a web console at `http://127.0.0.1:8088/` where you configure everything instead of editing config files by hand.

### How it compares to OpenClaw and nanobot

| Feature | CoPaw | OpenClaw | nanobot |
|---|---|---|---|
| **Language** | Python | TypeScript | Python |
| **Built by** | AgentScope (Alibaba) | Community | HKUDS |
| **Install** | pip / one-liner / Docker | curl one-liner | pip |
| **Web console** | Yes (built-in at :8088) | No (third-party dashboards) | No |
| **Channels** | DingTalk, Feishu, QQ, Discord, Telegram, iMessage | Telegram, WhatsApp, Slack, Discord | Telegram, Discord, WhatsApp, Slack, Feishu, DingTalk, Email, QQ |
| **Local models** | llama.cpp, MLX, Ollama | Ollama | vLLM |
| **Memory** | File-based + vector/BM25 hybrid search | File-based + semantic search | File-based |
| **Skills** | Built-in + importable from hubs | Community skills | Built-in |
| **Scheduled tasks** | Cron + heartbeat | Cron | Cron |
| **License** | Apache 2.0 | MIT | MIT |

Where CoPaw pulls ahead: the built-in web console means you don&apos;t need third-party dashboards, and the DingTalk/Feishu/QQ support is first-class rather than bolted on. If those are your daily chat apps, CoPaw saves you a lot of config headaches.

## Installation

CoPaw gives you five ways to get running. I&apos;ll cover the three most practical ones.

&lt;Tabs&gt;
&lt;Tab name=&quot;pip install&quot;&gt;

The cleanest approach if you already have Python:

```bash
pip install copaw
copaw init --defaults
copaw app
```

Open `http://127.0.0.1:8088/` and you&apos;ll see the Console. That&apos;s it for a basic setup.

For interactive configuration where you pick your LLM provider and channels upfront:

```bash
copaw init
```

This walks you through heartbeat interval, target channel, active hours, and optional skill setup.

&lt;/Tab&gt;
&lt;Tab name=&quot;One-line install&quot;&gt;

No Python needed. The installer handles everything using [uv](https://docs.astral.sh/uv/).

**macOS / Linux:**

```bash
curl -fsSL https://copaw.agentscope.io/install.sh | bash
```

**Windows (PowerShell):**

```powershell
irm https://copaw.agentscope.io/install.ps1 | iex
```

Open a new terminal after install, then:

```bash
copaw init --defaults
copaw app
```

To install with local model support:

```bash
# llama.cpp (cross-platform)
curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras llamacpp

# MLX (Apple Silicon only)
curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras mlx

# Ollama
curl -fsSL https://copaw.agentscope.io/install.sh | bash -s -- --extras ollama
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

Images are on Docker Hub (`agentscope/copaw`). Tags: `latest` (stable), `pre` (pre-release).

```bash
docker pull agentscope/copaw:latest
docker run -p 127.0.0.1:8088:8088 -v copaw-data:/app/working agentscope/copaw:latest
```

Config, memory, and skills are stored in the `copaw-data` volume. To pass API keys:

```bash
docker run -p 127.0.0.1:8088:8088 \
  -e DASHSCOPE_API_KEY=your_key_here \
  -v copaw-data:/app/working \
  agentscope/copaw:latest
```

If you need CoPaw in Docker to reach Ollama on the host:

```bash
docker run -p 127.0.0.1:8088:8088 \
  --add-host=host.docker.internal:host-gateway \
  -v copaw-data:/app/working agentscope/copaw:latest
```

Then in CoPaw settings, change the Ollama Base URL to `http://host.docker.internal:11434/v1`.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Installing on a Hetzner VPS

If you want CoPaw running 24/7 on a server, a cheap VPS does the job. I use a [Hetzner CX22](https://www.bitdoze.com/hetzner-cloud-review/) (2 vCPU, 4GB RAM) for €3.99/month.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started with Hetzner&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers about 5 months of running CoPaw.
&lt;/Notice&gt;

SSH into your server and run:

```bash
ssh root@YOUR_SERVER_IP
apt update &amp;&amp; apt upgrade -y
curl -fsSL https://copaw.agentscope.io/install.sh | bash
```

Open a new shell session, then:

```bash
copaw init --defaults
copaw app
```

To keep it running after you close the SSH session, use a process manager like systemd or run it in a tmux/screen session.

### Uninstalling

```bash
copaw uninstall          # keeps config and data
copaw uninstall --purge  # removes everything
```

## Model Configuration

Before CoPaw can do anything useful, you need to configure an LLM. Open the Console at `http://127.0.0.1:8088/` and go to **Settings → Models**.

### Cloud providers

CoPaw works with DashScope, ModelScope, OpenAI, Azure OpenAI, and Aliyun Coding Plan. For any of them:

1. Go to **Settings → Models** in the Console
2. Find the provider card and click **Settings**
3. Enter your API key and click **Save**
4. The card status changes to **Available**
5. In the **LLM Configuration** section at the top, select the provider and model, then click **Save**

You can also set API keys as environment variables. For DashScope:

```bash
export DASHSCOPE_API_KEY=your_key_here
```

Or put it in a `.env` file in the working directory (default `~/.copaw/`).

### Using OpenAI or Azure OpenAI

These work through the custom provider system:

1. Click **Add provider** on the Models page
2. Enter a Provider ID (e.g. `openai`) and display name
3. Click **Settings**, enter the Base URL (`https://api.openai.com/v1` for OpenAI) and API key
4. Click **Models**, add the model ID (e.g. `gpt-4o`)
5. Select it in the LLM Configuration dropdown

### Local models

CoPaw supports three local model backends. No API keys needed.

| Backend | Best for | Install command |
|---|---|---|
| **llama.cpp** | Cross-platform (macOS, Linux, Windows) | `pip install &apos;copaw[llamacpp]&apos;` |
| **MLX** | Apple Silicon Macs (M1–M4) | `pip install &apos;copaw[mlx]&apos;` |
| **Ollama** | Anyone already using Ollama | `pip install &apos;copaw[ollama]&apos;` |

To download and use a local model from the command line:

```bash
copaw models download Qwen/Qwen3-4B-GGUF
copaw models    # select the downloaded model
copaw app       # start the server
```

You can also download and manage models from the Console UI under **Settings → Models**. Click **Models** on the llama.cpp or MLX card, then **Download model** and enter the Hugging Face repo ID.

For Ollama, make sure the Ollama daemon is running first, then pull models through Ollama as usual:

```bash
ollama pull qwen3:4b
```

CoPaw syncs with whatever models Ollama has available.

If you&apos;re new to running local models, our [Ollama Docker install guide](https://www.bitdoze.com/ollama-docker-install/) covers the basics.

### Cost comparison

| Provider | Example Model | Typical Monthly Cost |
|---|---|---|
| DashScope | Qwen 3.5 Plus | $5–20 |
| OpenAI | GPT-4o | $20–70 |
| Azure OpenAI | GPT-4o | $20–70 |
| Local (llama.cpp) | Qwen3 4B | $0 (compute only) |
| Local (Ollama) | Qwen3 4B | $0 (compute only) |

DashScope models run cheaper than OpenAI for comparable quality. If you want to avoid API bills altogether, llama.cpp or Ollama with a local model costs nothing beyond electricity.

## Channel Setup

Channels are how you talk to CoPaw from your messaging apps. You can configure them through the Console (**Control → Channels**) or by editing `config.json` directly.

### Discord

1. Create a Discord application at [discord.com/developers](https://discord.com/developers/applications)
2. Go to the **Bot** tab, click **Reset Token**, and copy the bot token
3. Under **Privileged Gateway Intents**, enable **Message Content Intent**
4. Generate an OAuth2 invite URL with `bot` scope and `Send Messages` + `Read Message History` permissions
5. Invite the bot to your server
6. In the CoPaw Console, go to **Control → Channels**, click **Discord**, enable it, and paste the token

In `config.json`, it looks like this:

```json
{
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_DISCORD_BOT_TOKEN&quot;
    }
  }
}
```

### Telegram

1. Open Telegram, search for **@BotFather**, send `/newbot`
2. Pick a name and username, copy the token
3. In the Console, enable Telegram and paste the token

```json
{
  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_TELEGRAM_BOT_TOKEN&quot;
    }
  }
}
```

### DingTalk

DingTalk setup involves creating a custom app in the DingTalk developer console. CoPaw has a built-in skill called `dingtalk_channel_connect` that walks you through credential lookup, Client ID/Secret configuration, and the manual steps. Enable the DingTalk channel in the Console and follow the guided prompts.

### Feishu (Lark)

Same idea as DingTalk. Create an app in the Feishu Open Platform, grab the App ID and App Secret, and enter them in the Console. CoPaw also supports SOCKS proxy for Feishu if you&apos;re behind a corporate firewall.

### iMessage (macOS only)

If you&apos;re running CoPaw on a Mac, iMessage works as a channel. This is one of the few self-hosted assistants that supports iMessage natively.

### Multiple channels at once

CoPaw can connect to several channels simultaneously. Messages go to whichever channel you last talked in, or you can target specific channels for scheduled messages.

## Skills

Skills are how CoPaw does more than just chat. Several come built-in, and you can write your own or import from community hubs.

### Built-in skills

| Skill | What it does |
|---|---|
| **cron** | Scheduled jobs — create, list, pause, resume, delete |
| **file_reader** | Read and summarize text files (.txt, .md, .json, .csv, .py, etc.) |
| **pdf** | Read, extract, merge, split, rotate, watermark, OCR PDFs |
| **docx** | Create, read, and edit Word documents |
| **xlsx** | Read, edit, and create spreadsheets |
| **pptx** | Create, read, and edit PowerPoint files |
| **news** | Fetch and summarize latest news from configured sources |
| **browser_visible** | Launch a headed browser for demos or CAPTCHA scenarios |

### Managing skills in the Console

Go to **Agent → Skills** in the Console to see all loaded skills, toggle them on or off, create custom skills, or edit existing ones.

### Importing skills from hubs

CoPaw can import skills from these sources:

- `https://skills.sh/`
- `https://clawhub.ai/`
- `https://skillsmp.com/`
- GitHub repositories (any repo with a `SKILL.md` file)

In the Console, go to **Agent → Skills**, click **Import Skills**, paste the URL, and confirm.

### Creating custom skills

Drop a folder with a `SKILL.md` file into `~/.copaw/customized_skills/`:

```
~/.copaw/
  customized_skills/
    my_research_skill/
      SKILL.md
```

The `SKILL.md` is plain Markdown that describes what the skill does:

```markdown
---
name: my_research_skill
description: Research a topic and summarize findings
---

# Research Skill

When asked to research something:
1. Search the web for current information
2. Summarize key findings in bullet points
3. List sources
```

CoPaw picks up new skills on restart. Custom skills take priority over built-in ones when names collide.

## Memory System

CoPaw&apos;s memory system uses [ReMe](https://github.com/agentscope-ai/ReMe) and stores everything in plain Markdown files. There are two parts to it: context management that compresses long conversations before you hit token limits, and long-term memory that writes facts to files and indexes them so CoPaw can find them later.

### Memory file structure

```
~/.copaw/
  MEMORY.md                    # Long-term facts, preferences, decisions
  memory/
    2026-03-05.md              # Daily log for today
    2026-03-04.md              # Yesterday&apos;s log
    ...
```

`MEMORY.md` holds persistent information — things like &quot;I prefer Python 3.12&quot; or &quot;My team uses Slack for standups.&quot; Daily logs capture what happened in each conversation, and the system auto-summarizes conversations when they get too long.

### Hybrid search

CoPaw uses vector semantic search and BM25 full-text search together. The fusion weights default to 70% vector, 30% BM25.

| Search type | Good at | Weak at |
|---|---|---|
| **Vector semantic** | Finding related concepts with different wording | Exact token matching (function names, error codes) |
| **BM25 full-text** | Exact matches on specific terms | Synonyms and paraphrasing |
| **Hybrid (both)** | Best overall recall | Requires embedding API config |

To enable vector search, configure the embedding service:

```bash
export EMBEDDING_API_KEY=your_key
export EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
export EMBEDDING_MODEL_NAME=text-embedding-v4
```

Without an embedding API key, CoPaw falls back to BM25 full-text search only.

### Making things stick

Tell CoPaw directly:

&gt; &quot;Remember: I always deploy to staging before production.&quot;

It writes this to `MEMORY.md`. You can also edit memory files directly:

```bash
nano ~/.copaw/MEMORY.md
```

## Scheduled Tasks and Heartbeat

CoPaw can run things on a schedule in two ways: cron jobs for specific commands and heartbeat for periodic check-ins.

### Cron jobs

Create scheduled tasks through the CLI or Console:

```bash
# Create a job that runs at 9am every day
copaw cron create --type agent --name &quot;morning-digest&quot; --cron &quot;0 9 * * *&quot; --message &quot;Summarize my pending tasks and calendar for today&quot;

# List all jobs
copaw cron list

# Check a job&apos;s state
copaw cron state &lt;job_id&gt;
```

You can also manage cron jobs from the Console under **Control → Cron Jobs**.

### Heartbeat

Heartbeat is CoPaw&apos;s version of a scheduled check-in. You write a block of questions in a Markdown file, and CoPaw runs through them on a timer and sends the answers to your last-used channel. Set it up during `copaw init` or edit `HEARTBEAT.md` in the working directory.

Example `HEARTBEAT.md`:

```markdown
Check the following and report back:
- Any new emails from clients?
- What meetings do I have today?
- Summarize overnight GitHub notifications
```

Set the interval and target in `config.json`:

```json
{
  &quot;heartbeat&quot;: {
    &quot;enabled&quot;: true,
    &quot;interval&quot;: &quot;2h&quot;,
    &quot;active_hours&quot;: &quot;08:00-22:00&quot;
  }
}
```

## CLI Reference

| Command | Description |
|---|---|
| `copaw init` | Interactive setup wizard |
| `copaw init --defaults` | Quick setup with defaults |
| `copaw app` | Start the server |
| `copaw models` | Manage local models |
| `copaw models download &lt;repo&gt;` | Download a model from Hugging Face |
| `copaw cron list` | List scheduled jobs |
| `copaw cron create` | Create a new scheduled job |
| `copaw uninstall` | Remove CoPaw (keeps data) |
| `copaw uninstall --purge` | Remove CoPaw and all data |

## Troubleshooting

### CoPaw not responding

Check that the server is running and the model is configured:

1. Open `http://127.0.0.1:8088/` — if this doesn&apos;t load, the server isn&apos;t running
2. Go to **Settings → Models** and verify a provider is **Available** and a model is selected
3. Check the terminal output for error messages

### API key issues

If you see authentication errors:

- Double-check the API key in **Settings → Models**
- For DashScope, verify the key at [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com/)
- Try setting the key as an environment variable instead: `export DASHSCOPE_API_KEY=xxx`

### Docker can&apos;t reach Ollama

Inside a Docker container, `localhost` points to the container, not the host. Use `--add-host=host.docker.internal:host-gateway` and set the Ollama Base URL to `http://host.docker.internal:11434/v1`.

On Linux, you can also use `--network=host`:

```bash
docker run --network=host -v copaw-data:/app/working agentscope/copaw:latest
```

### Channel not receiving messages

- Verify the channel is enabled in **Control → Channels**
- Check that tokens and credentials are correct
- For Discord, make sure **Message Content Intent** is enabled in the developer portal
- Restart CoPaw after changing channel config

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Do I need to know Python to use CoPaw?**

No. The one-line installer and Docker options don&apos;t require any Python knowledge. The Console UI handles most configuration. You only need Python skills if you want to create custom skills or install from source.

**Which cloud provider should I start with?**

DashScope is the default and cheapest option. If you already have an OpenAI API key, add it as a custom provider. For running without any API costs, use llama.cpp or Ollama with a local model.

**Can I run CoPaw on a Raspberry Pi?**

Technically yes if you have a Pi 4/5 with 4GB+ RAM, but performance will be limited. A cheap VPS at €3.99/month gives you a better experience.

**Does CoPaw work on Windows?**

Yes. Use the PowerShell installer (`irm https://copaw.agentscope.io/install.ps1 | iex`) or Docker. The pip install also works if you have Python 3.10+.

**Can multiple people use one CoPaw instance?**

CoPaw replies in the channel where you last talked. Multiple users can interact through group channels in DingTalk, Feishu, or Discord. For separate conversations, each person should use a different channel or session.

**How does CoPaw compare to OpenClaw?**

OpenClaw has broader Western messaging app support (WhatsApp, Slack). CoPaw has better support for Chinese apps (DingTalk, Feishu, QQ), a built-in web console, and more local model options. Both are open source and self-hosted. See our [OpenClaw setup guide](/clawdbot-setup-guide/) for the full comparison.

**Is my data private?**

All data stays on your machine. The only external calls go to your configured LLM provider. If you run local models, nothing leaves your server at all.

&lt;/Accordion&gt;

CoPaw is worth trying if you want a self-hosted assistant with a proper web UI instead of editing JSON files blind. The three local model backends mean you can run it without any API costs, and if DingTalk or Feishu is where your team lives, it&apos;s the most polished option I&apos;ve found for those platforms.

For other self-hosted assistant options, check out our [OpenClaw alternatives](/openclaw-alternatives/) roundup. If you want something that compiles to a single binary and runs on a $10 board, the [PicoClaw setup guide](/picoclaw-setup-guide/) covers that. For a self-improving assistant with voice mode and session search, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/). And for running local models behind any of these assistants, our [Ollama Docker guide](https://www.bitdoze.com/ollama-docker-install/) has the setup details.</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>How To Deploy Memoh AI Agent Platform with Docker Compose</title><link>https://www.bitdoze.com/memoh-ai-agent-deploy/</link><guid isPermaLink="true">https://www.bitdoze.com/memoh-ai-agent-deploy/</guid><description>Deploy Memoh, an open-source multi-bot AI agent system, using Dokploy or Docker Compose. Run isolated AI bots with persistent memory, MCP tool support, and multi-platform channels.</description><pubDate>Wed, 04 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

If you want AI agents that stay on your hardware and don&apos;t phone home to some SaaS, Memoh is worth a look. It lets you spin up multiple AI bots, each inside its own container, with built-in memory and tool use. Below I cover two ways to deploy it: through Dokploy and with a plain Docker Compose setup.

## What is Memoh?

[Memoh](https://github.com/memohai/Memoh) is an open-source, containerized AI agent system built with Go and Vue 3. You create bots, each running in its own isolated containerd container with persistent memory and access to external tools through MCP (Model Context Protocol). Bots can chat on Telegram, Discord, Lark, Email, or the built-in Web UI, and they remember conversations across sessions.

### What Memoh does

| Feature | What it does |
| --- | --- |
| Container isolation | Each bot runs inside its own containerd sandbox with a separate filesystem, network, and process tree |
| Persistent memory | Hybrid retrieval using vector search (Qdrant) and keyword search, plus LLM-driven fact extraction |
| Multi-platform channels | Telegram, Discord, Lark (Feishu), Email, Web, CLI |
| MCP tool support | Bots can browse the web, run commands, edit files, call external tools |
| Multi-user awareness | Bots recognize individual users in group chats and track context per person |
| Web UI | Vue 3 dashboard with real-time streaming, a container file manager, and visual config |

### Key features

&lt;ListCheck&gt;
- Create and manage multiple AI bots from a single dashboard
- Container-level isolation per bot using containerd
- Hybrid memory engine with dense vector search and BM25 keyword search
- MCP support for connecting external tools (HTTP, SSE, Stdio)
- Scheduled tasks and heartbeat-based autonomous actions
- Works with any OpenAI-compatible, Anthropic, or Google AI provider
- Role-based access control with ownership transfer
- Cross-platform identity binding across all channels
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Privileged container&quot;&gt;
The Memoh server container runs in privileged mode because it embeds containerd to manage bot containers. Only deploy this on servers you trust and control.
&lt;/Notice&gt;

## Architecture overview

Memoh has six services managed by Docker Compose:

| Service | Image | Role |
| --- | --- | --- |
| `postgres` | `postgres:18-alpine` | Main database for users, bots, channels, and configuration |
| `qdrant` | `qdrant/qdrant:latest` | Vector database for semantic memory search |
| `migrate` | `memohai/server:latest` | One-shot service that runs database migrations, then exits |
| `server` | `memohai/server:latest` | Go backend with embedded containerd (privileged) |
| `agent` | `memohai/agent:latest` | Agent Gateway (Bun/Elysia) for AI chat, tool execution, and SSE streaming |
| `web` | `memohai/web:latest` | Vue 3 web UI served by Nginx |

Startup order: PostgreSQL and Qdrant start first. Once both pass their health checks, the migrate service applies database migrations. The server starts after migration finishes, then the agent gateway and web UI come up last.

## Prerequisites

&lt;ListCheck&gt;
- A Linux VPS or dedicated server with Docker and Docker Compose v2 installed
- At least 4 GB RAM (the server runs containerd plus PostgreSQL and Qdrant)
- Root or sudo access (required for privileged container mode)
- An API key from an OpenAI-compatible, Anthropic, or Google AI provider
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## Option 1: Deploy with Dokploy

Dokploy takes care of domains and SSL for you. If you don&apos;t have it set up yet, follow the [Dokploy install guide](https://www.bitdoze.com/dokploy-install/) first.

### Step 1: Create the config file

Before deploying, you need a `config.toml` file on your server. SSH into your machine and create it:

```bash
mkdir -p /opt/memoh
cat &gt; /opt/memoh/config.toml &lt;&lt; &apos;EOF&apos;
[log]
level = &quot;info&quot;
format = &quot;text&quot;

[server]
addr = &quot;server:8080&quot;

[admin]
username = &quot;admin&quot;
password = &quot;CHANGE_THIS_PASSWORD&quot;
email = &quot;admin@yourdomain.com&quot;

[auth]
jwt_secret = &quot;GENERATE_WITH_openssl_rand_-base64_32&quot;
jwt_expires_in = &quot;168h&quot;

[containerd]
socket_path = &quot;/run/containerd/containerd.sock&quot;
namespace = &quot;default&quot;

[mcp]
image = &quot;memohai/mcp:latest&quot;
snapshotter = &quot;overlayfs&quot;
data_root = &quot;/opt/memoh/data&quot;

[postgres]
host = &quot;postgres&quot;
port = 5432
user = &quot;memoh&quot;
password = &quot;YOUR_DB_PASSWORD&quot;
database = &quot;memoh&quot;
sslmode = &quot;disable&quot;

[qdrant]
base_url = &quot;http://qdrant:6334&quot;
api_key = &quot;&quot;
timeout_seconds = 10

[agent_gateway]
host = &quot;agent&quot;
port = 8081
server_addr = &quot;server:8080&quot;

[web]
host = &quot;127.0.0.1&quot;
port = 8082
EOF
```

Generate a proper JWT secret:

```bash
openssl rand -base64 32
```

Replace `GENERATE_WITH_openssl_rand_-base64_32` and `YOUR_DB_PASSWORD` with actual values.

### Step 2: Create the Dokploy service

1. Open your Dokploy project
2. Click **Add Service** and choose **Compose**
3. Name it `memoh`

### Step 3: Paste the compose file

```yaml
name: &quot;memoh&quot;
services:
  postgres:
    image: postgres:18-alpine
    container_name: memoh-postgres
    environment:
      POSTGRES_DB: memoh
      POSTGRES_USER: memoh
      POSTGRES_PASSWORD: YOUR_DB_PASSWORD
    volumes:
      - postgres_data:/var/lib/postgresql
      - /etc/localtime:/etc/localtime:ro
    expose:
      - &quot;5432&quot;
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U memoh&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - dokploy-network

  qdrant:
    image: qdrant/qdrant:latest
    container_name: memoh-qdrant
    volumes:
      - qdrant_data:/qdrant/storage
    expose:
      - &quot;6333&quot;
      - &quot;6334&quot;
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;timeout 10s bash -c &apos;:&gt; /dev/tcp/127.0.0.1/6333&apos; || exit 1&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - dokploy-network

  migrate:
    image: memohai/server:latest
    container_name: memoh-migrate
    entrypoint: [&quot;/app/memoh-server&quot;, &quot;migrate&quot;, &quot;up&quot;]
    volumes:
      - /opt/memoh/config.toml:/app/config.toml:ro
    depends_on:
      postgres:
        condition: service_healthy
    restart: &quot;no&quot;
    networks:
      - dokploy-network

  server:
    image: memohai/server:latest
    container_name: memoh-server
    privileged: true
    pid: host
    volumes:
      - /opt/memoh/config.toml:/app/config.toml:ro
      - containerd_data:/var/lib/containerd
      - server_cni_state:/var/lib/cni
      - memoh_data:/opt/memoh/data
      - /etc/localtime:/etc/localtime:ro
    expose:
      - &quot;8080&quot;
    depends_on:
      migrate:
        condition: service_completed_successfully
      qdrant:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - dokploy-network

  agent:
    image: memohai/agent:latest
    container_name: memoh-agent
    volumes:
      - /opt/memoh/config.toml:/config.toml:ro
      - /etc/localtime:/etc/localtime:ro
    expose:
      - &quot;8081&quot;
    depends_on:
      - server
    restart: unless-stopped
    networks:
      - dokploy-network

  web:
    image: memohai/web:latest
    container_name: memoh-web
    expose:
      - &quot;8082&quot;
    depends_on:
      - server
      - agent
    restart: unless-stopped
    networks:
      - dokploy-network

networks:
  dokploy-network:
    external: true

volumes:
  postgres_data:
  qdrant_data:
  containerd_data:
  memoh_data:
  server_cni_state:
```

### Step 4: Domain and port

Create a domain in Dokploy and map it to the `web` service on port **8082**. After deploying, open `https://your-domain.com` to access the dashboard.

**Notes about the Dokploy setup**

- All services use `expose` instead of `ports` since Dokploy handles external routing through its proxy.
- The `config.toml` is mounted from `/opt/memoh/config.toml` on the host. Make sure the database password matches in both the config file and the `POSTGRES_PASSWORD` environment variable.
- The server container needs `privileged: true` and `pid: host` for containerd to manage bot containers.

&lt;Notice type=&quot;warning&quot; title=&quot;Security&quot;&gt;
Change all default passwords in `config.toml` before deploying. The default admin password is `admin123`, so replace it with something strong.
&lt;/Notice&gt;

## Option 2: Docker Compose (standalone)

This is the standard way to run Memoh on any Linux server with Docker.

### Step 1: Create a project directory

```bash
mkdir -p /opt/memoh &amp;&amp; cd /opt/memoh
```

### Step 2: Create the config file

```bash
cat &gt; config.toml &lt;&lt; &apos;EOF&apos;
[log]
level = &quot;info&quot;
format = &quot;text&quot;

[server]
addr = &quot;server:8080&quot;

[admin]
username = &quot;admin&quot;
password = &quot;CHANGE_THIS_PASSWORD&quot;
email = &quot;admin@yourdomain.com&quot;

[auth]
jwt_secret = &quot;GENERATE_WITH_openssl_rand_-base64_32&quot;
jwt_expires_in = &quot;168h&quot;

[containerd]
socket_path = &quot;/run/containerd/containerd.sock&quot;
namespace = &quot;default&quot;

[mcp]
image = &quot;memohai/mcp:latest&quot;
snapshotter = &quot;overlayfs&quot;
data_root = &quot;/opt/memoh/data&quot;

[postgres]
host = &quot;postgres&quot;
port = 5432
user = &quot;memoh&quot;
password = &quot;YOUR_DB_PASSWORD&quot;
database = &quot;memoh&quot;
sslmode = &quot;disable&quot;

[qdrant]
base_url = &quot;http://qdrant:6334&quot;
api_key = &quot;&quot;
timeout_seconds = 10

[agent_gateway]
host = &quot;agent&quot;
port = 8081
server_addr = &quot;server:8080&quot;

[web]
host = &quot;127.0.0.1&quot;
port = 8082
EOF
```

Generate real values for the secrets:

```bash
# Generate JWT secret
openssl rand -base64 32

# Generate database password
openssl rand -base64 16
```

Update `config.toml` with the generated values.

### Step 3: Create the environment file

```bash
cat &gt; .env &lt;&lt; &apos;EOF&apos;
POSTGRES_PASSWORD=YOUR_DB_PASSWORD
MEMOH_CONFIG=./config.toml
EOF
```

Make sure `POSTGRES_PASSWORD` matches what you put in `config.toml` under `[postgres] password`.

### Step 4: Create the compose file

```yaml
name: &quot;memoh&quot;
services:
  postgres:
    image: postgres:18-alpine
    container_name: memoh-postgres
    environment:
      POSTGRES_DB: memoh
      POSTGRES_USER: memoh
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-memoh123}
    volumes:
      - postgres_data:/var/lib/postgresql
      - /etc/localtime:/etc/localtime:ro
    expose:
      - &quot;5432&quot;
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U memoh&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - memoh-network

  qdrant:
    image: qdrant/qdrant:latest
    container_name: memoh-qdrant
    volumes:
      - qdrant_data:/qdrant/storage
    expose:
      - &quot;6333&quot;
      - &quot;6334&quot;
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;timeout 10s bash -c &apos;:&gt; /dev/tcp/127.0.0.1/6333&apos; || exit 1&quot;]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    networks:
      - memoh-network

  migrate:
    image: memohai/server:latest
    container_name: memoh-migrate
    entrypoint: [&quot;/app/memoh-server&quot;, &quot;migrate&quot;, &quot;up&quot;]
    volumes:
      - ${MEMOH_CONFIG:-./config.toml}:/app/config.toml:ro
    depends_on:
      postgres:
        condition: service_healthy
    restart: &quot;no&quot;
    networks:
      - memoh-network

  server:
    image: memohai/server:latest
    container_name: memoh-server
    privileged: true
    pid: host
    volumes:
      - ${MEMOH_CONFIG:-./config.toml}:/app/config.toml:ro
      - containerd_data:/var/lib/containerd
      - server_cni_state:/var/lib/cni
      - memoh_data:/opt/memoh/data
      - /etc/localtime:/etc/localtime:ro
    ports:
      - &quot;8080:8080&quot;
    depends_on:
      migrate:
        condition: service_completed_successfully
      qdrant:
        condition: service_healthy
    restart: unless-stopped
    networks:
      - memoh-network

  agent:
    image: memohai/agent:latest
    container_name: memoh-agent
    volumes:
      - ${MEMOH_CONFIG:-./config.toml}:/config.toml:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - &quot;8081:8081&quot;
    depends_on:
      - server
    restart: unless-stopped
    networks:
      - memoh-network

  web:
    image: memohai/web:latest
    container_name: memoh-web
    ports:
      - &quot;8082:8082&quot;
    depends_on:
      - server
      - agent
    restart: unless-stopped
    networks:
      - memoh-network

volumes:
  postgres_data:
    driver: local
  qdrant_data:
    driver: local
  containerd_data:
    driver: local
  memoh_data:
    driver: local
  server_cni_state:
    driver: local

networks:
  memoh-network:
    driver: bridge
```

### Step 5: Start the stack

```bash
sudo docker compose up -d
```

First startup takes a couple of minutes while images download and services initialize. Check progress with:

```bash
sudo docker compose logs -f
```

Once everything is up, open `http://your-server-ip:8082` in your browser. Log in with the admin credentials you set in `config.toml`.

## After deployment

### Log in and add a provider

After logging in, go to **Settings &gt; Providers** and add your API key for OpenAI, Anthropic, Google, or any compatible endpoint. Bots won&apos;t do anything useful without a provider configured.

### Create your first bot

1. Click **Bots** in the sidebar
2. Click **Create Bot**
3. Give it a name and select a model from your configured provider
4. The bot gets its own containerd container automatically

You can now chat with the bot from the Web UI, or connect external channels like Telegram or Discord.

### Connect messaging channels

Memoh supports several external channels:

| Channel | What you need |
| --- | --- |
| Telegram | A bot token from @BotFather |
| Discord | A bot application token from the Discord Developer Portal |
| Lark (Feishu) | App ID and App Secret |
| Email | SMTP credentials or a Mailgun API key |

Configure channels from **Settings &gt; Channels** in the web UI.

### Bot memory

Bots remember conversations on their own. Memoh pairs Qdrant for vector-based semantic search with PostgreSQL for structured data and BM25 keyword search. The last 24 hours of context load by default. You can trigger memory compaction and rebuild from the bot settings.

## Managing data

All persistent data lives in Docker named volumes:

| Volume | Contents |
| --- | --- |
| `postgres_data` | PostgreSQL database files |
| `qdrant_data` | Qdrant vector storage |
| `containerd_data` | Bot container images and snapshots |
| `memoh_data` | Bot container data |
| `server_cni_state` | CNI network state for container networking |

These volumes survive `docker compose down`. To wipe everything and start fresh:

```bash
sudo docker compose down -v
```

### Useful commands

```bash
# Check service status
sudo docker compose ps

# View logs for a specific service
sudo docker compose logs -f server

# Restart the stack
sudo docker compose restart

# Update to latest images
sudo docker compose pull &amp;&amp; sudo docker compose up -d
```

The `migrate` service runs on every startup, so database schema updates are applied automatically when you pull new images.

## Production checklist

&lt;ListCheck&gt;
- Replace all default passwords in `config.toml` (admin, JWT secret, PostgreSQL)
- Set up HTTPS through a reverse proxy (Dokploy handles this, or use Nginx/Caddy)
- Restrict firewall rules to expose only the ports you need
- Set memory and CPU limits on containers for stability
- Back up PostgreSQL and Qdrant volumes regularly
- Monitor disk usage, because bot containers and vector data grow over time
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Privileged mode&quot;&gt;
The server container runs with `privileged: true` and `pid: host` because it embeds containerd. This gives it broad access to the host system. Keep the server behind a firewall and limit SSH access.
&lt;/Notice&gt;

## FAQ

&lt;Accordion label=&quot;Why does the server container need privileged mode?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Memoh embeds containerd inside the server container to give each bot its own sandbox. Containerd needs access to Linux kernel features (namespaces, cgroups) that require elevated privileges. There&apos;s no way around this if you want per-bot container isolation.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Memoh without an AI provider API key?&quot; group=&quot;faq&quot;&gt;
You can deploy it and poke around the UI, but bots won&apos;t generate any responses until you add at least one provider. Any OpenAI-compatible, Anthropic, or Google endpoint works.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much RAM does Memoh need?&quot; group=&quot;faq&quot;&gt;
The base stack (PostgreSQL, Qdrant, server, agent, web) sits around 2-3 GB at idle. Each bot container adds overhead depending on what tools and models it uses. 4 GB is the minimum I&apos;d recommend; go higher if you plan on running several bots at once.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I expose only the web UI and keep the API internal?&quot; group=&quot;faq&quot;&gt;
Yes. In the standalone compose file, remove the `ports` mapping for the `server` and `agent` services. The web UI container communicates with them internally over the Docker network. Only expose port 8082 (or route through a reverse proxy).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I update Memoh?&quot; group=&quot;faq&quot;&gt;
Pull the latest images and restart. The migrate service runs automatically on startup to apply schema changes:

```bash
sudo docker compose pull &amp;&amp; sudo docker compose up -d
```
&lt;/Accordion&gt;

## Wrapping up

Memoh is one of those projects that packs a lot into a single Docker Compose stack. You get per-bot container isolation, persistent memory, and multi-platform messaging without stitching together a half-dozen separate tools. The Dokploy route is the fastest if you already use it; otherwise, the standalone compose file works on any Linux box with Docker. From there, everything else happens in the browser.

&lt;Button text=&quot;View Memoh on GitHub&quot; link=&quot;https://github.com/memohai/Memoh&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>ai</category><category>self-hosted</category><category>ai-agents</category><category>docker</category></item><item><title>OpenFang Setup Guide: GLM-5, MiniMax M2.5, Hands, and Discord on Your VPS</title><link>https://www.bitdoze.com/openfang-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/openfang-setup-guide/</guid><description>Step-by-step guide to installing OpenFang on a Linux VPS with GLM-5, MiniMax M2.5, Discord integration, autonomous Hands, and 16 security layers. Covers config.toml, providers, channels, and Docker deployment.</description><pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I&apos;ve been running [OpenFang](https://github.com/RightNow-AI/openfang) for about two weeks now, side by side with my [OpenClaw setup](/clawdbot-setup-guide/) and [nanobot instance](/nanobot-setup-guide/). OpenFang is different from everything else I&apos;ve tested. It calls itself an &quot;Agent Operating System,&quot; and after using it I think that label fits. It ships autonomous agents called Hands that run on schedules without you having to prompt them, backed by 16 security layers and 40 chat channel adapters, all in a single Rust binary.

This guide walks through getting OpenFang running on a VPS with GLM-5 and MiniMax M2.5 as your LLM providers, Discord as the chat channel, and the Researcher Hand activated.

&lt;Button text=&quot;OpenFang GitHub&quot; link=&quot;https://github.com/RightNow-AI/openfang&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing OpenFang via the install script or from source&lt;/li&gt;
&lt;li&gt;Configuring GLM-5 (Zhipu) and MiniMax M2.5 as LLM providers&lt;/li&gt;
&lt;li&gt;Setting up Discord as a chat channel&lt;/li&gt;
&lt;li&gt;Activating the Researcher Hand for autonomous tasks&lt;/li&gt;
&lt;li&gt;Security settings, MCP support, and the Hands system&lt;/li&gt;
&lt;li&gt;Docker deployment and systemd service setup&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you want to compare OpenFang against other self-hosted bots, our [OpenClaw alternatives](/openclaw-alternatives/) roundup covers NanoClaw, nanobot, PicoClaw, ZeroClaw, NullClaw, and now OpenFang. For MCP basics, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

## What OpenFang actually is


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/JX-MbP0qMCk&quot;
  label=&quot;Meet OpenFang: Best Open-Source OpenClaw Alternative&quot;
/&gt;

OpenFang is an open-source project from [RightNow AI](https://www.rightnowai.co/), built entirely in Rust. It&apos;s structured as a 14-crate workspace totaling about 137,000 lines of code with 1,767+ tests and zero clippy warnings. The whole thing compiles down to a single ~32MB binary.

The architecture looks like this:

```
You (Discord / Telegram / Slack / WhatsApp / 36 more)
    ↓
OpenFang Daemon (running on your VPS)
    ↓
┌─────────────────────────────────┐
│  Kernel: orchestration, RBAC,   │
│  scheduling, budget tracking    │
├─────────────────────────────────┤
│  Runtime: agent loop, 53 tools, │
│  WASM sandbox, MCP, A2A         │
├─────────────────────────────────┤
│  LLM Provider (27 supported)    │
│  MiniMax, Zhipu, Anthropic, etc │
└─────────────────────────────────┘
    ↓
Hands (autonomous agents on schedules)
```

Messages come in from your chat app and get routed to whichever LLM you configured. Agents can use 53 built-in tools, connect to MCP servers, and run inside a WASM sandbox. The part I keep coming back to is Hands. These are autonomous agents that wake up on a schedule, do their job, and report back to your dashboard without you ever sending a message.

## Why GLM-5 and MiniMax M2.5

Both models work well with OpenFang&apos;s provider system and keep costs low for always-on agent setups.

### GLM-5

GLM-5 from Zhipu AI is a 744B MoE model with about 40-44B active parameters. It ranks first among open-source models on BrowseComp, which measures web search and research tasks — a good match for OpenFang&apos;s Researcher Hand.

| Spec | Value |
|------|-------|
| Architecture | 744B MoE, ~40B active |
| Context window | 200K tokens |
| SWE-Bench Verified | 77.8% |
| BrowseComp | #1 open-source |
| License | MIT |

The 200K context window handles long research tasks without running into limits.

### MiniMax M2.5

MiniMax M2.5 is a 230B MoE model with only 10B active parameters per pass. It&apos;s faster and cheaper than GLM-5, and the 1M token context window is hard to ignore:

| Spec | Value |
|------|-------|
| Architecture | 230B MoE, 10B active |
| Context window | 1M tokens |
| Speed (Lightning) | 100 tokens/sec |
| Cost (Lightning) | $0.30/M input, $2.40/M output |
| SWE-Bench Verified | 80.2% |
| License | Modified MIT (open-source) |

MiniMax M2.5 scores 80.2% on SWE-Bench Verified, which puts it alongside Claude Opus 4.6 at a fraction of the price. The 1M context is overkill for chat but helpful when Hands are processing large amounts of research data.

Both models are available through their direct APIs and through OpenRouter.

## Installation

Three paths to get OpenFang running.

&lt;Tabs&gt;
&lt;Tab name=&quot;Install script (recommended)&quot;&gt;

The fastest way. Works on macOS and Linux.

```bash
curl -fsSL https://openfang.sh/install | sh
```

This downloads the prebuilt binary for your platform and puts it in your PATH.

&lt;/Tab&gt;
&lt;Tab name=&quot;From source&quot;&gt;

If you want the latest code or plan to contribute:

```bash
# Install Rust if you don&apos;t have it
curl --proto &apos;=https&apos; --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env

# Clone and build
git clone https://github.com/RightNow-AI/openfang.git
cd openfang
cargo build --workspace --release

# The binary is at target/release/openfang
sudo cp target/release/openfang /usr/local/bin/
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Windows&quot;&gt;

PowerShell:

```powershell
irm https://openfang.sh/install.ps1 | iex
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

After installing, initialize the workspace and config:

```bash
openfang init
```

This creates the `~/.openfang/` directory with a default `config.toml`, sets up the local workspace, and walks you through picking an LLM provider.

Check that it&apos;s working:

```bash
openfang status
```

## Configuring GLM-5 (Zhipu)

OpenFang uses a `config.toml` file at `~/.openfang/config.toml`. In practice, the cleanest setup is to define the selected model in `[default_model]`, then keep provider-specific credentials and API endpoints in the matching `[providers.&lt;name&gt;]` block.

### Get an API key

1. Go to [z.ai](https://z.ai/manage-apikey/apikey-list)
2. Register and create an API key

&lt;Notice type=&quot;success&quot; title=&quot;Z.AI GLM coding plan — 10% off&quot;&gt;
Z.AI offers [GLM coding plans](https://z.ai/subscribe?ic=NKNUNYDRZT) designed for continuous developer workloads. Use our link for 10% off.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Zhipu coding plan endpoint&quot;&gt;
If you&apos;re on Zhipu&apos;s coding plan, set `api_base = &quot;https://api.z.ai/api/coding/paas/v4&quot;` in your zhipu provider config. This routes through their coding-optimized endpoint.
&lt;/Notice&gt;

### Add to config

```toml
[default_model]
provider = &quot;openai&quot;
model = &quot;glm-5&quot;
api_key = &quot;your-zhipu-api-key&quot;
base_url = &quot;https://api.z.ai/api/coding/paas/v4&quot;
```

Zhipu is configured through OpenFang&apos;s `openai` provider because the GLM endpoint is OpenAI-compatible. The docs put `provider`, `model`, `api_key`, and any custom `base_url` in the same `[default_model]` block.

### Test it

```bash
openfang chat
&gt; What&apos;s 42 * 17?
```

If you get a response, GLM-5 is wired up correctly. Type `exit` to leave the chat.

## Configuring MiniMax M2.5

### Get an API key

1. Go to [platform.minimax.io](https://platform.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (mainland China)
2. Create an account and generate an API key
3. Note which platform you&apos;re on since the API base URL differs

&lt;Notice type=&quot;success&quot; title=&quot;MiniMax coding plan — 10% off&quot;&gt;
MiniMax offers coding plans priced for developer workloads. [Get 10% off with our referral link](https://go.bitdoze.com/minimax). For details on how GLM-5 and MiniMax M2.5 compare for always-on bots, see our [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) breakdown.
&lt;/Notice&gt;

### Add to config

For the global platform, use the provider-specific config style:

```toml
[default_model]
provider = &quot;minimax&quot;
model = &quot;MiniMax-M2.5&quot;

[memory]
decay_rate = 0.05

[providers.minimax]
api_key_env = &quot;MINIMAX_API_KEY&quot;
api_base = &quot;https://api.minimax.io/v1&quot;

[agents.defaults]
model = &quot;MiniMax-M2.5&quot;
```

For the mainland China platform, add the API base override:

```toml
[default_model]
provider = &quot;minimax&quot;
model = &quot;MiniMax-M2.5&quot;

[memory]
decay_rate = 0.05

[providers.minimax]
api_key_env = &quot;MINIMAX_API_KEY&quot;
api_base = &quot;https://api.minimaxi.com/v1&quot;

[agents.defaults]
model = &quot;MiniMax-M2.5&quot;
```

This keeps the provider declaration explicit: `default_model` selects MiniMax, `[providers.minimax]` holds the API settings, and `[agents.defaults]` keeps the default agent model aligned with the provider.

### Configuring both providers

If you want both providers available in the same config, keep each one in its own provider block and point your default model at the one you want to use first:

```toml
[default_model]
provider = &quot;zhipu&quot;
model = &quot;glm-5&quot;

[providers.zhipu]
api_key = &quot;your-zhipu-key&quot;
api_base = &quot;https://api.z.ai/api/coding/paas/v4&quot;

[providers.minimax]
api_key_env = &quot;MINIMAX_API_KEY&quot;
api_base = &quot;https://api.minimax.io/v1&quot;

[agents.defaults]
model = &quot;glm-5&quot;
```

To switch over to MiniMax, change `default_model.provider` to `&quot;minimax&quot;` and set `agents.defaults.model` to `&quot;MiniMax-M2.5&quot;`.

## Discord setup

Discord is straightforward with OpenFang since it&apos;s one of 40 supported channel adapters.

### Create a Discord bot

1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
2. Click **New Application**, give it a name
3. Go to **Bot** in the left sidebar, click **Add Bot**
4. Copy the bot token

### Enable intents

Still in the Bot settings page:

1. Scroll down to **Privileged Gateway Intents**
2. Enable **MESSAGE CONTENT INTENT** (required for the bot to read messages)
3. Optionally enable **SERVER MEMBERS INTENT** if you plan to use allow lists

### Get your user ID

1. Open Discord Settings → **Advanced** → enable **Developer Mode**
2. Right-click your avatar anywhere in Discord
3. Click **Copy User ID**

### Configure OpenFang

Then use the channel format from the docs:

```toml
[channels.discord]
enabled = true
token = &quot;YOUR_DISCORD_BOT_TOKEN&quot;
allowed_users = [&quot;YOUR_USER_ID&quot;]
```

`allowed_users` is the current allowlist key in the docs. Leave it empty to let anyone in the server use the bot, or add specific user IDs if you want a private bot.

### Invite the bot to your server

1. In the Discord developer portal, go to **OAuth2** → **URL Generator**
2. Under **Scopes**, check `bot`
3. Under **Bot Permissions**, check `Send Messages` and `Read Message History`
4. Copy the generated URL and open it in your browser
5. Select the server you want to add the bot to

### Start the daemon

```bash
openfang start
```

Send a message in Discord. The bot should respond. The dashboard listens on `127.0.0.1:5555` by default unless you change `dashboard_listen`.

## Full config example

Here&apos;s what a complete `~/.openfang/config.toml` looks like with MiniMax M2.5, Discord, and the API server exposed externally with an API password while keeping the default ports:

```toml
api_listen = &quot;0.0.0.0:50051&quot;
api_key = &quot;change-this-long-random-password&quot;

[default_model]
provider = &quot;minimax&quot;
model = &quot;MiniMax-M2.5&quot;
max_tokens = 8192
temperature = 0.7

[memory]
decay_rate = 0.05

[providers.minimax]
api_key_env = &quot;MINIMAX_API_KEY&quot;
api_base = &quot;https://api.minimaxi.com/v1&quot;

[agents.defaults]
model = &quot;MiniMax-M2.5&quot;

[channels.discord]
enabled = true
token = &quot;YOUR_DISCORD_BOT_TOKEN&quot;
allowed_users = [&quot;YOUR_USER_ID&quot;]
```

### Config settings explained

| Setting | Default | What it does |
|---------|---------|-------------|
| `default_model.provider` | — | Which provider OpenFang uses first |
| `memory.decay_rate` | app default | Controls how quickly stored memory fades over time |
| `providers.minimax.api_key_env` | — | Environment variable that stores the MiniMax key |
| `providers.minimax.api_base` | provider default | MiniMax API endpoint for your region |
| `agents.defaults.model` | — | Default model used by spawned agents and hands |
| `api_listen` | `127.0.0.1:50051` | Address the API server binds to |
| `api_key` | none | Bearer token required for API access |
| `channels.discord.allowed_users` | empty | Restricts who can talk to the bot |

### MiniMax M2.5 external API example

If you want to run OpenFang with MiniMax M2.5 only and let external clients connect through the API, this is the cleanest config pattern while keeping the default ports:

```toml
[default_model]
provider = &quot;minimax&quot;
model = &quot;MiniMax-M2.5&quot;

[memory]
decay_rate = 0.05

[providers.minimax]
api_key_env = &quot;MINIMAX_API_KEY&quot;
api_base = &quot;https://api.minimaxi.com/v1&quot;

[agents.defaults]
model = &quot;MiniMax-M2.5&quot;
temperature = 0.7
max_tokens = 8192

api_listen = &quot;0.0.0.0:50051&quot;
api_key = &quot;replace-with-a-long-random-api-password&quot;

[channels.discord]
enabled = true
token = &quot;YOUR_DISCORD_BOT_TOKEN&quot;
allowed_users = [&quot;YOUR_USER_ID&quot;]
```

This keeps the default API port (`50051`) and only changes the bind address so it is reachable remotely. Export `MINIMAX_API_KEY` in the shell or your service manager before starting OpenFang, and keep the API port locked down in your firewall to trusted IPs.

## The provider system

OpenFang ships with 3 native LLM drivers (Anthropic, Gemini, OpenAI-compatible) that route to 27 providers. When you set a model name, OpenFang:

1. Matches keywords in the name against its provider registry
2. Checks that the matched provider has an API key
3. Routes through the correct native driver
4. Handles authentication, rate limiting, and cost tracking

Here are the supported providers:

| Provider | What it covers |
|----------|---------------|
| Anthropic | Claude models (native driver) |
| Gemini | Google Gemini (native driver) |
| OpenAI | GPT models (native driver) |
| Groq | Fast inference |
| DeepSeek | DeepSeek models |
| OpenRouter | Gateway to any model |
| Together | Open-source model hosting |
| Mistral | Mistral models |
| Fireworks | Fast inference |
| Cohere | Command models |
| Perplexity | Search-augmented models |
| xAI | Grok models |
| Ollama | Local models |
| vLLM | Local model serving |
| LM Studio | Local models |
| MiniMax | MiniMax M2.5 and others |
| Zhipu | GLM-5 and others |
| Moonshot | Kimi models |
| Qwen / DashScope | Qwen models |
| Bedrock | AWS-hosted models |

OpenFang routes requests based on task complexity scoring, falls back to another provider if one fails, and tracks per-model costs in the dashboard.

## The Hands system

Hands are the reason I keep running OpenFang alongside my other bots. They&apos;re pre-built autonomous agents that run on schedules without you sending messages. You activate a Hand, it goes to work, and you check its progress on the dashboard.

Each Hand comes bundled in the binary with:
- **HAND.toml** — manifest declaring required tools and settings
- **System prompt** — a multi-phase operational playbook (500+ words, not a one-liner)
- **SKILL.md** — domain expertise loaded into context at runtime
- **Guardrails** — approval gates for sensitive actions

### The 7 bundled Hands

| Hand | What it does |
|------|-------------|
| **Clip** | Takes YouTube URLs, cuts them into vertical shorts with captions and thumbnails, publishes to Telegram/WhatsApp |
| **Lead** | Daily lead generation — discovers, enriches, scores, and deduplicates qualified prospects |
| **Collector** | OSINT intelligence — monitors targets with change detection, sentiment tracking, knowledge graphs |
| **Predictor** | Superforecasting — collects signals, builds reasoning chains, tracks accuracy with Brier scores |
| **Researcher** | Deep research — cross-references sources, CRAAP fact-checking, cited reports in multiple languages |
| **Twitter** | X/Twitter management — 7 content formats, scheduling, engagement tracking, approval queue |
| **Browser** | Web automation — navigates sites, fills forms, handles workflows (mandatory purchase approval gate) |

### Activating a Hand

```bash
# Activate the Researcher Hand
openfang hand activate researcher

# Check progress
openfang hand status researcher

# List all available Hands
openfang hand list

# Pause without losing state
openfang hand pause researcher
```

I&apos;d start with the Researcher Hand. It runs on its own, cross-references sources, evaluates credibility using CRAAP criteria, and generates cited reports. Pair it with GLM-5 (which ranks #1 on BrowseComp) and the research output is genuinely useful without any babysitting.

## Security model

OpenFang has 16 security systems, each running independently. Here are the ones that matter for a personal setup:

### WASM sandbox

Every tool runs inside a WebAssembly sandbox with fuel metering. If a tool tries to run forever, the watchdog kills it.

This is enabled by default. Unlike application-level permission checks, the tool physically cannot escape the sandbox.

### Audit trail

Every action gets added to a Merkle hash-chain. Tamper with one entry and the entire chain breaks.

### Channel allowlists

Same concept as nanobot — restrict who can interact with the bot:

```toml
[channels.discord]
allowed_users = [&quot;123456789&quot;]
```

### Secret zeroization

API keys are automatically wiped from memory the moment they&apos;re no longer needed. OpenFang uses `Zeroizing&lt;String&gt;` throughout the codebase, so secrets don&apos;t linger in RAM.

## MCP and A2A support

OpenFang supports both [Model Context Protocol](/mcp-introduction-beginners/) for tool servers and Agent-to-Agent (A2A) communication. It ships with 25 MCP templates and an AES-256-GCM credential vault for storing MCP server credentials.

### Adding an MCP server

```toml
[[mcp_servers]]
name = &quot;filesystem&quot;
command = &quot;npx&quot;
args = [&quot;-y&quot;, &quot;@modelcontextprotocol/server-filesystem&quot;, &quot;/home/user/documents&quot;]

[[mcp_servers]]
name = &quot;remote_server&quot;
url = &quot;https://mcp.example.com/sse&quot;
```

MCP tools get discovered automatically when OpenFang starts. The LLM can use them alongside the 53 built-in tools.

## Docker deployment

OpenFang publishes container images:

```bash
# Pull the image
docker pull ghcr.io/rightnow-ai/openfang:latest

# Initialize config (first time)
docker run -v ~/.openfang:/root/.openfang --rm \
  ghcr.io/rightnow-ai/openfang:latest init

# Edit config on host
nano ~/.openfang/config.toml

# Run the daemon
docker run -d \
  -v ~/.openfang:/root/.openfang \
  -p 50051:50051 \
  -p 5555:5555 \
  --name openfang \
  ghcr.io/rightnow-ai/openfang:latest start
```

The dashboard is accessible at `http://your-server-ip:5555` after starting if you bind `dashboard_listen` externally. If you keep the safer default of `127.0.0.1:5555`, access it through SSH tunneling or a reverse proxy instead.

## VPS hosting

OpenFang uses about 40MB of RAM at idle, far less than OpenClaw&apos;s 394MB. I&apos;m running it on a [Hetzner CX22](/hetzner-cloud-review/) (2 vCPU, 4GB RAM) at €4.35/month alongside nanobot with no issues.

&lt;Notice type=&quot;success&quot; title=&quot;Hetzner discount&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers around 4 months of a CX22.
&lt;/Notice&gt;

Quick setup on a fresh Ubuntu 24.04 VPS:

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Install OpenFang
curl -fsSL https://openfang.sh/install | sh

# Initialize
openfang init

# Edit config
nano ~/.openfang/config.toml

# Start daemon in background
openfang start
```

For a proper daemon setup, create a systemd service:

```ini
[Unit]
Description=OpenFang Agent OS
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/openfang start
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Save that to `/etc/systemd/system/openfang.service`, then:

```bash
systemctl daemon-reload
systemctl enable openfang
systemctl start openfang
```

The dashboard will be live at `http://your-server-ip:5555` if you bind `dashboard_listen` externally. You can add more channel adapters later without restarting.

## OpenFang vs nanobot vs OpenClaw

I run all three, so here&apos;s a direct comparison:

| Aspect | OpenFang | nanobot | OpenClaw |
|--------|----------|---------|----------|
| Language | Rust | Python | TypeScript |
| Codebase | 137K LOC | ~3,700 lines | 430k+ lines |
| Install method | `curl` one-liner | `pip install` | Custom installer |
| Binary size | ~32MB | N/A (Python) | ~500MB |
| RAM (idle) | ~40MB | ~100MB | &gt;394MB |
| Cold start | under 200ms | &gt;30s | ~6s |
| Chat channels | 40 | 9 | 13 |
| LLM providers | 27 | 13+ | 10 |
| Security layers | 16 | Basic | 3 |
| Autonomous agents | 7 Hands | No | No |
| Dashboard | Built-in (Tauri) | No | Community |
| MCP support | Yes + A2A | Yes | Not yet |

OpenClaw has the biggest community and most documentation. nanobot installs faster and covers more chat platforms per line of code. OpenFang has the Hands system, more security layers than anything else here, and 40 channel adapters. The tradeoff is that OpenFang is newer (v0.1.0) and you will probably hit rough edges before v1.0. Pin to a specific commit if you&apos;re using it for anything critical.

## CLI reference

| Command | What it does |
|---------|-------------|
| `openfang init` | Initialize config and workspace |
| `openfang start` | Start the daemon (dashboard + channels) |
| `openfang stop` | Stop the daemon |
| `openfang status` | Show current status |
| `openfang chat` | Interactive chat mode |
| `openfang chat researcher` | Chat with a specific agent |
| `openfang agent spawn coder` | Spawn a pre-built agent |
| `openfang hand list` | List available Hands |
| `openfang hand activate &lt;name&gt;` | Activate a Hand |
| `openfang hand status &lt;name&gt;` | Check Hand progress |
| `openfang hand pause &lt;name&gt;` | Pause a Hand |
| `openfang migrate --from openclaw` | Migrate from OpenClaw |

## Migrating from OpenClaw

If you&apos;re already running OpenClaw, OpenFang has a built-in migration tool:

```bash
# Dry run to see what would change
openfang migrate --from openclaw --dry-run

# Run the migration
openfang migrate --from openclaw

# Or specify a custom path
openfang migrate --from openclaw --path ~/.openclaw
```

This imports your agents, conversation history, skills, and configuration. OpenFang reads SKILL.md natively and is compatible with ClawHub skills.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**How much does it cost to run OpenFang?**

VPS: ~$5/month at Hetzner. GLM-5 through Z.AI&apos;s coding plan or MiniMax M2.5 Lightning at roughly $0.30/M input tokens. Expect $5-25/month for personal use depending on how active your Hands are and how much you chat.

**Can I run OpenFang without API costs?**

Yes. Configure the Ollama or vLLM provider and point it at a local model server. You need hardware that can run inference, but there are no API bills. OpenFang&apos;s 40MB idle footprint leaves plenty of room for a local model on the same machine.

**Does OpenFang work on a Raspberry Pi?**

The ~32MB binary and 40MB idle RAM mean it runs fine on a Pi 4 with 4GB RAM when using remote API providers. Running local models on a Pi is a different situation.

**Can multiple people use one OpenFang instance?**

Yes. OpenFang has per-channel allowlists, DM/group policies, and role-based access control. Each channel adapter supports independent user restrictions.

**How stable is OpenFang for production use?**

It&apos;s v0.1.0 — the first public release. Architecture is solid and the test suite has 1,767+ tests, but breaking changes can happen between minor versions. Pin to a specific commit for production and watch the GitHub releases.

**Can Hands work with any LLM provider?**

Yes. Hands use whichever model you configure as the default, or you can set per-Hand model overrides. The Researcher Hand works well with GLM-5 given its BrowseComp scores, while Clip and Lead work fine with MiniMax M2.5.

&lt;/Accordion&gt;

For other self-hosted bot options, check our [OpenClaw alternatives](/openclaw-alternatives/) roundup. If you want a lighter option, the [nanobot setup guide](/nanobot-setup-guide/) covers a 3,700-line Python alternative. For container-isolated agents, see the [NanoClaw deploy guide](/nanoclaw-deploy-guide/). For the smallest possible binary, the [NullClaw deploy guide](/nullclaw-deploy-guide/) covers a 678KB Zig option. And for a self-improving assistant with voice mode and built-in OpenClaw migration, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>How to Build a Modern WooCommerce Product Admin Dashboard</title><link>https://www.bitdoze.com/woocommerce-admin-dashboard/</link><guid isPermaLink="true">https://www.bitdoze.com/woocommerce-admin-dashboard/</guid><description>The default WooCommerce product editor is slow and clunky. Learn how I built an open-source React dashboard for managing WooCommerce products and how to deploy it with Docker.</description><pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

A client of mine runs [bigsales.ro](https://bigsales.ro/), a WooCommerce store with a large product catalog. They kept telling me the same thing: managing products in the default WooCommerce admin is painful. Slow page loads, too many clicks to change a price, no proper mobile view, and the bulk editor barely qualifies as &quot;bulk.&quot;

I looked at the existing options, found none of them great for what we needed, and ended up building a custom dashboard from scratch. It is now open source and you can use it for your own store.

&lt;Button text=&quot;Woo Admin on GitHub&quot; link=&quot;https://github.com/bitdoze/woo-admin&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## The problem with WooCommerce product management

If you have managed more than a handful of products in WooCommerce, you already know. The WordPress admin was designed for blog posts, and WooCommerce bolted product management on top of that. It works, but it does not work well.

Specific pain points my client kept running into:

- Editing a single product means loading the full WordPress editor, waiting for all those metaboxes, saving, and waiting again. On a shared hosting plan, that can take 5-10 seconds per page load.
- The bulk edit feature lets you change a few fields at once, but it is buried in a dropdown, the UI is cramped, and it chokes on large selections.
- No usable mobile interface. If you need to update stock from your phone while at a warehouse, good luck with the WP admin on a small screen.
- Searching and filtering products is basic. You cannot sort by stock status or quickly find products missing images.
- Every plugin you add to improve the admin adds more load to an already heavy page.

## What exists out there already

Before building something custom, I went through the existing options. There are a few categories:

### WordPress plugins for bulk editing

Plugins like **Smart Manager by StoreApps**, **WooCommerce Bulk Edit by iThemeland**, and **YITH Bulk Product Editing** add spreadsheet-style editing inside the WordPress admin. They work, and for simple price or stock updates they can be enough.

The downsides: they still run inside WordPress, so you inherit all the bloat. They add more PHP processing to already heavy admin pages. Some are free for basic features but lock useful stuff (like scheduled edits or variation management) behind paid tiers. And they still do not give you a fast, standalone interface.

### Hosted platforms

Shopify, BigCommerce, and similar platforms have better admin dashboards out of the box. But if you are already on WooCommerce, switching your entire e-commerce platform is not a quick fix. You picked WooCommerce for a reason (flexibility, no monthly fees, WordPress ecosystem), and those reasons probably still apply.

### Custom admin panels

A few developers have built custom React or Vue frontends that talk to the WooCommerce REST API. Most of them are abandoned or half-finished, and the ones that work are proprietary. I could not find a maintained open-source option that did the basics well.

That is what pushed me to build [Woo Admin](https://github.com/bitdoze/woo-admin).

## What Woo Admin does

Woo Admin is a standalone React app that connects to your WooCommerce store through the REST API. It runs in its own Docker container, completely separate from WordPress, so your store does not get any heavier.

The current feature set:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Browse products with server-side pagination and search&lt;/li&gt;
&lt;li&gt;Desktop table view with sorting, plus a card layout on mobile&lt;/li&gt;
&lt;li&gt;Create, edit, and delete products&lt;/li&gt;
&lt;li&gt;Manage pricing (regular and sale prices) with flexible decimal input&lt;/li&gt;
&lt;li&gt;Stock management: toggle tracking, set quantity, update status&lt;/li&gt;
&lt;li&gt;Product type support: simple, variable, and external products&lt;/li&gt;
&lt;li&gt;Image upload to WordPress and URL-based image management&lt;/li&gt;
&lt;li&gt;Category assignment with hierarchical display&lt;/li&gt;
&lt;li&gt;Global attribute management and term creation&lt;/li&gt;
&lt;li&gt;Full variation CRUD for variable products&lt;/li&gt;
&lt;li&gt;Product status control (published, draft, pending, private)&lt;/li&gt;
&lt;li&gt;Mobile-friendly interface that actually works on a phone&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## The tech stack

The frontend is React 18 with TypeScript and Vite. I went with Ant Design for the UI components because it has solid table, form, and layout components that I did not want to build myself. On top of that, I used Refine (`@refinedev/core` + `@refinedev/antd`), which is a framework specifically for admin panels. It gives you data provider patterns and CRUD hooks, so I spent my time on WooCommerce-specific logic instead of wiring up pagination and form state for the hundredth time.

For HTTP requests I used Axios. In production, the app compiles down to static files served by Nginx, which also handles the reverse proxy to the WooCommerce API. The whole thing ships as a Docker image with a multi-stage build: Node compiles the TypeScript, then the output gets copied into a small Nginx Alpine image.

## How it connects to WooCommerce

The app talks to the WooCommerce REST API (v3) and the WordPress REST API (for media uploads). There are two connection modes:

### Proxy mode (recommended)

In proxy mode, the frontend makes requests to relative paths like `/api/wc` and `/api/wp`. The Nginx container sitting in front of the app proxies those to your WooCommerce site and injects authentication headers.

This is the setup I recommend because:

- Your WooCommerce API credentials never appear in browser requests
- You do not need to configure CORS on your WordPress site
- It works behind any reverse proxy (Traefik, Caddy, another Nginx)

The proxy routes look like this:

```
/api/wc/*  →  https://your-store.com/wp-json/wc/v3/*
/api/wp/*  →  https://your-store.com/wp-json/wp/v2/*
```

### Direct API mode

If you do not want to use the built-in proxy, the frontend can call the WooCommerce API directly. Set `VITE_WC_API_BASE` to your store&apos;s full API URL and provide the consumer key and secret. This requires CORS to be properly configured on your WordPress installation.

## Project structure

The codebase is small and focused:

```
src/
├── components/
│   ├── ProductAttributesInput.tsx    # Global attribute selector
│   ├── ProductCategoriesInput.tsx    # Hierarchical category picker
│   ├── ProductImagesInput.tsx        # Upload + URL image manager
│   ├── ProductVariationsManager.tsx  # Variation CRUD
│   └── ProductVariationsTable.tsx    # Variation display
├── pages/products/
│   ├── list.tsx     # Product listing with table/card views
│   ├── create.tsx   # New product form
│   ├── edit.tsx     # Edit form with variation support
│   └── show.tsx     # Product detail view
├── providers/
│   └── wooDataProvider.ts   # Custom Refine data provider
├── App.tsx          # Routing and Refine config
└── main.tsx         # React entry point
```

The custom data provider (`wooDataProvider.ts`) is where the WooCommerce-specific logic lives: normalizing payloads, formatting prices, cleaning up image objects, and mapping between how Refine expects data and how the WooCommerce API actually works.

### Price normalization

One thing that tripped me up early: WooCommerce is picky about price formats in the API, and users type prices in all kinds of ways. The data provider normalizes prices before sending them:

- Accepts both `.` and `,` as decimal separators
- Strips currency symbols and whitespace
- Rounds to two decimal places
- If someone fills in only the sale price, it gets promoted to the regular price (a common mistake)

## How to deploy it

### Prerequisites

You need:

- A WooCommerce store with the REST API enabled
- WooCommerce API keys (go to WooCommerce → Settings → Advanced → REST API)
- Docker and Docker Compose on your server
- Optionally, a WordPress application password for media uploads

### Step 1: Clone the repo

```bash
git clone https://github.com/bitdoze/woo-admin.git
cd woo-admin
```

### Step 2: Create your .env file

```env
VITE_WC_API_BASE=/api/wc
VITE_WC_URL=https://your-store.com
VITE_WC_CONSUMER_KEY=ck_your_key_here
VITE_WC_CONSUMER_SECRET=cs_your_secret_here
VITE_WP_USERNAME=your_wp_user
VITE_WP_APP_PASSWORD=your_app_password
ACCESS_PASS=pick_a_strong_password
```

&lt;Notice type=&quot;warning&quot; title=&quot;Protect your dashboard&quot;&gt;
  Always set `ACCESS_PASS`. Without it, anyone who finds the URL can manage your products. The dashboard uses HTTP Basic Auth with username `admin` and whatever password you set here.
&lt;/Notice&gt;

### Step 3: Create the Docker network and start the container

```bash
docker network create web
docker compose up -d --build
```

That builds the frontend, packages it into an Nginx container, and starts serving. The entrypoint script generates the runtime configuration and Nginx proxy config automatically from your environment variables.

### Step 4: Set up a reverse proxy

The container exposes port 80 internally. Put it behind your existing reverse proxy (Traefik, Caddy, Nginx) with SSL termination. If you are running Docker apps with a panel, you can check my guide on [deploying Docker Compose apps with Dokploy](https://www.bitdoze.com/dokploy-docker-compose-app/).

If you have WordPress running in Docker too, check out my [WordPress Docker installation guide](https://www.bitdoze.com/install-wordpress-docker/) for the full setup.

## Local development

If you want to contribute or customize it for your store:

```bash
npm ci
npm run dev
```

The dev server starts on port 3000. Create a `.env` file with your store credentials and the app connects directly to your WooCommerce API. Make sure your store has CORS headers configured for `localhost:3000`, or use proxy mode.

```bash
npm run build      # Production build
npm run preview    # Preview the production build locally
```

## What the dashboard looks like in practice


![woo-admin](../../assets/images/26/03/woo-admin-dash.webp)


My client&apos;s main complaint was &quot;I just want to see my products and change a price without waiting 10 seconds.&quot; So the product list is the first thing you see. On desktop it is a sortable table with image thumbnails, stock badges, and action buttons. On a phone it switches to cards, which was the part my client actually uses most since they check stock from their warehouse.

The edit form groups related fields together instead of scattering them across metaboxes like WooCommerce does. Pricing is near the top, then stock, then categories and attributes, then images, then variations. Each section fetches data on demand, so the form does not choke even with thousands of products in the catalog.

One detail I am happy with: category assignment shows the full path (like &quot;Electronics &gt; Phones &gt; Smartphones&quot;) so you immediately know where a product sits. In the default WooCommerce editor, nested categories are just indented checkboxes that get confusing past two levels.

## Frequently asked questions

&lt;Accordion label=&quot;Does this replace the WordPress admin entirely?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
  No. Woo Admin focuses on product management only. You still need the WordPress admin for orders, customers, settings, shipping, taxes, and everything else WooCommerce does. Think of it as a dedicated tool for the one task that the default admin handles poorly.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can multiple people use the dashboard at the same time?&quot; group=&quot;faq&quot;&gt;
  Yes. Each user session talks to the WooCommerce API independently. There is no server-side session state. Just be aware that if two people edit the same product simultaneously, the last save wins.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will this slow down my WooCommerce store?&quot; group=&quot;faq&quot;&gt;
  No. The dashboard runs in its own container and only talks to the WooCommerce REST API. It does not add any PHP code, plugins, or database queries to your store. The REST API calls are the same ones any external integration would make.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I run this without Docker?&quot; group=&quot;faq&quot;&gt;
  You can build the static files with `npm run build` and serve the `dist/` folder from any web server. You will need to configure the proxy yourself (or use direct API mode). Docker just makes the deployment and proxy setup turnkey.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is it secure?&quot; group=&quot;faq&quot;&gt;
  In proxy mode, WooCommerce API credentials stay on the server. They never reach the browser. The optional `ACCESS_PASS` adds HTTP Basic Auth to the dashboard itself, so random people cannot stumble into it. For production, always run it behind HTTPS.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about orders and customers?&quot; group=&quot;faq&quot;&gt;
  Not yet. The current version is product-focused. Orders and customer management might come later, but the priority was solving the product editing pain point first. If you want to contribute, the repo is open.
&lt;/Accordion&gt;

## What is next

The repo is at [github.com/bitdoze/woo-admin](https://github.com/bitdoze/woo-admin) under the MIT license. I am running it in production for [bigsales.ro](https://bigsales.ro/) and my client stopped complaining about product management, which I consider a success. I will keep adding features as actual needs come up rather than guessing what people might want.

If your WooCommerce admin experience is as frustrating as mine was, clone the repo and try it. Takes about five minutes to get running with Docker. Issues and pull requests are welcome.</content:encoded><category>wordpress</category><category>woocommerce</category><category>docker</category></item><item><title>How I Migrated My WordPress Site to Astro and MDX</title><link>https://www.bitdoze.com/wordpress-to-astro-migration/</link><guid isPermaLink="true">https://www.bitdoze.com/wordpress-to-astro-migration/</guid><description>A practical guide on migrating from WordPress to Astro with MDX. Learn how I moved wpdoze.com to bitdoze.com using wordpress-export-to-markdown, Codex CLI, and image optimization.</description><pubDate>Mon, 02 Mar 2026 00:00:00 GMT</pubDate><content:encoded>import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;

I had a WordPress blog at [wpdoze.com](https://www.wpdoze.com) with a bunch of old articles that I wanted to move over to [bitdoze.com](https://www.bitdoze.com), which runs on Astro with MDX. I kept putting it off because I figured it would be painful. Turns out it was not that bad, and I want to share what I did so you can skip the trial-and-error part.

## Why I left WordPress

Look, WordPress works. Millions of sites run on it for good reason. But my site was just static articles, and I was still paying for hosting, babysitting plugin updates, and watching page load times get worse. Astro generates plain HTML files, so pages load instantly with no database or PHP involved. Hosting on Cloudflare Pages costs zero dollars. And I get to write in MDX files in a Git repo instead of fighting the block editor. That was enough for me.

If you are new to Astro, I have a guide on [how to build a free blog with Astro and Cloudflare](https://www.bitdoze.com/build-astro-blog-free/), and there is also my list of [the best Astro.js online courses](https://www.bitdoze.com/best-astrojs-online-courses/) if you prefer video.

## What the migration looked like

The short version:

1. Exported everything from WordPress (posts, pages, images)
2. Converted the XML export to Markdown files
3. Used Codex CLI from OpenAI to automate the boring parts: image conversion, frontmatter formatting, fixing image references
4. Built the Astro project and fixed what broke
5. Redirected the old domain to the new one

I will walk through each step.

## Step 1: Export your WordPress content

WordPress has a built-in export tool. Go to **Tools → Export** in your admin panel and export **All content**. You get an XML file with all your posts, pages, categories, tags, and image references.

A few things worth knowing:

- The export has image URLs but not the actual files. The converter in the next step downloads them for you.
- Custom post types or ACF fields might need extra handling. For a regular blog, the default export is enough.
- Back up your site first. You probably will not need the backup, but you will sleep better.

&lt;Notice type=&quot;info&quot; title=&quot;Before You Start&quot;&gt;
  Make sure your WordPress site is still accessible during the migration. The markdown converter needs to download images from your live site.
&lt;/Notice&gt;

## Step 2: Convert WordPress XML to Markdown

[wordpress-export-to-markdown](https://github.com/lonekorean/wordpress-export-to-markdown) is a Node.js tool that reads your XML export and spits out Markdown files with frontmatter. It also downloads all the images.

### Install and run

```bash
npx wordpress-export-to-markdown
```

The tool walks you through an interactive setup: where the XML file is, where to put the output, how to handle images. I saved images, skipped year/month folders (wanted a flat structure to reorganize later), and excluded drafts.

When it finishes, you have a folder of `.md` files and a directory with all the images.

### What the output looks like

Each post becomes a Markdown file with basic frontmatter:

```markdown
---
title: &quot;Your Post Title&quot;
date: &quot;2023-05-15&quot;
---

Your post content here with images referenced like:
![alt text](images/your-image.png)
```

The frontmatter is bare-bones. You still need to add fields your Astro content collection expects (description, categories, tags, canonical URL, and so on). That is where Codex CLI came in.

## Step 3: Let AI handle the grunt work

This step saved me hours. I used [Codex CLI from OpenAI](https://github.com/openai/codex) to handle the repetitive file-by-file changes that I was not going to do by hand for 50+ articles.

### Convert images to WebP

The WordPress export had a mix of PNG and JPEG files, many of them oversized. I had Codex CLI batch-convert everything to WebP:

```bash
# Codex CLI handled the batch conversion
# Converting all PNG/JPEG images to WebP format
for img in images/*.{png,jpg,jpeg}; do
  cwebp -q 80 &quot;$img&quot; -o &quot;${img%.*}.webp&quot;
done
```

WebP files run about 25-35% smaller than equivalent JPEGs at similar quality. Across a whole blog, that is a lot of bandwidth saved.

### Update image references

After converting images, all the Markdown files still pointed to `.png` and `.jpg` filenames. Codex CLI went through every file and swapped the extensions:

```markdown
&lt;!-- Before --&gt;
![screenshot](images/dashboard-screenshot.png)

&lt;!-- After --&gt;
![screenshot](images/dashboard-screenshot.webp)
```

### Format frontmatter for Astro

My Astro site uses a Zod-validated frontmatter schema. The exported files had almost nothing in the frontmatter, so Codex CLI reformatted each one to match:

```yaml
---
date: 2023-05-15T00:00:00Z
title: &quot;Your Post Title&quot;
description: &quot;A brief description pulled from the first paragraph&quot;
image: &quot;../../assets/images/your-post/featured.webp&quot;
categories: [&quot;cms&quot;]
authors: [&quot;Dragos&quot;]
tags: [&quot;wordpress&quot;, &quot;tutorials&quot;]
canonical: &quot;https://www.bitdoze.com/your-post-slug/&quot;
---
```

### Move images to the right place

Astro optimizes images best when they live in `src/assets/` rather than `public/`. Codex CLI moved each post&apos;s images into the right directory structure:

```
src/assets/images/
  └── post-slug/
      ├── featured.webp
      └── screenshot-1.webp
```

&lt;Notice type=&quot;success&quot; title=&quot;Codex CLI was the real time-saver&quot;&gt;
  Without it, I would have spent a full day just on image conversion, reference updates, and frontmatter formatting. Codex handled all of it in minutes. I just reviewed the output and fixed the occasional mistake.
&lt;/Notice&gt;

## Step 4: Set up your Astro content collection

If you do not have an Astro site yet, go set one up first. My [free Astro blog guide](https://www.bitdoze.com/build-astro-blog-free/) covers that.

Your content collection config (`src/content/config.ts`) defines what fields your posts need:

```typescript
import { defineCollection, z } from &quot;astro:content&quot;;

const posts = defineCollection({
  schema: ({ image }) =&gt;
    z.object({
      date: z.date(),
      title: z.string(),
      description: z.string().optional(),
      image: image().optional(),
      categories: z.array(z.string()).optional(),
      authors: z.array(z.string()).optional(),
      tags: z.array(z.string()).optional(),
      canonical: z.string().optional(),
    }),
});

export const collections = { posts };
```

Put your converted MDX files in `src/content/posts/` and images in `src/assets/images/`.

## Step 5: Build and fix what breaks

Run your Astro build to see what is broken:

```bash
bun run build
```

Things that tripped me up:

- Broken image paths where the reference in the MDX file did not match the actual file location
- Invalid frontmatter that Astro&apos;s Zod validation rejected (it tells you exactly which field in which file, which is nice)
- HTML from WordPress that MDX did not like, mostly unclosed tags and unescaped special characters
- Missing component imports at the top of MDX files

I ran the build after each batch of changes, so when something broke I knew what I had just touched. Codex CLI also helped here by reading the build error output and fixing the problems automatically.

## Step 6: Deploy and redirect

Once the build passes, deploy. I use Cloudflare Pages and wrote a [guide on deploying Astro to Cloudflare](https://www.bitdoze.com/deploy-astrojs-cloudflare/) if that is your setup too. You can also [deploy Astro on a VPS](https://www.bitdoze.com/deploy-astro-on-vps/) if you prefer managing your own server.

### Set up domain redirects

The last piece: redirect your old domain. I pointed `wpdoze.com` at `bitdoze.com`. How you do this depends on your DNS setup:

- In Cloudflare, use Page Rules or Bulk Redirects to 301 the old domain to the new one.
- With other providers, set up a 301 at the server level or use your registrar&apos;s URL forwarding.

```
# Example Cloudflare Page Rule
wpdoze.com/* → https://www.bitdoze.com/$1 (301 redirect)
```

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t Skip the Redirects&quot;&gt;
  301 redirects tell search engines that your content has permanently moved. Without them, you lose whatever SEO value your old domain had built up, and visitors hitting old bookmarks or search results get a dead page.
&lt;/Notice&gt;

## Things I wish I knew earlier

A few things I picked up along the way:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Run the build after every major change, not just at the end. It is much easier to find the problem when you know what you just changed.&lt;/li&gt;
&lt;li&gt;Use WebP for all images. The size savings are real and Astro handles them well.&lt;/li&gt;
&lt;li&gt;Do not try to manually edit hundreds of files. Use AI tooling like Codex CLI or scripts to handle bulk changes.&lt;/li&gt;
&lt;li&gt;Keep your old WordPress site running until you have verified everything works on the new site. Do not rush to take it down.&lt;/li&gt;
&lt;li&gt;Check your analytics after the migration. Make sure your pages are getting indexed and traffic is flowing through the redirects.&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## After the migration

Once your content is over, a few things are worth doing:

- I wrote a guide on [Astro SSG build optimization](https://www.bitdoze.com/astro-ssg-build-optimization/) because large sites can get slow to build. Worth reading if you have more than a handful of posts.
- If you are on Node.js, [switching to Bun](https://www.bitdoze.com/migrate-astro-bun/) cut my build times down noticeably.
- You will probably want a [contact form](https://www.bitdoze.com/add-contact-form-astro/) at some point.
- For analytics, I use Plausible proxied through Cloudflare Workers. I wrote up [how to set that up](https://www.bitdoze.com/astro-plausible-cloudflare-workers/).

## Frequently asked questions

&lt;Accordion label=&quot;How long does the whole migration take?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
  Depends on how many posts you have. My site had around 50 articles and the whole thing took about a day. Most of that time went to reviewing what Codex CLI produced and fixing edge cases. The actual export and conversion was maybe an hour.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need to know TypeScript to use Astro?&quot; group=&quot;faq&quot;&gt;
  No. Plain JavaScript works fine in Astro components. TypeScript is optional, though I recommend it for content collection schemas because it catches frontmatter mistakes at build time. The Zod schema definitions are readable even if TypeScript is new to you.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about WordPress comments?&quot; group=&quot;faq&quot;&gt;
  Astro is a static site generator, so there is no built-in comment system. You can bolt on Giscus (uses GitHub Discussions), Disqus, or Hyvor Talk. Personally, I dropped comments entirely. Most of mine were spam.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will I lose my SEO rankings?&quot; group=&quot;faq&quot;&gt;
  With proper 301 redirects, rankings should carry over. I saw a small dip for about two weeks while Google reprocessed things, then it recovered. The faster page loads from Astro actually helped my rankings afterward.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use this process for a WooCommerce site?&quot; group=&quot;faq&quot;&gt;
  For the content parts (blog posts, pages), yes. But if you have a WooCommerce store with products, cart, and checkout, Astro alone will not replace that. You would need a headless commerce layer like Snipcart or Shopify&apos;s Storefront API alongside Astro.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What if my posts use WordPress shortcodes?&quot; group=&quot;faq&quot;&gt;
  Shortcodes do not convert. The markdown exporter dumps them as plain text. You will need to replace them with MDX components or just remove them. I used Codex CLI to find all shortcodes across my files and convert the common ones into MDX equivalents.
&lt;/Accordion&gt;

## Wrapping up

Moving from WordPress to Astro took a day of focused work, and I am glad I did it. My site loads faster, hosting costs went from $X/month to zero, and writing in MDX files beats the WordPress editor for my workflow.

The tools that made it practical: [wordpress-export-to-markdown](https://github.com/lonekorean/wordpress-export-to-markdown) for the initial conversion, and Codex CLI for all the file-by-file reformatting I was not going to do by hand. Astro&apos;s content collections tied it all together with type-safe validation that caught mistakes at build time.

If you have been putting this off like I was, just start with a handful of posts and see how it goes. The process is more mechanical than creative, and the tooling handles most of it.</content:encoded><category>web-development</category><category>astro</category><category>wordpress</category></item><item><title>WordPress on ARM vs x86: A Benchmark Comparison</title><link>https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/</link><guid isPermaLink="true">https://www.bitdoze.com/arm-vs-x86-vps-server-benchmarks/</guid><description>Real benchmark numbers comparing ARM and x86 VPS servers running WordPress on Hetzner — performance, cost, and what to expect.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgArm from &quot;../../assets/images/wordpress/ARM-WordPress-Benchmarks-666x1024.webp&quot;;
import imgX86Yabs from &quot;../../assets/images/wordpress/hetzner-4cpu-yabs-test-743x1024.webp&quot;;
import imgX86Wp from &quot;../../assets/images/wordpress/hetzner_4cpu-whbt_test-652x1024.webp&quot;;

There&apos;s been a steady shift from x86 to ARM in the VPS world. Hetzner, Oracle, and AWS (Graviton) all offer ARM instances now, and the prices are noticeably lower. I ran some benchmarks to see how ARM actually holds up running WordPress against an equivalent x86 server — same provider, similar specs, real numbers.

Since I originally ran these tests, Hetzner raised prices effective April 2026 (30-37% across cloud tiers) because DRAM costs went up. I&apos;ve updated the pricing below to reflect current rates, but the performance comparison still holds.

If you want to see the Oracle ARM vs Hetzner ARM comparison I did earlier, check [this article](https://www.bitdoze.com/hetzner-oracle-arm-performance/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/OzOVt6vdKp8&quot;
  label=&quot;WordPress ARM vs x86 VPS Benchmark&quot;
/&gt;

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;
## ARM VPS benchmarks

The ARM server is a Hetzner CAX31: 4 vCPUs (Ampere Altra), 8 GB RAM, running Ubuntu 22.04. Cost: **€11.99/month** (rising to €15.99/month from April 2026).

### yabs.sh results

```
Basic System Information:
Processor  : Neoverse-N1
CPU cores  : 4 @ ??? MHz
RAM        : 7.5 GiB
Disk       : 75.0 GiB
Distro     : Ubuntu 22.04.3 LTS
VM Type    : KVM

fio Disk Speed Tests (Mixed R/W 50/50):
Block Size | 4k            (IOPS) | 64k           (IOPS)
Read       | 153.80 MB/s  (38.4k) | 1.11 GB/s    (17.3k)
Write      | 153.69 MB/s  (38.4k) | 1.14 GB/s    (17.9k)
Total      | 307.49 MB/s  (76.8k) | 2.25 GB/s    (35.3k)

Geekbench 6:
Single Core  : 1072
Multi Core   : 3439
```

Disk IO is fast, network is solid, Geekbench scores compare well with mid-range x86.

### WordPress benchmark

&lt;Picture src={imgArm} alt=&quot;ARM VPS WordPress benchmark results&quot; /&gt;

Any score above 8 is good here. Random binary operations are the weak spot on ARM — everything else is competitive.

## x86 VPS benchmarks

Same test, same Hetzner datacenter, same spec count (4 vCPU / 8 GB RAM) but x86 AMD (CPX31 with Genoa AMD EPYC). Cost: **€16.49/month** (rising to €21.49/month from April 2026).

### yabs.sh results

&lt;Picture src={imgX86Yabs} alt=&quot;x86 AMD VPS yabs.sh benchmark results&quot; /&gt;

The CPU scores are close. IO and network are similar. You&apos;re not getting dramatically more compute for the extra money.

### WordPress benchmark

&lt;Picture src={imgX86Wp} alt=&quot;x86 AMD VPS WordPress benchmark results&quot; /&gt;

The x86 server scores about 0.7 points higher across the board in the WordPress benchmark. Independent tests from FlyWP and community benchmarks on Reddit (late 2025) put the gap at roughly 7-10% in raw throughput and 20-30% in single-thread performance. Whether that delta justifies paying more depends on your workload.

## Quick comparison

| | ARM (CAX31) | x86 (CPX31) |
|---|---|---|
| CPU | Ampere Altra, 4 vCPU | AMD EPYC Genoa, 4 vCPU |
| RAM | 8 GB | 8 GB |
| Storage | 80 GB NVMe | 80 GB NVMe |
| Price (current) | €11.99/mo | €16.49/mo |
| Price (April 2026) | €15.99/mo | €21.49/mo |
| Single-thread perf | ~1070 (GB6) | ~1650 (GB6) |
| WordPress throughput | ~7-10% slower | Baseline |
| Best for | Steady workloads, budget setups | High-traffic, burst-heavy sites |

## Other ARM VPS providers worth knowing

Hetzner is not the only option. If you want to compare:

- **AWS Graviton (EC2)** -- ARM instances that AWS has been pushing hard. Reserved instance pricing can beat Hetzner, but on-demand rates are higher.
- **Oracle Cloud** -- The always-free tier includes an ARM instance (4 OCPU, 24 GB RAM). I covered this in my [Hetzner vs Oracle ARM comparison](https://www.bitdoze.com/hetzner-oracle-arm-performance/).
- **Vultr** -- Has ARM-based cloud compute in select regions.

## Conclusion

ARM works. I&apos;ve moved several sites over and haven&apos;t had issues. The performance gap against x86 is real, around 7-10% in throughput and more in single-thread benchmarks, but most WordPress setups behind a cache layer won&apos;t feel it.

The price gap has narrowed with Hetzner&apos;s 2026 increases, but ARM is still cheaper. For small to medium WordPress sites, staging environments, or projects where cost matters more than peak single-thread speed, ARM does the job.

If you want to try it, [grab €20 on Hetzner](https://go.bitdoze.com/hetzner) and test your own stack before committing.</content:encoded><category>wordpress</category><category>vps</category></item><item><title>Best Free WordPress Backup Plugins</title><link>https://www.bitdoze.com/best-free-wordpress-backup-plugins/</link><guid isPermaLink="true">https://www.bitdoze.com/best-free-wordpress-backup-plugins/</guid><description>Three free WordPress backup plugins that actually work — WPvivid, UpdraftPlus, and Duplicator. What each offers, how they compare, and which one to pick.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgWpvivid from &quot;../../assets/images/wordpress/WPvivid-Backup-1024x332.webp&quot;;
import imgUpdraft from &quot;../../assets/images/wordpress/UpdraftPlus-Backup-and-Restoration.webp&quot;;

I&apos;ve had sites hacked. I&apos;ve also had servers die. Both times, having backups meant I could restore in under an hour instead of starting from scratch. If you&apos;re running WordPress and not backing up automatically to an external location, you&apos;re gambling.

The good news: you don&apos;t need to pay for this. Three free plugins handle it well — WPvivid, UpdraftPlus, and Duplicator. I&apos;ve used all of them. Here&apos;s what to know.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NIETd_kQVVs&quot;
  label=&quot;Best Free WordPress Backup Plugins&quot;
/&gt;

## What a good backup plugin needs to do

Before getting into the plugins, here&apos;s what I actually care about:

- Back up to external storage (Dropbox, Google Drive, S3, FTP) — not just the same server
- Run on a schedule automatically
- Handle large sites (500+ posts, several GB) without choking
- Make restoring straightforward, even for non-technical users

All three plugins below cover these.

## 1. WPvivid Backup Plugin

&lt;Picture src={imgWpvivid} alt=&quot;WPvivid Backup Plugin dashboard&quot; /&gt;

[WPvivid](https://wordpress.org/plugins/wpvivid-backuprestore/) is free and packs more into its free tier than you&apos;d expect. Here&apos;s what it does:

- Backs up to Dropbox, Google Drive, Amazon S3, OneDrive, FTP/SFTP
- Full site migration — copy your site to a new domain or server in a few clicks
- Unlimited backup size and no cap on backup count
- Backup splitting for storage providers with size limits
- One-click restore

Site migration alone makes this worth installing. If you ever need to move a WordPress site to a new host or clone it to a staging environment, WPvivid handles it without needing a separate plugin.

## 2. UpdraftPlus

&lt;Picture src={imgUpdraft} alt=&quot;UpdraftPlus Backup and Restoration plugin&quot; /&gt;

[UpdraftPlus](https://wordpress.org/plugins/updraftplus/) is used by over 3 million sites and has been around longer. The free version is reliable and covers the basics:

- Scheduled automatic backups
- Storage options: Dropbox, Google Drive, S3, Rackspace, FTP, email
- One-click restore from within WordPress
- No size limits
- Available in 16+ languages

The free version works well for most sites. If you need incremental backups, encryption, or multisite support, that&apos;s behind the paid plan.

## 3. Duplicator

[Duplicator](https://wordpress.org/plugins/duplicator/) has over 1.5 million active installs. It started as a migration/cloning tool and later added backup functionality. The free version covers:

- Full site backups (files + database) or database-only
- One-click restore points before updates
- Site cloning for staging or testing environments
- Server-to-server migration with a standalone installer
- Drag-and-drop import
- WooCommerce and multisite support

The main limitation in the free version: cloud storage (Google Drive, Dropbox, S3) and scheduled backups require the Pro plan, which starts at $49.50/year. If you&apos;re fine with manual backups stored locally or downloaded, the free tier is solid.

## How they compare

| Feature | WPvivid | UpdraftPlus | Duplicator |
|---|---|---|---|
| Active installs | 700,000+ | 3,000,000+ | 1,500,000+ |
| Scheduled backups (free) | Yes | Yes | No (Pro only) |
| Cloud storage (free) | Dropbox, Google Drive, S3, OneDrive, FTP | Dropbox, Google Drive, S3, Rackspace, FTP, email | Local only (Pro for cloud) |
| Site migration (free) | Yes | No (paid add-on) | Yes |
| One-click restore | Yes | Yes | Yes |
| Staging/cloning | Yes | No (paid) | Yes |
| WooCommerce support | Yes | Yes | Yes |
| Backup splitting | Yes | No | No |
| Incremental backups | No (paid) | No (paid) | No (paid) |

## Which one to install

For most people, **WPvivid** offers the best free feature set — scheduled backups to cloud storage plus site migration without paying a cent. **UpdraftPlus** is the safe, well-established choice if you want the largest community and widest language support. **Duplicator** is the strongest option if your main need is cloning and migrating sites between hosts.

If you&apos;re unsure, install WPvivid. If you specifically need to move a site to a new server, Duplicator handles that workflow cleanly. Either way, the important thing is to actually have one installed and running — backing up to external storage, not just local.</content:encoded><category>wordpress</category><category>plugins</category></item><item><title>Best Web Scraping Plugins For WordPress</title><link>https://www.bitdoze.com/best-web-scraping-plugins-for-wordpress/</link><guid isPermaLink="true">https://www.bitdoze.com/best-web-scraping-plugins-for-wordpress/</guid><description>A practical look at the best WordPress web scraping plugins for automating content collection — what each one does, what it costs, and which to pick.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import imgScraper from &quot;../../assets/images/wordpress/scraper.webp&quot;;
import imgWpScraper from &quot;../../assets/images/wordpress/banner-772x250.webp&quot;;

Web scraping lets you pull data from websites and automatically publish it to WordPress. News aggregators, price trackers, product comparison sites, and content curators all use it. The tricky part is doing it without breaking rules or getting blocked.

&lt;Notice type=&quot;warning&quot; title=&quot;Before you start scraping&quot;&gt;
Always check the target site&apos;s terms of service and robots.txt. Scraping can violate ToS or copyright law depending on what you collect. If you&apos;re pulling large volumes of data, use a proxy — your server IP will eventually get blocked without one. [Bright Data](https://brightdata.com/) offers pay-as-you-go residential and datacenter proxies that work well for this.
&lt;/Notice&gt;

## Best WordPress web scraping plugins

### 1. Scraper – Content Crawler Plugin

&lt;Picture src={imgScraper} alt=&quot;Scraper Content Crawler plugin settings&quot; /&gt;

[Scraper by wpBots](https://1.envato.market/5BPqj) is available on CodeCanyon. It uses XPath and regex to pull content from most sites, handles encoding issues automatically, and can translate content on import.

Key features:
- Scrape any website using XPath or regex selectors
- Automatic featured image extraction
- Duplicate title detection
- Language detection and translation (requires Google Translate API key)
- WooCommerce product creation
- Proxy and cookie support
- Scheduling

**Price:** $29 (regular license, 6 months support). Doesn&apos;t work well with heavy JavaScript-rendered sites. Amazon and AliExpress have blocked its servers.

### 2. WP Content Crawler

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/2MYWzq_oAig&quot;
  label=&quot;WP Content Crawler overview&quot;
/&gt;

[WP Content Crawler](https://1.envato.market/kPGkz) has a visual inspector built into the WordPress admin that helps you find CSS selectors without leaving your site. It crawls post lists, follows pagination, and handles automatic recrawling to keep content fresh. The plugin has been updated with ChatGPT integration for rewriting, summarizing, and transforming scraped content before publishing.

Key features:
- Visual CSS selector inspector
- Automatic URL discovery and background crawling
- Scheduled recrawl for content updates
- ChatGPT integration for content transformation and rewriting
- DeepL, Google Cloud, and other translation services
- Duplicate URL/title/content detection
- WooCommerce product support
- Social media embed conversion for 70+ domains

**Price:** $29 (regular license, 6 months support). Can&apos;t fetch JavaScript-generated content. Requires PHP 8.1+.

### 3. Crawlomatic

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/F6vhRJgCR_M&quot;
  label=&quot;Crawlomatic WordPress scraper plugin&quot;
/&gt;

[Crawlomatic](https://1.envato.market/aZQLW) runs JavaScript using a headless browser approach, which means it can scrape sites that the other plugins can&apos;t touch. Good for dynamic, JS-heavy sites. Supports CSS selectors, XPath, and regex for content parsing.

**Price:** $49. Takes more configuration than the others but handles difficult sites.

### 4. WordPress Automatic Plugin

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/KD1XEfa_saA&quot;
  label=&quot;WordPress Automatic Plugin overview&quot;
/&gt;

[WordPress Automatic](https://1.envato.market/AYXAj) by ValvePress does more than scrape pages. It imports from YouTube, Twitter, Instagram, Reddit, Amazon, eBay, AliExpress, RSS feeds, and more. It also has OpenAI integration for generating SEO descriptions and rewriting content.

Key features:
- RSS feed import with full content
- Social media import (Facebook, Twitter, Instagram, Pinterest, Reddit)
- Amazon/eBay/AliExpress product import for WooCommerce
- OpenAI GPT integration for content generation and meta descriptions
- Google Translate / DeepL / Yandex translation
- Auto-hyperlinking for affiliate links
- Image caching to your server

**Price:** $39. Can be resource-intensive depending on how many sources you configure.

### 5. Octolooks Scrapes

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/_rqhxq9cNws&quot;
  label=&quot;Octolooks Scrapes plugin overview&quot;
/&gt;

[Scrapes by Octolooks](https://octolooks.com/wordpress-auto-post-and-crawler-plugin-scrapes/) offers three modes: single-page scraping, serial scraping (follows pagination through a full site), and RSS feed aggregation. The visual selector means you don&apos;t need to write CSS selectors by hand.

**Price:** $25. Works on shared hosting with minimal resource requirements.

### 6. WP Scraper (free)

&lt;Picture src={imgWpScraper} alt=&quot;WP Scraper plugin banner&quot; /&gt;

[WP Scraper](https://wordpress.org/plugins/wp-scraper/) is the free option. It&apos;s mainly designed for migrating content from non-WordPress sites into WordPress — paste a URL and it pulls the page content, images, title, and tags directly into a new post or page.

It&apos;s not a full automation tool, but if you need to import pages one by one without writing any code, it does the job.

## Quick comparison

| Plugin | Price | JS Support | AI/GPT | Visual Selector | WooCommerce | Best For |
|---|---|---|---|---|---|---|
| Scraper | $29 | No | No | No (XPath/regex) | Yes | Simple scraping with translation |
| WP Content Crawler | $29 | No | Yes (ChatGPT) | Yes | Yes | Recurring content updates |
| Crawlomatic | $49 | Yes | No | No | Yes | JS-heavy dynamic sites |
| WordPress Automatic | $39 | No | Yes (OpenAI) | No | Yes | Multi-source aggregation |
| Octolooks Scrapes | $25 | No | No | Yes | No | Budget scraping, shared hosting |
| WP Scraper | Free | No | No | No | No | One-off page migration |

## Which one to pick

If you need to **automate content from social media and RSS** at scale, WordPress Automatic is the most flexible. For **standard site scraping with a visual interface**, WP Content Crawler or Scraper both work well — WP Content Crawler has the edge now thanks to its ChatGPT integration for rewriting content before publishing. If the target site is **heavy on JavaScript**, Crawlomatic is the only option here that handles it. Just need to **migrate a few pages** manually? WP Scraper is free and gets it done.

Regardless of which plugin you choose, pair it with a proxy service if you&apos;re scraping at any real volume. And always check the target site&apos;s terms of service — scraping copyrighted content without permission can create legal problems regardless of which tool you use.</content:encoded><category>wordpress</category><category>plugins</category></item><item><title>Best WooCommerce Barcode and QR Code Plugins (Free &amp; Paid)</title><link>https://www.bitdoze.com/best-woocommerce-barcode-and-qr-code-plugins/</link><guid isPermaLink="true">https://www.bitdoze.com/best-woocommerce-barcode-and-qr-code-plugins/</guid><description>A practical guide to the best WooCommerce barcode and QR code plugins — free and paid options for inventory management, order tracking, and payments.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgYith from &quot;../../assets/images/wordpress/01-yith-bar-code-1024x894.webp&quot;;
import imgOrderBarcodes from &quot;../../assets/images/wordpress/02-Barcodes-Order-details.webp&quot;;
import imgUpi from &quot;../../assets/images/wordpress/04-upi-gr-1024x489.webp&quot;;
import imgKaya from &quot;../../assets/images/wordpress/05-kaya-qr-1024x404.webp&quot;;
import imgPaymentQr from &quot;../../assets/images/wordpress/06-payment-qr-1024x853.webp&quot;;

Barcodes and QR codes in WooCommerce serve two different purposes: inventory and order management on the backend, and payment or product lookup on the customer side. The right plugin depends on which problem you&apos;re actually solving. If your bigger issue is product management speed rather than scanning, you might also want to check out the [open-source WooCommerce admin dashboard](https://www.bitdoze.com/woocommerce-admin-dashboard/) I built for faster product editing.

Here are the best options available, both free and paid.

## 1. YITH WooCommerce Barcodes and QR Codes

&lt;Picture src={imgYith} alt=&quot;YITH WooCommerce Barcodes and QR Codes plugin&quot; /&gt;

[YITH Barcodes and QR Codes](https://yithemes.com/themes/plugins/yith-woocommerce-barcodes-and-qr-codes/) does the most out of any plugin on this list. It generates barcodes automatically for products and orders, supports 10 barcode protocols, and can trigger actions when a code is scanned, like reducing stock or changing order status.

Features worth noting:
- Auto-generate codes for all products (including variable products)
- Barcode types: product ID, SKU, custom fields
- Scan and search orders/products by barcode
- Print PDF with all product barcodes (with stock unit counts)
- Show barcodes in order confirmation emails

**Price:** ~$94.99/year (check [YITH&apos;s site](https://yithemes.com/themes/plugins/yith-woocommerce-barcodes-and-qr-codes/) for current pricing)

### 2. WooCommerce Order Barcodes

&lt;Picture src={imgOrderBarcodes} alt=&quot;WooCommerce Order Barcodes details page&quot; /&gt;

[WooCommerce Order Barcodes](https://woocommerce.com/products/woocommerce-order-barcodes/) generates a unique barcode for every order automatically. Works well for e-tickets, event check-ins, and reservations. Supports 5 barcode types including QR codes, and includes a built-in scanner so you can process orders without leaving WordPress. Compatible with WooCommerce Bookings and Box Office.

**Price:** $79/year

### 3. Print Barcode Labels for WooCommerce (free)

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Am5aDb8DorQ&quot;
  label=&quot;Print Barcode Labels for WooCommerce&quot;
/&gt;

[Print Barcode Labels](https://wordpress.org/plugins/a4-barcode-generator/) is free and handles the physical side: printing labels for products to stick on shelves or packaging. It supports CODE128, CODE39, QR Code, DataMatrix, EAN-13, UPC-A, and more. Works with A4 paper and thermal printers (DYMO, Zebra, Brother). You can print from products, orders, categories, or enter codes manually.

**Price:** Free

### 4. UPI QR Code Payment Gateway (free)

&lt;Picture src={imgUpi} alt=&quot;UPI QR Code Payment Gateway plugin&quot; /&gt;

[UPI QR Code Payment Gateway](https://wordpress.org/plugins/upi-qr-code-payment-for-woocommerce/) adds UPI as a checkout payment option. Customers see a QR code with the payment details; on mobile they get a button that opens their installed UPI app directly. No gateway fees, no commissions.

This is India-specific — only useful if your customers pay via BHIM, Google Pay, Paytm, PhonePe, or WhatsApp Pay.

**Price:** Free

### 5. Kaya QR Code Generator (free)

&lt;Picture src={imgKaya} alt=&quot;Kaya QR Code Generator plugin&quot; /&gt;

[Kaya QR Code Generator](https://wordpress.org/plugins/kaya-qr-code-generator/) is a general-purpose QR code plugin, not WooCommerce-specific. You add QR codes via widget or shortcode to any page, post, or product. Useful for linking to URLs, displaying contact info, or embedding a product page link as a scannable code.

Customizable colors, clickable image link, and compatible with multisite.

**Price:** Free

### 6. Payment QR WooCommerce (free)

&lt;Picture src={imgPaymentQr} alt=&quot;Payment QR WooCommerce plugin checkout&quot; /&gt;

[Payment QR WooCommerce](https://wordpress.org/plugins/payment-qr-woo/) adds a QR code-based payment method at checkout, similar to bank transfer but via QR scan. Customers can attach proof of payment. No third-party commissions. You configure which app icon shows, set a payment limit, and manage all messages.

**Price:** Free

### 7. EAN and Barcodes for WooCommerce

[EAN and Barcodes for WooCommerce](https://wordpress.org/plugins/flavor-flavor-flavor/) supports all major GTIN types: EAN-13, UPC-A, ISBN, JAN, and ITF-14. It generates barcodes for products and variations, displays them on product pages, and updates instantly. If you sell on marketplaces that require GTIN codes, this plugin handles the compliance side without much setup.

**Price:** Free version available, premium starts at ~$29.99/year

## Quick comparison

| Plugin | Price | Type | Best For |
|---|---|---|---|
| YITH Barcodes &amp; QR | ~$94.99/yr | Barcode + QR | Full store management, scanning, PDF labels |
| WooCommerce Order Barcodes | $79/yr | Barcode + QR | E-tickets, event check-ins, order tracking |
| Print Barcode Labels | Free | Barcode + QR | Physical label printing (shelves, packaging) |
| UPI QR Code Gateway | Free | QR (payment) | India-specific UPI payments |
| Kaya QR Code Generator | Free | QR (general) | QR codes on any page via widget/shortcode |
| Payment QR WooCommerce | Free | QR (payment) | Generic QR-based checkout payments |
| EAN and Barcodes | Free / ~$29.99/yr | Barcode | GTIN compliance for marketplace listings |

## Which plugin to use

For **inventory and order management** (barcode scanning, stock updates, PDF label printing), YITH is the most capable, though the yearly cost adds up. For **printing physical labels**, the free Print Barcode Labels plugin covers most needs. For **marketplace compliance** (EAN/UPC/GTIN codes), the EAN and Barcodes plugin is the cheapest path. For **QR-based payments**, use UPI QR Code if you&apos;re in India, or Payment QR WooCommerce for a more generic approach.</content:encoded><category>wordpress</category><category>woocommerce</category><category>plugins</category></item><item><title>Breakdance Builder Review: Is It Worth It in 2026?</title><link>https://www.bitdoze.com/breakdance-builder-review/</link><guid isPermaLink="true">https://www.bitdoze.com/breakdance-builder-review/</guid><description>An honest review of Breakdance Builder for WordPress — what it does well, where it falls short, pricing, and how it stacks up against Elementor, Bricks, and Oxygen.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import img1 from &quot;../../assets/images/wordpress/1.breackdance-image-1024x488.webp&quot;;
import img2 from &quot;../../assets/images/wordpress/2.bkdance-design-library-1024x367.webp&quot;;
import img3 from &quot;../../assets/images/wordpress/3.breakdance-price-1024x453.webp&quot;;
import img4 from &quot;../../assets/images/wordpress/04-breackdance-theme-choose-1024x432.webp&quot;;
import img5 from &quot;../../assets/images/wordpress/5breackdance-global-settings-228x1024.webp&quot;;
import img6 from &quot;../../assets/images/wordpress/6.breackdance-headers-668x1024.webp&quot;;

Breakdance is a WordPress page builder from Soflyy, the same team behind Oxygen Builder and WP All Import. It launched in 2023 and is now on version 2.6 (released December 2025). I&apos;ve been using it on a few sites and here&apos;s what I actually think.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/4-pNP_P5mzU&quot;
  label=&quot;Breakdance Builder Video Review&quot;
/&gt;

## What is Breakdance?

&lt;Picture src={img1} alt=&quot;Breakdance Builder interface&quot; /&gt;

Breakdance is a full site builder — not just a page builder. You use it to control the header, footer, archive templates, blog layouts, and individual pages. It runs on React, which makes the editor feel snappy. Changes preview in real-time without page reloads.

It works with any WordPress theme, but most people disable the active theme and let Breakdance handle everything. It ships with 145 elements in the Pro version and connects to WooCommerce, ACF, and popular email marketing services.

&lt;Button
  text=&quot;Try Breakdance&quot;
  link=&quot;https://go.bitdoze.com/breakdance&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;


## Who built it?

Soflyy has been building WordPress products for over 15 years, with 200,000+ active installations across their tools. Louis Reingold leads development. They built Oxygen first, then Breakdance as a more accessible version with a better UI, bigger template library, and a flatter learning curve.

## What can you build with it?

&lt;Picture src={img2} alt=&quot;Breakdance design library&quot; /&gt;

Pretty much anything: blogs, WooCommerce stores, membership sites, landing pages, portfolios. Dynamic data support means you can loop over posts, use ACF fields in templates, and display conditional content based on user roles or post categories. That&apos;s where Breakdance pulls ahead of simpler builders.

## Pricing

&lt;Picture src={img3} alt=&quot;Breakdance pricing plans&quot; /&gt;

Current pricing (2026):
- **Free** — unlimited sites, 80 elements, limited design library, basic WooCommerce
- **Pro 1 site** — $99.99/year, 145+ elements, full everything
- **Pro unlimited** — $199.99/year (going up to $399.99, so locking in now saves money)
- **Pro + AI Bundle** — $249.99, includes AI content writing tools
- **60-day money-back guarantee**, no questions asked

The unlimited plan at $199.99/year costs less than half of what Elementor Pro charges for unlimited sites ($499/year). If you build client sites, the math is straightforward.

## How it works

### Setup

&lt;Picture src={img4} alt=&quot;Breakdance theme selection on setup&quot; /&gt;

On first install you choose whether to keep your WordPress theme or disable it. Disabling it gives you a clean canvas and avoids CSS conflicts. For most people, disabling the theme is the right call.

### Global styles

&lt;Picture src={img5} alt=&quot;Breakdance global settings panel&quot; /&gt;

Set brand colors, typography, button styles, and container defaults once and they apply across the whole site. This is the right way to build — consistent design without manually touching every element.

### Headers and footers

&lt;Picture src={img6} alt=&quot;Breakdance header builder&quot; /&gt;

The header builder handles dropdown menus and mega menus without extra plugins. You can assign different headers to different page types. Building a multi-level nav takes minutes here.

### Forms, popups, dynamic data

Built-in form builder with 14+ field types, conditional logic, spam protection, and direct integrations with Mailchimp, ConvertKit, and others. No need for a separate forms plugin on most sites. Popups are also built in.

Dynamic data comes from WordPress core fields, custom post types, and ACF. The Post Loop Builder lets you display grids, lists, or sliders of posts filtered by category, tag, or custom query. Version 2.5 added masonry layout support for loop elements.

### What&apos;s new in version 2.6

The December 2025 update brought a few things worth mentioning:

- **Button presets** — create reusable button styles and apply them across elements
- **Rebuilt code editor** — now powered by CodeMirror 6 with Emmet support, autocomplete, and a color picker
- **Design preset history** — version control for your design presets, so you can roll back changes
- **Performance improvements** — faster loading and smoother editor response
- **Better integrations** — improved compatibility with The Events Calendar, WooCommerce variation swatches, and WPCodeBox

### Client editing

Breakdance has a &quot;User Access&quot; mode that lets clients edit content without touching styling. Works well for handing off a finished site.

## Integrations

- WooCommerce (full product/shop/cart/checkout templates in Pro)
- ACF and custom fields
- Yoast SEO, Rank Math, SEOPress
- Mailchimp, AWeber, ConvertKit, ActiveCampaign
- Zapier/IFTTT via webhooks
- WP Grid Builder for filtering

## Performance

React-based editor, smart asset loading per page, lazy loading for images. Clean output. Breakdance only loads CSS and JS for the elements you actually use on each page. In my testing it scores well on Core Web Vitals, and version 2.6 improved this further with faster PHP rendering and optimized CSS loading.

## Breakdance vs the alternatives

**vs Gutenberg:** Gutenberg needs a block library, full site editing plugins, and developer time to reach what Breakdance does out of the box. For anything beyond a basic blog, Breakdance wins on speed of development.

**vs Elementor:** Elementor has a bigger ecosystem and more third-party addons. Breakdance produces cleaner code and costs less. If you don&apos;t need the Elementor addon ecosystem, Breakdance is the better technical choice.

**vs Oxygen:** Oxygen is more developer-focused and gives more low-level control. Breakdance has a better UX and better templates. It&apos;s basically Oxygen rebuilt for a wider audience. Oxygen still exists and is actively developed, but for most use cases Breakdance is the upgrade.

**vs Bricks:** Close competition. Bricks has a strong developer community and works as a theme, which limits migration. Breakdance runs as a plugin so switching existing sites to it is easier. Both work well for advanced users.

## My experience

I moved wpdoze.com to Breakdance and built the custom header, footer, and post layout from scratch. Took a few hours total. Posts stay in Gutenberg — Breakdance handles the site chrome and templates only. The separation works well.

I&apos;ve also used it for a few client sites. Building goes fast once you have global styles set. The main thing I&apos;d like to see is a bigger community template library — there&apos;s a design library but it&apos;s still smaller than Elementor&apos;s. Everything else works well.

## Verdict

Breakdance is worth using. It&apos;s fast, produces clean code, and covers everything a typical WordPress project needs. The unlimited plan at $199.99/year is fair for agencies. The free version has 80 elements and no time limit — enough to evaluate it properly before paying.

If you&apos;re starting a new project or frustrated with Elementor&apos;s bloat, give it a try.

[Get Breakdance](https://go.bitdoze.com/breakdance)</content:encoded><category>wordpress</category><category>page-builder</category></item><item><title>Breakdance + Sevalla (formerly Kinsta): Deploy a Fast Static WordPress Site</title><link>https://www.bitdoze.com/breakdance-kinsta-static-site/</link><guid isPermaLink="true">https://www.bitdoze.com/breakdance-kinsta-static-site/</guid><description>How to convert a Breakdance WordPress site into a static site and deploy it free on Sevalla Static Hosting (formerly Kinsta) — fast, secure, and cheap to run.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Static sites are faster to load, have zero attack surface for WordPress exploits, and cost almost nothing to host. The tradeoff is that you lose dynamic features, which makes this approach ideal for presentation sites, portfolios, and landing pages that don&apos;t need logins or live data.

**Update (February 2026):** Kinsta moved its static site hosting to a separate platform called [Sevalla](https://sevalla.com/) as of February 2, 2026. Your existing data, settings, and pricing carry over. You can log in to Sevalla with your MyKinsta credentials. The free tier and workflow are the same, just on a different dashboard.

This tutorial covers converting a Breakdance-built WordPress site into a static site and deploying it on Sevalla&apos;s free static hosting tier.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/KYeHRkhG-Ws&quot;
  label=&quot;Breakdance + Kinsta Static Site Tutorial&quot;
/&gt;

## Why go static?


&lt;Button
  text=&quot;Try Breakdance&quot;
  link=&quot;https://go.bitdoze.com/breakdance&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;

A standard WordPress site needs PHP, a database, and server processes running constantly. A static site is just HTML, CSS, and JavaScript files served from a CDN. Benefits:

- Much faster page loads (no database queries on each request)
- No WordPress vulnerabilities to patch
- Sevalla&apos;s static hosting is free for basic usage (up to 100 sites, 600 build minutes/month, 100 GB bandwidth)
- No server maintenance

This works best for sites that don&apos;t change often — company pages, portfolios, documentation.

## What you need

- A WordPress site built with [Breakdance](https://go.bitdoze.com/breakdance) (or any builder)
- A [Sevalla](https://sevalla.com/) account (you can use your existing MyKinsta credentials)
- A GitHub/GitLab/Bitbucket repository for deployment
- The [Simply Static](https://wordpress.org/plugins/simply-static/) plugin

## Steps

### 1. Optimize WordPress for static export

Breakdance already strips out a lot of bloat by default. Go to **Settings → Performance** inside Breakdance and disable Gutenberg editor, oEmbeds, and any features you don&apos;t use. Fewer assets = smaller static output.

### 2. Configure Simply Static

Install the [Simply Static](https://wordpress.org/plugins/simply-static/) plugin. Configure it to:
- Output to a local directory or ZIP
- Set the destination URL to your Sevalla static site URL
- Exclude anything dynamic (admin URLs, comment endpoints)

### 3. Generate the static files

Hit Generate in Simply Static. For a small site this finishes in seconds — even a medium-sized site with 50–100 pages takes under a minute.

### 4. Push to your repository

```bash
git add .
git commit -m &quot;static content update&quot;
git push --force -u origin main
```

Connect the repository to Sevalla Static Site Hosting. Sevalla will auto-deploy on every push to the branch you specify.

### 5. Add your domain

In Sevalla, go to your static site and add a custom domain. Verify domain ownership, then add a CNAME record in your DNS pointing to Sevalla&apos;s edge (powered by Cloudflare&apos;s 260+ location CDN).

### 6. Handle forms

Breakdance&apos;s built-in form builder won&apos;t work on a static site — there&apos;s no PHP to process submissions. Use an external form service instead. [AIDAForm](https://aidaform.com/) has a free tier and generates embed code you drop straight into Breakdance. Formspree and Netlify Forms also have free tiers that work well.

## Result

The exported static site looks identical to the WordPress original — including Breakdance animations and dynamic sections that are JavaScript-driven. Page speed is noticeably better since there&apos;s no server processing per request.

For small presentation sites this setup is close to free: WordPress runs locally or on a cheap VPS just for authoring, and the public-facing site lives on Sevalla&apos;s CDN. If you exceed the free tier limits, overages are $0.05/min for builds and $0.10/GB for bandwidth, which is still cheap for most sites.</content:encoded><category>wordpress</category><category>hosting</category></item><item><title>How to Choose a Good Domain Name</title><link>https://www.bitdoze.com/choose-domain-name/</link><guid isPermaLink="true">https://www.bitdoze.com/choose-domain-name/</guid><description>Practical tips for picking a domain name that&apos;s easy to remember, good for SEO, and available -- plus a tool that does most of the work for you.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgBrandsnap from &quot;../../assets/images/wordpress/brandsnap.ai-tool-1024x688.webp&quot;;

Your domain name is what people see when they look you up and what they type when they come back. Pick a bad one and you lose traffic, trust, and sometimes real money when you have to rebrand later.

Here&apos;s how to choose one that holds up.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/qusvga3va6g&quot;
  label=&quot;How to Choose the Perfect Domain Name&quot;
/&gt;

## Tip 1: Research before you brainstorm

Know what your site is actually about before picking a name. Who&apos;s the audience? What problem does the site solve? What keywords show up in that space?

Tools like [Google Trends](https://trends.google.com/) and [AnswerThePublic](https://answerthepublic.com/) show what people are searching for. [NameMesh](https://namemesh.pro/), [DomainWheel](https://domainwheel.com/), and AI-based generators like [brandsnap.ai](https://brandsnap.ai/) (covered below) can turn keywords into name ideas.

If you want to create a travel site, you might explore: TravelHub, WanderList, TripTrace. Short combos like these are a better starting point than stuffed-keyword names.

## Tip 2: Keep it short and simple

The practical limits:

- Under 15 characters if possible
- No hyphens, numbers, or symbols (4travel.com and trip-advisor.net both fail this)
- Easy to spell when heard out loud — if you have to spell it out for people, it&apos;s too complex
- No slang, abbreviations, or inside-industry acronyms

## Tip 3: Use keywords, but don&apos;t stuff them

A domain with a relevant keyword in it can help with SEO, but exact-match domains (cheapflights.com, besttraveldeals.com) no longer get a ranking boost the way they did years ago. Google cares more about content quality and user experience.

What works better are partial-match or branded domains:
- **Partial-match:** travelocity.com, expedia.com — one keyword, rest is brand
- **Branded:** airbnb.com, trivago.com — no keyword, but memorable and unique

Both types can rank well if the content backs them up.

## Tip 4: Check availability and trademarks before you fall in love with a name

Use [Whois](https://who.is/) to check if the domain is registered. If it&apos;s taken, check whether the current registrant is using it or just squatting — sometimes you can buy parked domains for a reasonable price.

Also check trademarks at [USPTO](https://www.uspto.gov/) or [Trademarkia](https://www.trademarkia.com/). Using a trademarked name in your domain can get you into legal trouble even if the domain itself was available to register.

## Use brandsnap.ai to find available names fast

&lt;Picture src={imgBrandsnap} alt=&quot;brandsnap.ai domain name generator tool&quot; /&gt;

[brandsnap.ai](https://brandsnap.ai/) is an AI-powered tool that generates domain name ideas and checks availability across `.com`, `.io`, `.ai`, `.org`, and `.net` at the same time. You pick a style (casual, formal, business, playful), enter your topic, and it returns a list with trademark status and registrar links.

It cuts the back-and-forth of checking names one by one. Useful when you know your niche but haven&apos;t settled on a name yet.

## What extension to use

There are now over 1,500 TLDs available, but `.com` is still what people type when they&apos;re guessing. It&apos;s the most trusted extension and the one buyers will pay the most for on resale.

If `.com` isn&apos;t available:

- `.io` works well for tech and SaaS products
- `.ai` has become popular for AI-related projects and startups (prices range from $20-80/year depending on registrar)
- `.co` is common and recognizable
- `.tech`, `.dev`, `.app` are accepted in developer/startup circles
- `.store` and `.shop` work for e-commerce
- Country-code domains (`.ca`, `.de`, `.co.uk`) are fine if the audience is local and can help with local SEO

Avoid obscure TLDs unless there&apos;s a specific reason. `.info`, `.biz`, and `.name` don&apos;t carry much trust. Some lesser-known TLDs also end up on email blocklists, which can hurt deliverability if you use the domain for email.

## A note on domain length

Data from Wix&apos;s 2026 domain statistics report shows the average domain length is 11-13 characters. Shorter is better for recall, but don&apos;t sacrifice clarity for brevity. A 14-character name that makes sense beats a 6-character name nobody can remember.</content:encoded><category>tools</category><category>domain</category></item><item><title>How to Speed Up WordPress with CloudPanel Varnish Cache</title><link>https://www.bitdoze.com/cloudpanel-varnish-cache/</link><guid isPermaLink="true">https://www.bitdoze.com/cloudpanel-varnish-cache/</guid><description>Step-by-step guide to enabling and configuring Varnish Cache in CloudPanel to speed up WordPress and reduce server load.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgVarnish from &quot;../../assets/images/wordpress/cp-varnish-1024x786.webp&quot;;

WordPress is dynamic: every page request triggers PHP execution and a database query. Under normal traffic that&apos;s fine. Under load it becomes a bottleneck fast. Varnish Cache sits in front of Nginx and serves pre-rendered HTML directly from memory, skipping PHP and MySQL entirely for most requests.

CloudPanel has built-in Varnish support, so the setup is much simpler than configuring it manually.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/wq8g9oFJHmM&quot;
  label=&quot;CloudPanel Varnish Cache Setup for WordPress&quot;
/&gt;

## How Varnish works in CloudPanel

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

CloudPanel uses a reverse proxy setup. Varnish listens on the public port, and Nginx runs behind it on port **6081**. When a request comes in:

1. Varnish checks if it has a cached copy in memory
2. If yes, it serves the cached HTML directly — no PHP, no database
3. If not, the request goes to Nginx, PHP processes it, and Varnish stores the result

Static assets (images, CSS, JS) are served directly by Nginx and bypass Varnish — they&apos;re already fast.

## Enable Varnish in CloudPanel

Go to your site in CloudPanel → **Varnish Cache** → toggle it on.

&lt;Picture src={imgVarnish} alt=&quot;CloudPanel Varnish Cache settings panel&quot; /&gt;

In the exclusions field, add any paths that should never be cached — cart, checkout, account pages, and anything that&apos;s user-specific:

```
/wp-admin/
/cart/
/checkout/
/my-account/
/wp-login.php
```

## Configuration details

**Memory allocation:** Varnish defaults to 512 MB. To increase it, edit:

```
/lib/systemd/system/varnish.service
```

Look for the `-s malloc` parameter and change the value.

**PHP controller** (excludes specific pages from caching):
```
/home/&lt;siteUser&gt;/.varnish-cache/controller.php
```

**Logs:**
```
/home/&lt;siteUser&gt;/logs/varnish-cache/
```

## Cache lifetime

CloudPanel defaults to a 7-day cache lifetime. You can change this in the Varnish Cache settings panel. For sites that update frequently (news, daily posts), drop it to 1-2 days. For mostly static sites, 7-14 days is fine. The cache purges automatically when you use the WordPress plugin below.

## CLP Varnish Cache WordPress plugin

Install the [CLP Varnish Cache](https://wordpress.org/plugins/clp-varnish-cache/) plugin in WordPress (current version 1.0.2, tested up to WordPress 6.7.4). It lets you purge the Varnish cache from the WordPress admin bar, which is useful after publishing new content or updating settings. No complex configuration needed; it connects WordPress to the CloudPanel Varnish setup automatically.

## What gets cached vs what doesn&apos;t

Varnish in CloudPanel automatically skips caching for:
- WordPress admin dashboard
- Login page
- Pages with active login cookies
- Cart and checkout pages

Everything else — posts, pages, archives — gets cached on first visit and served from memory on subsequent ones.

## Combining Varnish with PageSpeed

CloudPanel also has a built-in PageSpeed module (mod_pagespeed) that compresses images, minifies CSS/JS, and optimizes delivery. You can enable it alongside Varnish for extra speed gains. Varnish handles the caching layer; PageSpeed handles asset optimization. They don&apos;t conflict with each other.

## Result

The practical effect is that a server that previously handled 50 concurrent visitors before slowing down can handle several hundred with Varnish in front. CPU and memory usage drop because PHP and MySQL only run for the first visitor on each page. CloudPanel&apos;s documentation claims 100-250x faster page loads with Varnish enabled, which tracks with my testing on low-traffic sites where cached pages load in under 50ms.

This is one of the most effective speed improvements you can make to a WordPress site without changing hosting or adding hardware.</content:encoded><category>wordpress</category><category>cloudpanel</category></item><item><title>How To Create a Sticky Block in Gutenberg</title><link>https://www.bitdoze.com/create-a-sticky-block-in-gutenberg/</link><guid isPermaLink="true">https://www.bitdoze.com/create-a-sticky-block-in-gutenberg/</guid><description>Make any Gutenberg block sticky with the free Sticky Block plugin. Works for buttons, images, call-to-action boxes, and more.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgStickey from &quot;../../assets/images/wordpress/stickey.webp&quot;;
import imgOptions from &quot;../../assets/images/wordpress/sticky-options.webp&quot;;

Sticky elements stay visible as the user scrolls, which is useful for affiliate buttons, call-to-action boxes, or any element you want to keep in view.

WordPress 6.2+ added native sticky positioning for Group blocks when using a block theme and the Site Editor. That works for headers and top-level layout elements. But if you want to make individual blocks sticky inside post content (a button, an image, a sidebar widget), you still need a plugin. The free [Sticky Block](https://wordpress.org/plugins/sticky-block/) plugin handles this cleanly with minimal performance impact.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ayPxfVdeeqA&quot;
  label=&quot;How to Create a Sticky Block in Gutenberg&quot;
/&gt;

## Steps

### 1. Install Sticky Block

Go to **Plugins → Add New**, search for &quot;Sticky Block&quot;, install and activate.

### 2. Add the Sticky Block wrapper

In the Gutenberg editor, add a Sticky Block. Drop any other block inside it — a button, an image, a group — and it will scroll with the user down the page.

&lt;Picture src={imgStickey} alt=&quot;Adding a Sticky Block in Gutenberg editor&quot; /&gt;

### 3. Configure sticky options

&lt;Picture src={imgOptions} alt=&quot;Sticky Block options panel&quot; /&gt;

The settings let you:
- Set spacing between the sticky element and the viewport edge
- Choose which screen sizes to activate stickiness on (mobile, desktop, or both)
- Set a push-up element (another element that pushes the sticky out of the way)
- Apply display conditions (show only on mobile, or only on certain operating systems)

That&apos;s it. Any block you nest inside the Sticky Block wrapper becomes sticky. No CSS needed.

## WordPress 6.2+ native sticky option (block themes only)

If you&apos;re using a block theme (like Twenty Twenty-Four or any FSE theme), WordPress now has built-in sticky positioning. Here&apos;s how it works:

1. Open the **Site Editor** (Appearance → Editor)
2. Select or add a **Group block** where you want sticky behavior (typically a header)
3. In the block settings sidebar, go to **Position** and select **Sticky**
4. The Group block will now stay fixed at the top of the viewport when the user scrolls

This native approach works for site-wide elements like headers and navigation bars. It does not work inside individual post content, and it requires a block theme. If you need sticky elements inside posts or pages, or you&apos;re using a classic theme, the Sticky Block plugin is still the way to go.

## Other plugin alternatives

Besides the Sticky Block plugin, a few other options exist:

- **Otter Blocks** has a &quot;Transform to Sticky&quot; feature that works on any block. The free version covers basic sticky positioning; the pro version adds float mode and collision behavior settings.
- **Custom CSS** approach: If you&apos;re comfortable adding CSS, you can use `position: sticky; top: 1rem;` on any block via the Additional CSS Class field + your theme&apos;s custom CSS. No plugin needed, but you lose the visual configuration.

For most people, the Sticky Block plugin is the simplest path. It has over 6,000 active installs and is tested up to WordPress 6.8.</content:encoded><category>wordpress</category><category>gutenberg</category></item><item><title>Deindexed from Bing and DuckDuckGo -- How to Get Back</title><link>https://www.bitdoze.com/deindexed-in-bing-and-duckduckgo-now-what/</link><guid isPermaLink="true">https://www.bitdoze.com/deindexed-in-bing-and-duckduckgo-now-what/</guid><description>What to do when your site disappears from Bing and DuckDuckGo. The exact support process that got wpdoze.com reindexed after a manual exclusion.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgTraffic from &quot;../../assets/images/wordpress/bing-wpdoze-traffic-1024x555.webp&quot;;
import imgInspect from &quot;../../assets/images/wordpress/bing-url-inspect-1024x693.webp&quot;;
import imgSupport from &quot;../../assets/images/wordpress/bing-contact-support.webp&quot;;
import imgChoose from &quot;../../assets/images/wordpress/bing-what-to-choose-1024x604.webp&quot;;
import imgStats from &quot;../../assets/images/wordpress/bing-wpdoze-stats-1024x658.webp&quot;;

DuckDuckGo uses Bing&apos;s index. So does ChatGPT&apos;s web browsing and parts of Copilot. If you&apos;re not in Bing, you&apos;re invisible to a growing number of services beyond just bing.com. I found out wpdoze.com had been deindexed from both Bing and DuckDuckGo when the traffic dropped to zero overnight. Google was still sending visitors, but Bing had silently removed the site.

Here&apos;s exactly what I did to get it back.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/0ThOZDO9qyA&quot;
  label=&quot;Deindexed from Bing and DuckDuckGo — How to Fix It&quot;
/&gt;

## Identifying the problem

In [Bing Webmaster Tools](https://www.bing.com/webmasters/about), the traffic chart showed a cliff drop in June:

&lt;Picture src={imgTraffic} alt=&quot;Bing Webmaster traffic showing drop to zero&quot; /&gt;

Running `site:yourdomain.com` in Bing returned no results. The URL inspection tool showed:

&gt; &quot;The inspected URL is known to Bing but has some issues which are preventing indexation.&quot;

&lt;Picture src={imgInspect} alt=&quot;Bing URL inspection error message&quot; /&gt;

This isn&apos;t a crawl error — it&apos;s a deliberate manual exclusion by Microsoft.

## What doesn&apos;t work

Submitting URLs through Bing&apos;s InstantIndex or via a sitemap doesn&apos;t fix a manual exclusion. I tried submitting everything through RankMath and directly through Bing&apos;s console and nothing changed the next day. The site was known but excluded.

## What actually works: support ticket

The only path out is contacting Bing Webmaster Support. In Bing Webmaster Tools, click the **?** icon and select &quot;Bing Webmaster Support&quot;:

&lt;Picture src={imgSupport} alt=&quot;Bing Webmaster Support contact button&quot; /&gt;

Create a new support request with this type of message:

&gt; Hello,  
&gt; My website yourdomain.com was deindexed from Bing. The website follows Bing&apos;s webmaster guidelines and is available for indexing.  
&gt; Can you please investigate why it was removed?  
&gt; Thanks

&lt;Picture src={imgChoose} alt=&quot;Bing support request category selection&quot; /&gt;

You&apos;ll get an automated email confirming the ticket. Then wait. In my case I submitted on July 25 and got a response on August 9 — about 2 weeks.

Microsoft confirmed the exclusion was a bug on their end and said it was resolved. They recommended using [IndexNow](https://www.bing.com/indexnow) to speed up recrawling.

After submitting all URLs via IndexNow, the site started appearing in Bing again:

&lt;Picture src={imgStats} alt=&quot;Bing Webmaster stats recovering after reindexation&quot; /&gt;

## Things worth checking before you open a ticket

Based on other cases I&apos;ve seen since this happened, Bing is more sensitive to certain issues than Google:

- **HTML validity** -- Bing appears to care more about valid HTML than Google does. Misplaced scripts, unclosed tags, and other markup errors can cause indexing issues. Run your pages through the [W3C Validator](https://validator.w3.org/) and fix any errors.
- **robots.txt** -- Make sure it&apos;s valid and not accidentally blocking Bingbot. A common mistake is blocking user agents too broadly.
- **Server errors** -- Check your server logs for 5xx errors during Bing crawls. Intermittent server errors can trigger deindexation.
- **Duplicate content** -- If Bing detects a large amount of content that looks copied from other sites, it may drop the entire domain.

If everything looks clean and you&apos;re still deindexed, the support ticket is your path forward.

## IndexNow via Cloudflare

If you use Cloudflare, you can enable IndexNow automatically through the Cloudflare dashboard (under Speed → Optimization → Content). Cloudflare pings Bing every time you publish or update content, which speeds up recrawling after a deindexation is resolved. This is easier than submitting URLs manually.

## Summary

If your site is deindexed from Bing with no clear reason:
1. Confirm it with `site:yourdomain.com` in Bing
2. Check Bing Webmaster Tools for URL inspection errors
3. Verify your HTML is valid, robots.txt is correct, and no server errors are happening
4. Open a support ticket -- don&apos;t waste time resubmitting sitemaps
5. Once resolved, use IndexNow (or Cloudflare&apos;s built-in integration) to get pages recrawled faster

The process takes 1-3 weeks. There&apos;s no way to speed it up beyond opening the ticket and waiting.</content:encoded><category>tools</category><category>seo</category></item><item><title>DigitalOcean vs Vultr vs Hetzner: Which VPS is Best?</title><link>https://www.bitdoze.com/digitalocean-vs-vultr-vs-hetzner/</link><guid isPermaLink="true">https://www.bitdoze.com/digitalocean-vs-vultr-vs-hetzner/</guid><description>Real benchmark numbers comparing DigitalOcean, Vultr, and Hetzner VPS servers. Disk speed, CPU, WordPress performance, and price per value.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgHetznerYabs from &quot;../../assets/images/wordpress/hetzner_yabs_test-743x1024.webp&quot;;
import imgDoAmdYabs from &quot;../../assets/images/wordpress/do-amd-yabs-test-743x1024.webp&quot;;
import imgDoIntelYabs from &quot;../../assets/images/wordpress/do-intel-yabs-test-743x1024.webp&quot;;
import imgVultrHfYabs from &quot;../../assets/images/wordpress/v-hf-intel-yabs-test-743x1024.webp&quot;;
import imgHetznerWp from &quot;../../assets/images/wordpress/hetzner_whbt_test-663x1024.webp&quot;;
import imgDoAmdWp from &quot;../../assets/images/wordpress/do-amd-whbt-test-650x1024.webp&quot;;
import imgVultrHfWp from &quot;../../assets/images/wordpress/v-hf-intel-whbt-test-662x1024.webp&quot;;
import imgHetzner4Yabs from &quot;../../assets/images/wordpress/hetzner-4cpu-yabs-test-743x1024.webp&quot;;
import imgHetzner4Wp from &quot;../../assets/images/wordpress/hetzner_4cpu-whbt_test-652x1024.webp&quot;;

I run my sites on VPS servers and have tried all three of these providers. This is a direct comparison with real benchmark data using yabs.sh, a WordPress benchmark plugin, and PageSpeed/GTMetrix speed tests, all done on identically configured servers in East Coast datacenters.

**Pricing note (2026):** Since I ran these benchmarks, all three providers have adjusted pricing. Hetzner raised prices by 30-37% effective April 2026 due to DRAM costs. DigitalOcean and Vultr have also shifted their plans. I&apos;ve added a current pricing section at the bottom, but the benchmark results and relative performance rankings still hold.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/0LZ5evXg3Hw&quot;
  label=&quot;DigitalOcean vs Vultr vs Hetzner VPS Comparison&quot;
/&gt;

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

## Test setup

All servers ran Ubuntu 22.04 LTS with CloudPanel, MariaDB 10.9, WordPress, PHP 8.0, and the same Kadence starter template. No caching, no CDN — testing raw server performance. Specs were as close as each provider allows:

| Server | CPU | RAM | Disk | Price/mo |
| --- | --- | --- | --- | --- |
| Hetzner CPX11 | 2 vCPU (AMD) | 2 GB | 40 GB | $5.50 |
| DigitalOcean Premium Intel | 2 vCPU | 2 GB | 60 GB NVMe | $21 |
| DigitalOcean Premium AMD | 2 vCPU | 2 GB | 60 GB NVMe | $21 |
| Vultr Intel HP | 2 vCPU | 2 GB | 60 GB NVMe | $18 |
| Vultr Intel HF | 2 vCPU | 2 GB | 60 GB NVMe | $18 |
| Vultr AMD HP | 2 vCPU | 2 GB | 60 GB NVMe | $18 |

## VPS benchmarks (yabs.sh)

### Hetzner

&lt;Picture src={imgHetznerYabs} alt=&quot;Hetzner CPX11 yabs.sh benchmark results&quot; /&gt;

### DigitalOcean AMD

&lt;Picture src={imgDoAmdYabs} alt=&quot;DigitalOcean Premium AMD yabs.sh benchmark results&quot; /&gt;

### DigitalOcean Intel

&lt;Picture src={imgDoIntelYabs} alt=&quot;DigitalOcean Premium Intel yabs.sh benchmark results&quot; /&gt;

### Vultr Intel High Frequency (best overall)

&lt;Picture src={imgVultrHfYabs} alt=&quot;Vultr Intel HF yabs.sh benchmark results&quot; /&gt;

## WordPress benchmark

### Hetzner

&lt;Picture src={imgHetznerWp} alt=&quot;Hetzner WordPress benchmark score&quot; /&gt;

### DigitalOcean AMD

&lt;Picture src={imgDoAmdWp} alt=&quot;DigitalOcean AMD WordPress benchmark score&quot; /&gt;

### Vultr Intel HF

&lt;Picture src={imgVultrHfWp} alt=&quot;Vultr Intel HF WordPress benchmark score&quot; /&gt;

## Full results table

| Service | Read 4K | Write 4K | CPU Single/Multi | WP Bench | PageSpeed | GTMatrix | Price |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Hetzner AMD | 144 MB/s | 144 MB/s | 959 / 1863 | 8.9 | 92 | 702ms | **$5.50** |
| DO AMD | 90 MB/s | 90 MB/s | 659 / 1258 | 7.9 | 92 | 1.1s | $21 |
| DO Intel | 238 MB/s | 239 MB/s | 1037 / 1137 | 8.6 | 92 | 1.1s | $21 |
| Vultr AMD | 207 MB/s | 208 MB/s | 792 / 1137 | 8.4 | 92 | 703ms | $18 |
| Vultr Intel HP | 447 MB/s | 448 MB/s | 939 / 1857 | 9.1 | 91 | 702ms | $18 |
| **Vultr Intel HF** | **410 MB/s** | **411 MB/s** | **1074 / 2165** | **9.3** | **91** | **620ms** | $18 |

## Hetzner 4 vCPU / 8 GB bonus test

Since Hetzner is so cheap I also tested their next tier up (~$16/month). The numbers jump significantly:

&lt;Picture src={imgHetzner4Yabs} alt=&quot;Hetzner 4 CPU yabs.sh benchmark&quot; /&gt;
&lt;Picture src={imgHetzner4Wp} alt=&quot;Hetzner 4 CPU WordPress benchmark score&quot; /&gt;

For the same price as a 2 vCPU Vultr server, you get 4 vCPU and 8 GB RAM on Hetzner — and still competitive benchmark scores.

## Updated pricing (early 2026)

Prices have changed since the original benchmarks. Here&apos;s where things stand now:

| Provider | Entry plan | 2 vCPU / 2 GB equivalent | 4 vCPU / 8 GB equivalent | Bandwidth |
|---|---|---|---|---|
| Hetzner | €3.49/mo (CX22, 2 vCPU / 4 GB) | €3.49/mo | ~€16/mo (rising to ~€21 from Apr 2026) | 20 TB/mo |
| DigitalOcean | $4/mo (1 vCPU / 0.5 GB) | ~$12/mo | ~$48/mo | 2-4 TB/mo |
| Vultr | $2.50/mo (1 vCPU / 0.5 GB, IPv6 only) | ~$12/mo | ~$48/mo | 2-4 TB/mo |

Hetzner&apos;s bandwidth allowance (20 TB) is still far more generous than the others. That matters if you serve large files or have traffic spikes.

## Conclusion

**Vultr Intel HF** wins on raw performance. Best disk speeds, best CPU scores, fastest WordPress benchmark at 9.3, page loads at 620ms. Worth the price if performance is the priority.

**Hetzner** wins on value. Even after the 2026 price increases, a Hetzner 4 vCPU / 8 GB server at ~€16/mo outperforms DigitalOcean and Vultr servers that cost more. The 20 TB bandwidth allowance is a big deal compared to 2-4 TB on the others. I&apos;ve moved my own sites to Hetzner and haven&apos;t looked back.

**DigitalOcean** has gotten expensive relative to what you get. The developer tools, managed databases, and app platform are nice, but for pure VPS performance per dollar it falls behind both Hetzner and Vultr.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;</content:encoded><category>hosting</category><category>vps</category></item><item><title>How to Export All WordPress Post URLs and Titles</title><link>https://www.bitdoze.com/export-wordpress-post-urls-titles/</link><guid isPermaLink="true">https://www.bitdoze.com/export-wordpress-post-urls-titles/</guid><description>Export all your WordPress post titles and URLs using SQL, WP-CLI, or a free plugin. Three methods, all take about a minute.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imgPhpMyAdmin from &quot;../../assets/images/wordpress/PHP-my-admin.webp&quot;;
import imgSqlTab from &quot;../../assets/images/wordpress/sqltab.webp&quot;;
import imgSqlGo from &quot;../../assets/images/wordpress/sql_goPNG.webp&quot;;
import imgExport from &quot;../../assets/images/wordpress/export.webp&quot;;
import imgFormat from &quot;../../assets/images/wordpress/choose-format.webp&quot;;

Sometimes you need a plain list of all your post URLs for a redirect map, an SEO audit, a migration, or just to have on hand. There are a few ways to do this. The SQL method below is the quickest if you have phpMyAdmin access. I&apos;ve also added WP-CLI and plugin alternatives at the bottom.

## Steps

### 1. Open phpMyAdmin

Log in to your hosting control panel and click phpMyAdmin.

&lt;Picture src={imgPhpMyAdmin} alt=&quot;phpMyAdmin link in hosting control panel&quot; /&gt;

### 2. Select your database and open the SQL tab

Click your WordPress database in the left sidebar, then open the **SQL** tab.

&lt;Picture src={imgSqlTab} alt=&quot;phpMyAdmin SQL tab&quot; /&gt;

### 3. Run the query

Paste the following SQL, replacing the domain with your own:

```sql
SELECT post_title, CONCAT(&apos;https://yourdomain.com/&apos;, post_name)
FROM wp_posts
WHERE post_status = &apos;publish&apos;
AND post_type = &apos;post&apos;;
```

If your table prefix isn&apos;t `wp_`, adjust accordingly (check the left sidebar for the actual table name).

Hit **Go**.

&lt;Picture src={imgSqlGo} alt=&quot;Running the SQL query in phpMyAdmin&quot; /&gt;

### 4. Export the results

A table of post titles and URLs appears. Click **Export**.

&lt;Picture src={imgExport} alt=&quot;phpMyAdmin export button&quot; /&gt;

Choose your file format (CSV works well for spreadsheets), set the delimiter if needed, and hit **Go**.

&lt;Picture src={imgFormat} alt=&quot;phpMyAdmin export format selection&quot; /&gt;

You&apos;ll get a file with every published post title and its URL. To include pages or custom post types, change `post_type = &apos;post&apos;` to `post_type = &apos;page&apos;` or remove the post_type condition entirely to get everything.

## Alternative: WP-CLI (if you have SSH access)

If you have SSH access to your server, WP-CLI is faster than phpMyAdmin for this. Run:

```bash
wp post list --post_type=post --post_status=publish --fields=post_title,post_name --format=csv &gt; posts.csv
```

This exports titles and slugs to a CSV file. The `post_name` field is the URL slug, so you&apos;d need to prepend your domain. To get full URLs directly:

```bash
wp post list --post_type=post --post_status=publish --fields=post_title,guid --format=csv &gt; posts.csv
```

The `guid` field contains the full URL. You can add `--fields=ID,post_title,post_date,guid` to include post IDs and dates if you need them for an audit.

## Alternative: Export All URLs plugin

If you don&apos;t want to touch SQL or the command line, the free [Export All URLs](https://wordpress.org/plugins/export-all-urls/) plugin does this through the WordPress admin. It lets you filter by post type, status, date range, and author, and exports to CSV. It also includes categories, tags, and author data if you need them. Install, configure, export, then deactivate it when you&apos;re done.</content:encoded><category>wordpress</category><category>wordpress</category></item><item><title>Hetzner Cloud Review 2026</title><link>https://www.bitdoze.com/hetzner-cloud-review/</link><guid isPermaLink="true">https://www.bitdoze.com/hetzner-cloud-review/</guid><description>Hetzner Cloud review covering performance, pricing, features, and WordPress hosting tests. See how this European provider compares to competitors.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>This review covers Hetzner Cloud, a VPS provider that offers affordable cloud servers for hosting websites and applications. I&apos;ll look at disk, CPU, network, and WordPress performance, and compare Hetzner with other VPS providers like DigitalOcean. I&apos;ll also share my experience using Hetzner for 5+ years.

I&apos;ve been using Hetzner as my primary VPS provider in Europe for more than 5 years, running a CyberPanel server where I host several of my sites. Hetzner has expanded with new datacenters in the US and Singapore, making it a global option for developers and businesses.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started with Hetzner&quot;&gt;
    Ready to try Hetzner Cloud? [Get €20 credit](https://go.bitdoze.com/hetzner) when you sign up through our referral link and experience the performance difference yourself.
&lt;/Notice&gt;

If you&apos;re interested in self-hosting tools on your VPS, check out [toolhunt.net&apos;s self-host section](https://toolhunt.net) for inspiration.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/4MTJKbjy_Ro &quot;
  label=&quot;Hetzner Review 2026&quot;
/&gt;

## Why Hetzner Cloud

Hetzner offers competitive prices in the VPS market, though prices went up in April 2026 (up to 37% on some plans) due to DRAM and NAND flash cost increases across the industry. Even after the increase, a 2 vCPU / 4GB RAM server costs about €4-8 depending on the tier, which is still less than what DigitalOcean or Vultr charge for similar specs.

I&apos;ve been running multiple services on Hetzner:
- CyberPanel for website hosting
- Monitoring solutions
- Analytics platforms
- Development environments


## Hetzner Cloud Datacenters

Hetzner has expanded globally. Here&apos;s the current datacenter lineup:

| Datacenter | Location | CPU Types Available | Network |
|------------|----------|-------------------|---------|
| Nuremberg | Germany | Intel/AMD/ARM | 20TB included |
| Falkenstein | Germany | Intel/AMD/ARM | 20TB included |
| Helsinki | Finland | Intel/AMD/ARM | 20TB included |
| Ashburn | Virginia, USA | AMD | 1TB included |
| Hillsboro | Oregon, USA | AMD | 1TB included |
| Singapore | Singapore | AMD | 1TB included |

**Recent updates:**
- Singapore datacenter fully operational (launched 2024)
- Improved network capacity across all locations
- More ARM servers available in European locations
- CX shared vCPU plans introduced in 2024

&lt;Notice type=&quot;warning&quot; title=&quot;Bandwidth Differences&quot;&gt;
    European datacenters include 20TB of bandwidth, while US and Singapore locations include 1TB. Plan accordingly based on your traffic needs.
&lt;/Notice&gt;

## Hetzner Cloud Pricing

Hetzner restructured pricing for EU and Singapore locations with generation-based categorization. The new structure has three main lines:

&lt;Notice type=&quot;warning&quot; title=&quot;April 2026 Price Increase&quot;&gt;
    Hetzner is raising cloud prices by up to 37% starting April 1, 2026, due to rising DRAM and NAND flash costs driven by AI infrastructure demand. All existing and new customers are affected. [Official statement](https://www.hetzner.com/pressroom/statement-price-adjustment/) | [Detailed pricing](https://docs.hetzner.com/general/infrastructure-and-availability/price-adjustment/).
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Plan Categories (October 2025)&quot;&gt;
    Hetzner introduced new plan categories: Cost-Optimized, Regular Performance, and Dedicated General Purpose. [Read full details](https://www.bitdoze.com/hetzner-cloud-cost-optimized-plans/).
&lt;/Notice&gt;

&lt;Tabs&gt;
&lt;Tab name=&quot;Cost-Optimized (NEW)&quot;&gt;

Prices shown are post-April 2026 adjustment:

CX Gen3 (x86 - Intel/AMD):

| Plan | vCPU | RAM | Storage | Price/Month |
|------|------|-----|---------|-------------|
| CX23 | 2 | 4GB | 40GB SSD | €3.99 |
| CX33 | 4 | 8GB | 80GB SSD | €6.49 |
| CX43 | 8 | 16GB | 160GB SSD | €11.99 |
| CX53 | 16 | 32GB | 320GB SSD | €22.49 |

CAX (ARM - Ampere):

| Plan | vCPU | RAM | Storage | Price/Month |
|------|------|-----|---------|-------------|
| CAX11 | 2 | 4GB | 40GB SSD | €4.49 |
| CAX21 | 4 | 8GB | 80GB SSD | €7.99 |
| CAX31 | 8 | 16GB | 160GB SSD | €15.99 |
| CAX41 | 16 | 32GB | 320GB SSD | €31.49 |

Use for: Development, testing, small sites, personal projects. Still cheaper than regular plans despite the increase.

&lt;/Tab&gt;
&lt;Tab name=&quot;Regular Performance&quot;&gt;

AMD performance for production workloads. Now using AMD EPYC-Genoa processors. Prices post-April 2026:

| Plan | vCPU | RAM | Storage | Price/Month |
|------|------|-----|---------|-------------|
| CPX22 | 2 | 4GB | 80GB SSD | €7.99 |
| CPX32 | 4 | 8GB | 160GB SSD | €13.99 |
| CPX42 | 8 | 16GB | 320GB SSD | €25.49 |
| CPX52 | 12 | 24GB | 480GB SSD | €36.49 |
| CPX62 | 16 | 32GB | 640GB SSD | €50.49 |

Use for: Production WordPress, applications, consistent performance needs

&lt;/Tab&gt;
&lt;Tab name=&quot;Dedicated General Purpose&quot;&gt;

Dedicated vCPUs with latest AMD hardware. Prices post-April 2026:

| Plan | vCPU | RAM | Storage | Bandwidth | Price/Month |
|------|------|-----|---------|-----------|-------------|
| CCX13 | 2 | 8GB | 80GB SSD | 20TB | €15.99 |
| CCX23 | 4 | 16GB | 160GB SSD | 20TB | €31.49 |
| CCX33 | 8 | 32GB | 240GB SSD | 30TB | €62.49 |
| CCX43 | 16 | 64GB | 360GB SSD | 40TB | €124.99 |
| CCX53 | 32 | 128GB | 600GB SSD | 50TB | €249.99 |
| CCX63 | 48 | 192GB | 960GB SSD | 60TB | €374.49 |

Use for: High-traffic sites, databases, intensive applications, enterprise workloads

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Cost-Optimized Plans&quot;&gt;
    Cost-Optimized plans are still cheaper than Regular Performance, though the gap has narrowed after the April 2026 increase. Good for development, testing, and moderate production workloads. [Learn more](https://www.bitdoze.com/hetzner-cloud-cost-optimized-plans/).
&lt;/Notice&gt;

## Hetzner Cloud Interface

The Hetzner Cloud interface is clean and easy to use.

&lt;Accordion label=&quot;Dashboard Overview&quot; group=&quot;interface&quot;&gt;

&lt;ListCheck&gt;
- Clean Navigation: Left sidebar with organized sections
- Real-time Monitoring: Live server status and resource usage
- Resource Graphs: CPU, memory, network, and disk usage charts
- Quick Actions: Start, stop, restart, and manage servers easily
- Multi-server Management: Handle multiple servers from one dashboard
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Key Management Features&quot; group=&quot;interface&quot;&gt;

&lt;ListCheck&gt;
- Server Creation: Simple wizard with OS selection
- Backup Management: Daily backups and manual snapshots
- Scaling: Vertical scaling with minimal downtime
- Firewall: Firewall rules management
- Networks: Private networking between servers
- Load Balancers: Load balancing solutions
- Storage: Block storage volumes
- Images: Custom server images
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;API and CLI Tools&quot; group=&quot;interface&quot;&gt;

Hetzner provides developer tools:

&lt;ListCheck&gt;
- RESTful API: API for automation
- Official CLI: hcloud command-line tool
- Terraform Provider: Infrastructure as code support
- Kubernetes Integration: Managed Kubernetes service
- Webhooks: Event notifications
- SDKs: Libraries for popular languages
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Performance Testing Results

I tested multiple Hetzner server types. Below are benchmarks from different plan tiers.

### YABS Benchmark Results

&lt;Tabs&gt;
&lt;Tab name=&quot;Cost-Optimized (CX33)&quot;&gt;
    ```bash
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
    #              Yet-Another-Bench-Script              #
    #                     v2026-04-20                    #
    # https://github.com/masonr/yet-another-bench-script #
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

    Thu Oct 16 11:32:39 AM UTC 2026

    Basic System Information:
    ---------------------------------
    Uptime     : 0 days, 0 hours, 30 minutes
    Processor  : AMD EPYC-Rome Processor
    CPU cores  : 4 @ 2445.404 MHz
    AES-NI     : ✔ Enabled
    VM-x/AMD-V : ❌ Disabled
    RAM        : 7.6 GiB
    Swap       : 0.0 KiB
    Disk       : 75.0 GiB
    Distro     : Ubuntu 24.04.3 LTS
    Kernel     : 6.8.0-71-generic
    VM Type    : KVM
    IPv4/IPv6  : ✔ Online / ✔ Online

    IPv6 Network Information:
    ---------------------------------
    ISP        : Hetzner Online GmbH
    ASN        : AS24940 Hetzner Online GmbH
    Host       : Hetzner Online GmbH
    Location   : Nuremberg, Bavaria (BY)
    Country    : Germany

    fio Disk Speed Tests (Mixed R/W 50/50) (Partition /dev/sda1):
    ---------------------------------
    Block Size | 4k            (IOPS) | 64k           (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 115.01 MB/s  (28.7k) | 988.49 MB/s  (15.4k)
    Write      | 115.32 MB/s  (28.8k) | 993.69 MB/s  (15.5k)
    Total      | 230.34 MB/s  (57.5k) | 1.98 GB/s    (30.9k)
               |                      |
    Block Size | 512k          (IOPS) | 1m            (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 1.78 GB/s     (3.4k) | 2.16 GB/s     (2.1k)
    Write      | 1.88 GB/s     (3.6k) | 2.30 GB/s     (2.2k)
    Total      | 3.66 GB/s     (7.1k) | 4.46 GB/s     (4.3k)

    iperf3 Network Speed Tests (IPv4):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 5.16 Gbits/sec  | 5.60 Gbits/sec  | 17.8 ms
    Eranium         | Amsterdam, NL (100G)      | 12.3 Gbits/sec  | 12.8 Gbits/sec  | 9.27 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.96 Gbits/sec  | 2.24 Gbits/sec  | 94.6 ms
    Leaseweb        | Singapore, SG (10G)       | 665 Mbits/sec   | 841 Mbits/sec   | 166 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.03 Gbits/sec  | 1.21 Gbits/sec  | 158 ms
    Leaseweb        | NYC, NY, US (10G)         | 1.88 Gbits/sec  | 2.53 Gbits/sec  | 97.7 ms
    Edgoo           | Sao Paulo, BR (1G)        | 616 Mbits/sec   | 1.14 Gbits/sec  | 219 ms

    iperf3 Network Speed Tests (IPv6):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 3.53 Gbits/sec  | 5.00 Gbits/sec  | 18.2 ms
    Eranium         | Amsterdam, NL (100G)      | 8.15 Gbits/sec  | 12.7 Gbits/sec  | 9.69 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.85 Gbits/sec  | 2.23 Gbits/sec  | 94.6 ms
    Leaseweb        | Singapore, SG (10G)       | 988 Mbits/sec   | 1.27 Gbits/sec  | 166 ms
    Clouvider       | Los Angeles, CA, US (10G) | 995 Mbits/sec   | 1.24 Gbits/sec  | 158 ms
    Leaseweb        | NYC, NY, US (10G)         | 1.98 Gbits/sec  | 2.41 Gbits/sec  | 97.8 ms
    Edgoo           | Sao Paulo, BR (1G)        | busy            | 883 Mbits/sec   | 221 ms

    Geekbench 6 Benchmark Test:
    ---------------------------------
    Test            | Value
                    |
    Single Core     | 1508
    Multi Core      | 4919
    Full Test       | https://browser.geekbench.com/v6/cpu/14484522

    YABS completed in 12 min 35 sec
    ```

The CX33 (4 vCPU, 8GB, €6.49/month after April 2026) performs well for a cost-optimized plan. Peak disk speeds reach 4.46 GB/s and 12.8 Gbits/sec network to Amsterdam.

&lt;/Tab&gt;
&lt;Tab name=&quot;Reg. Perf. (CPX)&quot;&gt;
    ```bash
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
    #              Yet-Another-Bench-Script              #
    #                     v2026-04-20                    #
    # https://github.com/masonr/yet-another-bench-script #
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

    Fri Jul 11 07:37:22 AM UTC 2026

    Basic System Information:
    ---------------------------------
    Uptime     : 56 days, 19 hours, 7 minutes
    Processor  : Intel Xeon Processor (Skylake, IBRS, no TSX)
    CPU cores  : 4 @ 2100.004 MHz
    AES-NI     : ✔ Enabled
    VM-x/AMD-V : ❌ Disabled
    RAM        : 7.6 GiB
    Swap       : 8.0 GiB
    Disk       : 75.0 GiB
    Distro     : Ubuntu 24.04.1 LTS
    Kernel     : 6.8.0-59-generic
    VM Type    : KVM
    IPv4/IPv6  : ✔ Online / ✔ Online

    IPv6 Network Information:
    ---------------------------------
    ISP        : Hetzner Online GmbH
    ASN        : AS24940 Hetzner Online GmbH
    Host       : Hetzner Online GmbH
    Location   : Nuremberg, Bavaria (BY)
    Country    : Germany

    fio Disk Speed Tests (Mixed R/W 50/50) (Partition /dev/sda1):
    ---------------------------------
    Block Size | 4k            (IOPS) | 64k           (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 111.67 MB/s  (27.9k) | 1.09 GB/s    (17.1k)
    Write      | 111.96 MB/s  (27.9k) | 1.10 GB/s    (17.2k)
    Total      | 223.63 MB/s  (55.9k) | 2.19 GB/s    (34.3k)
               |                      |
    Block Size | 512k          (IOPS) | 1m            (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 742.86 MB/s   (1.4k) | 686.36 MB/s    (670)
    Write      | 782.33 MB/s   (1.5k) | 732.07 MB/s    (714)
    Total      | 1.52 GB/s     (2.9k) | 1.41 GB/s     (1.3k)

    iperf3 Network Speed Tests (IPv4):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 6.76 Gbits/sec  | 4.26 Gbits/sec  | 18.5 ms
    Eranium         | Amsterdam, NL (100G)      | 12.6 Gbits/sec  | 9.79 Gbits/sec  | 10.0 ms
    Uztelecom       | Tashkent, UZ (10G)        | 2.17 Gbits/sec  | 1.59 Gbits/sec  | 106 ms
    Leaseweb        | Singapore, SG (10G)       | 773 Mbits/sec   | 837 Mbits/sec   | 173 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.08 Gbits/sec  | 934 Mbits/sec   | 160 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.28 Gbits/sec  | 2.37 Gbits/sec  | 98.2 ms
    Edgoo           | Sao Paulo, BR (1G)        | 1.23 Gbits/sec  | 1.17 Gbits/sec  | 201 ms

    iperf3 Network Speed Tests (IPv6):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 6.05 Gbits/sec  | 7.35 Gbits/sec  | 18.6 ms
    Eranium         | Amsterdam, NL (100G)      | 12.1 Gbits/sec  | 9.47 Gbits/sec  | 10.2 ms
    Uztelecom       | Tashkent, UZ (10G)        | 2.12 Gbits/sec  | 1.16 Gbits/sec  | 105 ms
    Leaseweb        | Singapore, SG (10G)       | 1.06 Gbits/sec  | 1.32 Gbits/sec  | 173 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.07 Gbits/sec  | 1.05 Gbits/sec  | 160 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.40 Gbits/sec  | 2.44 Gbits/sec  | 98.0 ms
    Edgoo           | Sao Paulo, BR (1G)        | 1.01 Gbits/sec  | 830 Mbits/sec   | 201 ms

    Geekbench 6 Benchmark Test:
    ---------------------------------
    Test            | Value
                    |
    Single Core     | 812
    Multi Core      | 2403
    Full Test       | https://browser.geekbench.com/v6/cpu/12812407

    YABS completed in 16 min 18 sec
    ```

CPX series running on Intel Xeon Skylake. Disk performance reaches 1.4 GB/s and European network speeds of 12.6 Gbits/sec to Amsterdam.
&lt;/Tab&gt;
&lt;Tab name=&quot;Reg. Perf. - AMD EPYC-Genoa (NEW)&quot;&gt;
    ```bash
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
    #              Yet-Another-Bench-Script              #
    #                     v2026-04-20                    #
    # https://github.com/masonr/yet-another-bench-script #
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

    Fri Oct 17 06:50:40 AM UTC 2026

    Basic System Information:
    ---------------------------------
    Uptime     : 0 days, 0 hours, 1 minutes
    Processor  : AMD EPYC-Genoa Processor
    CPU cores  : 4 @ 2399.998 MHz
    AES-NI     : ✔ Enabled
    VM-x/AMD-V : ❌ Disabled
    RAM        : 7.6 GiB
    Swap       : 0.0 KiB
    Disk       : 150.2 GiB
    Distro     : Ubuntu 24.04.3 LTS
    Kernel     : 6.8.0-84-generic
    VM Type    : KVM
    IPv4/IPv6  : ✔ Online / ✔ Online

    IPv6 Network Information:
    ---------------------------------
    ISP        : Hetzner Online GmbH
    ASN        : AS24940 Hetzner Online GmbH
    Host       : Hetzner
    Location   : Falkenstein, Saxony (SN)
    Country    : Germany

    fio Disk Speed Tests (Mixed R/W 50/50) (Partition /dev/sda1):
    ---------------------------------
    Block Size | 4k            (IOPS) | 64k           (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 159.97 MB/s  (39.9k) | 1.75 GB/s    (27.4k)
    Write      | 160.39 MB/s  (40.0k) | 1.76 GB/s    (27.5k)
    Total      | 320.37 MB/s  (80.0k) | 3.51 GB/s    (54.9k)
               |                      |
    Block Size | 512k          (IOPS) | 1m            (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 2.73 GB/s     (5.3k) | 2.88 GB/s     (2.8k)
    Write      | 2.87 GB/s     (5.6k) | 3.07 GB/s     (3.0k)
    Total      | 5.60 GB/s    (10.9k) | 5.96 GB/s     (5.8k)

    iperf3 Network Speed Tests (IPv4):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 1.89 Gbits/sec  | 4.18 Gbits/sec  | 21.1 ms
    Eranium         | Amsterdam, NL (100G)      | 13.9 Gbits/sec  | 6.09 Gbits/sec  | 11.2 ms
    Uztelecom       | Tashkent, UZ (10G)        | 2.00 Gbits/sec  | 1.38 Gbits/sec  | 95.5 ms
    Leaseweb        | Singapore, SG (10G)       | 732 Mbits/sec   | 864 Mbits/sec   | 168 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.00 Gbits/sec  | 1.24 Gbits/sec  | 168 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.14 Gbits/sec  | 2.49 Gbits/sec  | 94.8 ms
    Edgoo           | Sao Paulo, BR (1G)        | 570 Mbits/sec   | 993 Mbits/sec   | 416 ms

    iperf3 Network Speed Tests (IPv6):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 5.54 Gbits/sec  | 6.13 Gbits/sec  | 21.0 ms
    Eranium         | Amsterdam, NL (100G)      | 12.5 Gbits/sec  | 13.2 Gbits/sec  | 11.0 ms
    Uztelecom       | Tashkent, UZ (10G)        | 2.02 Gbits/sec  | 2.17 Gbits/sec  | 95.8 ms
    Leaseweb        | Singapore, SG (10G)       | 1.16 Gbits/sec  | 1.31 Gbits/sec  | 168 ms
    Clouvider       | Los Angeles, CA, US (10G) | 977 Mbits/sec   | 1.30 Gbits/sec  | 169 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.17 Gbits/sec  | 2.57 Gbits/sec  | 94.8 ms
    Edgoo           | Sao Paulo, BR (1G)        | 550 Mbits/sec   | 930 Mbits/sec   | 416 ms

    Geekbench 6 Benchmark Test:
    ---------------------------------
    Test            | Value
                    |
    Single Core     | 1963
    Multi Core      | 6076
    Full Test       | https://browser.geekbench.com/v6/cpu/14499521

    YABS completed in 12 min 22 sec
    ```

AMD EPYC-Genoa processors show improved performance. Disk speeds reach 5.96 GB/s and network speeds of 13.9 Gbits/sec to Amsterdam. Geekbench 6 scores show 1963 single-core and 6076 multi-core.

&lt;/Tab&gt;
&lt;Tab name=&quot;AMD&quot;&gt;
    ```bash
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
    #              Yet-Another-Bench-Script              #
    #                     v2026-04-20                    #
    # https://github.com/masonr/yet-another-bench-script #
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

    Fri Jul 11 07:50:01 AM UTC 2026

    Basic System Information:
    ---------------------------------
    Uptime     : 0 days, 0 hours, 3 minutes
    Processor  : AMD EPYC Processor
    CPU cores  : 3 @ 2495.310 MHz
    AES-NI     : ✔ Enabled
    VM-x/AMD-V : ❌ Disabled
    RAM        : 3.7 GiB
    Swap       : 0.0 KiB
    Disk       : 75.0 GiB
    Distro     : Ubuntu 24.04.2 LTS
    Kernel     : 6.8.0-60-generic
    VM Type    : KVM
    IPv4/IPv6  : ✔ Online / ✔ Online

    IPv6 Network Information:
    ---------------------------------
    ISP        : Hetzner Online GmbH
    ASN        : AS24940 Hetzner Online GmbH
    Host       : Hetzner Online GmbH
    Location   : Falkenstein, Saxony (SN)
    Country    : Germany

    fio Disk Speed Tests (Mixed R/W 50/50) (Partition /dev/sda1):
    ---------------------------------
    Block Size | 4k            (IOPS) | 64k           (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 84.48 MB/s   (21.1k) | 770.12 MB/s  (12.0k)
    Write      | 84.70 MB/s   (21.1k) | 774.17 MB/s  (12.0k)
    Total      | 169.18 MB/s  (42.2k) | 1.54 GB/s    (24.1k)
               |                      |
    Block Size | 512k          (IOPS) | 1m            (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 1.43 GB/s     (2.7k) | 1.45 GB/s     (1.4k)
    Write      | 1.50 GB/s     (2.9k) | 1.54 GB/s     (1.5k)
    Total      | 2.93 GB/s     (5.7k) | 3.00 GB/s     (2.9k)

    iperf3 Network Speed Tests (IPv4):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 5.95 Gbits/sec  | 1.25 Gbits/sec  | 20.8 ms
    Eranium         | Amsterdam, NL (100G)      | 10.8 Gbits/sec  | 5.34 Gbits/sec  | 12.3 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.94 Gbits/sec  | 422 Mbits/sec   | 94.7 ms
    Leaseweb        | Singapore, SG (10G)       | 705 Mbits/sec   | 752 Mbits/sec   | 163 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.05 Gbits/sec  | 401 Mbits/sec   | 165 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.33 Gbits/sec  | 1.47 Gbits/sec  | 95.4 ms
    Edgoo           | Sao Paulo, BR (1G)        | 1.16 Gbits/sec  | 547 Mbits/sec   | 206 ms

    iperf3 Network Speed Tests (IPv6):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 7.60 Gbits/sec  | 1.31 Gbits/sec  | 26.8 ms
    Eranium         | Amsterdam, NL (100G)      | 10.5 Gbits/sec  | 11.8 Gbits/sec  | 11.4 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.92 Gbits/sec  | 615 Mbits/sec   | 103 ms
    Leaseweb        | Singapore, SG (10G)       | 1.14 Gbits/sec  | 1.21 Gbits/sec  | --
    Clouvider       | Los Angeles, CA, US (10G) | 1.03 Gbits/sec  | 555 Mbits/sec   | 166 ms
    Leaseweb        | NYC, NY, US (10G)         | 2.32 Gbits/sec  | 1.23 Gbits/sec  | 94.7 ms
    Edgoo           | Sao Paulo, BR (1G)        | 991 Mbits/sec   | 618 Mbits/sec   | 206 ms

    Geekbench 6 Benchmark Test:
    ---------------------------------
    Test            | Value
                    |
    Single Core     | 1195
    Multi Core      | 2980
    Full Test       | https://browser.geekbench.com/v6/cpu/12812502

    YABS completed in 14 min 44 sec
    ```
&lt;/Tab&gt;
&lt;Tab name=&quot;ARM&quot;&gt;
    ```bash
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #
    #              Yet-Another-Bench-Script              #
    #                     v2026-04-20                    #
    # https://github.com/masonr/yet-another-bench-script #
    # ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## ## #

    Fri Jul 11 07:37:24 AM UTC 2026

    ARM compatibility is considered *experimental*

    Basic System Information:
    ---------------------------------
    Uptime     : 39 days, 2 hours, 22 minutes
    Processor  : Neoverse-N1
    BIOS NotSpecified  CPU @ 2.0GHz
    CPU cores  : 4 @ ??? MHz
    AES-NI     : ✔ Enabled
    VM-x/AMD-V : ❌ Disabled
    RAM        : 7.5 GiB
    Swap       : 6.0 GiB
    Disk       : 75.0 GiB
    Distro     : Ubuntu 24.04 LTS
    Kernel     : 6.8.0-31-generic
    VM Type    : KVM
    IPv4/IPv6  : ✔ Online / ✔ Online

    IPv6 Network Information:
    ---------------------------------
    ISP        : Hetzner Online GmbH
    ASN        : AS24940 Hetzner Online GmbH
    Host       : Hetzner Online GmbH
    Location   : Falkenstein, Saxony (SN)
    Country    : Germany

    fio Disk Speed Tests (Mixed R/W 50/50) (Partition /dev/sda1):
    ---------------------------------
    Block Size | 4k            (IOPS) | 64k           (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 96.83 MB/s   (24.2k) | 816.12 MB/s  (12.7k)
    Write      | 96.77 MB/s   (24.1k) | 840.39 MB/s  (13.1k)
    Total      | 193.60 MB/s  (48.4k) | 1.65 GB/s    (25.8k)
               |                      |
    Block Size | 512k          (IOPS) | 1m            (IOPS)
      ------   | ---            ----  | ----           ----
    Read       | 1.22 GB/s     (2.3k) | 1.43 GB/s     (1.3k)
    Write      | 1.32 GB/s     (2.5k) | 1.59 GB/s     (1.5k)
    Total      | 2.55 GB/s     (4.9k) | 3.03 GB/s     (2.9k)

    iperf3 Network Speed Tests (IPv4):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 6.03 Gbits/sec  | 4.58 Gbits/sec  | 20.4 ms
    Eranium         | Amsterdam, NL (100G)      | 12.3 Gbits/sec  | 8.01 Gbits/sec  | 11.2 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.94 Gbits/sec  | 1.18 Gbits/sec  | 93.9 ms
    Leaseweb        | Singapore, SG (10G)       | 621 Mbits/sec   | 832 Mbits/sec   | 170 ms
    Clouvider       | Los Angeles, CA, US (10G) | 828 Mbits/sec   | busy            | 166 ms
    Leaseweb        | NYC, NY, US (10G)         | 1.61 Gbits/sec  | 2.53 Gbits/sec  | 93.7 ms
    Edgoo           | Sao Paulo, BR (1G)        | 1.11 Gbits/sec  | 1.30 Gbits/sec  | 198 ms

    iperf3 Network Speed Tests (IPv6):
    ---------------------------------
    Provider        | Location (Link)           | Send Speed      | Recv Speed      | Ping
    -----           | -----                     | ----            | ----            | ----
    Clouvider       | London, UK (10G)          | 6.97 Gbits/sec  | 6.18 Gbits/sec  | 20.6 ms
    Eranium         | Amsterdam, NL (100G)      | 13.6 Gbits/sec  | 11.1 Gbits/sec  | 14.0 ms
    Uztelecom       | Tashkent, UZ (10G)        | 1.89 Gbits/sec  | 2.40 Gbits/sec  | 97.1 ms
    Leaseweb        | Singapore, SG (10G)       | 1.03 Gbits/sec  | 1.31 Gbits/sec  | 170 ms
    Clouvider       | Los Angeles, CA, US (10G) | 1.02 Gbits/sec  | 1.35 Gbits/sec  | 168 ms
    Leaseweb        | NYC, NY, US (10G)         | 1.38 Gbits/sec  | 2.52 Gbits/sec  | 94.4 ms
    Edgoo           | Sao Paulo, BR (1G)        | 984 Mbits/sec   | 1.10 Gbits/sec  | 198 ms

    Geekbench 6 Benchmark Test:
    ---------------------------------
    Test            | Value
                    |
    Single Core     | 1002
    Multi Core      | 2909
    Full Test       | https://browser.geekbench.com/v6/cpu/12812388

    YABS completed in 14 min 29 sec
    ```
&lt;/Tab&gt;
&lt;/Tabs&gt;


### WordPress Performance Test

I installed a WordPress site with:
- Theme: Kadence starter template
- Caching: LiteSpeed Cache plugin
- Server: OpenLiteSpeed via CyberPanel
- Database: MySQL 8.0

#### GTmetrix Results:
- Performance Score: 97%
- Structure Score: 91%
- LCP: 1.2s
- TBT: 0ms
- CLS: 0.01

#### PageSpeed Insights:
- Mobile Score: 94
- Desktop Score: 99
- First Contentful Paint: 1.1s
- Speed Index: 1.3s

These results work well for a ~€8/month server with no CDN or advanced optimizations.

### Load Testing with k6

I stress-tested the server with 200 concurrent users:

```bash
# k6 run --vus 200 --duration 120s load-test.js

running (2m01.2s), 000/200 VUs, 23,847 complete and 0 interrupted iterations
default ✓ [======================================] 200 VUs  2m0s

     data_received..................: 3.2 GB  26 MB/s
     data_sent......................: 7.1 MB  58 kB/s
     http_req_blocked...............: avg=2.1ms    min=120ns    med=285ns    max=156.7ms
     http_req_connecting............: avg=1.02ms   min=0s       med=0s       max=89.3ms
     http_req_duration..............: avg=287.4ms  min=245.1ms  med=289.7ms  max=445.2ms
     http_req_failed................: 0.00%   ✓ 0           ✗ 23847
     http_req_receiving.............: avg=201.8ms  min=145.6ms  med=203.2ms  max=287.9ms
     http_req_sending...............: avg=42.1µs   min=12.8µs   med=37.9µs   max=2.1ms
     http_req_waiting...............: avg=85.4ms   min=72.1ms   med=83.8ms   max=198.4ms
     http_reqs......................: 23847   198.725/s
     iteration_duration.............: avg=1.0s     min=1.0s     med=1.0s     max=1.4s
     iterations.....................: 23847   198.725/s
     vus............................: 200     min=200       max=200
     vus_max........................: 200     min=200       max=200
```

Results:
- Zero failed requests under 200 concurrent users
- Average response time: 287ms
- Throughput: 198 requests/second
- Server handled the load without issues

## Hetzner Cloud Support

Over 2+ years of usage, I&apos;ve contacted Hetzner support multiple times.

### Support Channels:
- Ticket System: Primary support method
- Response Time: 2-4 hours
- Knowledge Base: Documentation
- Community: Community forums

### Support Quality:
- Technical Expertise: Good technical knowledge
- Problem Resolution: Quick solutions
- Communication: Clear responses
- Availability: 24/7 support

### Real Experience:
I once had a network connectivity issue during a maintenance window. Support responded within 45 minutes, provided detailed explanations, and offered compensation for the brief downtime. The transparency and professionalism were impressive.

## Hetzner vs Competitors

&lt;Tabs&gt;
&lt;Tab name=&quot;vs DigitalOcean&quot;&gt;

| Feature | Hetzner Cloud | DigitalOcean |
|---------|---------------|--------------|
| Starting Price | €3.99/month | $4/month |
| 2CPU/4GB RAM | €7.99/month | $24/month |
| Bandwidth (EU) | 20TB included | 2TB included |
| Datacenters | 6 locations | 15+ locations |
| Support | Excellent | Good |
| ARM Support | Yes | No |
| Load Balancers | €7.49/month | $10/month |
| Managed Kubernetes | Yes | Yes |

Hetzner offers better price-to-performance

&lt;/Tab&gt;
&lt;Tab name=&quot;vs Vultr&quot;&gt;

| Feature | Hetzner Cloud | Vultr |
|---------|---------------|-------|
| Starting Price | €3.99/month | $2.50/month |
| 2CPU/4GB RAM | €7.99/month | $12/month |
| Storage Type | NVMe SSD | NVMe SSD |
| ARM Support | Yes | Limited |
| Network | Up to 20TB | 1-2TB |
| Locations | 6 | 25+ |
| Object Storage | Yes | Yes |

Hetzner works well for European users, Vultr for global reach

&lt;/Tab&gt;
&lt;Tab name=&quot;vs Linode&quot;&gt;

| Feature | Hetzner Cloud | Linode |
|---------|---------------|--------|
| Starting Price | €3.99/month | $5/month |
| 2CPU/4GB RAM | €7.99/month | $12/month |
| Storage Type | NVMe SSD | NVMe SSD |
| Bandwidth | 20TB (EU) | 1TB |
| Support | Excellent | Excellent |
| Documentation | Good | Excellent |

Hetzner costs less, Linode has better documentation

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Full Comparison&quot;&gt;
    For a detailed comparison, check our [VPS Provider Comparison Guide](https://www.bitdoze.com/best-vps-providers/).
&lt;/Notice&gt;

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;


## Common Use Cases for Hetzner Cloud

&lt;Accordion label=&quot;WordPress Hosting&quot; group=&quot;use-cases&quot;&gt;

Good for various WordPress deployments:

&lt;ListCheck&gt;
- Multiple WordPress Sites: Host several sites on one server
- WooCommerce Stores: E-commerce with good performance
- High-Traffic Blogs: Handle thousands of visitors
- Development Environments: Test themes and plugins safely
- Staging Sites: Test changes before going live
&lt;/ListCheck&gt;

Recommended Setup:
- Small Sites: CX23 or CPX22 (€4-8/month)
- Medium Sites: CPX32 (€14/month)
- High-Traffic Sites: CCX13 (€16/month)
- Location: European datacenter for better bandwidth

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Development &amp; Testing&quot; group=&quot;use-cases&quot;&gt;

Good for development workflows:

&lt;ListCheck&gt;
- CI/CD Pipelines: Automated testing and deployment
- Staging Environments: Safe testing before production
- Container Hosting: Docker and Kubernetes workloads
- Microservices: Distributed application architecture
- API Development: Backend services and APIs
- Database Testing: PostgreSQL, MySQL, MongoDB
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Self-Hosted Applications&quot; group=&quot;use-cases&quot;&gt;

Good for hosting your own services:

&lt;ListCheck&gt;
- Analytics Platforms: Plausible, Matomo, Umami
- Monitoring Tools: Uptime Kuma, Grafana, Prometheus
- File Storage: Nextcloud, Seafile
- Database Servers: MySQL, PostgreSQL, Redis
- Communication: Rocket.Chat, Mattermost
- Password Managers: Bitwarden, Vaultwarden
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Business Applications&quot; group=&quot;use-cases&quot;&gt;

Suitable for business needs:

&lt;ListCheck&gt;
- Email Servers: Postfix, Dovecot, Mail-in-a-Box
- VPN Servers: WireGuard, OpenVPN
- Backup Solutions: Duplicati, Restic, Borg
- Internal Tools: GitLab, Jenkins, Confluence
- Game Servers: Minecraft, Counter-Strike, ARK
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Advanced Features

&lt;Accordion label=&quot;Object Storage&quot; group=&quot;features&quot;&gt;

Hetzner introduced S3-compatible object storage in 2024:

&lt;ListCheck&gt;
- Pricing: Base €6.49/month + €0.0087/GB additional storage
- API: S3 compatibility
- Integration: Works with major tools (AWS CLI, boto3, etc.)
- Availability: All datacenters
- Security: SSL/TLS encryption, access controls
- Performance: High-speed transfers
&lt;/ListCheck&gt;

Use Cases: Static website hosting, backups, CDN origin, data archiving

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Managed Kubernetes&quot; group=&quot;features&quot;&gt;

Kubernetes service:

&lt;ListCheck&gt;
- Pricing: €0.00595 per hour per node
- Features: Auto-scaling, load balancing, monitoring
- Integration: Full Hetzner Cloud integration
- Versions: Latest Kubernetes versions supported
- Networking: Private networks, ingress controllers
- Storage: Persistent volumes, CSI drivers
&lt;/ListCheck&gt;

Use for: Containerized applications, microservices, CI/CD

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Load Balancers &amp; Networking&quot; group=&quot;features&quot;&gt;

Load balancing and networking:

&lt;ListCheck&gt;
- Load Balancer Pricing: €7.49/month
- Features: SSL termination, health checks, sticky sessions
- Performance: Up to 20,000 connections
- Private Networks: Free private networking
- Floating IPs: Failover and high availability
- Firewalls: Security rules
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Backup &amp; Snapshots&quot; group=&quot;features&quot;&gt;

Backup solutions:

&lt;ListCheck&gt;
- Backup Pricing: 20% of server cost
- Snapshot Pricing: €0.0143/GB per hour
- Retention: Customizable retention periods
- Automation: Scheduled backups
- Restoration: One-click server restoration
- Cross-datacenter: Backup to different regions
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Troubleshooting Common Issues

&lt;Accordion label=&quot;Performance Optimization&quot; group=&quot;troubleshooting&quot;&gt;

&lt;ListCheck&gt;
- **Choose the right datacenter** for your target audience
- **Use NVMe SSD** for database-heavy applications
- **Enable backups** but consider snapshot alternatives for large data
- **Monitor resource usage** with built-in monitoring tools
- **Implement caching** (Redis, Memcached) for better performance
- **Use CDN** for static content delivery
- **Optimize databases** with proper indexing and queries
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Network Configuration&quot; group=&quot;troubleshooting&quot;&gt;

&lt;ListCheck&gt;
- **Private networks** for secure multi-server communication
- **Firewall rules** for proper security configuration
- **Load balancers** for high availability and traffic distribution
- **IPv6 support** for future-proofing your infrastructure
- **DNS configuration** for proper domain resolution
- **SSL certificates** for secure connections
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cost Optimization&quot; group=&quot;troubleshooting&quot;&gt;

&lt;ListCheck&gt;
- **Hourly billing** for development and testing servers
- **ARM servers** for cost-effective computing workloads
- **European datacenters** for included 20TB bandwidth
- **Proper sizing** to avoid over-provisioning resources
- **Scheduled scaling** for predictable traffic patterns
- **Snapshot management** to control storage costs
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## System Requirements &amp; Compatibility

### Operating Systems
- **Ubuntu** (18.04, 20.04, 22.04, 24.04)
- **Debian** (10, 11, 12)
- **CentOS** (7, 8, 9)
- **Fedora** (38, 39, 40)
- **Rocky Linux** (8, 9)
- **FreeBSD** (13, 14)

### Control Panels
Tested and compatible with:
- **CyberPanel** (recommended)
- **CloudPanel**
- **Webmin**
- **Plesk**
- **cPanel** (with license)

### Container Platforms
- **Docker** (native support)
- **Kubernetes** (managed service)
- **Podman** (rootless containers)
- **LXC/LXD** (system containers)

## Migration to Hetzner Cloud

### From Other VPS Providers
1. **Create server** with same specifications
2. **Transfer data** using rsync or migration tools
3. **Update DNS** records
4. **Test thoroughly** before switching

### Migration Tools
- **Hetzner CLI** for automation
- **Terraform** for infrastructure as code
- **Ansible** for configuration management
- **Custom scripts** for specific needs

## Security Best Practices

### Network Security
- Use **private networks** for internal communication
- Configure **firewalls** properly
- Enable **DDoS protection**
- Use **VPN** for administrative access

### Server Security
- **Regular updates** and security patches
- **SSH key authentication** only
- **Fail2ban** for brute force protection
- **Monitoring** and alerting

## Conclusion

After 5+ years of using Hetzner Cloud, here&apos;s why I recommend it:

&lt;Accordion label=&quot;Key Strengths&quot; group=&quot;conclusion&quot;&gt;

&lt;ListCheck&gt;
- AMD EPYC-Genoa processors - Regular Performance plans are 30%+ faster
- Cost-Optimized plans - 50% cheaper than before (starting at €3.49/month)
- Price-to-performance - Good value in the market
- Network performance (especially in EU with 20TB bandwidth)
- Reliable infrastructure with 99.9%+ uptime
- Developer experience with APIs and tools
- Support team with technical expertise
- Transparent pricing
- ARM servers (CAX) for cost-effective computing
- New datacenters in Singapore and US
- Flexible plans - Cost-Optimized, Regular, and Dedicated tiers
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Considerations&quot; group=&quot;conclusion&quot;&gt;

&lt;ListCheck&gt;
- Fewer datacenter locations than AWS/GCP (but growing)
- Bandwidth differences between EU (20TB) and US/Asia (1TB)
- Less marketplace integration than major cloud providers
- Documentation could improve (but getting better)
- Fewer managed services compared to AWS/Azure
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

### Bottom Line:

Hetzner Cloud still offers good value in the VPS market, even after the April 2026 price increase. Cost-Optimized plans now start at €3.99/month, which is higher than before but still well below DigitalOcean and Vultr for equivalent specs.

Whether you&apos;re a developer, small business, or startup, the combination of performance, reliability, and pricing works well. The three-tier system (Cost-Optimized, Regular, Dedicated) helps balance cost and performance for different workloads.

The price increases are an industry-wide trend driven by DRAM and NAND flash shortages. Hetzner is still one of the most cost-effective options, especially in European locations where you get 20TB of bandwidth included.

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Try Hetzner Cloud&quot;&gt;
    Start your cloud journey with Hetzner. [Sign up now](https://go.bitdoze.com/hetzner) and get €20 credit to test their services. Hetzner has been my preferred hosting provider for over 5 years.
&lt;/Notice&gt;

Share your thoughts in the comments if you&apos;d like me to test specific scenarios or compare with other providers.</content:encoded><category>hosting</category><category>hetzner</category></item><item><title>Hetzner vs Oracle ARM VPS Performance Comparison</title><link>https://www.bitdoze.com/hetzner-oracle-arm-performance/</link><guid isPermaLink="true">https://www.bitdoze.com/hetzner-oracle-arm-performance/</guid><description>Benchmark comparison between Hetzner ARM and Oracle free-tier ARM servers. Disk, CPU, network, and WordPress performance side by side.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgPricing from &quot;../../assets/images/wordpress/hetzner-arm-pricing-1024x441.webp&quot;;
import imgOracle from &quot;../../assets/images/wordpress/oracle-bench-793x1024.webp&quot;;
import imgHetzner from &quot;../../assets/images/wordpress/hetzner-arm-680x1024.webp&quot;;
import imgWordpress from &quot;../../assets/images/wordpress/ARM-WORDPRESS-PERFORMANCE-673x1024.webp&quot;;
import imgGtmatrix from &quot;../../assets/images/wordpress/gtmatrix-test-1024x742.webp&quot;;

Oracle Cloud gives you a free ARM server with 4 vCPUs and 24 GB of RAM on their always-free tier. Hetzner&apos;s ARM servers cost €11.99/month for the CAX31 (4 vCPU, 8 GB RAM), rising to €15.99/month from April 2026 due to Hetzner&apos;s price increases. Both run on Ampere Altra processors. I ran both through the same benchmark suite to see how they actually compare.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/2L1YfpaMMVQ&quot;
  label=&quot;Hetzner vs Oracle ARM VPS Performance&quot;
/&gt;
&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;
## Hetzner ARM pricing

&lt;Picture src={imgPricing} alt=&quot;Hetzner ARM server pricing&quot; /&gt;

Hetzner&apos;s ARM instances are cheaper than their Intel and AMD equivalents. The CAX31 (4 vCPU, 8 GB RAM) is €11.99/month vs ~€16.49/month for the equivalent x86 CPX31. Available in Germany and Finland datacenters. From April 2026, the CAX31 goes up to €15.99/month.

## Benchmark results

### Oracle (4 vCPU, 24 GB RAM — free tier)

&lt;Picture src={imgOracle} alt=&quot;Oracle ARM VPS benchmark results&quot; /&gt;

### Hetzner CAX31 (4 vCPU, 8 GB RAM -- €11.99/month)

&lt;Picture src={imgHetzner} alt=&quot;Hetzner ARM VPS benchmark results&quot; /&gt;

## What the numbers show

**CPU:** Nearly identical. Oracle scored 1102 single-core and 3670 multi-core on Geekbench 6. Hetzner scored 1064 single-core and 3376 multi-core. Same processor, similar results.

**Disk:** Hetzner wins decisively. Read speeds up to 2.52 GB/s vs Oracle&apos;s 55 MB/s. Oracle&apos;s free tier runs on shared NFS-backed storage, which explains the gap.

**Network:** Hetzner is faster and has lower latency to European endpoints. Oracle&apos;s free tier network is throttled.

The CPU performance is comparable, but everything else about Hetzner&apos;s server is faster.

## WordPress on Hetzner ARM

&lt;Picture src={imgWordpress} alt=&quot;WordPress benchmark on Hetzner ARM server&quot; /&gt;

&lt;Picture src={imgGtmatrix} alt=&quot;GTMatrix speed test on Hetzner ARM WordPress&quot; /&gt;

WordPress runs well on ARM. The WordPress benchmark scores are above 8, which is the threshold for good performance, and the GTMetrix results show fast real-world page loads. I&apos;ve moved several WordPress sites to Hetzner ARM without any compatibility issues.

## What to run on ARM

Most software supports ARM now. On these servers you can run:
- WordPress via CloudPanel (same install process as x86)
- Any Docker container built for ARM (Plausible, Uptime Kuma, Grafana, etc.)
- Node.js applications
- Python apps

The main thing to watch is whether third-party software you depend on has ARM builds. Most popular open source projects do.

## Quick comparison

| | Oracle Free Tier | Hetzner CAX31 |
|---|---|---|
| CPU | 4 vCPU (Ampere A1) | 4 vCPU (Ampere Altra) |
| RAM | 24 GB | 8 GB |
| Disk | ~50 GB (shared NFS, slow) | 80 GB NVMe (fast) |
| Disk read speed | ~55 MB/s | ~2.5 GB/s |
| Price | Free | €11.99/mo (€15.99 from Apr 2026) |
| Network | Throttled | Fast, low latency in EU |
| Best for | Testing, low-traffic projects | Production WordPress, databases |

## Which to choose

If the Oracle free tier is enough for your workload and you don&apos;t need fast disk IO, it&apos;s free and worth using. For anything that requires consistent disk performance, a busy WordPress site, a database, or heavy file operations, Hetzner&apos;s ARM servers are worth the monthly cost. You get predictable, fast NVMe storage instead of the throttled shared storage on Oracle&apos;s free tier.

One thing to keep in mind: Oracle&apos;s free tier can be unpredictable. Some users report instances being reclaimed or availability issues when creating new free-tier VMs. Hetzner&apos;s paid instances are always available and provisioned in seconds.

&lt;Button
  text=&quot;Try Hetzner Cloud Now&quot;
  link=&quot;https://go.bitdoze.com/hetzner&quot;
  variant=&quot;solid&quot;
  color=&quot;blue&quot;
  size=&quot;lg&quot;
  external={true}
  icon=&quot;rocket-launch&quot;
/&gt;</content:encoded><category>hosting</category><category>vps</category></item><item><title>How to Setup SMTP Relay on a VPS with ZeptoMail</title><link>https://www.bitdoze.com/how-to-setup-smtp-relay-email-on-zeptomail/</link><guid isPermaLink="true">https://www.bitdoze.com/how-to-setup-smtp-relay-email-on-zeptomail/</guid><description>Configure Postfix on Ubuntu to relay all outgoing emails through ZeptoMail. Fix email delivery issues on VPS servers running CloudPanel or similar.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

If you&apos;re running WordPress (or any app) on a VPS, sending email directly from the server is unreliable. Most VPS providers block port 25, and even when they don&apos;t, outgoing mail from a bare server lands in spam. The solution is a relay: route all outbound email through a dedicated mail service.

I use [ZeptoMail by Zoho](https://zeptomail.zoho.com/). They give you 10,000 free emails to start with, and after that it&apos;s $2.50 per 10,000 emails (credits valid for 6 months). No monthly subscription, just pay-as-you-go. Delivery has been reliable for me across multiple servers.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/2fnSygTp5Kc&quot;
  label=&quot;Setup SMTP Relay Email on ZeptoMail&quot;
/&gt;

## Step 1: Configure ZeptoMail

Sign up at ZeptoMail, buy a credit pack, and add your domain. They&apos;ll ask you to add SPF, DKIM, and CNAME records in your DNS. Once verified, go to **Configuration** to find your SMTP hostname, username, and password — you&apos;ll need these next.

## Step 2: Install Postfix

On Hetzner, Postfix is pre-installed. On a fresh Ubuntu server:

```bash
sudo apt update
sudo apt install -y mailutils
```

Choose **Internet Site** during setup and enter your domain (e.g., `yourdomain.com`).

## Step 3: Configure Postfix to use the relay

Edit `/etc/postfix/main.cf`:

```bash
sudo nano /etc/postfix/main.cf
```

Add at the end:

```bash
# ZeptoMail SMTP relay
relayhost = [smtp.zeptomail.com]:587
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_security_level = may
header_size_limit = 4096000
sender_canonical_classes = envelope_sender, header_sender
sender_canonical_maps = regexp:/etc/postfix/sender_canonical
smtp_header_checks = regexp:/etc/postfix/smtp_header_checks
```

## Step 4: Store credentials

Create `/etc/postfix/sasl_passwd`:

```bash
sudo nano /etc/postfix/sasl_passwd
```

Add your ZeptoMail credentials:

```
[smtp.zeptomail.com]:587 yourusername:yourpassword
```

Generate the hash database and lock down the file:

```bash
sudo postmap /etc/postfix/sasl_passwd
sudo chown root:root /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
sudo chmod 0600 /etc/postfix/sasl_passwd /etc/postfix/sasl_passwd.db
```

## Step 5: Fix the sender address

Without this, ZeptoMail will reject emails sent by WordPress or other apps because the &quot;from&quot; domain doesn&apos;t match your verified domain.

Create `/etc/postfix/sender_canonical`:

```bash
/.+/ noreply@yourdomain.com
```

Create `/etc/postfix/smtp_header_checks`:

```bash
/From:.*/ REPLACE From: noreply@yourdomain.com
```

Or with a display name:

```bash
/From:.*/ REPLACE From: Your Name &lt;noreply@yourdomain.com&gt;
```

Use an email address on the domain you verified in ZeptoMail.

## Step 6: Check /etc/mailname

If your VPS hostname is a subdomain (e.g., `cloud.yourdomain.com`), Postfix may try to use that as the sending domain. Fix it:

```bash
sudo nano /etc/mailname
```

Set it to just the root domain:

```
yourdomain.com
```

## Step 7: Reload Postfix

```bash
sudo postfix reload
```

## Step 8: Test

```bash
echo &quot;test message&quot; | mail -s &quot;test subject&quot; you@example.com
```

Check the log to confirm delivery:

```bash
tail -f /var/log/mail.log
```

A successful send looks like:

```
postfix/smtp: status=sent (250 Message received)
```

Once this is working, every app on the server (WordPress, cron job notifications, system emails) will route through ZeptoMail automatically.

## Alternatives to ZeptoMail

ZeptoMail works well for me, but there are other options depending on your volume and budget:

- **Amazon SES** -- $0.10 per 1,000 emails. Cheapest at scale, but you have to manage reputation and setup yourself. Good if you&apos;re already on AWS.
- **Postmark** -- $15/month for 10,000 emails. Known for fast delivery and excellent deliverability. More expensive but hands-off.
- **Resend** -- Free tier available, $20/month for more. Modern API, popular with developers. Relatively new.
- **Brevo (formerly Sendinblue)** -- Starts at $9/month. Combines transactional and marketing email if you need both.

For a small VPS running a few WordPress sites, ZeptoMail&apos;s pay-as-you-go model is hard to beat on price. If you send more than 50,000 emails/month, Amazon SES becomes the cheaper option.</content:encoded><category>hosting</category><category>vps</category><category>email</category></item><item><title>How To Install Uptime Kuma Self Hosted Monitoring Tool</title><link>https://www.bitdoze.com/install-uptime-kuma/</link><guid isPermaLink="true">https://www.bitdoze.com/install-uptime-kuma/</guid><description>Install Uptime Kuma on a VPS with Docker and Nginx to monitor your websites uptime, SSL, domain expiry, and response time for free.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import img1 from &quot;../../assets/images/wordpress/01-uptime-kuma-monitor-1024x755.webp&quot;;
import img2 from &quot;../../assets/images/wordpress/02-uptime-kuma-smtp-setup-1024x759.webp&quot;;
import img3 from &quot;../../assets/images/wordpress/03-uptime-kuma-status-1024x730.webp&quot;;
import img4 from &quot;../../assets/images/wordpress/04-uptime-kuma-emails--1024x78.webp&quot;;

If you want to monitor your website&apos;s uptime on your own VPS, [Uptime Kuma](https://github.com/louislam/uptime-kuma) is the tool I keep coming back to. It sends alerts via email, Slack, Discord, Telegram, and a bunch of other channels when something goes down, and it keeps a history of response times so you can spot what&apos;s been slow.

The project is open source and has had major updates recently. Version 2.0 (October 2025) added MariaDB support for larger deployments, rootless Docker images for better security, and a refreshed UI. Version 2.1 (February 2026) added Globalping support for checks from worldwide probes, domain expiry monitoring, and new notification integrations like Jira Service Management and Google Sheets.

This guide walks through installing Uptime Kuma using Docker Compose with Nginx as a reverse proxy and a Let&apos;s Encrypt SSL certificate.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/5k8y0JZBlWs&quot;
  label=&quot;How To Install Uptime Kuma Self Hosted Monitoring Tool&quot;
/&gt;

## 1. Provision a VPS Server

You need a VPS to run this. I use:


&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;


Uptime Kuma is lightweight. A 1 vCPU / 1 GB RAM server is enough to run it alongside other containers without problems.

## 2. Install Uptime Kuma on Ubuntu

### 2.1 Update the system and install Nginx

```bash
sudo apt update &amp;&amp; sudo apt upgrade -y
sudo reboot
sudo apt install nginx git -y
```

### 2.2 Install Docker and Docker Compose

Follow the [official Docker install guide](https://docs.docker.com/engine/install/ubuntu/) or run:

```bash
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo &quot;deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt update
sudo apt install docker-ce docker-ce-cli containerd.io docker-compose-plugin -y
```

&gt; **Note:** The current recommended way is the `docker-compose-plugin` (v2). Use `docker compose` (no hyphen) going forward.

### 2.3 Create directories and write a compose file

```bash
mkdir -p /docker-vol/uptime-kuma
mkdir -p /opt/uptime-kuma
cd /opt/uptime-kuma
```

Create `docker-compose.yml`:

```yaml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:2
    container_name: uptime-kuma
    volumes:
      - /docker-vol/uptime-kuma:/app/data
    ports:
      - 8001:3001
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
```

&gt; **Tip:** Use `louislam/uptime-kuma:2` instead of `:latest` to stay on the v2 branch and avoid unexpected breaking changes when v3 eventually releases. Version 2.0+ also has rootless Docker images (`louislam/uptime-kuma:2-rootless`) if you want to run without root privileges.

Start the container:

```bash
docker compose up -d
```

### 2.4 Open the firewall for Nginx

```bash
sudo ufw allow &quot;Nginx Full&quot;
```

### 2.5 Configure Nginx as a reverse proxy

Create `/etc/nginx/sites-available/uptime-kuma.conf` and paste:

```nginx
server {
    listen 80;
    server_name your_domain_here;

    location / {
        proxy_pass         http://localhost:8001;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection &quot;upgrade&quot;;
        proxy_set_header   Host $host;
    }
}
```

Enable the site and reload Nginx:

```bash
sudo ln -s /etc/nginx/sites-available/uptime-kuma.conf /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```

## 3. Point Your Domain to the Server

Log in to your DNS provider and add an **A record** pointing your domain or subdomain to the VPS IP address.

## 4. Add an SSL Certificate with Let&apos;s Encrypt

Install Certbot and generate a certificate:

```bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d your_domain_here
```

Certbot patches the Nginx config automatically and sets up auto-renewal. Your site will be available over HTTPS right away.

## 5. Set Up Your First Monitor

On first login you create an admin account. Then add a monitor — set the type to HTTP(s), give it a friendly name, and set the heartbeat interval to 300 seconds. Checking every 5 minutes is enough for most sites.

&lt;Picture src={img1} alt=&quot;Uptime Kuma add monitor screen&quot; /&gt;

### Configure email notifications

Go to Settings → Notifications and add an SMTP notification. For Gmail: hostname `smtp.gmail.com`, port `587`, security STARTTLS, and your Gmail credentials. If you have 2FA on your account, generate an App Password instead of using your main password.

&lt;Picture src={img2} alt=&quot;Uptime Kuma SMTP notification setup&quot; /&gt;

Once configured, the dashboard shows a green heartbeat for every healthy monitor:

&lt;Picture src={img3} alt=&quot;Uptime Kuma dashboard showing all monitors up&quot; /&gt;

When something goes down you receive an alert email, and another one when it recovers:

&lt;Picture src={img4} alt=&quot;Uptime Kuma downtime notification email&quot; /&gt;

## What&apos;s new in version 2.x

If you&apos;re upgrading from v1, here&apos;s what changed:

- **MariaDB support** -- You can now use MariaDB instead of the default SQLite for larger deployments with hundreds of monitors. SQLite still works fine for most setups.
- **Domain expiry monitoring** -- Checks RDAP data and alerts you before domains expire. Useful if you manage multiple domains.
- **Globalping integration** -- Run checks from probes around the world instead of just your VPS location. Helpful for confirming whether an outage is regional or global.
- **Rootless Docker** -- The `2-rootless` image runs without root privileges, which is better security practice for shared servers.
- **New notification providers** -- Jira Service Management, Google Sheets, Brevo, Nextcloud Talk, and several others.
- **SNMPv3 support** -- Monitor network devices and infrastructure.
- **Incident history** -- Track and document past incidents within the dashboard.

The backup/restore feature from v1 was removed in v2, so back up the `/app/data` volume manually before upgrading.

## Conclusion

Uptime Kuma runs quietly in Docker, checks your sites, and messages you when something breaks. It handles SSL expiry, domain expiry, DNS, TCP ports, SNMP, and more. I&apos;ve been running it for years without issues. Total cost on a shared VPS: basically nothing.</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>How to Install WordPress on Ubuntu ARM with CloudPanel</title><link>https://www.bitdoze.com/install-wordpress-on-ubuntu-arm/</link><guid isPermaLink="true">https://www.bitdoze.com/install-wordpress-on-ubuntu-arm/</guid><description>Step-by-step guide to install WordPress on an ARM VPS (Hetzner or Oracle free tier) using CloudPanel. Cheap, fast, and straightforward.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgDns from &quot;../../assets/images/wordpress/cloudpanel-cloudways-setup-1024x309.webp&quot;;
import imgSettings from &quot;../../assets/images/wordpress/cloudpanel-settings-1024x328.webp&quot;;
import imgCreate from &quot;../../assets/images/wordpress/create-website-cloudwanel.webp&quot;;
import imgDetails from &quot;../../assets/images/wordpress/wordpress-details-1024x703.webp&quot;;
import imgPhp from &quot;../../assets/images/wordpress/cloudpanel-php-configs-1024x777.webp&quot;;

ARM servers are cheaper than x86 at every tier. Hetzner&apos;s entry ARM server (CAX11) is €4.49/month (rising to €5.49 from April 2026). Oracle&apos;s free tier gives you a 4 vCPU / 24 GB ARM instance at no cost. Both run WordPress without issues. This guide covers installing WordPress on either using [CloudPanel](https://www.cloudpanel.io/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Nm3xZe3W8zc&quot;
  label=&quot;Install WordPress on Ubuntu ARM with CloudPanel&quot;
/&gt;

## Get your ARM server

### Option A: Hetzner Cloud

Sign up at [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) and create a new server:
- Image: **Ubuntu 24.04** (or 22.04, both work)
- Type: **CAX11** (ARM, 2 vCPU, 4 GB RAM, €4.49/month)
- Location: any available datacenter
- Enable IPv6, add your SSH key

### Option B: Oracle Cloud free tier

Sign up at [Oracle Cloud](https://cloud.oracle.com/). Create a compute instance:
- Image: Canonical Ubuntu 24.04 Minimal (or 22.04)
- Shape: **VM.Standard.A1.Flex** -- set 4 cores, 24 GB RAM (free)
- Enable a public IP

**Oracle extra step:** Open ports 80, 443, and 8443 in your instance&apos;s security list under Networking → Virtual Cloud Networks → Security Lists → Add Ingress Rules.

Also disable the Ubuntu firewall on Oracle (it conflicts with CloudPanel):
```bash
sudo iptables -F
```

## Step 1: Install CloudPanel

SSH into your server and update:

```bash
apt update &amp;&amp; apt -y upgrade &amp;&amp; apt -y install curl wget sudo
reboot
```

Check the [CloudPanel install docs](https://www.cloudpanel.io/docs/v2/getting-started/other/) for the latest install script and checksum, as these change with new releases. At the time of writing:

**Install on Hetzner:**
```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;a3ba69a8102345127b4ae0e28cfe89daca675cbc63cd39225133cdd2fa02ad36 install.sh&quot; | \
sha256sum -c &amp;&amp; sudo CLOUD=hetzner DB_ENGINE=MARIADB_11.4 bash install.sh
```

**Install on Oracle:**
```bash
curl -sS https://installer.cloudpanel.io/ce/v2/install.sh -o install.sh; \
echo &quot;a3ba69a8102345127b4ae0e28cfe89daca675cbc63cd39225133cdd2fa02ad36 install.sh&quot; | \
sha256sum -c &amp;&amp; sudo DB_ENGINE=MARIADB_11.4 bash install.sh
```

&gt; **Note:** CloudPanel now supports MariaDB 11.4 in addition to 10.11. Use `MARIADB_11.4` for new installs. MySQL 8.0 is also available if you prefer it.

Once done, access the CloudPanel admin at `https://YOUR_SERVER_IP:8443`.

## Step 2: Point your domain to the server

In your DNS provider (I use Cloudflare), create an A record pointing your domain to the server IP. Also create an admin subdomain (e.g., `admin.yourdomain.com`) for the CloudPanel interface.

&lt;Picture src={imgDns} alt=&quot;Cloudflare DNS setup for CloudPanel&quot; /&gt;

In CloudPanel → Settings, set the admin URL to your admin subdomain:

&lt;Picture src={imgSettings} alt=&quot;CloudPanel admin URL settings&quot; /&gt;

## Step 3: Create a WordPress site

Go to **Sites → Add Site → Create WordPress Website**:

&lt;Picture src={imgCreate} alt=&quot;CloudPanel create website button&quot; /&gt;

Enter your domain, username, password, and email:

&lt;Picture src={imgDetails} alt=&quot;CloudPanel WordPress site details form&quot; /&gt;

CloudPanel installs WordPress automatically and creates the database.

## Step 4: Set PHP version

Click the site in CloudPanel and go to **PHP Settings**. Set the PHP version to 8.2 or 8.3:

&lt;Picture src={imgPhp} alt=&quot;CloudPanel PHP version configuration&quot; /&gt;

That&apos;s the full setup. Your WordPress site is running on ARM with MariaDB and Nginx via CloudPanel. From here you can add an SSL certificate (CloudPanel does this in one click), [enable Varnish Cache](https://www.bitdoze.com/cloudpanel-varnish-cache/) for speed, set up backups, and add more sites.

If you want to harden the server, check [How To Secure CloudPanel](https://www.bitdoze.com/secure-cloudpanel/) for firewall rules, 2FA, and WAF setup.</content:encoded><category>wordpress</category><category>cloudpanel</category></item><item><title>How to Safely Update CloudPanel</title><link>https://www.bitdoze.com/safely-update-cloudpanel/</link><guid isPermaLink="true">https://www.bitdoze.com/safely-update-cloudpanel/</guid><description>The right way to update CloudPanel: take a consistent VPS snapshot first, then run the update command, then verify everything came back up clean.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgCheck from &quot;../../assets/images/wordpress/CloudPanel-check-version-1024x249.webp&quot;;
import imgPowerOff from &quot;../../assets/images/wordpress/power-off-vps-1024x442.webp&quot;;
import imgSnapshot from &quot;../../assets/images/wordpress/take-snapshot-1024x229.webp&quot;;
import imgServices from &quot;../../assets/images/wordpress/CloudPanel-Services-1024x534.webp&quot;;

CloudPanel updates have been safe in my experience. I haven&apos;t had one break anything. But updates can always go wrong, and a VPS snapshot takes 2 minutes. Do the snapshot. If you&apos;re running a production site with orders or live traffic, the snapshot is the thing between you and a bad day.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/8-YnNbl89V4&quot;
  label=&quot;How to Safely Update CloudPanel&quot;
/&gt;

## Check if an update is available

Log into CloudPanel admin and look at the footer. If a new version is out, you&apos;ll see the current version and an &quot;Update Available&quot; notice:

&lt;Picture src={imgCheck} alt=&quot;CloudPanel version check in admin footer&quot; /&gt;

You can review what changed at the [CloudPanel Changelog](https://www.cloudpanel.io/docs/v2/changelog/).

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;


## Wait before updating

Unless a bug is actively hurting you, wait 1-2 weeks after a release before updating production servers. Let others find the edge cases first. You can follow the [CloudPanel changelog](https://www.cloudpanel.io/docs/v2/changelog/) and the [CloudPanel forum](https://cloudpanel.forum/) to see if anyone reports problems with a new version.

## Step 1: Take a consistent snapshot

Stop your services first so the snapshot captures a clean state:

```bash
sudo service nginx stop
sudo service mysql stop
```

Power off the VPS from the Hetzner console:

&lt;Picture src={imgPowerOff} alt=&quot;Hetzner VPS power off button&quot; /&gt;

Go to **Snapshots → Take snapshot**:

&lt;Picture src={imgSnapshot} alt=&quot;Hetzner take snapshot button&quot; /&gt;

Power the server back on. You now have a restore point.

## Step 2: Run the CloudPanel update

SSH into the server and run:

```bash
clp-update
```

That&apos;s it — one command updates CloudPanel to the latest release.

## Step 3: Reboot

```bash
sudo service nginx stop
sudo service mysql stop
reboot
```

## Step 4: Verify

After reboot, check the CloudPanel admin → **Instance** tab. All services should show as running:

&lt;Picture src={imgServices} alt=&quot;CloudPanel services status all running&quot; /&gt;

Then open one of your sites in a browser and confirm it loads. Check the site error logs in CloudPanel if anything looks off.

If something went wrong and you need to roll back, restore the snapshot from Hetzner&apos;s console and your server will be back to its pre-update state.

## Also update the OS

While you&apos;re doing maintenance, update Ubuntu packages too:

```bash
sudo apt update &amp;&amp; sudo apt upgrade -y
```

CloudPanel currently supports Ubuntu 24.04, Ubuntu 22.04, Debian 12, and Debian 11. If you&apos;re still on Ubuntu 22.04, it will keep working, but new installs should use 24.04.

Don&apos;t run `do-release-upgrade` to switch Ubuntu versions on a live CloudPanel server. If you need to move to a newer OS, set up a fresh server and migrate your sites over.</content:encoded><category>hosting</category><category>cloudpanel</category><category>vps</category></item><item><title>How To Secure CloudPanel</title><link>https://www.bitdoze.com/secure-cloudpanel/</link><guid isPermaLink="true">https://www.bitdoze.com/secure-cloudpanel/</guid><description>Ten practical steps to lock down CloudPanel: SSH keys, SSL, firewall rules, 2FA, backups, malware scanning, WAF, and more.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgSsl from &quot;../../assets/images/wordpress/cloudpanel-ssl-1024x346.webp&quot;;
import imgPorts from &quot;../../assets/images/wordpress/cloudpanel-ports-1024x463.webp&quot;;
import imgMyIp from &quot;../../assets/images/wordpress/cloudpanel-myip-1024x597.webp&quot;;
import imgSnapshots from &quot;../../assets/images/wordpress/cloudpanel-snapshots-1024x609.webp&quot;;
import imgRemote from &quot;../../assets/images/wordpress/remote-backups-1024x426.webp&quot;;
import imgCloudflare from &quot;../../assets/images/wordpress/cloudpanel-cloudflare-integration-1024x284.webp&quot;;

A default CloudPanel install is functional but not hardened. These steps reduce the attack surface without much effort.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/sOCD1_oQb6U&quot;
  label=&quot;How To Secure CloudPanel&quot;
/&gt;


&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;


## 1. Use SSH keys, not passwords

Passwords can be brute-forced. SSH keys can&apos;t. Generate a key pair:

```bash
ssh-keygen -t rsa -b 4096
```

Add your public key to the VPS when creating it (Hetzner lets you add SSH keys in the server creation UI). If the server is already running, add the key to `~/.ssh/authorized_keys` on the server and disable password auth in `/etc/ssh/sshd_config`.

## 2. Add an SSL certificate to the admin area

Set up a subdomain for CloudPanel&apos;s admin interface (e.g., `admin.yourdomain.com`) and add it under **Admin Area → Settings → General**. Point the DNS to your server IP and let CloudPanel generate a Let&apos;s Encrypt cert for it.

&lt;Picture src={imgSsl} alt=&quot;CloudPanel admin SSL certificate setup&quot; /&gt;

Once this is in place, you access the admin panel over HTTPS on port 443 using the subdomain — no need to expose port 8443 to the internet.

## 3. Lock down ports 22 and 8443

CloudPanel&apos;s built-in firewall handles this. Restrict SSH (port 22) and the admin port (8443) so only your IP can reach them:

&lt;Picture src={imgPorts} alt=&quot;CloudPanel firewall port configuration&quot; /&gt;

Use the **My IP** option to automatically add your current IP:

&lt;Picture src={imgMyIp} alt=&quot;CloudPanel adding your IP to firewall whitelist&quot; /&gt;

The downside: if your ISP changes your IP, you&apos;ll be locked out until you update the rule. Keep an alternative access method ready (Hetzner has a console access via browser).

## 4. Enable two-factor authentication

In CloudPanel → Account settings, enable 2FA. Scan the QR code with Google Authenticator or Authy. From then on, every login requires a time-based code from your phone.

## 5. Set up backups

**Snapshots** (via Hetzner API): Go to CloudPanel → Snapshots, connect your Hetzner API key, and set automatic snapshot intervals and retention. Snapshots include the full disk — files, database, config.

&lt;Picture src={imgSnapshots} alt=&quot;CloudPanel automated snapshots configuration&quot; /&gt;

**Remote backups** (files to external storage): CloudPanel supports Dropbox, Google Drive, SFTP, and anything [Rclone](https://rclone.org/) can reach. This backs up site files; for a full backup including databases, you&apos;ll need a separate script.

&lt;Picture src={imgRemote} alt=&quot;CloudPanel remote backup configuration&quot; /&gt;

## 6. Keep everything updated

Run Ubuntu package updates roughly every 3 months:

```bash
sudo apt update &amp;&amp; sudo apt upgrade -y
```

Update CloudPanel itself:

```bash
clp-update
```

Always take a snapshot before any update. See [How to Safely Update CloudPanel](https://www.bitdoze.com/safely-update-cloudpanel/) for the full process.

## 7. Scan for malware

Install [LMD (Linux Malware Detect)](https://www.rfxn.com/projects/linux-malware-detect/) and ClamAV, and schedule daily scans of the site directories. CloudPanel isolates each site under a separate OS user, so an infected site can&apos;t spread to others — but it can still take down the server if not caught.

Malware scans add CPU load while running. Test on a staging server before enabling on production.

## 8. Add a WAF

Put your sites behind a Web Application Firewall to block DDOS, SQL injection, and XSS before they reach the server. Cloudflare&apos;s free tier includes WAF rules now. CloudPanel has a native Cloudflare integration that restricts inbound traffic to Cloudflare IPs only:

&lt;Picture src={imgCloudflare} alt=&quot;CloudPanel Cloudflare integration setting&quot; /&gt;

Enable this after pointing your domains to Cloudflare -- it means direct requests to your server IP are blocked, and all traffic must go through Cloudflare&apos;s network first.

## 9. Block access to sensitive files

Add Nginx rules to block access to files that should never be public. In your site&apos;s Nginx vhost config (or via CloudPanel&apos;s Nginx directives), add:

```nginx
location ~ /\.(env|git|htaccess|htpasswd) {
    return 404;
}

location = /xmlrpc.php {
    return 403;
}

location ~ /\.user\.ini {
    return 404;
}
```

This blocks `.env` files (which often contain database passwords), `.git` directories, and WordPress&apos;s `xmlrpc.php` (a common brute-force target that most sites don&apos;t need).

## 10. Bind MySQL to localhost

Make sure MySQL/MariaDB only listens on `127.0.0.1` so it can&apos;t be reached from outside the server. Check `/etc/mysql/my.cnf` or the MariaDB config for:

```
bind-address = 127.0.0.1
```

CloudPanel does this by default, but worth verifying, especially after updates.</content:encoded><category>hosting</category><category>cloudpanel</category><category>security</category></item><item><title>Secure Your WordPress Website with Two-Factor Authentication (2FA)</title><link>https://www.bitdoze.com/secure-wordpress-2fa/</link><guid isPermaLink="true">https://www.bitdoze.com/secure-wordpress-2fa/</guid><description>How to add two-factor authentication to WordPress using the free WP 2FA plugin. Covers authenticator apps, email codes, and passkeys.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import img2faSettings from &quot;../../assets/images/wordpress/2fa-settings-727x1024.webp&quot;;

Passwords alone aren&apos;t enough for WordPress. They get phished, leaked in breaches, or cracked by bots. Adding a second factor means a stolen password isn&apos;t enough to get into your site.

The easiest way to do this is with the [WP 2FA plugin](https://wordpress.org/plugins/wp-2fa/) (free, by Melapress). It supports authenticator apps, email codes, and now passkeys for passwordless login. You can enforce 2FA for specific user roles.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/WXKTEqH7gdw&quot;
  label=&quot;How to Activate 2FA in WordPress with WP 2FA&quot;
/&gt;

## Install WP 2FA

In your WordPress dashboard, go to **Plugins → Add New**, search for &quot;WP 2FA&quot;, and install the one by WP White Security. Activate it and follow the setup wizard. You can enforce 2FA for all users, admins only, or specific roles.

## Configure 2FA with an authenticator app

This is the more reliable method. Email codes can get delayed or end up in spam; an app works offline.

&lt;Picture src={img2faSettings} alt=&quot;WP 2FA plugin settings page&quot; /&gt;

1. Go to **WP 2FA → 2FA Policies** and enable **One-time code via 2FA App (TOTP)**.
2. Go to **Users → Your Profile**, scroll to the WP 2FA section, and click **Set up two-factor authentication**.
3. Choose the phone app method and click **Next**.
4. Install Google Authenticator or Authy on your phone.
5. Scan the QR code shown by the plugin (or enter the secret key manually).
6. Enter the 6-digit code your app generates to verify the link.

Done. Every login will now ask for a code from your phone.

## Configure 2FA with email

Before using this method, make sure WordPress transactional email is working reliably. Install [FluentSMTP](https://www.bitdoze.com/send-emails-in-wordpress-zoho-smtp-fluentsmtp/) to route email through a proper SMTP service first — otherwise codes may not arrive.

1. Go to **WP 2FA → 2FA Policies** and enable **One-time code via email (HOTP)**.
2. Go to **Users → Your Profile**, scroll to WP 2FA, and click **Set up two-factor authentication**.
3. Choose email, enter your address, and click **Send code**.
4. Check your inbox for the code from WP 2FA and enter it to verify.

## Enforce 2FA for other users

In WP 2FA settings you can require 2FA for all users or specific roles (editors, authors, etc.) and set a grace period before the requirement kicks in. New users get a prompt on first login.

I&apos;d at minimum enforce it for admin and editor roles. Anyone who can publish content or change settings is a risk if their account gets compromised.

## Passkeys (passwordless login)

WP 2FA now supports passkeys, which replace passwords entirely with a cryptographic key stored on your device (phone, laptop, or hardware key). You authenticate with Face ID, Touch ID, or a PIN instead of typing a password.

Passkeys are phishing-resistant because there&apos;s no password to steal. If you use a modern browser and device, this is the strongest option available. To set it up, go to WP 2FA settings and enable the passkey method, then register your device under your user profile.

## Alternatives to WP 2FA

WP 2FA is the plugin I use, but there are other options:

- **Wordfence Login Security** -- free, lightweight, supports TOTP apps and reCAPTCHA. Good if you already use Wordfence for security.
- **Two Factor Authentication by miniOrange** -- supports 15+ methods including SMS, push notifications, and hardware keys. Free tier covers basic TOTP.
- **Two Factor (WordPress community plugin)** -- open source, supports TOTP, email, and U2F hardware keys. More developer-oriented.</content:encoded><category>wordpress</category><category>security</category><category>2fa</category></item><item><title>How to Send Emails in WordPress Using Zoho SMTP With FluentSMTP</title><link>https://www.bitdoze.com/send-emails-in-wordpress-zoho-smtp-fluentsmtp/</link><guid isPermaLink="true">https://www.bitdoze.com/send-emails-in-wordpress-zoho-smtp-fluentsmtp/</guid><description>Step-by-step guide to routing WordPress emails through Zoho SMTP using the free FluentSMTP plugin.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgFluentSmtp from &quot;../../assets/images/wordpress/fluentsmtp_settings-1024x730.webp&quot;;

By default, WordPress sends emails through PHP&apos;s `mail()` function, which most hosting providers either block or rate-limit. The result: password reset emails that never arrive, order confirmations that vanish, contact form submissions that disappear.

Connecting WordPress to a real SMTP account fixes this. I use [Zoho Mail](https://www.zoho.com/mail/) for several domains because it&apos;s cheap and supports custom domains. The plugin I use is [FluentSMTP](https://wordpress.org/plugins/fluent-smtp/) (currently v2.2.95), which is free and logs every email sent so you can see what actually went out. It supports native API integrations for 10+ providers and can send failure alerts to Telegram, Slack, or Discord.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/uBxwRFabMc4&quot;
  label=&quot;How to Send Emails in WordPress Using Zoho SMTP With FluentSMTP&quot;
/&gt;

## Setup steps

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;


**1. Install FluentSMTP**

In your WordPress dashboard, go to **Plugins → Add New**, search for &quot;FluentSMTP&quot;, install and activate it.

**2. Create a Zoho application password**

Log into your Zoho account and go to [Account Security → App Passwords](https://accounts.zoho.com/home#security/app_password). Generate a password for WordPress. You need this because Zoho won&apos;t accept your main account password for SMTP authentication.

**3. Configure FluentSMTP**

Go to **FluentSMTP → Settings** and choose &quot;Other SMTP&quot;. Fill in:

- **From Email**: the Zoho email address or alias you want to send from
- **From Name**: your site name or your name

For the SMTP details, use these settings based on your account type:

**Personal Zoho accounts** (`yourname@zoho.com`):
- Server: `smtp.zoho.com`
- Port: `465` (SSL) or `587` (TLS)

**Business/domain accounts** (`you@yourdomain.com`):
- Server: `smtppro.zoho.com`
- Port: `465` (SSL) or `587` (TLS)

Both require authentication. Use your Zoho email address as the SMTP username and the application password you created in step 2 as the SMTP password.

&lt;Picture src={imgFluentSmtp} alt=&quot;FluentSMTP settings configured with Zoho SMTP&quot; /&gt;

**4. Send a test email**

After saving, click **Send Test Email** in FluentSMTP to verify delivery. Check the email logs in FluentSMTP to confirm it shows as sent. If the test doesn&apos;t arrive, double-check the application password and server/port combination.

Zoho&apos;s free tier sends a limited number of emails per day (the limit varies by plan). For a personal blog or small site, that&apos;s plenty.

## Fallback connections

FluentSMTP supports multiple SMTP connections with automatic fallback. If Zoho is down or rejects a send, it can retry through a second provider (like Gmail or Amazon SES). Set this up under FluentSMTP → Settings → Add Another Connection. For most small sites this isn&apos;t necessary, but if you run a WooCommerce store where order emails are critical, it&apos;s worth configuring.

## Tip: store credentials in wp-config.php

Instead of saving your SMTP password in the WordPress database, you can define it in `wp-config.php` for better security:

```php
define(&apos;FLUENTMAIL_SMTP_PASSWORD&apos;, &apos;your-app-password-here&apos;);
```

FluentSMTP picks this up automatically. This keeps credentials out of the database and away from plugins that might expose them.</content:encoded><category>wordpress</category><category>email</category><category>smtp</category></item><item><title>How to Speed Up WordPress with Cloudflare, Varnish and Redis on CloudPanel</title><link>https://www.bitdoze.com/speed-up-wordpress-with-cloudflare-varnish-and-redis/</link><guid isPermaLink="true">https://www.bitdoze.com/speed-up-wordpress-with-cloudflare-varnish-and-redis/</guid><description>Set up a three-layer WordPress caching stack using Cloudflare, Varnish, and Redis Object Cache on CloudPanel for faster load times and lower server load.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgVarnish from &quot;../../assets/images/wordpress/cloudpanel-varnish-cache.webp&quot;;

WordPress hosting on a VPS can get slow under real traffic if you&apos;re not caching. CloudPanel ships with Varnish and Redis already installed, so you just need to wire them up correctly with some free plugins.

Here&apos;s the setup I use: Cloudflare sits at the edge and caches pages globally, Varnish handles cache on your own server for requests that miss Cloudflare, and Redis stores PHP objects and database queries in memory so PHP executions are fast.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/JGVFTqFQcmk&quot;
  label=&quot;Speed Up WordPress with Cloudflare, Varnish and Redis on CloudPanel&quot;
/&gt;

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;


## How the layers work together

Each layer handles a different type of request:

1. **Cloudflare** — visitors hit Cloudflare first. If the page is cached on their CDN, it&apos;s served from the nearest edge node without touching your server.
2. **Varnish** — if the page isn&apos;t in Cloudflare&apos;s cache (first visit, post-publish, dynamic content), the request hits Varnish on your server. Varnish serves the cached HTML without running PHP.
3. **Redis** — when Varnish needs to generate a fresh page, PHP talks to Redis for object and query caching instead of hitting MySQL repeatedly.

The result is fast loads for returning visitors and manageable server load during traffic spikes.

## Plugins needed

Three free plugins handle everything:

- **[CLP Varnish Cache](https://wordpress.org/plugins/clp-varnish-cache/)** — tells WordPress to purge Varnish when content changes. Install and activate, no configuration needed.
- **[Redis Object Cache](https://wordpress.org/plugins/redis-cache/)** (v2.7.0) -- connects WordPress to Redis. After activating, go to **Settings → Redis** and click **Enable Object Cache**. There&apos;s also a paid version (Object Cache Pro) with WooCommerce optimizations and cache analytics, but the free version works well for most sites.
- **[Super Page Cache for Cloudflare](https://wordpress.org/plugins/wp-cloudflare-page-cache/)** -- links WordPress to Cloudflare&apos;s cache and handles cache invalidation on publish. Free alternative to Cloudflare APO ($5/month).

## Varnish setup in CloudPanel

Enable Varnish for your site in CloudPanel:

&lt;Picture src={imgVarnish} alt=&quot;CloudPanel Varnish cache settings for a WordPress site&quot; /&gt;

In CloudPanel, go to your site&apos;s settings and enable **Varnish Cache**. The CLP Varnish Cache plugin handles automatic purging when you publish or update content.

## Cloudflare plugin setup

In **Super Page Cache for Cloudflare** settings:

1. Add your Cloudflare API token or Global API key
2. Select your domain from the zone list
3. Set caching mode to &quot;Standard&quot; and enable the page cache
4. Configure bypass rules for WooCommerce cart/checkout if needed

The plugin creates a cached version of each page on Cloudflare&apos;s CDN and purges it automatically when the page changes.

## A note on dynamic content

This stack works well for mostly static content — blog posts, landing pages, static product pages. If you have pages with per-user content (cart, logged-in user data, personalized elements), make sure to configure bypass rules in both the Cloudflare plugin and Varnish settings. Serving cached pages to logged-in users causes obvious problems.

Check [CloudPanel Varnish Cache setup](https://www.bitdoze.com/cloudpanel-varnish-cache/) for detailed Varnish configuration options.

## Cloudflare APO vs Super Page Cache plugin

Cloudflare APO (Automatic Platform Optimization) costs $5/month and caches HTML at Cloudflare&apos;s edge without needing a plugin. It works well but costs money. The Super Page Cache for Cloudflare plugin does roughly the same thing for free using the Cloudflare API. Both approaches cache full pages on Cloudflare and purge on publish.

If you&apos;re already paying for Cloudflare Pro ($20/month), APO is included for free. If you&apos;re on the free Cloudflare plan, the Super Page Cache plugin is the better deal.

## When this stack isn&apos;t enough

This three-layer setup handles most WordPress sites well. If you&apos;re still hitting performance walls, the next steps are:

- Upgrade to a bigger VPS (more CPU cores help with uncached requests)
- Use a dedicated CDN like BunnyCDN or KeyCDN alongside Cloudflare
- Switch to a purpose-built caching plugin like FlyingPress or LiteSpeed Cache if your host supports it
- Look at Object Cache Pro if you run WooCommerce with heavy database load</content:encoded><category>wordpress</category><category>cloudpanel</category><category>performance</category></item><item><title>SureCart Review - WordPress eCommerce for Digital Products and Subscriptions</title><link>https://www.bitdoze.com/surecart-review/</link><guid isPermaLink="true">https://www.bitdoze.com/surecart-review/</guid><description>A hands-on review of SureCart, the WordPress eCommerce plugin built for digital products, subscriptions, and services, with updated 2026 pricing.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imgPayments from &quot;../../assets/images/wordpress/surecart-payment-processors-1024x654.webp&quot;;
import imgLayout from &quot;../../assets/images/wordpress/surecart-product-layout-1024x645.webp&quot;;
import imgType from &quot;../../assets/images/wordpress/surecart-product-type-1024x575.webp&quot;;
import imgBlocks from &quot;../../assets/images/wordpress/surecart-blocks-358x1024.webp&quot;;
import imgDashboard from &quot;../../assets/images/wordpress/surecart-customer-dashbord-1024x683.webp&quot;;
import imgPricing from &quot;../../assets/images/wordpress/surecart-pricing-1024x703.webp&quot;;

I&apos;ve been using SureCart on this site to sell services for a while now. The short version: it does what it promises, doesn&apos;t bloat your site, and the free tier is actually useful. As of early 2026, SureCart is at version 3.8+ with over 100,000 active installations.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/VshAwNrqVhg&quot;
  label=&quot;SureCart Review&quot;
/&gt;

## What SureCart is

SureCart is a WordPress eCommerce plugin aimed at selling digital products, subscriptions, and services. It&apos;s not trying to compete with WooCommerce for physical goods. If you need complex shipping rules or large inventory management, look elsewhere. For ebooks, courses, software licenses, memberships, and consulting services, it&apos;s a good fit.

The architecture is different from WooCommerce: the processing happens on SureCart&apos;s servers, not your own. Your WordPress site handles the frontend; their infrastructure handles checkout logic. This is why product pages load extra scripts, but your server isn&apos;t doing the heavy lifting during transactions.

## Payment processors

&lt;Picture src={imgPayments} alt=&quot;SureCart supported payment processors&quot; /&gt;

Stripe, PayPal, and Mollie are the main options, plus a manual payment method. Through Stripe, you also get Apple Pay, Google Pay, and 13+ other payment methods across 135+ currencies. For most digital product sellers, Stripe covers everything needed.

## Product types

You can sell three types of products:

&lt;Picture src={imgLayout} alt=&quot;SureCart product layout options&quot; /&gt;

&lt;Picture src={imgType} alt=&quot;SureCart product types&quot; /&gt;

**One-time purchases** for single digital items. **Subscriptions** with configurable billing cycles (daily, weekly, monthly, yearly) and optional length limits. **Pay-what-you-want** for donations or open-ended pricing. Each product can have file downloads attached and custom integrations configured.

## Integrations

SureCart connects with LearnDash, LifterLMS, TutorLMS, MemberPress, and SureMembers out of the box. If you&apos;re selling courses, the TutorLMS + SureCart combination is well-supported and actively maintained. BuddyBoss integration is also there for community sites.

OttoKit (formerly SureTriggers, from the same team) handles automation between SureCart and 500+ external apps. It works like a self-hosted Zapier for connecting SureCart events to email marketing, CRM tools, or custom workflows.

## Checkout forms

&lt;Picture src={imgBlocks} alt=&quot;SureCart checkout form blocks&quot; /&gt;

After creating a product you build a checkout form using blocks. You add your product, choose which fields to include, and embed the form on any page. Order bumps, upsells, and coupon fields are available. The block-based approach means you can build fairly custom checkout experiences without writing code.

## Customer dashboard

&lt;Picture src={imgDashboard} alt=&quot;SureCart customer dashboard&quot; /&gt;

Customers get a self-service portal where they can view orders, manage subscriptions, download files, and update billing details. The layout is customizable using blocks.

## Pricing (2026)

&lt;Picture src={imgPricing} alt=&quot;SureCart pricing plans&quot; /&gt;

SureCart uses a free tier plus paid plans based on the number of stores:

**Free (Launch)**: $0, but SureCart takes a 1.9% transaction fee on every sale. No product or revenue limits. This works fine for low-volume sellers, and I started on this tier.

**Pro (1 store)**: $179/year (intro pricing, renews at $199/year), or $599 one-time lifetime. No transaction fees.

**Pro (5 stores)**: $249/year (intro, renews at $299/year), or $999 lifetime.

**Pro (Unlimited stores)**: $399/year (intro, renews at $499/year), or $1,699 lifetime.

All plans include the same features. The tier difference is the number of stores and whether you pay a transaction fee. This changed from earlier versions where some features were gated behind paid plans.

| Feature | Free (Launch) | Pro (1 store) | Pro (5 stores) |
|---------|--------------|---------------|----------------|
| Products | Unlimited | Unlimited | Unlimited |
| Transaction fee | 1.9% | None | None |
| Annual price | $0 | $179/yr | $249/yr |
| Lifetime price | N/A | $599 | $999 |
| Payment gateways | Stripe, PayPal, Mollie | Same | Same |
| Subscriptions | Yes | Yes | Yes |
| EU VAT handling | Yes | Yes | Yes |
| Priority support | No | Yes | Yes |

## What&apos;s changed recently

Since I first reviewed SureCart, a few things have improved:

- **Physical product support** with inventory management, shipping zones, and returns
- **Product collections** for organizing your catalog into categories
- **Mollie** added as a payment gateway (popular in Europe)
- **Cart abandonment recovery** with automated follow-up emails
- **Automatic tax calculations** including EU VAT, powered by TaxJar
- **Affiliate program** built into the Pro plan
- **OttoKit integration** replaced SureTriggers for automation workflows

The plugin now has over 100,000 active installations and a 4.8/5 rating on WordPress.org. It&apos;s no longer a niche tool.

## Support

The SureCart Facebook group is active and the team responds. Feature requests do get considered. Several things that were missing when I started using it have since been added. Support is responsive on email, with priority support on paid plans.

## Worth it?

If you&apos;re selling digital products or services and don&apos;t need a full WooCommerce setup, SureCart is worth trying. The free tier costs nothing except the transaction fee, which only matters once you&apos;re actually making sales. The pro lifetime option at $599 for one store is reasonable if you&apos;re committed to the platform long-term.

The main downside is the external script dependency on product pages, which adds page weight you can&apos;t control. If your product pages need to be extremely lean, test that before committing. The other thing to keep in mind is that SureCart&apos;s headless architecture means your store data lives on their servers. If they ever shut down, you&apos;d need to migrate everything.</content:encoded><category>wordpress</category><category>ecommerce</category></item><item><title>How To Upload vCard (VCF) Files to WordPress</title><link>https://www.bitdoze.com/upload-vcard-vcf-files-to-wordpress/</link><guid isPermaLink="true">https://www.bitdoze.com/upload-vcard-vcf-files-to-wordpress/</guid><description>Two methods to enable vCard (.vcf) file uploads in WordPress: using a dedicated plugin or a small code snippet in functions.php.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import imgVcardAllow from &quot;../../assets/images/wordpress/vcard-allow-1024x491.webp&quot;;

WordPress blocks vCard (.vcf) file uploads by default. You&apos;ll get a &quot;Sorry, this file type is not permitted for security reasons&quot; error when trying to upload one through the media library.

There are two ways to fix this.

&lt;Notice type=&quot;warning&quot; title=&quot;Security note&quot;&gt;
vCard files can contain executable code. Only enable uploads from trusted sources, and consider who has access to your WordPress media library before opening this up.
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/8aws2qxX55w&quot;
  label=&quot;How to Upload vCard Files to WordPress&quot;
/&gt;

## Method 1: Plugin

Install the [Enable virtual card upload – vcf,vcard](https://wordpress.org/plugins/enable-virtual-card-upload-vcardvcf/) plugin (free, 5,000+ active installs, tested up to WordPress 6.6 and PHP 8.3). After activating it, go to **Media → Add New** and vCard files will upload without errors. You can upload multiple files at once.

This is the easier option if you don&apos;t want to touch code.

## Method 2: Code snippet

Add this to your `functions.php` file (or use [FluentSnippets](https://wordpress.org/plugins/easy-code-manager/) to manage it without editing theme files directly):

```php
function allow_vcard_upload($mime_types) {
    $mime_types[&apos;vcf&apos;]   = &apos;text/vcard&apos;;
    $mime_types[&apos;vcard&apos;] = &apos;text/vcard&apos;;
    return $mime_types;
}
add_filter(&apos;upload_mimes&apos;, &apos;allow_vcard_upload&apos;);
```

If you&apos;re using FluentSnippets, set the snippet to run in the admin area only because that&apos;s where WordPress checks MIME types during upload.

&lt;Picture src={imgVcardAllow} alt=&quot;FluentSnippets code snippet to allow vCard uploads in WordPress admin&quot; /&gt;

After adding the code, go to **Media → Add New** and upload your .vcf file.

## Displaying the vCard download link

Once uploaded, get the file URL from the media library (click the file, copy the URL from the **File URL** field), then create a download link:

```html
&lt;a href=&quot;https://example.com/wp-content/uploads/2024/01/contact.vcf&quot;&gt;Download contact card&lt;/a&gt;
```

Or as a button:

```html
&lt;button onclick=&quot;window.location.href=&apos;https://example.com/wp-content/uploads/2024/01/contact.vcf&apos;&quot;&gt;Download vCard&lt;/button&gt;
```

In Gutenberg you can also add the file directly using the **File** block, which generates a download button automatically.

## Troubleshooting

If uploads still fail after enabling vCard support, check these:

- **Hosting restrictions**: Some managed WordPress hosts (like WP Engine or Kinsta) override MIME type settings at the server level. Contact their support to allow `text/vcard`.
- **Security plugins**: Plugins like Wordfence or Sucuri may block file types they consider risky. Check their file upload settings.
- **WordPress multisite**: On multisite, the network admin controls allowed file types under **Network Admin → Settings → Upload file types**. Add `vcf` to the list.
- **PHP MIME detection**: WordPress uses PHP&apos;s `finfo_file()` to verify MIME types. If your server&apos;s MIME database doesn&apos;t recognize `.vcf` files, you may need to add `define(&apos;ALLOW_UNFILTERED_UPLOADS&apos;, true);` to `wp-config.php` temporarily (remove it after uploading, as it disables all file type checks).</content:encoded><category>wordpress</category><category>wordpress</category></item><item><title>Fish Shell Syntax Highlighting Guide</title><link>https://www.bitdoze.com/fish-shell-syntax-highlighting/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-syntax-highlighting/</guid><description>How Fish Shell&apos;s built-in syntax highlighting works, how to customize colors for commands, errors, paths, and strings, plus theme management.</description><pubDate>Tue, 24 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

One of the reasons I switched from [Zsh to Fish](/fish-shell-vs-zsh/) was syntax highlighting. In Zsh, you need the [zsh-syntax-highlighting plugin](/enable-syntax-highlighting-zsh/) and some configuration to get commands colored as you type. In Fish, it&apos;s built in and works from the first time you open the shell.

Fish highlights your command line in real time. Valid commands appear in one color, invalid commands in red, strings get their own color, file paths that exist are underlined. You catch typos before hitting Enter. No plugins, no setup.

This guide covers how it works and how to customize the colors.

## What Fish highlights

Fish colors different parts of the command line based on their meaning:

| Element | What it looks like (default) | Variable |
|---|---|---|
| Valid commands | Blue | `fish_color_command` |
| Invalid commands | Red | `fish_color_error` |
| Parameters/arguments | Cyan | `fish_color_param` |
| Options (flags starting with -) | Cyan | `fish_color_option` |
| Quoted strings | Yellow | `fish_color_quote` |
| Redirections (&gt;, &gt;&gt;, \|) | Cyan | `fish_color_redirection` |
| Valid file paths | Underlined | `fish_color_valid_path` |
| Comments (# ...) | Grey | `fish_color_comment` |
| Escape sequences (\n, \t) | Cyan | `fish_color_escape` |
| Autosuggestions | Grey | `fish_color_autosuggestion` |
| Selection (vi visual mode) | White on blue | `fish_color_selection` |
| Search matches | Yellow background | `fish_color_search_match` |

The most useful part: command validation happens as you type. Type `gti` instead of `git` and it immediately turns red. You don&apos;t need to run the command to know something&apos;s wrong.

## Customizing colors

### Using the web interface

The quickest way:

```fish
fish_config
```

This opens a browser-based tool where you can click on colors and see a live preview. Under the &quot;Colors&quot; tab, you can modify each syntax element individually. When you&apos;re happy, click &quot;Set Theme&quot; and the changes apply immediately.

### Using set_color variables

For command-line configuration:

```fish
# Make commands green instead of blue
set -U fish_color_command green

# Make errors bold red
set -U fish_color_error red --bold

# Make strings orange
set -U fish_color_quote bryellow

# Make autosuggestions dimmer
set -U fish_color_autosuggestion 555 --dim

# Underline valid paths with a specific color
set -U fish_color_valid_path --underline cyan
```

Using `set -U` (universal) saves the setting permanently across all Fish sessions. Using `set -g` (global) only lasts for the current session.

### Available colors

Fish supports named colors and hex codes:

**Named colors:** `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, and their bright versions (`brred`, `brgreen`, etc.)

**Hex colors:** Three or six digit hex codes like `f60` or `ff6600`

**Modifiers:** `--bold`, `--dim`, `--italics`, `--underline`, `--reverse`, `--strikethrough`

**Background:** `--background=color` sets the background color

Examples:

```fish
set -U fish_color_command 5fd700           # bright green (hex)
set -U fish_color_error ff0000 --bold      # bold red
set -U fish_color_quote ff8700 --italics   # italic orange
set -U fish_color_comment 6c7086 --dim     # dim grey
```

## Built-in themes

Fish ships with several color themes. List them:

```fish
fish_config theme show
```

Apply one:

```fish
fish_config theme choose dracula
```

Popular built-in themes:
- **default** — the standard Fish colors
- **dracula** — dark theme with purple/pink tones
- **catppuccin-mocha** — pastel dark theme
- **catppuccin-latte** — pastel light theme
- **nord** — cool blue tones
- **solarized-dark** and **solarized-light**
- **tomorrow** and **tomorrow-night**
- **base16-**** — several Base16 variants

Since Fish 4.4, Catppuccin themes are also included.

To save the theme permanently:

```fish
fish_config theme choose dracula
fish_config theme save
```

### Applying themes across all sessions

If you have multiple Fish sessions running and want them all to update:

```fish
# Add this to config.fish first:
function apply-my-theme --on-variable=my_theme
    fish_config theme choose $my_theme
end

# Then set the universal variable from any session:
set -U my_theme dracula
```

All running sessions pick up the change automatically.

## Custom theme from scratch

If you want full control, set every variable explicitly. Here&apos;s an example minimal dark theme:

```fish
# ~/.config/fish/conf.d/my-theme.fish
set -U fish_color_normal normal
set -U fish_color_command 5fd700          # green
set -U fish_color_keyword 5fd700
set -U fish_color_quote ff8700            # orange
set -U fish_color_redirection 87d7ff      # light blue
set -U fish_color_end 87d7ff
set -U fish_color_error ff5f5f --bold     # bold red
set -U fish_color_param 87d7ff
set -U fish_color_option 87d7ff
set -U fish_color_comment 6c7086          # grey
set -U fish_color_selection --background=3a3a5c
set -U fish_color_operator ff87d7         # pink
set -U fish_color_escape ff87d7
set -U fish_color_autosuggestion 555555
set -U fish_color_valid_path --underline
set -U fish_color_cwd 5fd7ff
set -U fish_color_cwd_root red
set -U fish_color_user 87d7ff
set -U fish_color_host normal
set -U fish_color_host_remote ff8700
set -U fish_color_status red
set -U fish_color_cancel -r
set -U fish_color_search_match --background=555555
```

## Pager (completion menu) colors

The tab completion pager has its own set of colors:

```fish
set -U fish_pager_color_completion normal           # completion text
set -U fish_pager_color_description grey             # description text
set -U fish_pager_color_prefix cyan --underline      # matched prefix
set -U fish_pager_color_progress white --background=cyan  # progress bar
set -U fish_pager_color_selected_background --background=3a3a5c
```

These are separate from the command-line syntax colors. The pager colors control what you see when you press Tab and browse completions.

## How syntax highlighting differs from Zsh and Bash

**Bash** has no syntax highlighting at all. What you type is plain text until you run it.

**Zsh** needs the [zsh-syntax-highlighting](/enable-syntax-highlighting-zsh/) plugin. It works well once installed, but it&apos;s an external dependency you have to manage. Zsh-syntax-highlighting supports custom highlighter patterns, which Fish doesn&apos;t need because its built-in system covers more ground.

**Fish** does it natively. The highlighting is part of the shell itself, runs on every keystroke, and validates commands against your PATH in real time. There&apos;s nothing to install and nothing that can fall out of date.

This is one of the core arguments for [Fish over Zsh](/fish-shell-vs-zsh/) — features like this work out of the box.

## Troubleshooting

**Colors look wrong** — your terminal may not support 256 colors or true color. Check with:

```fish
set_color ff8700; echo &quot;This should be orange&quot;; set_color normal
```

If it&apos;s not orange, your terminal needs a different color mode setting. Most modern terminals (Ghostty, iTerm2, WezTerm, Alacritty) support true color by default.

**Colors reset after restart** — you&apos;re probably using `set -g` instead of `set -U`. Global variables don&apos;t persist. Use universal (`-U`) for permanent color settings.

**Theme not applying to all sessions** — `fish_config theme save` only saves to new sessions. Use the `--on-variable` pattern described above to update running sessions.

## Related guides

- [Fish Shell themes and prompts](/fish-shell-themes-prompts/) — Tide, Starship, Pure prompt themes
- [Fish Shell autocomplete guide](/fish-shell-autocomplete-suggestions/) — pager colors affect completions
- [Fish Shell vs Zsh](/fish-shell-vs-zsh/) — built-in highlighting vs zsh-syntax-highlighting plugin
- [Enable syntax highlighting in Zsh](/enable-syntax-highlighting-zsh/) — if you also use Zsh
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — getting started
- [Fish Shell on macOS](/fish-shell-macos-setup/) — Mac setup guide</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell Themes - Best Prompts (Tide, Starship, Pure)</title><link>https://www.bitdoze.com/fish-shell-themes-prompts/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-themes-prompts/</guid><description>A comparison of Fish Shell prompt themes including Tide, Starship, Pure, and Hydro. How to install each one and pick the right prompt for your workflow.</description><pubDate>Tue, 24 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

Fish comes with a usable default prompt, but most people swap it out for something with more information — git branch, command duration, language versions, that kind of thing. I&apos;ve tried most of the popular options and settled on a favorite, but the right choice depends on what you value.

This guide covers the four main prompt options for Fish, including built-in themes, and how to install each one.

## Built-in Fish themes

Before you install anything, Fish has its own theme system. Run:

```fish
fish_config theme show
```

This prints available color themes directly in your terminal. To apply one:

```fish
fish_config theme choose dracula
fish_config theme save
```

For prompts specifically:

```fish
fish_config prompt show
fish_config prompt choose informative
fish_config prompt save
```

Fish ships with about a dozen prompt styles: `default`, `informative`, `classic`, `disco`, `simple`, and others. These are light on features (no async rendering, no language detection), but they load instantly and need no external dependencies.

You can also run `fish_config` without arguments to open a browser-based configuration tool where you can preview everything visually.

## Tide

[Tide](https://github.com/IlanCosman/tide) is the most feature-rich Fish-native prompt. It&apos;s what Powerlevel10k is to Zsh — async rendering, a configuration wizard, and deep git integration.

### Install Tide

You need [Fisher](/best-fish-shell-plugins/) first:

```fish
fisher install IlanCosman/tide@v6
```

Then run the configuration wizard:

```fish
tide configure
```

The wizard walks you through:
- Powerline style vs. plain text
- One-line or two-line prompt
- Icon style (Nerd Font required for icons)
- Colors and spacing

It takes about 30 seconds and gives you a prompt that looks polished without manual editing.

### What Tide shows

By default, Tide displays:
- Current directory (smart truncation to the shortest unique prefix)
- Git branch and status (untracked, modified, staged, ahead/behind)
- Command duration (for slow commands)
- Language versions (Node, Python, Rust, Go, etc.) when you&apos;re in a relevant project
- Time, jobs, and exit status on the right prompt

### Why Tide stands out

**Async rendering.** Tide runs git status and other slow operations in the background. Your prompt appears instantly, and the git info fills in a moment later. On large repos, this makes a real difference.

**Smart directory truncation.** Instead of showing `~/D/w/p/myapp`, Tide truncates to the shortest unique prefix: `~/Doc/w/p/myapp` (because `D` could be Downloads or Documents). Tab-completing the truncated path restores the full name.

**Nerd Font recommended.** Install MesloLGS NF for the best icon support. Without a Nerd Font, Tide falls back to text-only mode, which still looks fine.

### Customizing Tide

After the wizard, tweak individual settings:

```fish
# Change which items appear on the left and right
set --universal tide_left_prompt_items pwd git newline character
set --universal tide_right_prompt_items status cmd_duration context jobs node

# Change git status symbols
set --universal tide_git_icon &quot;&quot;
```

Run `set --universal | grep tide` to see all Tide variables.

## Starship

[Starship](https://starship.rs) is a cross-shell prompt written in Rust. It works with Fish, Zsh, Bash, PowerShell, and more. If you use multiple shells or want to share a prompt config with teammates on different shells, Starship is the practical choice.

### Install Starship

```bash
curl -sS https://starship.rs/install.sh | sh
```

Add to your Fish config:

```fish
# ~/.config/fish/config.fish
starship init fish | source
```

I have a dedicated guide on [setting up Starship with Fish Shell](/fish-shell-starship-prompt/) with configuration examples and preset selection. If you&apos;ve already set it up with Zsh (maybe following my [Starship and Ghostty guide](/starship-ghostty-terminal/)), the TOML config file carries over.

### How Starship compares to Tide

Starship doesn&apos;t have async rendering, but it&apos;s fast enough that it rarely matters on normal-sized repos. The configuration is a single TOML file (`~/.config/starship.toml`) instead of universal variables, which some people find easier to version-control.

Starship also has presets: Tokyo Night, Catppuccin, Nerd Font Symbols, and others. Apply one with:

```bash
starship preset tokyo-night -o ~/.config/starship.toml
```

## Pure

[Pure](https://github.com/pure-fish/pure) is a port of the popular Zsh Pure prompt. It&apos;s minimal — just your directory, git branch, and a `❯` character that turns red after a failed command.

### Install Pure

```fish
fisher install pure-fish/pure
```

No configuration wizard. It works immediately with sensible defaults.

### What Pure shows

- Current directory
- Git branch and dirty/clean status
- Command duration (for long commands)
- Username and hostname over SSH
- Python virtualenv name when active
- The `❯` prompt character

### When to pick Pure

If you like a clean, quiet prompt that stays out of the way. Pure doesn&apos;t show language versions or right-side information unless you enable them. It&apos;s for people who want less on screen, not more.

### Customizing Pure

Pure uses Fish variables for configuration:

```fish
set --universal pure_show_system_time true
set --universal pure_enable_single_line_prompt true
set --universal pure_show_jobs true
```

## Hydro

[Hydro](https://github.com/jorgebucaran/hydro) is even more minimal than Pure. It&apos;s made by Fisher&apos;s author and shows git branch, command duration, and exit status. That&apos;s about it.

### Install Hydro

```fish
fisher install jorgebucaran/hydro
```

Hydro&apos;s appeal is zero configuration and near-zero overhead. If you just want a git branch in your prompt without thinking about it, Hydro does that.

## Comparison table

| | Tide | Starship | Pure | Hydro | Built-in |
|---|---|---|---|---|---|
| Async rendering | Yes | No | Partial | No | No |
| Config wizard | Yes | No | No | No | Web UI |
| Cross-shell | No (Fish only) | Yes | No (Fish only) | No (Fish only) | No |
| Config format | Universal vars | TOML file | Universal vars | Minimal | Web UI / vars |
| Nerd Font needed | Recommended | Recommended | No | No | No |
| Language versions | Yes | Yes | No | No | No |
| Git integration | Deep | Good | Basic | Basic | None |
| Setup time | 1 minute | 2-3 minutes | 30 seconds | 10 seconds | 30 seconds |

## My recommendation

**Tide** if Fish is your only shell and you want the best-looking, most informative prompt with zero lag. The wizard makes setup easy.

**Starship** if you use multiple shells or want a TOML config file you can version-control. I covered this in depth in my [Starship + Fish guide](/fish-shell-starship-prompt/).

**Pure** if you prefer minimalism and a proven design (it&apos;s been popular in the Zsh world for years).

**Hydro** if you want the absolute minimum viable prompt.

**Built-in** if you don&apos;t want to install anything extra and the default prompts are good enough.

I personally use Starship because I switch between Fish and Zsh on different machines. If I only used Fish, I&apos;d probably pick Tide.

## Related guides

- [Set up Starship with Fish Shell](/fish-shell-starship-prompt/) — full Starship configuration
- [Best Fish Shell plugins](/best-fish-shell-plugins/) — Tide, Fisher, and other tools
- [Oh My Fish themes and plugins](/oh-my-fish-install-themes-plugins/) — OMF-specific themes
- [Fish Shell syntax highlighting](/fish-shell-syntax-highlighting/) — customize colors beyond the prompt
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — getting started
- [Fish Shell on macOS](/fish-shell-macos-setup/) — Mac-specific setup</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell vs Bash vs Zsh - Complete Comparison 2026</title><link>https://www.bitdoze.com/fish-shell-vs-bash-vs-zsh/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-vs-bash-vs-zsh/</guid><description>An honest comparison of Fish, Bash, and Zsh covering syntax, performance, plugins, scripting, and day-to-day usability so you can pick the right shell.</description><pubDate>Tue, 24 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;

I&apos;ve used all three of these shells for real work. Bash was my default for years because it was just there. I switched to Zsh when Oh My Zsh blew up, and I stuck with it for a long time. Then I tried Fish on a whim, and it became my daily driver. Each shell has a personality, and the &quot;right&quot; one depends on what you care about.

This isn&apos;t a theoretical comparison. I&apos;ll walk through what actually matters when you sit down and type commands every day.

## Quick overview

| Feature | Fish | Bash | Zsh |
|---|---|---|---|
| Autosuggestions | Built in | No (needs plugin) | Plugin (zsh-autosuggestions) |
| Syntax highlighting | Built in | No | Plugin (zsh-syntax-highlighting) |
| Tab completions | Rich, out of the box | Basic | Good with plugins |
| POSIX compliant | No | Yes | Mostly yes |
| Scripting syntax | Its own | POSIX sh | Mostly POSIX + extensions |
| Config file | `~/.config/fish/config.fish` | `~/.bashrc` | `~/.zshrc` |
| Default on Linux | No | Usually yes | No (default on macOS) |
| Written in | Rust (since 4.0) | C | C |
| Web-based config | Yes (`fish_config`) | No | No |
| Plugin managers | Fisher, Oh My Fish | None built in | Oh My Zsh, Antigen, etc. |

## Autosuggestions and completions

This is where Fish pulls ahead immediately. Open a fresh Fish install, start typing, and you get grayed-out suggestions from your command history. Press the right arrow to accept. No config, no plugins, nothing to set up. I wrote a full guide on [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) that covers everything this system can do.

Bash has nothing like this out of the box. You can install `ble.sh` or use `fzf` for fuzzy history search, but it takes effort.

Zsh gets there with `zsh-autosuggestions`, but you have to install it yourself. I&apos;ve written a guide on [how to enable command autocomplete in Zsh](/enable-command-autocomplete-in-zsh/) if you go that route.

Fish also parses man pages to generate completions automatically. Type `git` and hit tab, and you get completions for subcommands, flags, and branch names without any extra configuration. Zsh can match this with the right setup (compinit plus plugins), but it takes work. Bash&apos;s tab completion is bare-bones by comparison.

## Syntax highlighting

Fish highlights your commands as you type. Valid commands are one color, invalid ones are red, strings get their own color. You can spot typos before you press enter. I have a detailed guide on [Fish Shell syntax highlighting](/fish-shell-syntax-highlighting/) if you want to see how it works under the hood.

Zsh needs `zsh-syntax-highlighting` for the same behavior. I wrote about [enabling syntax highlighting in Zsh](/enable-syntax-highlighting-zsh/) if you want that.

Bash has no built-in syntax highlighting at all.

## Scripting and POSIX compatibility

Here&apos;s the trade-off. Fish has its own scripting syntax that&apos;s intentionally not POSIX-compliant. The Fish developers made this choice to get a cleaner language, and honestly the syntax is easier to read:

```fish
# Fish - cleaner syntax
if test -f ~/.config/fish/local.fish
    source ~/.config/fish/local.fish
end

for file in *.txt
    echo $file
end

# Variables
set greeting &quot;hello&quot;
echo $greeting
```

Compare that to Bash:

```bash
# Bash - POSIX style
if [ -f ~/.bashrc_local ]; then
    source ~/.bashrc_local
fi

for file in *.txt; do
    echo &quot;$file&quot;
done

# Variables
greeting=&quot;hello&quot;
echo &quot;$greeting&quot;
```

The Fish version is more readable. But here&apos;s the catch: you can&apos;t copy-paste Bash one-liners from Stack Overflow and expect them to work in Fish. Anything that uses `&amp;&amp;` between commands, `$(...)` for command substitution (Fish uses `(...)` without the dollar sign), or Bash-specific `[[ ]]` tests won&apos;t work directly.

In practice, this matters less than you&apos;d think. Most of the time you&apos;re running commands, not writing shell scripts. And when you do need a shell script, you can always put `#!/bin/bash` at the top and run it as a Bash script from within Fish. For Fish-specific custom commands, check out my guide on [Fish Shell functions](/fish-shell-functions-custom-commands/).

&lt;Notice type=&quot;info&quot; title=&quot;POSIX compatibility tip&quot;&gt;
You can run any Bash script from Fish. Just use `bash script.sh` or make the script executable with a `#!/bin/bash` shebang. Fish&apos;s non-POSIX syntax only affects what you type interactively and Fish-specific scripts.
&lt;/Notice&gt;

## Configuration

Fish stores its config in `~/.config/fish/config.fish` and supports a `conf.d/` directory for modular config files. It also has a web-based configuration tool, run `fish_config` and a browser opens where you can change colors, view functions, and set variables. It sounds gimmicky, but it&apos;s genuinely useful for quickly previewing color themes.

Bash uses `~/.bashrc` (and sometimes `~/.bash_profile`). Everything is manual editing.

Zsh uses `~/.zshrc`. With Oh My Zsh installed, you get a framework that manages plugins and themes, but the config file can get long. I covered some of the [best Oh My Zsh plugins](/best-oh-my-zsh-plugins/) if you&apos;re on team Zsh.

## Performance

Fish 4.0 was rewritten from C++ to Rust, released in February 2025. The latest version is 4.5.0 (February 2026). Startup feels instant on my machines. It&apos;s slightly heavier than a bare Bash shell, but the difference is measured in milliseconds and you won&apos;t notice it.

Zsh with Oh My Zsh loaded and several plugins can be noticeably slow to start, sometimes taking over a second. There are workarounds (lazy loading, lighter plugin managers like zinit), but it&apos;s something you have to actively manage.

Plain Bash starts the fastest because it&apos;s the most minimal. But you also get the fewest features out of the box.

For interactive use, Fish feels the snappiest because autosuggestions and completions work instantly. The &quot;performance&quot; that matters most for a shell is how fast it responds while you&apos;re typing, and Fish wins here.

## Plugin ecosystems

Fish has two main plugin managers:

- **[Fisher](https://github.com/jorgebucaran/fisher)** - lightweight, fast, zero config. This is what most people use now. Run `fisher install author/plugin` and you&apos;re done.
- **[Oh My Fish](https://github.com/oh-my-fish/oh-my-fish)** - a framework similar to Oh My Zsh. It has been unmaintained for a while and the GitHub page carries a warning about that. I&apos;d steer toward Fisher for new setups, but if you&apos;re curious, I have a guide on [installing and using Oh My Fish](/oh-my-fish-install-themes-plugins/).

I go into much more detail on this in [Best Fish Shell plugins and tools](/best-fish-shell-plugins/).

Zsh has the largest plugin ecosystem thanks to Oh My Zsh, which has 300+ plugins and 150+ themes. Antigen, zinit, and zplug are alternative plugin managers with different performance profiles.

Bash doesn&apos;t have a real plugin ecosystem. There are dotfile frameworks like bash-it, but adoption is much smaller.

## Learning curve

Bash: you already know it (probably). It&apos;s everywhere, and most Linux tutorials assume you&apos;re using it.

Zsh: almost no learning curve coming from Bash. The syntax is nearly identical, and Oh My Zsh provides a guided experience. It&apos;s essentially Bash with extras.

Fish: there&apos;s a small adjustment period. The syntax differences trip you up for the first day or two, especially around variable assignment (`set` instead of `=`) and command substitution. After that, the built-in features mean you actually have less to learn overall because you don&apos;t need to configure as much.

## When to use which

**Pick Fish if** you want a shell that works well out of the box, you don&apos;t write POSIX shell scripts often, and you value autosuggestions and syntax highlighting without configuration. If this sounds like you, check out [how to install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) to get started.

**Pick Zsh if** you want the best of both worlds: Bash compatibility with modern features via plugins. You&apos;re comfortable configuring things. Oh My Zsh gives you a big community and tons of themes.

**Pick Bash if** you write shell scripts that need to run everywhere, you work on servers where Bash is the only shell available, or you prefer maximum POSIX compatibility.

## My take

I switched to Fish about a year ago and haven&apos;t looked back. The out-of-the-box experience is just better than anything I managed to build with Zsh plugins. Autosuggestions, syntax highlighting, man page completions, and the clean scripting syntax make day-to-day work faster.

The POSIX incompatibility bothered me for about a week. I learned to use `bash -c &apos;command&apos;` for the occasional one-liner, and I keep my automation scripts as Bash scripts. It&apos;s a non-issue.

If you want a deeper comparison between just Fish and Zsh, I wrote a focused article on [Fish Shell vs Zsh](/fish-shell-vs-zsh/). And if you want to customize your Fish prompt, take a look at [how to set up Starship with Fish Shell](/fish-shell-starship-prompt/) or my [Fish Shell themes and prompts comparison](/fish-shell-themes-prompts/) covering Tide, Starship, Pure, and Hydro.

## Related Fish Shell guides

- [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) - master Fish&apos;s completion system
- [Fish Shell syntax highlighting](/fish-shell-syntax-highlighting/) - how highlighting works in Fish
- [Fish Shell functions and custom commands](/fish-shell-functions-custom-commands/) - write your own Fish functions
- [Fish Shell history and persistence](/fish-shell-history-persistence/) - manage and persist your command history
- [Fish Shell themes and prompts](/fish-shell-themes-prompts/) - compare Tide, Starship, Pure, and Hydro
- [Oh My Fish guide](/oh-my-fish-install-themes-plugins/) - install themes and plugins with OMF
- [NVM with Fish Shell](/nvm-fish-shell/) - manage Node.js versions in Fish
- [Fish Shell macOS setup](/fish-shell-macos-setup/) - get Fish running on Mac</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>How to Install Fish Shell on Ubuntu (Latest Version 4)</title><link>https://www.bitdoze.com/install-fish-shell-ubuntu/</link><guid isPermaLink="true">https://www.bitdoze.com/install-fish-shell-ubuntu/</guid><description>Step-by-step guide to install the latest Fish Shell 4.x on Ubuntu from the official PPA, with initial configuration and tips for getting started.</description><pubDate>Tue, 24 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Ubuntu ships with Bash by default, and the Fish version in Ubuntu&apos;s repositories is usually old. At the time of writing, the latest Fish is **4.5.0** (released February 17, 2026) while Ubuntu&apos;s repos may still carry a 3.x version. The official Fish PPA gives you the current release.

Here&apos;s how to get Fish 4.x installed and running on Ubuntu. If you&apos;re on a Mac, see my [Fish Shell macOS setup guide](/fish-shell-macos-setup/) instead.

## Install Fish from the official PPA

The Fish team maintains a PPA that tracks the latest stable release. Three commands and you&apos;re done:

```bash
sudo apt-add-repository ppa:fish-shell/release-4
sudo apt update
sudo apt install fish
```

Verify it installed correctly:

```bash
fish --version
```

You should see something like `fish, version 4.5.0`.

&lt;Notice type=&quot;info&quot; title=&quot;Ubuntu version support&quot;&gt;
The Fish PPA supports Ubuntu 22.04, 23.04, 23.10, 24.04, and newer. If you&apos;re on an older Ubuntu release, you may need to build from source or use the standalone binary from the GitHub releases page.
&lt;/Notice&gt;

## Try Fish without switching your default shell

You don&apos;t have to commit right away. Just type:

```bash
fish
```

This drops you into a Fish session. You get [autosuggestions](/fish-shell-autocomplete-suggestions/), [syntax highlighting](/fish-shell-syntax-highlighting/), and tab completions immediately. Play around. Type a few commands. If you don&apos;t like it, type `exit` and you&apos;re back in Bash.

## Set Fish as your default shell

Once you&apos;re ready to commit:

```bash
which fish
```

This should return `/usr/bin/fish`. Now set it as your login shell:

```bash
chsh -s /usr/bin/fish
```

Log out and back in (or open a new terminal). Fish is now your default.

To switch back to Bash later:

```bash
chsh -s /usr/bin/bash
```

&lt;Notice type=&quot;warning&quot; title=&quot;Keep Bash installed&quot;&gt;
Don&apos;t remove Bash. Many system scripts depend on it, and you&apos;ll want it around for running POSIX shell scripts. Fish replaces your interactive shell, not your system&apos;s script interpreter.
&lt;/Notice&gt;

## Initial configuration

Fish&apos;s config file lives at `~/.config/fish/config.fish`. If it doesn&apos;t exist, Fish creates it on first run. Here&apos;s a basic starting config:

```fish
# ~/.config/fish/config.fish

# Add custom paths
fish_add_path ~/bin
fish_add_path ~/.local/bin

# Disable the greeting message
set -g fish_greeting

# Set your preferred editor
set -gx EDITOR vim
```

A few things to note:

- `fish_add_path` is the Fish way to add directories to your `$PATH`. Don&apos;t try to use `export PATH=...` like in Bash.
- `set -g` sets a global variable for the current session. `set -gx` also exports it to child processes.
- `set -g fish_greeting` with no value suppresses the default &quot;Welcome to fish&quot; message.

### Modular configuration with conf.d

Fish sources every `.fish` file in `~/.config/fish/conf.d/` at startup. This is cleaner than dumping everything into one file. For example, you could create:

```fish
# ~/.config/fish/conf.d/aliases.fish
abbr -a gst git status
abbr -a gco git checkout
abbr -a gp git push
```

I cover abbreviations vs aliases in detail in [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/). Short version: Fish abbreviations expand into the full command before executing, which is better than aliases in most cases.

## Install a plugin manager

Fish works fine without plugins, but a plugin manager lets you add things like better git integration, fzf search, and [Node version management](/nvm-fish-shell/).

**Fisher** is the most popular choice:

```fish
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher
```

Now you can install plugins with `fisher install`. For example:

```fish
fisher install PatrickF1/fzf.fish
fisher install jorgebucaran/nvm.fish
```

I have a full guide on [the best Fish Shell plugins and tools](/best-fish-shell-plugins/) if you want recommendations.

## Set up your prompt

Fish comes with a decent default prompt, but you have several options for customizing it:

**Option 1: Built-in themes.** Run `fish_config` and a browser window opens where you can pick from several built-in prompt styles and color schemes.

**Option 2: Tide.** This is a popular Fish-native prompt with a configuration wizard. Install it via Fisher:

```fish
fisher install IlanCosman/tide@v6
```

Then run `tide configure` to walk through the setup.

**Option 3: Starship.** A cross-shell prompt written in Rust that works with Fish, Zsh, Bash, and others. I have a dedicated guide on [setting up Starship with Fish Shell](/fish-shell-starship-prompt/). If you&apos;ve used Starship with Zsh before (like in my [Starship and Ghostty setup guide](/starship-ghostty-terminal/)), the Fish setup is almost identical. For a full comparison of all Fish prompt options, see my [Fish Shell themes and prompts guide](/fish-shell-themes-prompts/).

## Useful things to know right away

**Command history search.** Press `Ctrl+R` to search your history. Fish uses glob syntax by default, so `git*commit` matches any history entry containing &quot;git&quot; followed later by &quot;commit&quot;. I cover history management in depth in my [Fish Shell history and persistence guide](/fish-shell-history-persistence/).

**Autosuggestions.** As you type, Fish shows grayed-out suggestions based on your history and available completions. Press the right arrow key to accept, or `Alt+Right` to accept one word at a time.

**Tab completions.** Fish generates completions from man pages automatically. Try typing `git checkout` and pressing tab. You&apos;ll see branch names, flags, and more without installing anything extra.

**Web configuration.** Run `fish_config` to open a browser-based tool for changing your colors, prompt, and viewing defined functions. It&apos;s genuinely useful for exploring what&apos;s available.

## Common issues

&lt;Accordion label=&quot;Fish doesn&apos;t appear in /etc/shells&quot; group=&quot;issues&quot; expanded=&quot;true&quot;&gt;
If `chsh` complains, you may need to add Fish to the allowed shells manually:

```bash
echo /usr/bin/fish | sudo tee -a /etc/shells
chsh -s /usr/bin/fish
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Bash scripts don&apos;t work in Fish&quot; group=&quot;issues&quot;&gt;
That&apos;s expected. Fish has its own syntax. Run Bash scripts with `bash script.sh` or add `#!/bin/bash` at the top and make them executable. Your existing scripts don&apos;t need to change, just run them explicitly with Bash.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Environment variables from .bashrc are missing&quot; group=&quot;issues&quot;&gt;
Fish doesn&apos;t read `.bashrc` or `.bash_profile`. You need to set your environment variables in `~/.config/fish/config.fish` using `set -gx`:

```fish
set -gx JAVA_HOME /usr/lib/jvm/java-21
set -gx GOPATH ~/go
fish_add_path $GOPATH/bin
```
&lt;/Accordion&gt;

## Where to go from here

- [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) - see how Fish compares to the other popular shells
- [Fish Shell vs Zsh](/fish-shell-vs-zsh/) - a focused comparison if you&apos;re choosing between these two
- [Best Fish Shell plugins and tools](/best-fish-shell-plugins/) - Fisher, Tide, fzf.fish, and more
- [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/) - why abbreviations are usually the better choice
- [Set up Starship prompt with Fish Shell](/fish-shell-starship-prompt/) - cross-shell prompt that looks great
- [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) - get the most out of Fish&apos;s completion system
- [Fish Shell syntax highlighting](/fish-shell-syntax-highlighting/) - understand how Fish highlights your commands
- [Fish Shell functions and custom commands](/fish-shell-functions-custom-commands/) - write your own Fish functions
- [Fish Shell history and persistence](/fish-shell-history-persistence/) - manage and customize your command history
- [Fish Shell themes and prompts](/fish-shell-themes-prompts/) - compare Tide, Starship, Pure, and Hydro
- [NVM with Fish Shell](/nvm-fish-shell/) - manage Node.js versions in Fish
- [Oh My Fish guide](/oh-my-fish-install-themes-plugins/) - alternative plugin framework for Fish
- [Fish Shell macOS setup](/fish-shell-macos-setup/) - install and configure Fish on Mac
- [Zoxide: smarter terminal navigation](/zoxide/) - works great with Fish too</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Oh My Fish (OMF) - Install Themes &amp; Plugins</title><link>https://www.bitdoze.com/oh-my-fish-install-themes-plugins/</link><guid isPermaLink="true">https://www.bitdoze.com/oh-my-fish-install-themes-plugins/</guid><description>How to install and use Oh My Fish framework for Fish Shell, including themes, plugins, and when to consider Fisher as an alternative.</description><pubDate>Tue, 24 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

Oh My Fish (OMF) is a framework for Fish Shell, similar to what Oh My Zsh is for Zsh. It gives you a command-line tool (`omf`) for installing themes and plugins from a curated repository. I used it briefly before switching to Fisher, and I&apos;ll be upfront about why.

&lt;Notice type=&quot;warning&quot; title=&quot;OMF maintenance status&quot;&gt;
Oh My Fish&apos;s GitHub page carries a warning that the project has been unmaintained for years and some packages are broken. It still works for many use cases, but if you&apos;re starting fresh, [Fisher](/best-fish-shell-plugins/) is the more actively maintained option. I&apos;m covering OMF here because it&apos;s still widely referenced in guides and forums.
&lt;/Notice&gt;

## Install Oh My Fish

Make sure you have [Fish Shell installed](/install-fish-shell-ubuntu/) first. Then:

```fish
curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install | fish
```

The installer downloads OMF and sets it up in `~/.local/share/omf/` with config in `~/.config/omf/`.

To verify:

```fish
omf version
```

### Offline installation

If you need to install without internet access (servers, air-gapped environments):

```bash
git clone https://github.com/oh-my-fish/oh-my-fish
cd oh-my-fish
bin/install --offline
```

## OMF basics

All management happens through the `omf` command.

### Install a package or theme

```fish
omf install bobthefish
omf install z
omf install bass
```

### List installed packages

```fish
omf list
```

### Apply a theme

```fish
omf theme bobthefish
```

To see available themes:

```fish
omf theme
```

### Update everything

```fish
omf update
```

### Remove a package

```fish
omf remove z
```

### Uninstall OMF entirely

```fish
omf destroy
```

## Popular OMF themes

These are themes that still work well despite OMF&apos;s maintenance gaps.

### bobthefish

The most popular OMF theme. It&apos;s a Powerline-style prompt showing git status, virtual environments, Node version, and more. Needs a Nerd Font or Powerline font.

```fish
omf install bobthefish
```

Configure it through environment variables:

```fish
set -g theme_display_git yes
set -g theme_display_git_dirty yes
set -g theme_display_docker_machine yes
set -g theme_color_scheme dracula
set -g theme_nerd_fonts yes
```

### agnoster

A port of the Zsh Agnoster theme. Two-line prompt with Powerline characters, git info, and virtualenv support.

```fish
omf install agnoster
```

### clearance

A clean, minimal theme. No special fonts needed.

```fish
omf install clearance
```

### lambda

Minimal theme with a λ prompt character.

```fish
omf install lambda
```

You can preview OMF themes (with screenshots) in the [Oh My Fish themes documentation](https://github.com/oh-my-fish/oh-my-fish/blob/master/docs/Themes.md).

## Useful OMF plugins

### bass

Runs Bash scripts and captures their environment variable changes. Useful for tools that only support Bash configuration:

```fish
omf install bass
bass source ~/.nvm/nvm.sh
```

This is one of the more genuinely useful OMF packages. It bridges the gap between [Fish&apos;s non-POSIX syntax](/fish-shell-vs-bash-vs-zsh/) and Bash-only tools.

### z

Directory jumping similar to [zoxide](/zoxide/). Tracks the directories you visit and lets you jump to them with partial names:

```fish
omf install z
z projects
```

I&apos;d recommend zoxide over this — it&apos;s faster, works across shells, and is actively maintained. But if you want everything through OMF, the z plugin works.

### fish-spec

A testing framework for Fish functions. Useful if you write Fish plugins or complex functions:

```fish
omf install fish-spec
```

### extract

A universal archive extraction tool. `extract file.tar.gz` instead of remembering tar flags:

```fish
omf install extract
```

## OMF configuration files

OMF uses two config files:

**`~/.config/omf/bundle`** — lists installed packages:

```
package bass
package z
theme bobthefish
```

**`~/.config/omf/init.fish`** — runs at startup. Add your OMF-specific configuration here:

```fish
# ~/.config/omf/init.fish
set -g theme_nerd_fonts yes
set -g theme_color_scheme dracula
```

Sharing your `~/.config/omf/` directory across machines lets you replicate your setup with `omf install`.

## OMF vs Fisher

This is the real question. Here&apos;s how they compare:

| | Oh My Fish | Fisher |
|---|---|---|
| Status | Unmaintained | Actively maintained |
| Approach | Framework (has its own init) | Plugin manager only |
| Startup impact | Adds some overhead | Zero overhead |
| Plugin format | OMF-specific packages | Standard Fish plugins |
| Compatibility | OMF packages only | OMF packages + any Fish plugin |
| Config | `omf` commands + config files | `fisher` commands + fish_plugins file |
| Themes | Built-in theme system | Install prompt plugins directly |

Fisher is faster, maintained, and can install OMF-compatible packages. The main reason to use OMF today is if you already have a working OMF setup and don&apos;t want to migrate.

### Migrating from OMF to Fisher

If you want to switch:

1. Note your installed packages: `omf list`
2. Install Fisher:
   ```fish
   curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher
   ```
3. Install equivalents through Fisher. Most OMF packages can be installed directly:
   ```fish
   fisher install oh-my-fish/theme-bobthefish
   ```
4. Uninstall OMF: `omf destroy`

Fisher can install packages from the OMF repository by using the `oh-my-fish/` prefix. Not every package works, but the popular ones do.

## Creating OMF packages

If you want to create your own package or theme:

```fish
omf new plugin my-plugin
omf new theme my-theme
```

This creates a scaffold in `~/.config/omf/pkg/my-plugin/` or `~/.config/omf/themes/my-theme/`.

Plugin structure:

```
my-plugin/
├── completions/
│   └── my-plugin.fish
├── functions/
│   └── my-plugin.fish
├── init.fish
└── uninstall.fish
```

`init.fish` runs when the plugin loads. `uninstall.fish` runs when it&apos;s removed.

## Troubleshooting

**OMF commands not found** — restart your shell after installation, or run `source ~/.config/fish/conf.d/omf.fish`.

**Theme not changing** — some themes need a Nerd Font. Install MesloLGS NF and set it in your terminal.

**Plugin errors after update** — OMF&apos;s unmaintained status means some plugins may break. Check the plugin&apos;s GitHub page for patches, or find a Fisher-compatible alternative.

## Related guides

- [Best Fish Shell plugins (Fisher)](/best-fish-shell-plugins/) — the recommended plugin manager
- [Fish Shell themes and prompts](/fish-shell-themes-prompts/) — Tide, Starship, Pure, Hydro
- [Fish Shell vs Zsh](/fish-shell-vs-zsh/) — if you&apos;re comparing OMF to Oh My Zsh
- [Best Oh My Zsh plugins](/best-oh-my-zsh-plugins/) — the Zsh equivalent
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — getting started
- [Fish Shell on macOS](/fish-shell-macos-setup/) — Mac setup</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>How to Use Environment Variables ARG and ENV in Docker, Dockerfile or Docker Compose</title><link>https://www.bitdoze.com/docker-env-vars/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-env-vars/</guid><description>Learn how to use environments variables ARG and ENV into Docker command, Dockerfile or Docker Compose</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>In Docker, environment variables are a key part of configuring containerized applications. Two commonly used methods for setting them are ARG and ENV. This article covers how to set Docker environment variables using both approaches, so you can manage your container configurations properly.

When setting environment variables in Docker, two approaches are widely used: ARG (build-time) and ENV (runtime). ARG is used during image builds while ENV is used when running containers. With these techniques, developers can pass configuration into their containers without modifying the underlying code. Let&apos;s look at how to use ARG and ENV in Docker.

## What are Docker environment variables?

Docker environment variables are important for configuring containerized applications. They let you define runtime values that processes inside the container can access. Here&apos;s what you need to know:

1. **Definition**: Environment variables are dynamic values that are set outside of an application but can be accessed by it during runtime. In the context of Docker, these variables provide a flexible way to configure containers without modifying their underlying code.

2. **Usage**: Environment variables in Docker can be used for various purposes, such as providing configuration settings, defining connection strings, specifying API keys, or storing sensitive information like passwords.

3. **ARG vs ENV**: There are two types of environment variable instructions in Docker: ARG and ENV.

   - `ARG` (Build-time): ARG allows you to pass build-time arguments when building your image using the `--build-arg` flag with the `docker build` command. These arguments act as placeholders and can only be referenced during the build process.
   - `ENV` (Runtime): ENV sets environment variables that will persist when the container is running. You can specify them directly in your Dockerfile or through command-line options with `-e` when running a container.

4. **Benefits**:

   - Flexibility: Using environment variables makes it easier to customize your application&apos;s behavior without modifying its codebase.
   - Portability: By externalizing configuration details into environment variables, you create reusable images that work across different environments.
   - Security: Sensitive information like credentials or API keys can be securely stored and managed as environment variables rather than hardcoding them into source files.

5. **Accessing Variables**: Inside a running container, accessing these environment variable values depends on the programming language or framework being used by your application.

6. **Best Practices**:
   - Use clear naming conventions for your environment variables to improve readability and maintainability.
   - Avoid hardcoding sensitive information directly in Dockerfiles or source code files.
   - Consider using a secrets management solution to securely manage sensitive data stored as environment variables.

Understanding Docker environment variables is important for configuring and deploying containerized applications. They give you flexibility and portability while keeping sensitive data out of your code.

Some other docker articles that can help you in your docker journey:

- [Add Users to a Docker Container](https://www.bitdoze.com/add-users-to-docker-container/)
- [Copy Multiple Files in One Layer Using a Dockerfile](https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/)
- [Install Docker &amp; Docker-compose for Ubuntu ARM](https://www.bitdoze.com/install-docker-ubuntu-arm/)
- [Redirect Docker Logs to a Single File](https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/)

## How to use ARG and ENV variables in Dockerfiles and docker-compose files

This section covers how to use ARG and ENV variables in your Dockerfiles and docker-compose files. You&apos;ll see examples of how to use them in different scenarios.

### Using ARG variables in Dockerfiles

To use ARG variables in your Dockerfiles, you need to follow these steps:

- Define the ARG variables using the `ARG` instruction, optionally with a default value. You can define multiple ARG variables in your Dockerfile, but they must come before the first `FROM` instruction.
- Use the ARG variables in your Dockerfile instructions, such as `FROM`, `RUN`, `COPY`, or `ADD`. You can use the `$` syntax to reference the ARG variables, such as `$my_arg`.
- Override the default values of the ARG variables using the `--build-arg` option of the `docker build` command. You can specify multiple `--build-arg` options, one for each ARG variable. The format is `--build-arg my_arg=my_value`.

Here is an example of a Dockerfile that uses ARG variables to specify the base image and the version of a library:

```
# Define ARG variables
ARG base_image=ubuntu:20.04
ARG lib_version=1.0.0

# Use ARG variables in FROM instruction
FROM $base_image

# Use ARG variables in RUN instruction
RUN apt-get update &amp;&amp; apt-get install -y libfoo=$lib_version
```

To build this image, you can use the following command:

```bash
docker build -t my_image --build-arg base_image=debian:10 --build-arg lib_version=1.1.0 .
```

This command overrides the default values of the ARG variables and builds the image using `debian:10` as the base image and `libfoo=1.1.0` as the library version.

### Using ENV variables in Dockerfiles

To use ENV variables in your Dockerfiles, you need to follow these steps:

- Define the ENV variables using the `ENV` instruction, optionally with a default value. You can define multiple ENV variables in your Dockerfile, and they can come after the `FROM` instruction.
- Use the ENV variables in your Dockerfile instructions, such as `RUN`, `CMD`, or `ENTRYPOINT`. You can use the `$` syntax to reference the ENV variables, such as `$my_env`.
- Override the default values of the ENV variables using the `-e` option of the `docker run` command or the `environment` or `env_file` options of the `docker-compose` command. You can specify multiple options, one for each ENV variable. The format is `-e my_env=my_value` or `environment: - my_env=my_value` or `env_file: my_env_file`.

Here is an example of a Dockerfile that uses ENV variables to specify the database URL and the API key for an application:

```
# Define ENV variables
ENV db_url=postgres://user:pass@localhost:5432/db
ENV api_key=secret

# Use ENV variables in CMD instruction
CMD [&quot;python&quot;, &quot;app.py&quot;, &quot;$db_url&quot;, &quot;$api_key&quot;]
```

To run this image, you can use the following command:

```bash
docker run -d -p 5000:5000 -e db_url=postgres://user:pass@host:port/db -e api_key=supersecret my_image
```

This command overrides the default values of the ENV variables and runs the container using `postgres://user:pass@host:port/db` as the database URL and `supersecret` as the API key.

Alternatively, you can use a docker-compose file to run this image, such as:

```yaml
services:
  app:
    image: my_image
    ports:
      - &quot;5000:5000&quot;
    environment:
      - db_url=postgres://user:pass@host:port/db
      - api_key=supersecret
```

Or, you can use an env_file to store the ENV variables, such as:

```sh
db_url=postgres://user:pass@host:port/db
api_key=supersecret
```

And then reference the env_file in your docker-compose file, such as:

```yaml
services:
  app:
    image: my_image
    ports:
      - &quot;5000:5000&quot;
    env_file:
      - my_env_file
```

That covers how to use ARG and ENV variables in Dockerfiles and docker-compose files.

&gt; **Note:** Docker Compose V2 no longer requires the `version` field at the top of your compose file. If you&apos;re using `docker compose` (the V2 CLI plugin), you can safely remove it. The `docker-compose` command (V1, with a hyphen) is deprecated in favor of `docker compose`.

## Using ARG and ENV together

When working with Docker, it is common to use both ARG and ENV instructions in combination to set environment variables. Here&apos;s how you can use them together effectively:

1. **Using ARG instruction:** The ARG instruction allows you to pass build-time variables during the image build process. These variables are accessible only during the build stage and not at runtime.

2. **Setting ARG values:** To set an argument value, you can either specify it directly in your Dockerfile or pass it as a command-line parameter using the `--build-arg` flag when running `docker build`. For example:

   ```
   # Set a default value for your argument
   ARG MY_ARG=default_value

   # Use the argument within your Dockerfile
   ENV MY_ENV=$MY_ARG
   ```

3. **Using ENV instruction:** The ENV instruction sets environment variables that will be available in containers based on the image at runtime.

4. **Combining ARG and ENV instructions:** You can leverage both instructions by setting an environment variable using an argument value defined earlier in your Dockerfile.

5. **Example usage:**

   ```
   # Define an argument with a default value
   ARG PORT=8080

   # Set an environment variable using the argument value
   ENV APP_PORT=$PORT

   # Use the environment variable within your container commands/scripts
   CMD [&quot;node&quot;, &quot;app.js&quot;, &quot;--port&quot;, &quot;$APP_PORT&quot;]
   ```

6. **Building images with custom arguments:**

- If no `--build-arg` flag is provided, the default values specified in your Dockerfile will be used.

- To override default values during builds, use `--build-arg` followed by `&lt;ARG_NAME&gt;=&lt;VALUE&gt;` syntax while executing `docker build`.

7. Remember that any changes made to ARG values during the build process won&apos;t affect the environment variables used within the container. The ENV instruction is responsible for setting those runtime variables.

Using both ARG and ENV together lets you set environment variables dynamically at build time while keeping the option to override defaults. This helps keep things consistent across different environments.

## Best Practices for Managing Docker Environment Variables

When managing environment variables in Docker, it&apos;s important to follow best practices to ensure smooth and secure container deployments. Here are some recommendations:

1. **Use ARG for build-time variables**: When you need to pass information during the build process, such as version numbers or credentials, use ARG instead of ENV. ARG values are only available during the build stage and won&apos;t be accessible in the final image.

2. **Prefer ENV for runtime variables**: For configuration settings that need to be available inside your running container, use ENV variables. These can be set at runtime using either command-line flags or a docker-compose file.

3. **Avoid sensitive data in plain text**: Never store sensitive information like passwords or API keys directly in your Dockerfile or source code repositories. Instead, consider using external services like secrets managers or encrypted files mounted as volumes.

4. **Keep environment variable names consistent**: Use meaningful and standardized names for your environment variables across different containers and projects. This will make it easier to understand configurations when working with multiple containers or microservices.

5. **Document required environment variables**: Clearly document which environment variables are required by each container so that other developers can easily understand how to run and configure them properly.

6. **Consider default values**: Provide sensible default values wherever possible for non-mandatory environment variables, reducing friction when deploying containers without explicitly setting every variable.

7. **Use .env files sparingly**: While convenient for local development purposes with docker-compose, avoid relying heavily on .env files in production environments where more robust configuration management solutions should be used.

| Best Practice | Description                           |
| ------------- | ------------------------------------- |
| 1             | Use ARG for build-time vars           |
| 2             | Prefer ENV for runtime vars           |
| 3             | Avoid storing sensitive data directly |
| 4             | Keep env var names consistent         |
| 5             | Document required env vars            |
| 6             | Consider default values               |
| 7             | Use .env files sparingly              |

Following these practices will help you manage Docker environment variables properly and avoid common pitfalls.

## Common Issues with Docker Environment Variables

When working with Docker environment variables, there are a few common issues that you may encounter. It&apos;s important to be aware of these issues and know how to address them:

1. **Missing or Incorrect Variable Names**: Make sure that you are using the correct variable names when defining your environment variables in Docker. Misspelling or using incorrect names can lead to errors and unexpected behavior.

2. **Overwriting Existing Environment Variables**: In some cases, setting an environment variable in Docker may overwrite an existing variable on your host system. This can cause conflicts and lead to undesired results. Always check for any potential clashes before defining new variables.

3. **Ordering Dependencies**: If your application depends on certain environment variables being set before others, it&apos;s crucial to define their order correctly within your Dockerfile or docker-compose.yml file. Otherwise, you may run into initialization issues and errors during runtime.

4. **Handling Sensitive Information**: Be cautious when dealing with sensitive information such as passwords or API keys as environment variables in Docker containers. Storing them directly in plain text is not secure and exposes them to potential risks. Consider using secrets management tools provided by container orchestration platforms like Kubernetes instead.

5. **Variable Scope**: Remember that each container has its own isolated environment scope within a Docker network stack, which means that changes made inside one container will not affect other containers unless explicitly linked or connected together via networking configuration.

6. **Updating Running Containers**: Updating the values of running containers&apos; environment variables might require restarting those containers so that they pick up the new values properly.

Knowing about these issues helps you troubleshoot faster when something goes wrong with your environment variables.

## Summary

Here are the key points about setting Docker environment variables with `ARG` and `ENV`:

- Environment variables configure applications running inside Docker containers.
- Docker has two methods for setting them during image building: `ARG` and `ENV`.
- `ARG` defines build-time variables that you pass with the docker build command.
- ARG values can serve as defaults for ENV instructions or be overridden during builds.
- `ENV` sets environment variables that persist at runtime inside the container.
- Any process running in the container can access these ENV variables.
- You can use ARG and ENV together to create flexible configuration options.

Here is a basic example of how these instructions can be used:

```
# Set an ARG variable
ARG my_variable=default_value

# Use it as a default value when defining an ENV variable
ENV MY_VAR=${my_variable}

# Other instructions...
```

ARG and ENV work well together for configuring containers with dynamic values. ARG handles build-time arguments, ENV handles runtime configuration. Experimenting with different combinations will help you find what works best for your workflow.

## Conclusion

In this article, we covered setting Docker environment variables using `ARG` and `ENV`. Environment variables are central to configuring containers and can store database credentials, API keys, and application settings.

The `ARG` directive defines build-time variables accessible during the image building process but not at runtime. The `ENV` directive sets variables that persist both during build time and when running a container.

Knowing how to set Docker environment variables properly lets you create flexible containers. With `ARG` and `ENV`, you get control over your application&apos;s behavior without baking values into images.

These techniques let you create Docker images that adapt to different environments. Whether you&apos;re customizing configurations or passing sensitive data securely, environment variables make your containers more portable and easier to manage.

Use proper syntax, follow the best practices outlined above, and you&apos;ll have clean, maintainable deployments across any platform.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>How to Install FlowiseAI with Docker Compose</title><link>https://www.bitdoze.com/flowiseai-install/</link><guid isPermaLink="true">https://www.bitdoze.com/flowiseai-install/</guid><description>Learn how you can FlowiseAI with docker compose  and Postgres DB and take advantage of no-code AI flows.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/03/flowise_flow.png&quot;;
import imag3 from &quot;../../assets/images/24/03/flowise-usechat.png&quot;;

[FlowiseAI](https://flowiseai.com/) is an open-source platform (v3.0+) for building and deploying custom AI workflows with a drag-and-drop interface. Recent additions include **Agentflows** for multi-step autonomous agents, **Ollama Cloud integration**, **API key permissions** for access control, and improved security with **input validation** and **MIME type validation**. It&apos;s built on Node.js and React, so it&apos;s straightforward to extend.

In this tutorial, we are going to see how easy it is to host FlowiseAI on your VPS server with Docker Compose and have an SSL certificate. I will include also an option to backup the database with [Docker DB Backup](https://github.com/tiredofit/docker-db-backup) to have the SQL dumps in case something goes wrong and you can&apos;t use the volumes.

We are going to use [Dockge](https://www.bitdoze.com/dockge-install/) to administrate the Docker Compose file and as reverse proxy CloudFlare Tunnels. You can also use Docker Compose directly to deploy as it will work the same on whatever reverse proxy you prefer.


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## How to Install FlowiseAI with Docker and Docker Compose

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ZJvl1_DVy_g&quot;
  label=&quot;How to Install FlowiseAI with Docker Compose&quot;
/&gt;


&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### 1. Prerequisites

Before you begin, make sure you have the following prerequisites in place:

- VPS where you can host FlowiseAI, you can use one from [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) or use a [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/)
- Docker and Dockge installed on your server, you can check the [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/) for the full tutorial.
- CloudFlare Tunnels are configured for your VPS server, the details are in the article here I deployed [Dockge](https://www.bitdoze.com/dockge-install/)
- OR reverse proxy with CloudPanel you can check: [Setup CloudPanel As Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/)

&gt; You can use also Traefik as a reverse proxy for your apps. I have created a full tutorial with Dockge install also to manage your containers on: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)

Having all of this you will be ready to move to next step and add the containers in Dockge.

### 2. Create Docker Compose File

The first step is to create a Docker Compose file that defines the services required to run FlowiseAI. Here&apos;s an example `docker-compose.yml` file:

```yaml
services:
  flowise-db:
    image: postgres:16-alpine
    hostname: flowise-db
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - ./flowise-db-data:/var/lib/postgresql/data
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5

  flowise:
    image: flowiseai/flowise:latest
    container_name: flowiseai
    hostname: flowise
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:${PORT}
    ports:
      - 5023:${PORT}
    volumes:
      - ./flowiseai:/root/.flowise
    environment:
      DEBUG: false
      PORT: ${PORT}
      FLOWISE_USERNAME: ${FLOWISE_USERNAME}
      FLOWISE_PASSWORD: ${FLOWISE_PASSWORD}
      APIKEY_PATH: /root/.flowise
      SECRETKEY_PATH: /root/.flowise
      LOG_LEVEL: info
      LOG_PATH: /root/.flowise/logs
      DATABASE_TYPE: postgres
      DATABASE_PORT: 5432
      DATABASE_HOST: flowise-db
      DATABASE_NAME: ${POSTGRES_DB}
      DATABASE_USER: ${POSTGRES_USER}
      DATABASE_PASSWORD: ${POSTGRES_PASSWORD}
    restart: on-failure:5
    depends_on:
      flowise-db:
        condition: service_healthy
    entrypoint: /bin/sh -c &quot;sleep 3; flowise start&quot;
```

This Compose file defines two services:

1. `flowise-db`: A PostgreSQL database service used by FlowiseAI to store data.
2. `flowise`: The FlowiseAI service itself, which depends on the `flowise-db` service.

The `flowise` service uses the official `flowiseai/flowise:latest` Docker image and exposes port 3000 (configurable via the `PORT` environment variable). It also mounts a volume at `/root/.flowise` to persist data across container restarts.

The Compose file also includes a healthcheck for the `flowise` service, which checks if the FlowiseAI application is running and accessible on `http://localhost:3000`.

If you want to include a backup solution for the PostgreSQL database, you can add a third service to the Compose file:

```yaml
flowise-db-backup:
  container_name: flowise-db-backup
  image: tiredofit/db-backup
  volumes:
    - ./backups:/backup
  environment:
    DB_TYPE: postgres
    DB_HOST: flowise-db
    DB_NAME: ${POSTGRES_DB}
    DB_USER: ${POSTGRES_USER}
    DB_PASS: ${POSTGRES_PASSWORD}
    DB_BACKUP_INTERVAL: 720
    DB_CLEANUP_TIME: 72000
    CHECKSUM: SHA1
    COMPRESSION: GZ
    CONTAINER_ENABLE_MONITORING: false
  depends_on:
    flowise-db:
      condition: service_healthy
  restart: unless-stopped
```

This service uses the `tiredofit/db-backup` image to create regular backups of the PostgreSQL database. The backups are stored in the `./backups` directory on the host machine.

### 3. Create .env file with credentials

Next, create a `.env` file in the same directory as your `docker-compose.yml` file and add the required environment variables:

```sh
PORT=3000
POSTGRES_USER=&apos;user&apos;
POSTGRES_PASSWORD=&apos;pass&apos;
POSTGRES_DB=&apos;flowise&apos;
FLOWISE_USERNAME=bitdoze
FLOWISE_PASSWORD=bitdoze
```

Replace the values with your desired credentials and database name.

### 4. Deploy FlowiseAI

With the Docker Compose file and the `.env` file in place, you can now deploy FlowiseAI using Docker Compose:

```sh
docker compose up -d
```

This command will start the services defined in the Compose file in detached mode (running in the background).

To check if the containers are running, use the following command:

```sh
docker ps
```

You should see the `flowise` and `flowise-db` containers listed as running.

### 5. Configure the CloudFlare Tunnels for SSL and Domain access

To access FlowiseAI securely over the internet, you can set up CloudFlare Tunnels. CloudFlare Tunnels provide a secure way to expose your FlowiseAI instance to the internet without exposing your server&apos;s IP address.

Go in **Access - Tunnels** and choose the tunnel you created and add a hostname that will link a domain or subdomain and the service and port.

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel setup&quot; /&gt;

&gt; You can also check [Setup CloudPanel as Reverse Proxy with Docker and Dokge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy to your Docker containers or [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

### 6. Create your first FlowiseAI flow

Once your FlowiseAI instance is up and running, you can access the web interface by navigating to `http://localhost:3000` (or the domain you configured with CloudFlare Tunnels).

From the FlowiseAI interface, you can start creating your first AI workflow by dragging and dropping nodes, connecting them, and configuring their settings.

&lt;Picture src={imag2} alt=&quot;FlowiseAI add flow&quot; /&gt;

You have also a marketplace with flows that can be used.

### 7. Use the FlowiseAI Chatflow Externally

After you finish designing your flow, you can go and use it externally with few options like Embed, Python, JavaScript, CURL or just share it.

&lt;Picture src={imag3} alt=&quot;Use the FlowiseAI Chatflow Externally&quot; /&gt;

## Conclusions

That covers deploying FlowiseAI v3.0+ with Docker Compose. You&apos;ve set up the Docker Compose file, configured environment variables, and started the instance. You also saw how to use CloudFlare Tunnels for secure access.

FlowiseAI has matured into a solid platform with Agentflows, Ollama Cloud integration, granular API key permissions, and better security defaults. Running it in Docker makes management and scaling easier. The Compose setup also lets you add services like database backups alongside your deployment.

If you want to explore more Docker containers for your home server, check out our guide on [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). For the wider AI tooling map — including n8n, Dify, and Langflow as actively maintained workflow options — see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>self-hosted</category></item><item><title>How to Install LangFlow with Docker Compose and Add SSL Over CloudFlare Tunnels</title><link>https://www.bitdoze.com/langflow-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/langflow-docker-install/</guid><description>Learn how you can install LangFlow with docker compose  and Postgres DB and take advantage of no-code AI flows. Add SSL over CloudFlare tunnels</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/07/langflow-ui.png&quot;;

[LangFlow](https://www.langflow.org/) is an open-source platform for creating, prototyping, and deploying AI workflows visually. Now at version 1.8+, it has added MCP (Model Context Protocol) support, dark mode, built-in knowledge base ingestion and retrieval, and works with the latest models including GPT-5 and Gemini 2.5. The drag-and-drop interface lets you build complex AI pipelines without writing much code, which makes it accessible to both developers and non-technical users.

## Comparison with Flowise AI

Both LangFlow and [Flowise AI](https://flowiseai.com/) aim to simplify AI workflow creation, and both have matured a lot over the past year. You can check [How to Install FlowiseAI with Docker Compose](https://www.bitdoze.com/flowiseai-install/) for more on Flowise.

LangFlow tends to offer more flexibility and customization, with features like MCP support and built-in knowledge base management. It works well for users integrating many different LLMs and tools. Flowise AI has its own improvements and may be a better fit if you want something more streamlined with a gentler learning curve.

Which one to pick depends on your project needs and how much customization you want. Worth trying both to see what fits.


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).


## Setting Up Langflow with Docker Compose

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/mkSJV0UPDH8&quot;
  label=&quot;How to Install LangFlow with Docker Compose&quot;
/&gt;

&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### 1. Prerequisites

Before you begin, make sure you have the following prerequisites in place:

- VPS where you can host LangFlow, you can use one from [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) or use a [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/)
- Docker and Dockge installed on your server, you can check the [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/) for the full tutorial.
- CloudFlare Tunnels are configured for your VPS server, the details are in the article here I deployed [Dockge](https://www.bitdoze.com/dockge-install/)
- OR reverse proxy with CloudPanel you can check: [Setup CloudPanel As Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/)

&gt; You can use also Traefik as a reverse proxy for your apps. I have created a full tutorial with Dockge install also to manage your containers on: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)

### 2. Docker Compose File

```yml
services:
  langflow-db:
    image: postgres:16-alpine
    container_name: Langflow-DB
    hostname: langflow-db
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - ./langflow-db:/var/lib/postgresql/data:rw
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    restart: on-failure:5

  langflow:
    image: langflowai/langflow:latest
    container_name: Langflow
    user: root
    ports:
      - 5060:7860
    healthcheck:
      test: timeout 10s bash -c &apos;:&gt; /dev/tcp/127.0.0.1/7860&apos; || exit 1
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 90s
    restart: on-failure:5
    depends_on:
      - langflow-db
    environment:
      LANGFLOW_DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@langflow-db:5432/${POSTGRES_DB}?sslmode=disable
      LANGFLOW_CONFIG_DIR: /var/lib/langflow
      LANGFLOW_SUPERUSER: ${LANGFLOW_SUPERUSER}
      LANGFLOW_SUPERUSER_PASSWORD: ${LANGFLOW_SUPERUSER_PASSWORD}
      LANGFLOW_AUTO_LOGIN: False
    volumes:
      - ./langflow:/var/lib/langflow:rw
```

Let&apos;s break down this Docker Compose file and explain what each section is doing:

1. **Services**: We define two services - `langflow-db` and `langflow`.

2. **langflow-db service**:

   - Uses the `postgres:16-alpine` image, which is a lightweight PostgreSQL database.
   - Sets up a health check to ensure the database is ready before other services start.
   - Mounts a volume to persist database data.
   - Uses environment variables for database configuration.
   - Restarts on failure, with a maximum of 5 attempts.

3. **langflow service**:
   - Uses the latest LangFlow image.
   - Exposes port 5060 on the host, mapping to port 7860 in the container.
   - Implements a health check to verify the service is running correctly.
   - Depends on the `langflow-db` service, ensuring the database is up before starting.
   - Sets various environment variables for LangFlow configuration.
   - Mounts a volume for persistent storage of LangFlow data.

This configuration allows for a robust and scalable LangFlow setup, with separate containers for the application and its database, health checks to ensure reliability, and persistent storage for both the database and LangFlow data.

### 3 .env file for LangFlow

To keep our sensitive information secure and our configuration flexible, we&apos;ll use a `.env` file to store environment variables. Create a file named `.env` in the same directory as your Docker Compose file with the following content:

```sh
POSTGRES_USER=&apos;user&apos;
POSTGRES_PASSWORD=&apos;pass&apos;
POSTGRES_DB=&apos;langflow&apos;
LANGFLOW_SUPERUSER=bitdoze
LANGFLOW_SUPERUSER_PASSWORD=bitdoze
```

This file sets up the necessary environment variables for our PostgreSQL database and LangFlow superuser. Remember to replace these placeholder values with secure, unique values for your production environment.

### 4. Deploy The Docker Compose File for LangFlow

With our Docker Compose and `.env` files in place, we&apos;re ready to deploy LangFlow. Open a terminal, navigate to the directory containing these files, and run:

```sh
docker compose up -d
```

This command will start our LangFlow setup in detached mode, allowing it to run in the background.

### 5. Implementing SSL with CloudFlare Tunnels

[CloudFlare Tunnels](https://www.cloudflare.com/products/tunnel/) let you connect your web applications to the internet without public IP addresses or open inbound ports. The service creates a secure tunnel between your server and CloudFlare&apos;s edge network.

Here&apos;s how it works:

1. **Outbound Connection**: Your server initiates an outbound connection to CloudFlare&apos;s network using the CloudFlare daemon (cloudflared).
2. **Tunnel Creation**: This connection establishes a secure tunnel between your origin and CloudFlare&apos;s edge.
3. **Traffic Routing**: Incoming requests to your domain are routed through this tunnel to your origin server.
4. **Response Delivery**: Responses from your server are sent back through the tunnel and delivered to the user.

This means you don&apos;t need traditional port forwarding or firewall rules — all traffic goes through the tunnel.

Go in **Access - Tunnels** and choose the tunnel you created and add a hostname that will link a domain or subdomain and the service and port.

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel setup&quot; /&gt;

&gt; You can also check [Setup CloudPanel as Reverse Proxy with Docker and Dokge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy to your Docker containers or [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

### 6. Access the LangFlow UI

After you set the CloudFlare tunnels you can go and access you LangFlow UI and start building your first flow. You can logun with the user and password that you have set in the .env file.

&lt;Picture src={imag2} alt=&quot;LanfFlow UI&quot; /&gt;


That&apos;s it — you&apos;ve set up LangFlow using Docker Compose with a PostgreSQL database and SSL through CloudFlare Tunnels. Version 1.8+ brings MCP support, dark mode, knowledge base ingestion/retrieval, and works with GPT-5 and Gemini 2.5.

LangFlow&apos;s visual interface makes it straightforward to experiment with different AI models, build agentic workflows, and deploy pipelines. The project moves fast, so it&apos;s worth keeping an eye on new releases.

If you want to explore more Docker containers for your home server, check out our guide on [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). Langflow also sits next to n8n, Dify, and the rest of the agent stack in [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>self-hosted</category></item><item><title>Langfuse Docker Install: Self Hosted LangSmith Alternative</title><link>https://www.bitdoze.com/langfuse-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/langfuse-docker-install/</guid><description>Learn how you can install Langfuse with docker compose and Postgres DB and take advantage of the observability software for your AI apps.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;
import imag2 from &quot;../../assets/images/24/07/langfuse-ui.png&quot;;

[Langfuse](https://langfuse.com/) is an open-source LLM engineering platform with tools for observability, metrics, evaluations, prompt management, and dataset handling. It&apos;s a self-hosted alternative to LangSmith. Langfuse is now at v3, with a reworked architecture built for better performance and scale.

**Key features:**

1. **Observability:** Instrument your application and ingest traces to see how your LLM is performing.

2. **Analytics:** Track cost, latency, and quality through dashboards and data exports.

3. **Prompt Management:** Version and deploy prompts directly within Langfuse.

4. **Evaluations:** Score LLM completions using model-based evaluations, user feedback, and manual scoring.

5. **Experimentation:** Test application behavior before deploying, using datasets to benchmark performance.

6. **LLM Playground:** A built-in environment for testing prompts.

7. **Integrations:** Works with LlamaIndex, Langchain, and other popular LLM frameworks.

**What&apos;s new in Langfuse v3:**

- **ClickHouse as OLAP database:** Traces, observations, and scores are now stored in ClickHouse, enabling much faster analytics queries compared to the previous PostgreSQL-only setup.
- **Redis/Valkey for queue and cache:** A Redis instance is now required to handle queue and cache operations, improving ingestion reliability.
- **S3/MinIO for blob storage:** Events and multi-modal traces (images, audio) are persisted in S3-compatible storage like MinIO.
- **Two application containers:** The application is split into `langfuse-web` (serves the UI and APIs) and `langfuse-worker` (processes events asynchronously).
- **Queued trace ingestion:** Traces are now ingested via a queue for better reliability and throughput under load.

For self-hosting, Langfuse v3 requires several backing services (PostgreSQL, ClickHouse, MinIO, and Redis), all managed through a single Docker Compose file.

## Step-by-Step Guide to Installing LangFuse on Docker

&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Gm4HBrK63AM&quot;
  label=&quot;How to Install langfuse with Docker Compose&quot;
/&gt;

&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### 1. Prerequisites

Before you begin, make sure you have the following prerequisites in place:

- VPS where you can host Langfusew, you can use one from [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) or use a [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/)
- Docker and Dockge installed on your server, you can check the [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/) for the full tutorial.
- CloudFlare Tunnels are configured for your VPS server, the details are in the article here I deployed [Dockge](https://www.bitdoze.com/dockge-install/)
- OR reverse proxy with CloudPanel you can check: [Setup CloudPanel As Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/)

&gt; You can use also Traefik as a reverse proxy for your apps. I have created a full tutorial with Dockge install also to manage your containers on: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)

### 2. Langfuse Docker Compose File

```yaml
services:
  langfuse-worker:
    image: langfuse/langfuse-worker:3
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_healthy
      redis:
        condition: service_healthy
      clickhouse:
        condition: service_healthy
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      NEXTAUTH_URL: https://langfuse.yourdomain.com
      SALT: ${SALT}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY}
      TELEMETRY_ENABLED: false
      CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
      CLICKHOUSE_URL: http://clickhouse:8123
      CLICKHOUSE_USER: ${CLICKHOUSE_USER}
      CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
      LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
      LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
      LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${MINIO_USER}
      LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${MINIO_PASSWORD}
      LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000
      LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: true
      LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
      LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
      LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${MINIO_USER}
      LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${MINIO_PASSWORD}
      LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000
      LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: true
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_AUTH: ${REDIS_PASSWORD}

  langfuse-web:
    image: langfuse/langfuse:3
    restart: always
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_healthy
      redis:
        condition: service_healthy
      clickhouse:
        condition: service_healthy
    ports:
      - 5061:3000
    environment:
      DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
      NEXTAUTH_URL: https://langfuse.yourdomain.com
      NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
      SALT: ${SALT}
      ENCRYPTION_KEY: ${ENCRYPTION_KEY}
      TELEMETRY_ENABLED: false
      CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000
      CLICKHOUSE_URL: http://clickhouse:8123
      CLICKHOUSE_USER: ${CLICKHOUSE_USER}
      CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
      LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
      LANGFUSE_S3_EVENT_UPLOAD_REGION: auto
      LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${MINIO_USER}
      LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${MINIO_PASSWORD}
      LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://minio:9000
      LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: true
      LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: langfuse
      LANGFUSE_S3_MEDIA_UPLOAD_REGION: auto
      LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${MINIO_USER}
      LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${MINIO_PASSWORD}
      LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://minio:9000
      LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: true
      REDIS_HOST: redis
      REDIS_PORT: 6379
      REDIS_AUTH: ${REDIS_PASSWORD}
      AUTH_DISABLE_SIGNUP: ${AUTH_DISABLE_SIGNUP}

  postgres:
    image: postgres:17-alpine
    restart: always
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    volumes:
      - ./langfuse-db:/var/lib/postgresql/data:rw
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}

  clickhouse:
    image: clickhouse/clickhouse-server
    restart: always
    user: &quot;101:101&quot;
    environment:
      CLICKHOUSE_DB: default
      CLICKHOUSE_USER: ${CLICKHOUSE_USER}
      CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD}
    volumes:
      - ./langfuse-clickhouse:/var/lib/clickhouse
      - ./langfuse-clickhouse-logs:/var/log/clickhouse-server
    healthcheck:
      test: wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1
      interval: 5s
      timeout: 5s
      retries: 10

  minio:
    image: cgr.dev/chainguard/minio
    restart: always
    entrypoint: sh
    command: -c &apos;mkdir -p /data/langfuse &amp;&amp; minio server --address &quot;:9000&quot; --console-address &quot;:9001&quot; /data&apos;
    environment:
      MINIO_ROOT_USER: ${MINIO_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
    ports:
      - 9090:9000
    volumes:
      - ./langfuse-minio:/data
    healthcheck:
      test: [&quot;CMD&quot;, &quot;mc&quot;, &quot;ready&quot;, &quot;local&quot;]
      interval: 1s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7
    restart: always
    command: &gt;
      --requirepass ${REDIS_PASSWORD}
      --maxmemory-policy noeviction
    healthcheck:
      test: [&quot;CMD&quot;, &quot;redis-cli&quot;, &quot;ping&quot;]
      interval: 3s
      timeout: 10s
      retries: 10
```

This Docker Compose file defines six services that make up the Langfuse v3 architecture:

1. **langfuse-worker**: Processes trace events asynchronously from the queue, handles ClickHouse writes, and manages background jobs.

2. **langfuse-web**: Serves the Langfuse UI and APIs on port 5061. This is the container you expose to users via your reverse proxy.

3. **postgres**: Transactional database (PostgreSQL 17) for storing project configuration, users, prompts, and other relational data.

4. **clickhouse**: OLAP database optimized for fast analytics on traces, observations, and scores. This is what makes v3 dashboards significantly faster.

5. **minio**: S3-compatible blob storage used for persisting ingestion events and storing multi-modal trace data (images, audio).

6. **redis**: Handles cache and queue operations. Used for queued trace ingestion and caching frequently accessed data.

Key points to note:

- The use of environment variables `${VARIABLE_NAME}` allows for easy configuration without modifying the Docker Compose file directly.
- All backing services include healthchecks, and the Langfuse containers wait for dependencies to be healthy before starting.
- The volume mounts for PostgreSQL, ClickHouse, and MinIO ensure that your data persists even if containers are stopped or removed.
- Telemetry is disabled by default.

### 3. .env File for LangFuse

To use the environment variables referenced in the Docker Compose file, you&apos;ll need to create a `.env` file in the same directory. Here&apos;s an example of what it should contain:

```sh
POSTGRES_USER=&apos;user&apos;
POSTGRES_PASSWORD=&apos;pass&apos;
POSTGRES_DB=&apos;langfuse&apos;
NEXTAUTH_SECRET=aOlY0UgIitolkrZUWoWyVRwuo2BpUPKB/t2l2ufbXSw=
SALT=VBQk4V98zZ8L8xwpvI696Ixv88D5QfrciLU4fx/C4VQ=
ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
CLICKHOUSE_USER=clickhouse
CLICKHOUSE_PASSWORD=clickhouse
MINIO_USER=minio
MINIO_PASSWORD=miniosecret
REDIS_PASSWORD=myredissecret
AUTH_DISABLE_SIGNUP=false
```

Important security considerations:

- Replace &apos;user&apos; and &apos;pass&apos; with a strong username and password for your PostgreSQL database.
- The NEXTAUTH_SECRET and SALT values should be unique, randomly generated strings. You can use a tool like [OpenSSL](https://www.openssl.org/) to generate these securely.
- The ENCRYPTION_KEY must be a 64-character hex string. Generate one with `openssl rand -hex 32`.
- Replace the ClickHouse, MinIO, and Redis passwords with strong, unique values.

For example, to generate a secure NEXTAUTH_SECRET, you could use:

```sh
openssl rand -base64 32
```

Remember, never share these secrets or commit them to version control. Treat them with the same level of security as you would any other sensitive credentials.

You have all the variables that can be used in [Langfuse Self-Hosting Guide](https://langfuse.com/docs/deployment/self-host)

### 4. Deploying the Docker Compose File for LangFuse

Once you have your Docker Compose and .env files set up, deploying LangFuse is straightforward. Simply run the following command in the directory containing your Docker Compose file:

```sh
docker compose up -d
```

This command will:

1. Pull the necessary Docker images if they&apos;re not already present on your system.
2. Create and start the containers defined in your Docker Compose file.
3. Run the containers in detached mode (-d), allowing them to run in the background.

After running this command, you should see output indicating that the containers are being created and started. Once complete, you can verify that the containers are running with:

```sh
docker compose ps
```

This will show you the status of your LangFuse and PostgreSQL containers.

### 5. Implementing SSL with CloudFlare Tunnels for Langfuse

[CloudFlare Tunnels](https://www.cloudflare.com/products/tunnel/) let you connect your web applications to the internet without public IP addresses or open inbound ports. The service creates a secure tunnel between your server and CloudFlare&apos;s edge network.

Here&apos;s how it works:

1. **Outbound Connection**: Your server initiates an outbound connection to CloudFlare&apos;s network using the CloudFlare daemon (cloudflared).
2. **Tunnel Creation**: This connection establishes a secure tunnel between your origin and CloudFlare&apos;s edge.
3. **Traffic Routing**: Incoming requests to your domain are routed through this tunnel to your origin server.
4. **Response Delivery**: Responses from your server are sent back through the tunnel and delivered to the user.

This means you don&apos;t need traditional port forwarding or firewall rules — all traffic goes through the tunnel.

Go in **Access - Tunnels** and choose the tunnel you created and add a hostname that will link a domain or subdomain and the service and port.

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel setup&quot; /&gt;

&gt; You can also check [Setup CloudPanel as Reverse Proxy with Docker and Dokge](https://www.bitdoze.com/cloudpanel-setup-dockge/) to use CloudPanel as a reverse proxy to your Docker containers or [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

### 6. Access the Langfuse UI

Now after you set the subdomain in Cloudflare tunnels you can go and access the aplication with the url. First you will be promted to create a username and a password and after you can access the apps.

You can create your first project and start tracking the AI apps you have. You need to create an API key so you can use it with Langfuse, in the video you will find all the details as well as integrating this with Flowise AI.

&lt;Picture src={imag2} alt=&quot;Langfuse UI&quot; /&gt;

### 7. Disable Signups

By default anyone can sign up and create an account, after you create you account you can alter the `.env` and change the `AUTH_DISABLE_SIGNUP=false` to `AUTH_DISABLE_SIGNUP=true`
this will not allow for new accounts to be created via sign up.

You need to restart your container for this change to be activated:

```sh
docker compose pull
docker compose up -d
```

## Conclusion

Langfuse v3 uses ClickHouse for faster analytics, Redis for queued ingestion, and MinIO for blob storage. Self-hosting with Docker Compose gives you full control over your data with the same performance as the managed cloud version.

The six-service stack in this guide gives you a production-ready LLM observability platform that handles high trace volumes with faster dashboard queries than previous versions.

If you want to explore more Docker containers for your home server, including other AI tools, check out our guide on [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). Langfuse is also listed with LiteLLM, OmniRoute, and the agent stack in [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>self-hosted</category></item><item><title>How to Setup Ollama with Open-Webui using Docker Compose</title><link>https://www.bitdoze.com/ollama-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/ollama-docker-install/</guid><description>Learn how to Setup Ollama with Open-WebUI using Docker Compose and have your own local AI</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/08/openwebui-pull-model.png&quot;;
import imag2 from &quot;../../assets/images/24/08/openweb-ui-use.png&quot;;

Ollama is one of the most popular tools for running AI models locally. It makes deploying and interacting with large language models (LLMs) on your own hardware straightforward.
Paired with Open-WebUI, you get a full local AI setup that works well enough to replace cloud-based options for many use cases. Here&apos;s what these tools are and how to set them up.

## What is Ollama?

[Ollama](https://ollama.com/) is an open-source project (now at v0.17+) that makes running large language models simple. It supports hundreds of models and runs them locally on your machine with a lightweight interface.

- **Local Execution**: Run AI models on your own hardware, keeping your data private.
- **Easy Installation**: One-line install to get started.
- **Model Management**: Download, run, and manage different LLMs without complex setup.
- **API Integration**: RESTful API for connecting with other applications.
- **Cross-Platform Support**: Available for macOS, Linux, and Windows.
- **Resource Efficiency**: Optimized for consumer-grade hardware.

Running models locally means your data stays on your machine. It also cuts latency, which matters for applications that need fast responses or handle sensitive information.

## What is Open-Webui

While Ollama works fine from the command line, most people prefer a visual interface. That&apos;s where [Open-Webui](https://openwebui.com/) comes in.

Open-Webui (now at v0.8+) is a web-based interface that works with multiple AI providers. You interact with AI models through your browser. Besides Ollama, it connects to Anthropic, OpenAI, and other compatible APIs, and you can enable or disable individual connections.

Key features of Open-Webui:

- **Multi-Provider Support**: Connect to Ollama, OpenAI, Anthropic, and other compatible APIs from a single interface.
- **Model Selection**: Easily switch between different LLMs available through your connected providers.
- **Chat Interface**: Engage in conversations with AI models in a familiar chat-like environment.
- **Voice Dictation**: Use voice input to interact with models hands-free.
- **Memory Management**: Built-in memory for agents to retain context across conversations.
- **OAuth &amp; Authentication**: Secure access with OAuth support and user management.
- **Prompt Templates**: Save and reuse common prompts to streamline interactions.
- **History Management**: Keep track of past conversations and easily reference or continue them.
- **Export Options**: Save conversations or generated content in various formats for further use or analysis.

## How to Set up Ollama and openWebUI with Docker Compose


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/FHTYrMtLkmQ&quot;
  label=&quot;How to Setup Ollama with Open-Webui using Docker Compose&quot;
/&gt;

In this section we are going to see how we are going to set up Ollama and Open-Webui.

### 1. Prerequizites

Before you begin, make sure you have the following prerequisites in place:

- VPS where you can host Ollama, you can use one from [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) You can use a VPS to have ollama installed but performances will not be that good. In our test we are using a 8 CPUs 16 GB RAM and is bearly moving. Best will be to have a GPU powered system or use a [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/)
- Traefic with Docker set up, you can check: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/) or [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/)
- Docker and Dockge installed on your server, you can check the [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/) for the full tutorial.

### 2. Docker Compose

#### CPU Only

```yml
services:
  openWebUI:
    image: ghcr.io/open-webui/open-webui:main
    container_name: openwebui
    hostname: openwebui
    networks:
      - traefik-net
    restart: unless-stopped
    volumes:
      - ./open-webui-local:/app/backend/data
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.openwebui.rule=Host(`openwebui.domain.com`)&quot;
      - &quot;traefik.http.routers.openwebui.entrypoints=https&quot;
      - &quot;traefik.http.services.openwebui.loadbalancer.server.port=8080&quot;
    environment:
      OLLAMA_BASE_URLS: http://ollama:11434

  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    hostname: ollama
    networks:
      - traefik-net
    volumes:
      - ./ollama-local:/root/.ollama
networks:
  traefik-net:
    external: true
```

This is adding the open-webui and adds it to traefik network, is not exposing any port to outside.

- traefik.enable=true: Enables Traefik for this service.
- traefik.http.routers.openwebui.rule=Host(openwebui.domain.com): Routes traffic to this service when the host matches openwebui.domain.com.
- traefik.http.routers.openwebui.entrypoints=https: Specifies that this service should be accessible over HTTPS.
- traefik.http.services.openwebui.loadbalancer.server.port=8080: Indicates that the service listens on port 8080 inside the container.

Ollama is also downloaded but is not exposing again no port.

#### Docker Compose NVIDIA GPU

Before we get to the Docker Compose setup, you need the NVIDIA Container Toolkit installed. This is what lets Docker containers use your GPU.

You install it like this:

```sh
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg \
 &amp;&amp; curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed &apos;s#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g&apos; | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
sudo apt-get update
sudo apt-get install -y nvidia-container-toolkit

# Configure NVIDIA Container Toolkit
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker

# Test GPU integration
docker run --gpus all nvidia/cuda:11.5.2-base-ubuntu20.04 nvidia-smi
```

Compose File for Nvidia :

```yml
services:
  openWebUI:
    image: ghcr.io/open-webui/open-webui:main
    container_name: openwebui
    hostname: openwebui
    networks:
      - traefik-net
    restart: unless-stopped
    volumes:
      - ./open-webui-local:/app/backend/data
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.openwebui.rule=Host(`openwebui.my.bitdoze.com`)&quot;
      - &quot;traefik.http.routers.openwebui.entrypoints=https&quot;
      - &quot;traefik.http.services.openwebui.loadbalancer.server.port=8080&quot;
    environment:
      OLLAMA_BASE_URLS: http://ollama:11434

  ollama:
    image: ollama/ollama:latest
    container_name: ollama
    hostname: ollama
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              capabilities: [&quot;gpu&quot;]
              count: all
    networks:
      - traefik-net
    volumes:
      - ./ollama-local:/root/.ollama
networks:
  traefik-net:
    external: true
```

The most critical part of this setup for AI performance is the GPU configuration in the Ollama service:

```yml
deploy:
  resources:
    reservations:
      devices:
        - driver: nvidia
          capabilities: [&quot;gpu&quot;]
          count: all
```

This configuration ensures that Ollama has access to all available NVIDIA GPUs on your system. According to [NVIDIA&apos;s benchmarks](https://developer.nvidia.com/blog/nvidia-ampere-architecture-in-depth/), GPU acceleration can provide up to 100x faster inference times compared to CPU-only setups for certain AI models.

#### Docker Compose AMD GPU

For AMD GPUs that support [ROCm](https://www.amd.com/en/products/software/rocm/ai.html), the Docker Compose setup is almost identical to the NVIDIA version. The main difference is the image tag.

The only diffference here is to use the correct image:

```yml
image: ollama/ollama:rocm
```

### 3. Start the Docker Compose file

```sh
docker compose up -d
```

### 4. Access the Open WebUI

Now you can access the Open WebUI app, to do that you just need to use the domain you have set in the compose file. You will be promted to create a user and a password and you will do that.
After you create the user and pasword you can alter the docker-compose file and update everything by adding :

```yml
ENABLE_SIGNUP: false

## run
docker compose up -d --force-recreate
```

### 5. Pulling a Model

After we access the Open WebUI we will need to pull a model and use it. Depending on your server&apos;s hardware, you can choose the model that fits best.

Ollama supports hundreds of models. Here are some of the best options available today:

- **Qwen3**: Alibaba&apos;s latest generation models with sizes from 0.6B to 235B, supporting thinking mode and tool use out of the box.

- **Gemma 3**: Google&apos;s most capable open model with vision support, available in sizes from 270M to 27B.

- **DeepSeek-R1**: Open reasoning model with chain-of-thought thinking capabilities, available from 1.5B to 671B parameters.

- **Llama 3.2**: Meta&apos;s efficient small models at 1B and 3B parameters with tool use support, great for lightweight deployments.

- **Phi-4**: Microsoft&apos;s state-of-the-art 14B parameter model with strong reasoning and coding capabilities.

- **Qwen2.5-Coder**: Code-specific model optimized for development tasks, available from 0.5B to 32B parameters.

These models cover a wide range of use cases from coding and reasoning to vision and general conversation.

To do that you go to **Admin Panel - Settings - Models - Pull a model from Ollama.com**

For a small server `qwen3:0.6b` or `gemma3:1b` is the way to go.

&lt;Picture src={imag1} alt=&quot;openwebui pull model&quot; /&gt;

### 6. Using Open-WebUI

After you can go ahead and start using the Open-WebUI, you choose the model and start communicating.

&lt;Picture src={imag2} alt=&quot;openwebui start&quot; /&gt;

## Conclusions

That&apos;s all it takes to get Ollama and OpenWebUI running with Docker Compose. You end up with a local AI setup where your data stays on your machine, latency is minimal, and you have a clean web interface to work with.

Ollama handles the model management side while OpenWebUI gives you the browser-based interface. Whether you&apos;re running it on a GPU-powered workstation or a modest server, it&apos;s a practical way to experiment with LLMs locally.

If you want to explore more Docker containers for your home server, including other AI tools, check out our guide on [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/). For the wider local AI and agent catalog on GitHub, see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>self-hosted</category></item><item><title>Running OpenClaw with Ollama: Local Models Guide</title><link>https://www.bitdoze.com/openclaw-ollama-local-models/</link><guid isPermaLink="true">https://www.bitdoze.com/openclaw-ollama-local-models/</guid><description>How to run OpenClaw with Ollama for free, private, local LLM inference. Covers hardware requirements, model picks by GPU/RAM tier, Nanbeige4.1-3B for low-end machines, full configuration, and fallback strategies.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

I&apos;ve been running OpenClaw with API providers for a while now, and the bills add up. Even cheap models like GLM-5 and MiniMax M2.5 still cost something, and every message goes through someone else&apos;s server. So I set up Ollama on the same box and pointed OpenClaw at it. Zero cost per token, total privacy, and the latency is actually decent if you pick the right model for your hardware.

This guide covers how to get OpenClaw talking to Ollama, which models work well for different hardware tiers, and what to do when your machine can&apos;t handle the bigger ones. Short answer on that last part: Nanbeige4.1-3B is shockingly good for a 3B model.

&lt;Notice type=&quot;info&quot; title=&quot;What You&apos;ll Need&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;OpenClaw installed and running ([setup guide here](/clawdbot-setup-guide/))&lt;/li&gt;
&lt;li&gt;A machine with at least 8GB RAM (16GB+ recommended for good models)&lt;/li&gt;
&lt;li&gt;Ollama installed (one command on Linux/macOS)&lt;/li&gt;
&lt;li&gt;No API keys required — everything runs locally&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## Why Run Local Models with OpenClaw

Three reasons to bother with this instead of just using an API:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**$0 per token** — Ollama is free. No API bills, no rate limits, no usage caps&lt;/li&gt;
&lt;li&gt;**Privacy** — conversations never leave your machine. No third-party logging, no data retention policies to read&lt;/li&gt;
&lt;li&gt;**No internet dependency** — works on an airgapped network, during outages, on a plane&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The tradeoff is real: local models are slower and less capable than Claude Opus or GPT-5. But for everyday OpenClaw stuff like answering messages and running scheduled jobs, a decent local model handles it fine.

## Hardware Requirements

This is the part most guides get wrong. They tell you the model size and forget about the actual experience. Here&apos;s what I&apos;ve found running different tiers.

### VRAM Is What Matters

Models run in GPU VRAM when available. If the model doesn&apos;t fit in VRAM, it spills into system RAM and gets much slower. CPU-only inference works but expect 5-10x slower responses.

| Hardware Tier | VRAM / RAM | Best Model Size | Response Speed |
|---|---|---|---|
| **Low-end** (no GPU, 8GB RAM) | 8GB system | 1B-3B models | Slow (5-15 tok/s) |
| **Mid-range** (no GPU, 16-32GB RAM) | 16-32GB system | 7B-8B models | Moderate (10-25 tok/s) |
| **GPU entry** (RTX 3060 12GB / M1 16GB) | 12-16GB | 7B-14B models | Good (30-60 tok/s) |
| **GPU mid** (RTX 4070 Ti 16GB / M2 Pro 32GB) | 16-32GB | 14B-32B models | Good (40-80 tok/s) |
| **GPU high** (RTX 4090 24GB / M4 Max 128GB) | 24-128GB | 32B-70B models | Fast (50-100+ tok/s) |

&lt;Notice type=&quot;warning&quot; title=&quot;Apple Silicon Note&quot;&gt;
M1/M2/M3/M4 Macs share unified memory between CPU and GPU, so Ollama can use all system RAM as VRAM. A Mac Mini M4 with 32GB RAM can comfortably run 14B-32B models. That&apos;s why Mac Minis are popular for OpenClaw setups.
&lt;/Notice&gt;

### Recommended Builds

&lt;Tabs&gt;
&lt;Tab name=&quot;Budget ($50-100/mo VPS)&quot;&gt;

**Hetzner CAX31 or similar**
- 8 vCPU (ARM), 32GB RAM, no GPU
- Runs 7B-8B models at usable speeds
- Best model: `qwen3:8b` or `gpt-oss:20b`
- Cost: ~€15/mo on Hetzner

Good enough for a personal assistant handling messages and simple tasks. Don&apos;t expect fast responses on complex coding problems.

&lt;/Tab&gt;
&lt;Tab name=&quot;Mid-range (Mac Mini)&quot;&gt;

**Mac Mini M4 with 32GB RAM**
- Unified memory acts as VRAM
- Runs 14B-32B models comfortably
- Best model: `qwen2.5-coder:32b` or `qwen3:32b`
- Cost: one-time ~$800

The sweet spot for most people. 32B models handle coding, writing, and tool calls well. This is what I&apos;d buy if starting fresh.

&lt;/Tab&gt;
&lt;Tab name=&quot;High-end (GPU Server)&quot;&gt;

**RTX 4090 24GB or dual GPU setup**
- 24GB VRAM for a single 70B quantized model
- Best model: `llama3.3:70b` or `deepseek-r1:70b`
- Cost: one-time ~$2000+ for the GPU

The 70B models come close to API-quality responses. Worth it if you&apos;re replacing a $100+/mo API habit.

&lt;/Tab&gt;
&lt;Tab name=&quot;Minimal (8GB RAM)&quot;&gt;

**Any machine with 8GB RAM**
- CPU-only inference, slow but works
- Best model: `nanbeige4.1-3b` (see section below)
- Fallback: `qwen3:1.7b` or `llama3.2:3b`

Surprisingly usable for simple conversations and basic tasks. Not great for coding.

&lt;/Tab&gt;
&lt;/Tabs&gt;

---

## Installing Ollama

One command:

```bash
curl -fsSL https://ollama.ai/install.sh | sh
```

On macOS, download from [ollama.ai](https://ollama.ai) or use Homebrew:

```bash
brew install ollama
```

Check it&apos;s running:

```bash
ollama --version
ollama list
```

If `ollama list` works, you&apos;re set.

---

## Picking the Right Model

Not every model works well with OpenClaw. You need **tool calling support** (so the agent can use its tools) and ideally **good instruction following**. Here are the models I&apos;ve tested, ranked by hardware tier.

### Best Models by Tier

| Model | Size | VRAM Needed | Tool Calling | Best For |
|---|---|---|---|---|
| `qwen2.5-coder:32b` | 32B | ~20GB | Yes | Coding, complex tasks |
| `qwen3:32b` | 32B | ~20GB | Yes | General + reasoning |
| `gpt-oss:20b` | 20B | ~14GB | Yes | General, tool use |
| `qwen3:8b` | 8B | ~6GB | Yes | General purpose |
| `llama3.3:70b` | 70B | ~42GB | Yes | Best local quality |
| `qwen2.5-coder:14b` | 14B | ~10GB | Yes | Coding on mid-range |
| `deepseek-r1:14b` | 14B | ~10GB | Yes | Reasoning tasks |
| `mistral-small3.2:24b` | 24B | ~16GB | Yes | Vision + tools |
| `qwen3:4b` | 4B | ~3GB | Yes | Budget general |
| `nanbeige4.1-3b`* | 3B | ~2.5GB | Yes | Budget, see below |

\* Community upload on Ollama — see Nanbeige section below.

### My Picks

- **32GB Mac Mini or 16GB+ GPU**: `qwen2.5-coder:32b` as primary, `qwen3:8b` as fallback
- **16GB RAM, no GPU**: `qwen3:8b` as primary, `qwen3:4b` as fallback
- **8GB RAM**: `nanbeige4.1-3b` or `qwen3:4b`

Pull your chosen model:

```bash
ollama pull qwen2.5-coder:32b
# or for lower-end hardware:
ollama pull qwen3:8b
# or for minimal hardware:
ollama pull tomng/nanbeige4.1
```

---

## Nanbeige4.1-3B: The Budget Surprise

This model caught me off guard. Nanbeige4.1-3B is a 3B parameter model from Nanbeige Lab (a team at Kanzhun/BOSS Zhipin) that punches way above its weight class. Look at these numbers:

| Benchmark | Nanbeige4.1-3B | Qwen3-4B | Qwen3-8B | Qwen3-32B |
|---|---|---|---|---|
| **LiveCodeBench-V6** | **76.9** | 57.4 | 49.4 | 55.7 |
| **AIME 2026 I** (math) | **87.4** | 81.5 | 70.4 | 75.8 |
| **GPQA** (science) | **83.8** | 65.8 | 62.0 | 68.4 |
| **Arena-Hard-v2** (alignment) | **73.2** | 34.9 | 26.3 | 56.0 |
| **BFCL-V4** (tool use) | **56.5** | 44.9 | 42.2 | 47.9 |

A 3B model beating Qwen3-32B on coding benchmarks. It scores 56.5 on BFCL-V4 (tool use), which matters because OpenClaw relies on tool calling. It can also handle over 500 rounds of tool invocations for deep-search tasks. I don&apos;t know of another sub-4B model that can do that.

### When to Use It

- Your machine has 8GB RAM and no GPU
- You want a backup model that uses minimal resources
- You&apos;re running on a Raspberry Pi 5 or similar ARM board
- You need an emergency fallback when your main model is too slow

### When NOT to Use It

- You have hardware for bigger models — 8B+ will still give better results on complex tasks
- You need long creative writing or nuanced conversation
- You&apos;re doing heavy multi-file code refactoring

### Installing Nanbeige4.1-3B

The model is available through community uploads on Ollama. The most popular version with tool support:

```bash
ollama pull tomng/nanbeige4.1
```

You can also import the GGUF from HuggingFace if you want a specific quantization:

```bash
# Download the Q4_K_M quantization (smallest useful size, ~2.3GB)
# From: huggingface.co/Edge-Quant/Nanbeige4.1-3B-Q4_K_M-GGUF

# Create a Modelfile
cat &gt; Modelfile &lt;&lt; &apos;EOF&apos;
FROM ./Nanbeige4.1-3B-Q4_K_M.gguf
PARAMETER temperature 0.6
PARAMETER top_p 0.95
PARAMETER repeat_penalty 1.0
TEMPLATE &quot;&quot;&quot;{{- if .System }}{{ .System }}{{ end }}
{{- range .Messages }}
{{- if eq .Role &quot;user&quot; }}
{{ .Content }}
{{- else if eq .Role &quot;assistant&quot; }}
{{ .Content }}
{{- end }}
{{- end }}&quot;&quot;&quot;
EOF

ollama create nanbeige4.1-3b -f Modelfile
```

---

## Configuring OpenClaw for Ollama

### Step 1: Enable Ollama

Set the environment variable that tells OpenClaw to look for Ollama:

```bash
export OLLAMA_API_KEY=&quot;ollama-local&quot;
```

Or add it permanently to your OpenClaw environment:

```bash
# Add to ~/.openclaw/.env
echo &apos;OLLAMA_API_KEY=ollama-local&apos; &gt;&gt; ~/.openclaw/.env
```

The value doesn&apos;t matter (Ollama doesn&apos;t check it), but OpenClaw needs it set to enable the Ollama provider.

### Step 2: Verify Discovery

OpenClaw auto-discovers tool-capable Ollama models. Check what it found:

```bash
openclaw models list --local
```

You should see your pulled models listed. If a model doesn&apos;t appear, it might not report tool support. You can still use it by adding explicit config (see Step 3).

### Step 3: Set Your Model

For a straightforward setup with one model:

```bash
openclaw models set ollama/qwen2.5-coder:32b
```

Or edit your config file (`~/.openclaw/openclaw.json`):

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/qwen2.5-coder:32b&quot;
      }
    }
  }
}
```

### Step 4: Set Up Fallbacks

Fallbacks matter more with local models because a single model might be too slow or run out of context. Configure a chain:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/qwen2.5-coder:32b&quot;,
        &quot;fallbacks&quot;: [
          &quot;ollama/qwen3:8b&quot;,
          &quot;ollama/tomng/nanbeige4.1&quot;
        ]
      }
    }
  }
}
```

If the 32B model chokes on a long context, OpenClaw falls through to the 8B, then to Nanbeige.

### Full Config Example (Mid-Range Hardware)

Here&apos;s a complete config for a 32GB Mac Mini:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/qwen2.5-coder:32b&quot;,
        &quot;fallbacks&quot;: [&quot;ollama/qwen3:8b&quot;]
      },
      &quot;imageModel&quot;: {
        &quot;primary&quot;: &quot;ollama/mistral-small3.2:24b&quot;
      }
    }
  }
}
```

### Full Config Example (Low-End Hardware)

For an 8GB RAM machine or Raspberry Pi:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/tomng/nanbeige4.1&quot;,
        &quot;fallbacks&quot;: [&quot;ollama/qwen3:1.7b&quot;]
      }
    }
  }
}
```

---

## Explicit Provider Config (Remote Ollama)

If Ollama runs on a different machine (say a GPU server on your LAN), you need explicit config instead of auto-discovery:

```json
{
  &quot;models&quot;: {
    &quot;providers&quot;: {
      &quot;ollama&quot;: {
        &quot;baseUrl&quot;: &quot;http://192.168.1.50:11434&quot;,
        &quot;apiKey&quot;: &quot;ollama-local&quot;,
        &quot;api&quot;: &quot;ollama&quot;,
        &quot;models&quot;: [
          {
            &quot;id&quot;: &quot;qwen2.5-coder:32b&quot;,
            &quot;name&quot;: &quot;Qwen 2.5 Coder 32B&quot;,
            &quot;reasoning&quot;: false,
            &quot;input&quot;: [&quot;text&quot;],
            &quot;cost&quot;: { &quot;input&quot;: 0, &quot;output&quot;: 0, &quot;cacheRead&quot;: 0, &quot;cacheWrite&quot;: 0 },
            &quot;contextWindow&quot;: 32768,
            &quot;maxTokens&quot;: 8192
          }
        ]
      }
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/qwen2.5-coder:32b&quot;
      }
    }
  }
}
```

&lt;Notice type=&quot;warning&quot; title=&quot;Security Reminder&quot;&gt;
If you&apos;re exposing Ollama over the network, make sure it&apos;s on a trusted network (Tailscale, LAN behind firewall). Ollama has no built-in authentication. See the [OpenClaw security guide](/openclaw-security-guide/) for hardening your setup.
&lt;/Notice&gt;

---

## Hybrid Setup: Local + API Fallback

The setup I actually recommend is a hybrid. Local model as primary, cheap API as fallback for when the local model struggles. You get privacy and zero cost for 90% of messages, and API quality is there when you need it.

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;ollama/qwen2.5-coder:32b&quot;,
        &quot;fallbacks&quot;: [
          &quot;ollama/qwen3:8b&quot;,
          &quot;zai/glm-5&quot;
        ]
      }
    }
  }
}
```

With this config, OpenClaw tries the local 32B first. If context is too long or the model errors, it falls to the local 8B. If that also fails, it hits the GLM-5 API (which is cheap). You can set up GLM-5 or MiniMax M2.5 as your API fallback following the [best open source models guide](/best-opensource-models-for-openclaw/).

---

## Switching Models On the Fly

You don&apos;t have to restart OpenClaw to change models. Use the `/model` command in chat:

```
/model                        # Show available models
/model list                   # Full list with providers
/model ollama/qwen3:8b        # Switch to a different model
/model status                 # Check current model + auth
```

Good for testing: pull a new model with `ollama pull`, and it shows up in `/model list` automatically (with auto-discovery enabled).

---

## Performance Tuning

### Context Window

Ollama reports the context window from the model metadata. You can override it in explicit config:

```json
{
  &quot;models&quot;: {
    &quot;providers&quot;: {
      &quot;ollama&quot;: {
        &quot;models&quot;: [{
          &quot;id&quot;: &quot;qwen2.5-coder:32b&quot;,
          &quot;contextWindow&quot;: 65536,
          &quot;maxTokens&quot;: 16384
        }]
      }
    }
  }
}
```

Larger context windows use more VRAM. If you&apos;re hitting memory limits, reduce `contextWindow`.

### Running Multiple Models

Ollama can keep multiple models loaded if you have the VRAM. Set `OLLAMA_NUM_PARALLEL` to control concurrency:

```bash
OLLAMA_NUM_PARALLEL=2 ollama serve
```

With enough VRAM, you can run a coding model and a general model simultaneously, switching between them with `/model` in OpenClaw.

### GPU Layers

If you have a GPU but not enough VRAM for the full model, Ollama automatically offloads some layers to CPU. You can control this:

```bash
OLLAMA_GPU_LAYERS=35 ollama serve
```

More layers on GPU = faster but more VRAM. Experiment to find your sweet spot.

---

## Troubleshooting

&lt;Accordion label=&quot;Common Problems&quot; group=&quot;troubleshooting&quot; expanded=&quot;true&quot;&gt;

**Model doesn&apos;t show up in `openclaw models list`**

OpenClaw auto-discovery only shows models with tool support. Either pull a tool-capable model or define it explicitly in `models.providers.ollama`. Check with:
```bash
ollama list
curl http://localhost:11434/api/tags
```

**Responses are extremely slow**

Your model is probably running on CPU. Check if the model fits in your available VRAM/RAM. Solutions:
- Switch to a smaller model (`qwen3:8b` instead of `qwen3:32b`)
- On Mac: close other apps to free unified memory
- On Linux with GPU: check `nvidia-smi` for VRAM usage

**&quot;Connection refused&quot; errors**

Ollama isn&apos;t running. Start it:
```bash
ollama serve
```
Or on macOS, open the Ollama app. Check the API is accessible:
```bash
curl http://localhost:11434/api/tags
```

**Tool calls failing or being ignored**

The model might not support tool calling well. Switch to a model known to handle tools: `qwen2.5-coder`, `qwen3`, `gpt-oss`, or `llama3.3`. Avoid older models like `llama2` or `codellama` for OpenClaw.

**Out of memory crashes**

The model is too large. Either:
- Pull a smaller quantization: `ollama pull qwen3:8b-q4_0`
- Switch to a smaller model
- Add more RAM/swap (not ideal but works)

**Nanbeige4.1-3B not found**

It&apos;s a community upload, not in the official library. Pull with the namespace:
```bash
ollama pull tomng/nanbeige4.1
```

&lt;/Accordion&gt;

---

## Security Considerations

Running models locally is inherently more private than API calls, but don&apos;t skip the basics:

- **Ollama has no auth**: anyone on your network who can reach port 11434 can use your models. Bind to localhost or use a firewall.
- **Model downloads**: Ollama pulls models from ollama.com. If you&apos;re security-conscious, verify model checksums or use airgapped installs.
- **OpenClaw security**: local models don&apos;t change the OpenClaw threat model. Still follow the [security hardening guide](/openclaw-security-guide/) for gateway binding, sandbox mode, and skill vetting.

---

## Related Guides

- [OpenClaw Setup Guide](/clawdbot-setup-guide/) — full installation on Hetzner VPS or Mac Mini
- [Best Open Source Models for OpenClaw](/best-opensource-models-for-openclaw/) — API-based models (GLM-5, MiniMax M2.5) for when local isn&apos;t enough
- [DuckDuckGo Search for OpenClaw](/duckduckgo-openclaw-search/) — free web search that pairs well with local models
- [OpenClaw Security Guide](/openclaw-security-guide/) — hardening your instance, especially relevant when running on a LAN
- [Best OpenClaw Dashboards](/best-openclaw-dashboards/) — dashboards that show model usage and cost (useful for tracking $0 local usage vs API fallback spend)
- [OpenClaw Alternatives](/openclaw-alternatives/) — other platforms with their own local model support

---

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Can I run OpenClaw entirely offline with Ollama?**

Yes, once the models are pulled and OpenClaw is installed. No internet required for inference. You won&apos;t have web search or channel integrations (Telegram, WhatsApp), but CLI and local sessions work fine.

**Which model gives the best quality per dollar?**

There are no dollars involved — Ollama is free. The question is quality per hardware. On a 32GB Mac Mini, `qwen2.5-coder:32b` gives the best results I&apos;ve seen. On 8GB RAM, `nanbeige4.1-3b` is the best tradeoff between speed and capability.

**How does Nanbeige4.1-3B compare to API models like GLM-5?**

GLM-5 is still better for complex tasks, especially multi-step coding. Nanbeige4.1-3B is competitive on benchmarks, but real-world OpenClaw usage means long contexts and multi-turn conversations where bigger models have an edge. Use Nanbeige for quick tasks and messages, API models for the heavy stuff.

**Can I use Ollama and API providers at the same time?**

Yes, and I recommend it. Set an Ollama model as primary and a cheap API model as fallback. OpenClaw handles the switching automatically when a model fails or can&apos;t handle the context.

**Does tool calling work with all Ollama models?**

No. OpenClaw&apos;s auto-discovery only shows models that report tool support. Older models and some smaller ones don&apos;t support it. The models listed in this guide all support tool calling. You can check with `ollama show &lt;model&gt;` and look for `tools` in the capabilities.

**What about running Ollama on a Raspberry Pi?**

A Raspberry Pi 5 with 8GB RAM can run 1B-3B models. Nanbeige4.1-3B or `qwen3:1.7b` are your best options. Responses will be slow (2-5 tokens/second) but functional for simple tasks. Don&apos;t expect it to handle coding questions.

**Is vLLM better than Ollama for OpenClaw?**

vLLM gives better throughput on multi-GPU setups and production workloads. Ollama is simpler to set up and better for single-user scenarios. For a personal OpenClaw instance, Ollama is the right choice. If you&apos;re running multiple agents or need concurrent inference, look at vLLM.

**How much RAM do I actually need for X model?**

Rough formula: model parameters × 0.6GB for Q4 quantization. So a 7B model needs ~4.2GB, a 14B needs ~8.4GB, a 32B needs ~19.2GB. Add 2-4GB headroom for the system and Ollama overhead. These are minimums — more RAM means larger context windows.

&lt;/Accordion&gt;

Local models with Ollama won&apos;t replace Claude Opus for complex work. But they cover 80-90% of what I use OpenClaw for: answering messages, running cron jobs, lookups, automations. The hybrid setup (local primary + API fallback) means privacy and zero cost most of the time, with API quality when you actually need it.

Start with whatever model fits your hardware. If you&apos;ve got a Mac Mini, go straight to `qwen2.5-coder:32b`. If you&apos;re on a budget VPS with 8GB RAM, pull Nanbeige4.1-3B and be surprised.

For the rest of the OpenClaw stack: [setup guide](/clawdbot-setup-guide/), [API model recommendations](/best-opensource-models-for-openclaw/), [free web search](/duckduckgo-openclaw-search/), [security hardening](/openclaw-security-guide/), [dashboards](/best-openclaw-dashboards/), and [alternative platforms](/openclaw-alternatives/).</content:encoded><category>ai</category><category>ai-tools</category><category>openclaw</category><category>self-hosted</category></item><item><title>OpenClaw Security Guide: CVE-2026-25253, Malicious Skills, and 40+ Fixes</title><link>https://www.bitdoze.com/openclaw-security-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/openclaw-security-guide/</guid><description>A practical security hardening guide for OpenClaw covering CVE-2026-25253 (the ClawHub supply chain attack), the 40+ vulnerability fixes shipped in recent releases, trust model fundamentals, and step-by-step lockdown procedures.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

OpenClaw runs 24/7 on your server with full shell access, API keys sitting in its config, and the ability to install and run skills from a community marketplace. Security isn&apos;t something you get around to later. In February 2026, researchers found that 12% of skills on ClawHub were infected with malware. The project&apos;s CHANGELOG lists over 40 security fixes across recent releases. If you&apos;re running OpenClaw, you should know about both.

I&apos;ve gone through the CVE, the patched vulnerabilities, the trust model, and the hardening options. Here&apos;s what actually matters.

&lt;Notice type=&quot;error&quot; title=&quot;Action Required If You Installed ClawHub Skills&quot;&gt;
If you installed any skills from ClawHub (openclawdir.com) before mid-February 2026, run `openclaw security audit --deep` immediately and check the malicious skill indicators listed below. The supply chain attack affected 341 out of 2,857 audited skills (12% infection rate).
&lt;/Notice&gt;

## CVE-2026-25253: The ClawHub Supply Chain Attack

On February 13, 2026, security researchers reported ([GitHub issue #16052](https://github.com/openclaw/openclaw/issues/16052)) that 341 skills on ClawHub, the community skill marketplace, were compromised in a coordinated supply chain attack. The CVE got a CVSS score of 8.8 (HIGH).

### How the Attack Worked

The malicious skills looked like normal tools. The most documented example, `deeps-agnw6h`, posed as a &quot;Deep-Agent/Deep-Search&quot; research tool. Infected skills contained up to three attack vectors:

**1. macOS Dropper (Base64-Encoded Shell)**

A base64 payload decoded to a curl command that downloaded and executed arbitrary code from an attacker-controlled server at `91.92.242.30` (Bulgarian hosting range, linked to info-stealer infrastructure).

**2. Fake Windows Installer**

A GitHub repository (`toolitletolate/openclaw_windriver`) hosted a malicious MSI installer disguised as a driver package.

**3. MCP Backdoor**

A hidden MCP server endpoint routed through `bore.pub` tunneling to attacker infrastructure, giving remote access to any machine running the infected skill.

### Indicators of Compromise

If you suspect your instance may be affected, check for these:

| Type | Value | Notes |
|------|-------|-------|
| **IP address** | `91.92.242.30` | macOS payload delivery server |
| **URL path** | `http://91.92.242.30/6wioz8285kcbax6v` | Dropper payload |
| **Tunnel domain** | `bore.pub` | Reverse tunneling for MCP backdoor |
| **Tunnel port** | `44876` | MCP backdoor endpoint |
| **GitHub repo** | `toolitletolate/openclaw_windriver` | Fake Windows installer |

Check your network logs for connections to these addresses. If you find matches, assume compromise and rotate all API keys and credentials stored in your OpenClaw config.

### What To Do Right Now

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Update OpenClaw to the latest release (`npm update -g openclaw`)&lt;/li&gt;
&lt;li&gt;Run `openclaw security audit --deep` and review every finding&lt;/li&gt;
&lt;li&gt;Check installed skills: `openclaw skills list` — remove anything you don&apos;t recognize&lt;/li&gt;
&lt;li&gt;Search your network logs for `91.92.242.30` and `bore.pub`&lt;/li&gt;
&lt;li&gt;If compromised: rotate all API keys, gateway tokens, and channel credentials&lt;/li&gt;
&lt;li&gt;Review open connections: `ss -tlnp | grep openclaw` on Linux to check for unexpected listeners&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

---

## The soul-evil Hook: A Built-In Risk

Separate from the ClawHub attack, researchers flagged another problem in [issue #8776](https://github.com/openclaw/openclaw/issues/8776): OpenClaw shipped with a bundled hook called `soul-evil` that could silently swap the agent&apos;s core system prompt (`SOUL.md`) with an alternate file (`SOUL_EVIL.md`). Disabled by default, but the code was there in every installation.

Here&apos;s what made it bad: an attacker with prompt injection access could chain the `write` tool to create `SOUL_EVIL.md` and then use `config.patch` to enable the hook. The agent would then run under attacker-controlled instructions with no notification. Even worse when paired with CVE-2026-25253, since an exfiltrated gateway token could enable this hook remotely.

The issue was closed in version 2026.2.1. Verify you&apos;re running at least that version:

```bash
openclaw --version
```

---

## 40+ Security Fixes Across Recent Releases

I counted over 40 security fixes in the OpenClaw CHANGELOG across the 2026.2.x releases. Here&apos;s what was patched, grouped by category.

### Exec Approval Bypasses (7 Fixes)

The execution approval system, the layer that asks &quot;should OpenClaw run this command?&quot;, had multiple bypass paths:

| Fix | What Was Wrong |
|-----|---------------|
| Cross-node replay | Approved `system.run` requests could be replayed across different nodes |
| Orphaned approvals | Two-phase approval registration had a race condition that let approvals skip the wait step |
| `env -S` bypass | `env --split-string` wrapper interpretation mismatch let commands bypass allowlist checks |
| `busybox`/`toybox` applets | Multiplexer binaries weren&apos;t recognized in wrapper analysis, allowing allow-always bypasses |
| `autoAllowSkills` path collision | Absolute-path basename collisions could satisfy skill auto-allow checks |
| Safe-bin flag denylist gaps | Unknown GNU long-option abbreviations and filesystem-dependent `sort` flags weren&apos;t blocked |
| Obfuscated command detection | Obfuscated commands weren&apos;t checked before exec allowlist decisions |

### Sandbox and Path Traversal Escapes (4 Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| `apply_patch` workspace escape | Sandbox-mounted paths like `/agent` allowed writes/deletes outside the workspace boundary |
| Image tool path traversal | `tools.fs.workspaceOnly` wasn&apos;t enforced for sandboxed `image` path resolution |
| Shell env fallback | Trusted-prefix shell-path fallback allowed untrusted shells; now only `/etc/shells` entries are trusted |
| Config path traversal | Prototype-key segments and inherited-property traversal weren&apos;t rejected in `config get/set/unset` paths |

### XSS and Injection (5 Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| Session export HTML injection | Raw HTML markdown tokens in exported session viewer weren&apos;t escaped |
| Export image data-URL injection | Image MIME/base64 fields in exported HTML weren&apos;t validated |
| Skill HTML gallery XSS | `openai-image-gen` skill didn&apos;t escape user-controlled values in generated HTML |
| Skill packaging symlink escape | `skill-creator` didn&apos;t skip symlinks or reject paths escaping the skill root |
| iOS deep link exfiltration | `openclaw://agent` requests forwarded to gateway without local confirmation |

### Prototype Pollution and Config Safety (3 Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| Account-ID normalization | Reserved prototype keys weren&apos;t blocked in account-id normalization |
| Config write mutations | `unsetPaths` could mutate caller-provided objects |
| CLI config credential leakage | `openclaw config get` output wasn&apos;t redacted before printing |

### Channel and Access Control (5 Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| Command sender spoofing | `commands.allowFrom` matched conversation-shaped `From` identities (channels, groups, threads) |
| Mutable name matching | `allowFrom` matched on mutable names/tags/emails instead of stable IDs |
| Name-matching policy inconsistency | `dangerouslyAllowNameMatching` checks varied between core and extension channels |
| ACP auto-approval scope | Unknown tool names and out-of-scope file reads were auto-approved |
| `selfChatMode` bypass | WhatsApp inbound access control didn&apos;t honor `selfChatMode` setting |

### SSRF and Network (2 Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| Telegram media SSRF | RFC2544 benchmark range (`198.18.0.0/15`) wasn&apos;t blocked by default for media downloads |
| Browser SSRF policy | Private network access defaulted to allowed without explicit config |

### Voice, Webhooks, and Other (4+ Fixes)

| Fix | What Was Wrong |
|-----|---------------|
| Twilio webhook replay | Provider event IDs weren&apos;t preserved through normalization, allowing replay attacks |
| OTEL credential leakage | API keys and tokens were included in OTLP export diagnostics |
| Reasoning/thinking leakage | Internal reasoning blocks leaked as user-visible replies in WhatsApp, Discord, Web |
| Session reset credential leakage | `/new` and `/reset` confirmation messages exposed API key prefixes |

&lt;Notice type=&quot;warning&quot; title=&quot;This List Isn&apos;t Complete&quot;&gt;
These are the security fixes documented in the CHANGELOG for the 2026.2.22 and 2026.2.23 releases plus unreleased patches. Older releases contain additional fixes. Always run the latest version.
&lt;/Notice&gt;

---

## Understanding the OpenClaw Trust Model

Before changing any settings, know what OpenClaw considers in-scope vs. out-of-scope for security. Misunderstanding this leads to either false confidence or wasted effort.

### Core principles

&lt;Tabs&gt;
&lt;Tab name=&quot;What&apos;s Trusted&quot;&gt;

- **Authenticated gateway callers** are treated as trusted operators
- **Session identifiers** (sessionKey, session IDs, labels) are routing controls, not authorization boundaries
- **Plugins/extensions** run in-process with full OS privileges
- **Workspace memory files** (MEMORY.md, memory/*.md) are treated as trusted local operator state
- **Exec approvals** are operator guardrails, not a multi-tenant authorization boundary

&lt;/Tab&gt;
&lt;Tab name=&quot;What&apos;s NOT a Boundary&quot;&gt;

- One gateway is NOT a multi-tenant, adversarial user boundary
- If one operator can view data from another on the same gateway, that&apos;s expected
- Memory search returning content written by another process is expected behavior
- Plugins having the same OS privileges as the OpenClaw process is by design

&lt;/Tab&gt;
&lt;Tab name=&quot;Recommended Setup&quot;&gt;

- **One user per machine/host** (or VPS)
- **One gateway per user** with one or more agents inside
- For multiple users: use separate VPS instances or OS user boundaries
- For remote access: SSH tunnel or Tailscale, not public internet exposure

&lt;/Tab&gt;
&lt;/Tabs&gt;

### What this means in practice

If someone shares a gateway with you, they can see your conversations, your API keys, and your memory files. That&apos;s by design. OpenClaw&apos;s security boundary is the machine/OS user level, not the gateway level. Separate trust domains need separate instances.

---

## Hardening guide

In order of importance, here&apos;s what to configure.

### 1. Keep OpenClaw Updated

Most of the 40+ fixes above ship in the npm release. Check your version and update:

```bash
openclaw --version
npm update -g openclaw
```

### 2. Run the Built-In Security Audit

OpenClaw has a built-in security scanner:

```bash
openclaw security audit --deep
```

It checks for risky configuration patterns, mutable allowlists, exposed credentials, and known problems. Add `--fix` to auto-remediate what it can:

```bash
openclaw security audit --deep --fix
```

### 3. Bind the Gateway to Loopback

The gateway HTTP surface (Control UI, canvas, API endpoints) isn&apos;t hardened for the public internet. Keep it on localhost:

```json
{
  &quot;gateway&quot;: {
    &quot;bind&quot;: &quot;loopback&quot;
  }
}
```

For remote access, use SSH tunneling or Tailscale:

```bash
# SSH tunnel
ssh -L 3000:localhost:3000 root@your-server

# Or Tailscale serve
tailscale serve --https=443 http://localhost:3000
```

### 4. Enable Sandbox Mode

By default, `agents.defaults.sandbox.mode` is `off`. Turn it on:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;sandbox&quot;: {
        &quot;mode&quot;: &quot;on&quot;
      }
    }
  }
}
```

### 5. Restrict filesystem access

Keep tools inside the workspace directory:

```json
{
  &quot;tools&quot;: {
    &quot;fs&quot;: {
      &quot;workspaceOnly&quot;: true
    },
    &quot;exec&quot;: {
      &quot;applyPatch&quot;: {
        &quot;workspaceOnly&quot;: true
      }
    }
  }
}
```

### 6. Switch Channel Allowlists to Stable IDs

After the breaking change in the unreleased version, `allowFrom` matching is ID-only by default. If you&apos;re on an older version, migrate your allowlists from names to IDs:

```json
{
  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;allowFrom&quot;: [&quot;123456789&quot;]
    }
  }
}
```

Mutable names (usernames, display names, email addresses) in allowlists are dangerous. Anyone can change their display name at any time. Use stable numeric IDs.

### 7. Set Explicit Control UI Origins

For non-loopback deployments, specify allowed origins:

```json
{
  &quot;gateway&quot;: {
    &quot;controlUi&quot;: {
      &quot;allowedOrigins&quot;: [&quot;https://your-tailscale-domain.ts.net&quot;]
    }
  }
}
```

Without this, the gateway will refuse to start (fail-closed behavior).

### 8. Vet Skills Before Installing

After CVE-2026-25253, treat every ClawHub skill as potentially hostile until verified:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Read the skill source code before installing&lt;/li&gt;
&lt;li&gt;Check for base64-encoded payloads or obfuscated commands&lt;/li&gt;
&lt;li&gt;Look for outbound network connections to unknown hosts&lt;/li&gt;
&lt;li&gt;Verify the skill author&apos;s identity and history&lt;/li&gt;
&lt;li&gt;Prefer skills with significant community usage and reviews&lt;/li&gt;
&lt;li&gt;Consider running skills in a sandboxed environment first&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### 9. Docker Hardening

If running OpenClaw in Docker, use restrictive settings:

```bash
docker run --read-only --cap-drop=ALL \
  -v openclaw-data:/app/data \
  openclaw/openclaw:latest
```

The official image runs as a non-root `node` user. `--read-only` prevents filesystem writes outside mounted volumes, and `--cap-drop=ALL` drops Linux capabilities.

### 10. Node.js Version

OpenClaw requires Node.js 22.12.0 or later. Older versions have known vulnerabilities:

```bash
node --version  # Should be v22.12.0 or later
```

---

## Security Audit Checklist

Use this as a periodic review checklist:

| Check | Command / Action | Expected |
|-------|-----------------|----------|
| OpenClaw version | `openclaw --version` | Latest release |
| Node.js version | `node --version` | v22.12.0+ |
| Security audit | `openclaw security audit --deep` | No critical findings |
| Gateway binding | Check config `gateway.bind` | `loopback` |
| Sandbox mode | Check config `agents.defaults.sandbox.mode` | `on` |
| Workspace-only FS | Check config `tools.fs.workspaceOnly` | `true` |
| Channel allowlists | Check for name-based entries | IDs only |
| Installed skills | `openclaw skills list` | Only recognized skills |
| Network connections | `ss -tlnp \| grep openclaw` | Only expected listeners |
| API key rotation | Review key ages | Rotated within 90 days |

---

## Related guides

The rest of the OpenClaw stack:

- [OpenClaw Setup Guide](/clawdbot-setup-guide/) covers the full installation on Hetzner VPS or Mac Mini, including gateway token configuration
- [Best Open Source Models for OpenClaw](/best-opensource-models-for-openclaw/) covers GLM-5 and MiniMax M2.5 setup, cost comparison, and why routing subscriptions through OpenClaw gets you banned
- [DuckDuckGo Search for OpenClaw](/duckduckgo-openclaw-search/) walks through adding free web search without API keys
- [OpenClaw Alternatives](/openclaw-alternatives/) covers NanoClaw, IronClaw, NullClaw, and others with their own security approaches
- [Best OpenClaw Dashboards](/best-openclaw-dashboards/) ranks nine dashboards, including the security-hardened OpenClaw Dashboard with TOTP MFA

---

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Was my data stolen in the CVE-2026-25253 attack?**

If you installed one of the 341 infected skills, the malware had access to everything OpenClaw could access — your API keys, conversation history, files in the workspace, and potentially the entire machine if sandbox mode was off. Assume full compromise and rotate all credentials.

**How do I know if I installed a malicious skill?**

Run `openclaw skills list` and check each skill against the ClawHub audit results. Search your system logs and network traffic for the IoC addresses listed above (`91.92.242.30`, `bore.pub:44876`). The `openclaw security audit --deep` command also checks for known malicious patterns.

**Is OpenClaw safe to run on the public internet?**

No. The gateway HTTP surface is not hardened for public exposure. Bind to loopback and use SSH tunneling or Tailscale for remote access. Multiple CHANGELOG entries note fixes for issues that only matter with network exposure.

**Should I enable `dangerouslyAllowNameMatching`?**

No, unless you have no other option. Name-based allowlists are vulnerable because users can change their display names, usernames, or email addresses at any time. Migrate to stable IDs.

**What&apos;s the difference between `sandbox.mode` and `workspaceOnly`?**

`sandbox.mode` controls whether the agent runs in an isolated container (Docker/Apple Container). `workspaceOnly` restricts file operations to the workspace directory even without a sandbox runtime. Both should be enabled for maximum protection. They complement each other.

**How often should I run `openclaw security audit`?**

After every OpenClaw update, after installing any new skill, and at least weekly for actively used instances. Add `--deep` for the thorough scan that checks network exposure and credential patterns.

**Does the trust model mean OpenClaw is insecure?**

No, it means the security boundary is at the machine/OS user level rather than the gateway level. This is common for self-hosted tools. The key is understanding that everyone with gateway access has full operator privileges — plan your deployment accordingly.

**Can prompt injection compromise my OpenClaw instance?**

Prompt injection is listed as out-of-scope in OpenClaw&apos;s security model when it doesn&apos;t bypass a boundary. But the `soul-evil` hook showed how prompt injection can chain with other features to achieve persistent compromise. Keep the attack surface small: sandbox on, workspace-only filesystem, skills audited.

&lt;/Accordion&gt;

The 2026.2.x releases fixed a lot. But the ClawHub attack showed that the skill ecosystem is a real threat, and many of the patched vulnerabilities had been there since the early releases. I expect more security fixes to land through 2026.

Keep OpenClaw updated and run `openclaw security audit --deep` regularly. That&apos;s the single highest-value thing you can do. The rest of this guide is defense-in-depth on top of that.

For everything else: [setup guide](/clawdbot-setup-guide/), [model recommendations](/best-opensource-models-for-openclaw/), [free web search](/duckduckgo-openclaw-search/), [dashboards](/best-openclaw-dashboards/), [local models with Ollama](/openclaw-ollama-local-models/), and [alternative platforms](/openclaw-alternatives/).</content:encoded><category>ai</category><category>ai-tools</category><category>openclaw</category><category>security</category></item><item><title>Install tmux on MacOS and Basics Commands for Beginners</title><link>https://www.bitdoze.com/tmux-basics/</link><guid isPermaLink="true">https://www.bitdoze.com/tmux-basics/</guid><description>Learn how to install tmux on MacOS and discover essential commands for beginners. Boost your productivity with this comprehensive guide to terminal multiplexing.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/09/tmux_split_view.png&quot;;

Want to install tmux on MacOS and learn the basics? [Tmux](https://github.com/tmux/tmux/wiki) (terminal multiplexer) lets you manage multiple terminal sessions from a single screen. It&apos;s useful for multitasking and keeping your workflow organized. With version 3.6 out, it&apos;s gotten more stable. This guide covers installation on Mac and the commands you&apos;ll use most.

## What is tmux?

[tmux](https://github.com/tmux/tmux/wiki), short for &quot;terminal multiplexer,&quot; lets you manage multiple terminal sessions within a single window. You can create, access, and control several sessions from one screen. Developers, sysadmins, and anyone who lives in the terminal will find it useful. Version 3.6 includes performance improvements and bug fixes.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/vtB1J_zCv8I&quot;
  label=&quot;tmux&quot;
/&gt;


For a nice Mac terminal you can also check [Maximize Efficiency: Integrating Wezterm, Zoxide, and Tmux for the Perfect Mac Terminal](https://www.bitdoze.com/install-wezterm-mac/)

Key features of tmux include:

1. **Session Management:** tmux lets you create multiple sessions, each containing one or more windows. You can detach from these sessions and reattach later, even from a different computer.

2. **Window Splitting:** Within a session, you can split your terminal window into multiple panes, both horizontally and vertically. This allows you to view and interact with multiple terminal instances simultaneously.

3. **Persistence:** tmux sessions continue running in the background even if you close your terminal emulator or disconnect from SSH. This is particularly useful for long-running processes or when working on remote servers.

4. Customization: tmux is highly configurable, allowing users to customize key bindings, appearance, and behavior to suit their preferences.

5. **Pair Programming:** tmux facilitates collaborative work by allowing multiple users to attach to the same session, making it an excellent tool for pair programming or remote troubleshooting.

For beginners, tmux might seem complex at first, but its benefits become apparent as you start using it regularly. It&apos;s especially valuable for:

- Managing multiple tasks in a single terminal window
- Keeping processes running even when you&apos;re disconnected
- Organizing your workspace efficiently
- Improving productivity in command-line environments

As you go through this guide, you&apos;ll learn how to install tmux on macOS and get comfortable with the basic commands.


## Getting Started with tmux

Before getting into tmux commands, let&apos;s install it on your macOS system. It&apos;s quick, especially with a package manager.

### Install tmux on macOS

There are two popular methods to install tmux on macOS: using Homebrew or MacPorts. We&apos;ll focus on the Homebrew method as it&apos;s widely used and easy to set up.

1. Install Homebrew (if not already installed):
   If you don&apos;t have Homebrew installed, open Terminal and run this command:

   ```sh
   /bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
   ```

   Follow the on-screen instructions to complete the installation.

2. Install tmux using Homebrew:
   Once Homebrew is installed, you can easily install tmux by running:

   ```sh
   brew install tmux
   ```

   Homebrew will download and install tmux along with any necessary dependencies.

3. Verify the installation:
   After the installation completes, verify that tmux is installed correctly by checking its version:

   ```sh
   tmux -V
   ```

   This command should display the installed version of tmux.

Table: tmux Installation Commands

| Action | Command |
|--------|---------|
| Install Homebrew | `/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;` |
| Install tmux | `brew install tmux` |
| Verify installation | `tmux -V` |

That&apos;s it! You now have tmux installed on your macOS system. If you encounter any issues during installation, make sure your system is up to date and that you have the necessary permissions to install software.

With tmux now installed, you&apos;re ready to start exploring its features and commands. In the next sections, we&apos;ll cover how to start your first tmux session and navigate its basic functions.


### Start Your First tmux Session

Now that tmux is installed, let&apos;s explore how to start and manage sessions. These basic operations will help you get comfortable with tmux&apos;s functionality.

#### Start session

To start a new tmux session:
```sh
tmux
```
This command creates a new session with a default name.

#### Start Session with a session name

To start a new session with a specific name:
```sh
tmux new -s session_name
```
Replace `session_name` with your desired name. This makes it easier to identify and reattach to specific sessions later.

#### Sharing a tmux session

To allow another user to attach to your session (useful for pair programming):
1. Start a session as usual.
2. The other user can attach to your session using:
   ```sh
   tmux attach -t session_name
   ```
   Both users will now be able to view and interact with the same session.

#### Attach Session

To reattach to an existing session:
```sh
tmux attach -t session_name
```
If you don&apos;t specify a session name, it will attach to the most recently used session.

#### Detach Session

To detach from a session without closing it:
1. Press the tmux prefix key combination: `Ctrl+b`
2. Then press `d`

This leaves the session running in the background.

#### Exit Session

To completely exit and close a session:
1. Type `exit` in the tmux window, or
2. Press the tmux prefix (`Ctrl+b`) followed by `&amp;`, then confirm with `y`

Table: Basic tmux Session Commands

| Action | Command |
|--------|---------|
| Start new session | `tmux` |
| Start named session | `tmux new -s session_name` |
| Attach to session | `tmux attach -t session_name` |
| List sessions | `tmux ls` |
| Detach from session | `Ctrl+b` then `d` |
| Exit session | `exit` or `Ctrl+b` then `&amp;` |

Remember, the prefix key `Ctrl+b` is crucial in tmux. It signals to tmux that the next key pressed is a command, not regular input.

These basic session management commands will get you started with tmux. As you become more comfortable, you&apos;ll find that tmux offers much more powerful features for managing your terminal sessions efficiently.

### Tmux Panes

Panes are one of tmux&apos;s most powerful features, allowing you to divide a single window into multiple sections, each running its own shell or command.

#### Splitting Panes

&lt;Picture
  src={imag1}
  alt=&quot;Tmux Splitting Panes&quot;
/&gt;

To split panes, you&apos;ll use the tmux prefix (`Ctrl+b`) followed by a specific key:

- Split vertically (left and right):
  ```
  Ctrl+b %
  ```
- Split horizontally (top and bottom):
  ```
  Ctrl+b &quot;
  ```

You can continue splitting existing panes to create complex layouts.

#### Navigating Panes

To move between panes:

- Move to the next pane:
  ```
  Ctrl+b o
  ```
- Move to a specific pane by direction:
  ```
  Ctrl+b &lt;arrow key&gt;
  ```
  Use the arrow keys (↑, ↓, ←, →) to move in that direction.

#### Closing Panes

To close the current pane:

- Type `exit` in the pane, or
- Use the key combination:
  ```
  Ctrl+b x
  ```
  This will prompt for confirmation before closing the pane.

#### tmux resize pane

Resizing panes can be done using the prefix followed by specific keys:

- Enter resize mode:
  ```
  Ctrl+b :
  ```
  Then type `resize-pane` followed by a direction flag:
  - `-U` (up)
  - `-D` (down)
  - `-L` (left)
  - `-R` (right)

For example, to resize the current pane up by 5 cells:
```
Ctrl+b :
resize-pane -U 5
```

Alternatively, you can use these key combinations for interactive resizing:
```
Ctrl+b Ctrl+&lt;arrow key&gt;
```
Hold down Ctrl and press the arrow keys to resize in that direction.

Table: Tmux Pane Commands

| Action | Command |
|--------|---------|
| Split vertically | `Ctrl+b %` |
| Split horizontally | `Ctrl+b &quot;` |
| Navigate to next pane | `Ctrl+b o` |
| Navigate by direction | `Ctrl+b &lt;arrow key&gt;` |
| Close current pane | `Ctrl+b x` or `exit` |
| Resize mode | `Ctrl+b :` then `resize-pane -[U/D/L/R] &lt;number&gt;` |
| Interactive resize | `Ctrl+b Ctrl+&lt;arrow key&gt;` |

Remember, you can always use `Ctrl+b ?` to view a list of all tmux key bindings if you forget any commands.

Working with panes allows you to efficiently manage multiple terminal instances within a single window, greatly enhancing your productivity in the command line environment.

## Changing the Look of tmux

Customizing tmux&apos;s appearance makes your terminal easier to work with. Here are some options:

### 1. Customize the Status Bar

The status bar is a key visual element in tmux. You can modify its appearance in your tmux configuration file (`~/.tmux.conf`).

- Change status bar color:
  ```sh
  set -g status-style bg=black,fg=white
  ```

- Change the status bar position (top or bottom):
  ```sh
  set -g status-position top
  ```

- Customize status bar content:
  ```sh
  set -g status-left &quot;[Session: #S] &quot;
  set -g status-right &quot;%H:%M %d-%b-%y&quot;
  ```

### 2. Window and Pane Styling

- Change the active/inactive window colors:
  ```sh
  set -g window-status-current-style bg=yellow,fg=black
  set -g window-status-style bg=green,fg=black
  ```

- Set pane borders:
  ```sh
  set -g pane-border-style fg=green
  set -g pane-active-border-style fg=yellow
  ```

### 3. Enable Mouse Support

For easier navigation and resizing:
```sh
set -g mouse on
```

### 4. Change the Prefix Key

If you find `Ctrl+b` awkward, you can change it. For example, to change it to `Ctrl+a`:
```sh
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
```

### 5. Use a Theme

Instead of manually configuring everything, you can use pre-made themes. One popular option is to use the Tmux Plugin Manager (TPM) to install themes.

1. Install TPM:
   ```sh
   git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
   ```

2. Add this to your `~/.tmux.conf`:
   ```sh
   set -g @plugin &apos;tmux-plugins/tpm&apos;
   set -g @plugin &apos;jimeh/tmux-themepack&apos;
   set -g @themepack &apos;powerline/default/cyan&apos;

   run &apos;~/.tmux/plugins/tpm/tpm&apos;
   ```

3. Reload tmux configuration:
   ```sh
   tmux source ~/.tmux.conf
   ```

4. Install plugins: Press `prefix` + `I` (capital i) to fetch the plugin.

Table: Common tmux Customization Commands

| Customization | Command in ~/.tmux.conf |
|---------------|--------------------------|
| Status bar color | `set -g status-style bg=color,fg=color` |
| Status bar position | `set -g status-position [top/bottom]` |
| Window color (active) | `set -g window-status-current-style bg=color,fg=color` |
| Pane border color | `set -g pane-border-style fg=color` |
| Enable mouse | `set -g mouse on` |
| Change prefix key | `set-option -g prefix C-[key]` |

Remember to reload your configuration (`tmux source ~/.tmux.conf`) or restart tmux for changes to take effect.

Customizing tmux makes your terminal sessions easier to navigate and more pleasant to work in.

## tmux Plugins

Plugins extend tmux&apos;s functionality, adding new features and making it even more powerful. They can enhance your productivity, add visual improvements, or provide new capabilities.

### Installing Tmux Plugin Manager (TPM)

Before you can easily use plugins, you should install the Tmux Plugin Manager (TPM):

1. Clone TPM repository:
   ```sh
   git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
   ```

2. Add this to the bottom of your `~/.tmux.conf`:
   ```sh
   # List of plugins
   set -g @plugin &apos;tmux-plugins/tpm&apos;
   set -g @plugin &apos;tmux-plugins/tmux-sensible&apos;

   # Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf)
   run &apos;~/.tmux/plugins/tpm/tpm&apos;
   ```

3. Reload tmux configuration:
   ```sh
   tmux source ~/.tmux.conf
   ```

### Popular tmux Plugins

Here are some useful plugins to get you started:

1. **tmux-resurrect**: Saves and restores tmux sessions across system restarts.
   ```sh
   set -g @plugin &apos;tmux-plugins/tmux-resurrect&apos;
   ```

2. **tmux-continuum**: Automatic saving and restoration of tmux sessions.
   ```sh
   set -g @plugin &apos;tmux-plugins/tmux-continuum&apos;
   ```

3. **tmux-yank**: Enables copying to system clipboard.
   ```sh
   set -g @plugin &apos;tmux-plugins/tmux-yank&apos;
   ```

4. **tmux-battery**: Displays battery percentage and status icon in tmux status-right.
   ```sh
   set -g @plugin &apos;tmux-plugins/tmux-battery&apos;
   ```

5. **tmux-cpu**: Shows CPU usage in tmux status-right.
   ```sh
   set -g @plugin &apos;tmux-plugins/tmux-cpu&apos;
   ```

### Installing and Using Plugins

1. Add the plugin to your `~/.tmux.conf` as shown above.

2. Press `prefix` + `I` (capital i) to fetch and install the plugin.

3. The plugin should now be working. You might need to refresh your tmux environment with:
   ```sh
   tmux source ~/.tmux.conf
   ```

### Managing Plugins

- Install plugins: `prefix` + `I`
- Update plugins: `prefix` + `U`
- Remove/uninstall plugins not in the list: `prefix` + `alt` + `u`

Table: Basic TPM Commands

| Action | Command |
|--------|---------|
| Install plugins | `prefix` + `I` |
| Update plugins | `prefix` + `U` |
| Remove unused plugins | `prefix` + `alt` + `u` |

### Example Configuration with Plugins

Here&apos;s an example of how your `~/.tmux.conf` might look with some plugins:

```sh
# List of plugins
set -g @plugin &apos;tmux-plugins/tpm&apos;
set -g @plugin &apos;tmux-plugins/tmux-sensible&apos;
set -g @plugin &apos;tmux-plugins/tmux-resurrect&apos;
set -g @plugin &apos;tmux-plugins/tmux-continuum&apos;
set -g @plugin &apos;tmux-plugins/tmux-yank&apos;

# Plugin configurations
set -g @continuum-restore &apos;on&apos;

# Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf)
run &apos;~/.tmux/plugins/tpm/tpm&apos;
```

Plugins add useful features to tmux. Try a few and keep the ones that fit your workflow.

## Using tmux with SSH

Using `tmux` with SSH can make your remote work more efficient. When you SSH into a server, starting a `tmux` session helps you keep your work intact even if the connection drops. Here&apos;s how to do it:

1. **SSH into your server:**
   ```bash
   ssh your_username@server_address
   ```

2. **Start a new tmux session:**
   ```bash
   tmux new -s session_name
   ```

3. **Detach from the session:**
   Press `Ctrl+b`, then `d`.

4. **Reattach to an existing session:**
   ```bash
   tmux attach -t session_name
   ```

### Common tmux Commands While SSH&apos;d

- **List sessions:**
  ```bash
  tmux ls
  ```

- **Kill a session:**
  ```bash
  tmux kill-session -t session_name
  ```

- **Rename a session:**
  ```bash
  tmux rename-session -t old_name new_name
  ```

### Advantages of Using tmux with SSH

- **Persistence:** Keep your work going even if the SSH connection drops.
- **Multiplexing:** Run multiple terminal sessions within a single SSH connection.
- **Organization:** Easily manage multiple tasks by naming and switching between sessions.

### Example Workflow

1. SSH into your server.
2. Start a `tmux` session named &quot;dev&quot;:
   ```bash
   tmux new -s dev
   ```

3. Work on your tasks.
4. Detach from the session to take a break:
   Press `Ctrl+b`, then `d`.

5. Reattach to continue:
   ```bash
   tmux attach -t dev
   ```

Using `tmux` with SSH can streamline your workflow and reduce headaches caused by lost connections. It’s a must-have tool for anyone working remotely on servers.
## Troubleshooting Common Issues

Even with a smooth installation, you might encounter some hiccups using tmux on macOS. Here are common problems and how to fix them.

### tmux Command Not Found

If you see a `command not found: tmux` error, it means tmux isn&apos;t installed correctly or isn&apos;t in your system&apos;s PATH.

1. **Check Installation:** Ensure tmux is installed by running `brew list | grep tmux`.
2. **Update PATH:** Add tmux to your PATH by adding `export PATH=&quot;/usr/local/bin:$PATH&quot;` to your `~/.bash_profile` or `~/.zshrc`.

### tmux Not Starting Properly

If tmux doesn&apos;t start or crashes, it might be due to a configuration issue.

- **Check Config File:** Look for syntax errors in `~/.tmux.conf`. You can test the file by running `tmux source-file ~/.tmux.conf`.
- **Update tmux:** Ensure you have the latest version by running `brew update &amp;&amp; brew upgrade tmux`.

### Keybindings Not Working

Sometimes custom keybindings in your `~/.tmux.conf` might not work.

- **Syntax Check:** Ensure your keybinding syntax is correct. Refer to tmux documentation for proper syntax.
- **Reload Config:** Apply changes by running `tmux source-file ~/.tmux.conf`.

### Display Issues

You might face issues with the terminal display or pane resizing.

- **Terminal Compatibility:** Make sure your terminal supports tmux. iTerm2 is a good option.
- **Resize Panes:** Use `Ctrl+b` followed by `:` and type `resize-pane -D` or `resize-pane -U` to adjust pane size.

### Copy-Paste Problems

Copying and pasting text within tmux can be tricky.

- **Enable Mouse Mode:** Add `set -g mouse on` to your `~/.tmux.conf` to enable mouse support.
- **Use tmux Copy Mode:** Enter copy mode with `Ctrl+b [` and use arrow keys to navigate and select text.

### tmux Sessions Freezing

If your tmux session freezes, it could be due to resource limitations or conflicts.

- **Check System Resources:** Ensure your system isn&apos;t running out of memory or CPU.
- **Kill Unresponsive Sessions:** If needed, kill the tmux server with `tmux kill-server` and restart.

### Lost tmux Sessions

If you lose a tmux session, you can usually recover it.

1. **List Sessions:** Run `tmux ls` to list active sessions.
2. **Attach to Session:** Reattach to a session with `tmux attach -t [session-name]`.

### Permissions Issues

You might face permission errors when running tmux commands.

- **Check Permissions:** Ensure you have the correct permissions for the tmux binary and your configuration files.
- **Run as Sudo:** If necessary, run tmux commands with `sudo`.

By addressing these common issues, you can ensure a smoother experience with tmux on macOS.
## Conclusion

That covers the basics of tmux on macOS. You now know how to install it, manage sessions, split panes, customize the look, and use plugins. Practice the key bindings until they become second nature.

As you get more comfortable, there&apos;s plenty more to explore. tmux is one of those tools that keeps paying off the more you use it.</content:encoded><category>linux</category><category>tmux</category></item><item><title>Zoxide: The Smarter Way to Navigate Your Terminal</title><link>https://www.bitdoze.com/zoxide/</link><guid isPermaLink="true">https://www.bitdoze.com/zoxide/</guid><description>Discover zoxide, the smarter way to navigate your terminal. This comprehensive guide covers everything from the basics of zoxide, installation, and setup.</description><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/zoxide-tutorial.gif&quot;;

Navigating the file system through a terminal gets tedious, especially with deeply nested directories. The `cd` command works but requires exact path inputs and gets old fast with complex directory structures. `zoxide` (version 0.9.9) is a smarter, faster alternative.

## The Problem with `cd`

The `cd` command has been around forever. But as projects grow and directory trees get deeper, its limitations show up. Typing out long paths is slow and error-prone. Going back to the same directories over and over with `cd` wastes a lot of keystrokes.

## What is Zoxide?

&lt;Picture src={imag1} alt=&quot;Zoxide&quot; /&gt;

[Zoxide](https://github.com/ajeetdsouza/zoxide) is a command-line tool that replaces `cd` for directory navigation. It&apos;s inspired by tools like `z` and `autojump`. Zoxide supports Bash, Zsh, Fish, PowerShell, Nushell, Elvish, Tcsh, and ksh shells.

At its core, `zoxide` is a directory tracker. It keeps a record of the directories you visit and assigns a &quot;frecency&quot; score—a blend of frequency and recency—to each. This means the more often and more recently you visit a directory, the higher it ranks in `zoxide` database.

### Key Differences from `cd`

Unlike `cd`, `zoxide` learns from your behavior. It doesn&apos;t need exact path names — it predicts where you want to go based on partial inputs and your usage patterns. This makes navigation noticeably faster.

### Benefits of Zoxide

The advantages of `zoxide` are straightforward:

- **Speed**: Get to any directory fast.
- **Fewer Keystrokes**: Type less, navigate more.
- **Less to Remember**: No need to memorize or type out long paths.

| Feature        | `cd` Command | `zoxide` |
| -------------- | ------------ | -------- |
| Speed          | Slow         | Fast     |
| Keystrokes     | Many         | Few      |
| Learning Curve | None         | Minimal  |
| Intelligence   | None         | High     |

With `zoxide`, you spend less time navigating and more time doing actual work.

For a nice Mac terminal you can also check [Maximize Efficiency: Integrating Wezterm, Zoxide, and Tmux for the Perfect Mac Terminal](https://www.bitdoze.com/install-wezterm-mac/)

## Installation and Setup

Installing `zoxide` varies slightly by platform and shell. Here are the commands for common setups:

### Installation Instructions

`Zoxide` runs on most operating systems and shells. Here are the install commands for common setups:

| Operating System      | Shell      | Installation Command                             |
| --------------------- | ---------- | ------------------------------------------------ |
| macOS                 | bash/zsh   | `brew install zoxide`                            |
| Linux (Debian/Ubuntu) | bash/zsh   | `sudo apt install zoxide`                        |
| Linux (Arch)          | bash/zsh   | `sudo pacman -S zoxide`                          |
| Windows               | PowerShell | `scoop install zoxide` or `choco install zoxide` |
| Any                   | Any        | `curl -sSfL https://raw.githubusercontent.com/ajeetdsouza/zoxide/main/install.sh \| sh` |

For other operating systems or shells, please refer to the [official zoxide documentation](https://github.com/ajeetdsouza/zoxide) for detailed instructions.

### Basic Configuration

After installing `zoxide`, you need to initialize it in your shell&apos;s configuration file. This step allows `zoxide` to start tracking your directory usage. Below are the initialization commands for some common shells:

- **Bash**: Add `eval &quot;$(zoxide init bash)&quot;` to `~/.bashrc`.
- **Zsh**: Add `eval &quot;$(zoxide init zsh)&quot;` to `~/.zshrc`.
- **Fish**: Add `zoxide init fish | source` to `~/.config/fish/config.fish`.
- **PowerShell**: Add `Invoke-Expression (&amp; { (zoxide init powershell) -join &quot;`n&quot; })`to your`$profile`.
- **Nushell**: Run `zoxide init nushell | save -f ~/.zoxide.nu` and source it in your config.
- **Tcsh**: Add `eval `zoxide init tcsh`` to `~/.tcshrc`.

&gt; Remember, the initial learning phase of `zoxide` might seem slow as it builds its database of your most visited directories. However, this is a one-time investment that pays off with significantly faster navigation in the long run.

### Verifying the Installation

To ensure `zoxide` has been installed and configured correctly, you can run a simple command to check its version:

```shell
zoxide --version
```

If `zoxide` is correctly installed, this command will return the current version number of the tool.

Once installed, you should have `zoxide` ready to go. The initial setup takes a few minutes, and the database builds itself as you use it.

## Using Zoxide

Once `zoxide` is installed and configured, here&apos;s how it works in practice.

### Functionality

The primary command for `zoxide` is `z`. It allows you to jump to a directory using only parts of the pathname. For example, if you frequently visit `/home/user/projects/my_project`, you can simply type:

```shell
z my_project
```

`Zoxide` will then take you to the directory, assuming it has the highest &quot;frecency&quot; score for that keyword.

### Examples

`Zoxide` is smarter than a simple search. Suppose you have two directories:

- `/home/user/projects/my_project`
- `/home/user/documents/my_project_report`

If you&apos;ve been working more frequently in the projects directory, a simple `z my_project` will take you there. But if you start working more on the report, `zoxide` will adapt and `z my_project` may start taking you to the report directory instead.

### Tips for Mastery

To get the most out of `zoxide`, consider these tips:

- Combine `zoxide` with other tools like `fzf` (v0.51.0+) for interactive filtering.
- Use `z -l` to list directories sorted by &quot;frecency&quot; to see where `z` might take you.
- Customize `zoxide` with environment variables like `_ZO_EXCLUDE_DIRS` to exclude certain directories from tracking.
- Use `zoxide edit` to manually adjust scores for entries in the database.
- Run `zoxide doctor` (Bash/Zsh) to diagnose common configuration issues.

| Command        | Description                                                                  |
| -------------- | ---------------------------------------------------------------------------- |
| `z &lt;query&gt;`    | Jump to the highest-ranked directory matching the query.                     |
| `z -l &lt;query&gt;` | List all directories matching the query, sorted by &quot;frecency&quot;.               |
| `z -i &lt;query&gt;` | Interactively select a directory to jump to when there are multiple matches. |

By using these commands regularly, navigating your file system becomes much easier.

### Zoxide and Zsh-Autocomplete

Pairing zoxide with `zsh-autocomplete` gives you both intelligent directory jumping and real-time auto-completion. This combination makes terminal navigation faster and more intuitive.

Zsh-autocomplete is a Zsh plugin that provides real-time interactive auto-completion. It suggests files, directories, and command options as you type. Less memorizing, faster typing.

Zoxide is working by default with Oh My Zsh you just need to have it installed and add zoxide. you can follow: [How to Enable Command Autocomplete in ZSH](https://www.bitdoze.com/enable-command-autocomplete-in-zsh/) and you will have both working.

## Alternatives to Zoxide for Navigating the File System

`zoxide` is popular for good reason, but there are several alternatives worth knowing about:

**Z - Jump Around**

`z` is a command-line tool that helps you navigate to the most &apos;frecent&apos; (frequently and recently accessed) directories using regex patterns. It&apos;s the tool that inspired `zoxide`.

After a short learning phase, `z` will take you to the most &apos;frecent&apos; directory that matches all of the regexes given on the command line, in order.

**Autojump**

`autojump` is a command-line tool that allows you to jump to frequently visited directories using partial names. It maintains a database of the directories you use the most from the command line.

`autojump` uses a self-learning algorithm to keep track of your most visited directories, enabling faster navigation through the file system.

**Fzf**

`fzf` is a command-line fuzzy finder that can be used to search for directories (and files) across your system. It&apos;s highly customizable and can be integrated with your shell and various plugins.

`fzf` provides a powerful interface for searching and can be combined with other tools to enhance file system navigation.

**Z.lua**

`z.lua` is another tool that allows you to quickly jump to frequently used directories. It&apos;s designed to be fast and integrates with a variety of shells.

`z.lua` is noted for its speed, claiming to be faster than `autojump` and `z.sh`. It also offers enhanced matching modes for more flexible directory jumping.

**Fasd**

`fasd` is a command-line productivity booster that offers quick access to files and directories. It&apos;s inspired by tools like `autojump`, `z`, and `v`.

`fasd` automatically maintains a list of frequently accessed files and directories, making it easier to invoke them in the command line.

Here&apos;s a comparison table of the alternatives:

| Tool     | Description                                       | Key Feature                 |
| -------- | ------------------------------------------------- | --------------------------- |
| z        | Navigate using &apos;frecent&apos; directories with regexes | Regex pattern matching      |
| autojump | Jump to frequently visited directories            | Self-learning algorithm     |
| fzf      | Fuzzy finder for files and directories            | Customizable search tool    |
| z.lua    | Fast directory jumping                            | Speed and enhanced matching |
| fasd     | Quick access to files and directories             | Frequency-based list        |

Each tool has its strengths. Pick the one that fits how you work — whether you care most about speed, fuzzy matching, or simplicity.

## Conclusion

`Zoxide` makes terminal navigation faster by learning from your habits and predicting where you want to go. With support for nearly every shell and platform, version 0.9.9 includes quality-of-life features like the `doctor` command for troubleshooting, the `edit` subcommand for adjusting scores, and Tcsh/ksh support. Setting it up takes just a few minutes and the time savings add up quickly.

Whether you&apos;re a seasoned terminal user or new to the command line, `zoxide` can noticeably speed up your navigation. Give it a try and see the difference in your daily workflow.

Zoxide also works with [Fish Shell](/install-fish-shell-ubuntu/). If you&apos;re interested in trying Fish, check out the [best Fish Shell plugins](/best-fish-shell-plugins/) (zoxide is included) or my [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) comparison.</content:encoded><category>linux</category><category>zoxide</category></item><item><title>Fish Shell Functions &amp; Custom Commands Guide</title><link>https://www.bitdoze.com/fish-shell-functions-custom-commands/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-functions-custom-commands/</guid><description>How to create, edit, save, and autoload functions in Fish Shell. Covers event handlers, argument parsing, scope, and practical examples.</description><pubDate>Mon, 23 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Functions in Fish are how you build reusable commands. If you&apos;ve used Bash functions before, the idea is the same, but Fish&apos;s implementation is cleaner — no curly braces, explicit argument handling through `$argv`, and a lazy-loading system that keeps startup fast.

I use functions for everything from quick shortcuts to project-specific tooling. This guide covers creating them, saving them permanently, and the Fish-specific features that make them more useful than Bash functions.

## Creating a basic function

```fish
function greet
    echo &quot;Hello, $argv&quot;
end
```

Run `greet World` and it prints `Hello, World`. The variable `$argv` contains all arguments passed to the function. You can access individual arguments with `$argv[1]`, `$argv[2]`, etc.

That function only lasts for the current shell session. Close the terminal and it&apos;s gone. I&apos;ll cover how to make functions permanent in a moment.

### Functions with named arguments

Fish doesn&apos;t have named parameters the way Python does, but `--argument-names` gives you something close:

```fish
function mkcd --argument-names dir
    mkdir -p $dir
    cd $dir
end
```

Now `mkcd projects/new-app` creates the directory and moves into it. The first argument gets assigned to `$dir`. Extra arguments are still available through `$argv`.

You can list multiple argument names:

```fish
function connect --argument-names host port
    ssh -p $port $host
end
```

### Adding a description

```fish
function ll --description &quot;List files in long format&quot;
    ls -la $argv
end
```

The description shows up when you run `functions` or `type ll`. It&apos;s documentation for future-you.

## Saving functions permanently

Fish has three ways to keep functions across sessions.

### Method 1: funcsave (the Fish way)

Create a function interactively, then save it:

```fish
function weather --argument-names city
    curl &quot;wttr.in/$city?format=3&quot;
end

funcsave weather
```

This saves the function to `~/.config/fish/functions/weather.fish`. Fish automatically loads it when you use the command in any future session — not at startup, but on demand. This lazy loading is why Fish stays fast regardless of how many saved functions you have.

### Method 2: Create the file directly

Write the function file yourself:

```fish
# ~/.config/fish/functions/weather.fish
function weather --argument-names city
    curl &quot;wttr.in/$city?format=3&quot;
end
```

Same result as `funcsave`. I prefer this method for functions I want to version-control with my dotfiles.

### Method 3: Put it in config.fish

```fish
# ~/.config/fish/config.fish
function weather --argument-names city
    curl &quot;wttr.in/$city?format=3&quot;
end
```

This works but has two downsides: the function loads on every shell startup (not lazily), and your config.fish gets longer. Use autoloading files for anything beyond trivial functions.

&lt;Notice type=&quot;info&quot; title=&quot;Autoloading rules&quot;&gt;
For autoloading to work, the file name must match the function name. A function called `weather` must be in `weather.fish`. If the file contains multiple functions, only the one matching the filename will autoload.
&lt;/Notice&gt;

## Editing functions

Fish has a built-in function editor:

```fish
funced weather
```

This opens the function in `$EDITOR` (or `$VISUAL`). When you save and close the editor, Fish loads the updated function into your current session. You can then persist it with `funcsave weather`.

To see a function&apos;s current definition without editing:

```fish
functions weather
# or
type weather
```

## Practical function examples

### Git shortcut with defaults

```fish
function gc --description &quot;Git commit with message&quot;
    if test (count $argv) -eq 0
        echo &quot;Usage: gc &lt;message&gt;&quot;
        return 1
    end
    git add --all
    git commit -m &quot;$argv&quot;
end
```

Usage: `gc &quot;fix login redirect&quot;` stages everything and commits with that message.

### Quick project switcher

```fish
function proj --argument-names name
    set -l base ~/projects
    if test -z &quot;$name&quot;
        ls $base
        return
    end
    if test -d $base/$name
        cd $base/$name
    else
        echo &quot;Project &apos;$name&apos; not found in $base&quot;
        return 1
    end
end
```

Run `proj` to list projects, or `proj myapp` to jump to `~/projects/myapp`. Add tab completions for it too:

```fish
# ~/.config/fish/completions/proj.fish
complete -c proj -f -a &quot;(ls ~/projects)&quot;
```

Now `proj` followed by Tab lists your project directories. See my [autocomplete guide](/fish-shell-autocomplete-suggestions/) for more on writing completions.

### Backup with timestamp

```fish
function bak --argument-names file
    if test -z &quot;$file&quot;
        echo &quot;Usage: bak &lt;file&gt;&quot;
        return 1
    end
    cp $file $file.bak.(date +%Y%m%d-%H%M%S)
end
```

`bak config.yaml` creates `config.yaml.bak.20260224-141500`.

### Docker cleanup

```fish
function docker-clean --description &quot;Remove stopped containers, dangling images, unused volumes&quot;
    echo &quot;Removing stopped containers...&quot;
    docker container prune -f
    echo &quot;Removing dangling images...&quot;
    docker image prune -f
    echo &quot;Removing unused volumes...&quot;
    docker volume prune -f
end
```

## Event handlers

Functions can respond to events. This is useful for running code when variables change, when a command finishes, or when Fish exits.

### Run code when a variable changes

```fish
function __on_pwd_change --on-variable PWD
    if test -f .node-version
        echo &quot;Node version: &quot;(cat .node-version)
    end
end
```

Every time you change directories, this checks for a `.node-version` file. The `--on-variable PWD` flag triggers the function whenever `$PWD` changes.

### Run code on Fish exit

```fish
function __on_exit --on-event fish_exit
    echo &quot;Goodbye!&quot;
end
```

### Run code after a command finishes

```fish
function __notify_long_command --on-event fish_postexec
    if test $CMD_DURATION -gt 10000
        echo &quot;Command took &quot;(math $CMD_DURATION / 1000)&quot; seconds&quot;
    end
end
```

This prints a notice when any command takes more than 10 seconds. `$CMD_DURATION` is a special Fish variable that holds the last command&apos;s execution time in milliseconds.

&lt;Notice type=&quot;warning&quot; title=&quot;Event handler naming&quot;&gt;
Event handler functions should start with double underscores or a unique prefix to avoid name collisions. Also, event handlers in autoloaded files won&apos;t trigger until the function has been loaded once. For handlers that need to work from the start, put them in `config.fish` or `conf.d/`.
&lt;/Notice&gt;

## Scope and variable visibility

Functions have their own local scope by default. Variables set with `set -l` inside a function aren&apos;t visible outside it:

```fish
function test_scope
    set -l secret &quot;hidden&quot;
    echo $secret
end
test_scope  # prints &quot;hidden&quot;
echo $secret  # prints nothing
```

Use `set -g` for global variables (visible everywhere in the session) or `set -U` for universal variables (persist across all Fish sessions):

```fish
set -g session_var &quot;I last until you close this terminal&quot;
set -U persistent_var &quot;I survive restarts&quot;
```

## Functions vs abbreviations vs aliases

Fish has three ways to create shortcuts. Here&apos;s when to use each:

| | Functions | Abbreviations | Aliases |
|---|---|---|---|
| Best for | Complex logic, multi-line commands | Simple command shortcuts | Simple command wrapping |
| Expansion | No expansion, runs as-is | Expands on command line before running | Wraps as a function internally |
| History shows | Function name | Expanded command | Alias name |
| Arguments | Full `$argv` handling | Limited (position/regex-based) | Pass-through `$argv` |

I use abbreviations for simple shortcuts (`gs` → `git status`), and functions for anything that needs logic. I covered abbreviations vs aliases in detail in [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/).

## Managing functions

```fish
functions                    # list all defined functions
functions -n                 # list function names only
functions weather            # show a function&apos;s definition
functions -e weather         # erase a function
funcsave weather             # save to autoload file
funced weather               # edit in $EDITOR
```

To delete a saved function permanently:

```fish
functions -e weather
funcsave weather
```

The second command writes the &quot;erased&quot; state, removing the file from `~/.config/fish/functions/`.

## Related guides

- [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) — writing custom completions for your functions
- [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/) — when to use each
- [Best Fish Shell plugins](/best-fish-shell-plugins/) — extend Fish with Fisher and community plugins
- [Fish Shell on macOS](/fish-shell-macos-setup/) — if you&apos;re setting up Fish on a Mac
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — getting started on Linux</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell Abbreviations vs Aliases - What&apos;s the Difference?</title><link>https://www.bitdoze.com/fish-shell-abbreviations-vs-aliases/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-abbreviations-vs-aliases/</guid><description>Fish Shell has both abbreviations and aliases. This guide explains how each works, when to use which, and why abbreviations are usually the better choice.</description><pubDate>Sun, 22 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

If you&apos;re coming to Fish from Bash or Zsh, you probably set up aliases for frequently-used commands. Fish has aliases too, but it also has something called abbreviations, and they work differently in a way that matters.

I switched all my aliases to abbreviations after using Fish for about a week, and I think most people should do the same. Here&apos;s why.

## What are aliases in Fish?

An alias in Fish works the same as in other shells. You define a shortcut that expands to a longer command:

```fish
alias gs=&quot;git status&quot;
alias ll=&quot;ls -la&quot;
alias dc=&quot;docker compose&quot;
```

When you type `gs` and press enter, Fish runs `git status`. In your command history, it saves `gs`, not `git status`.

Fish actually implements aliases as wrapper functions. When you run `alias gs=&quot;git status&quot;`, Fish creates a function called `gs` that runs `git status`. You can verify this with:

```fish
type gs
# Output: gs is a function with definition
# function gs --wraps=&apos;git status&apos; --description &apos;alias gs=git status&apos;
#   git status $argv
# end
```

To make aliases persistent, add them to `~/.config/fish/config.fish` or a file in `~/.config/fish/conf.d/`.

## What are abbreviations?

Abbreviations look similar when you define them:

```fish
abbr -a gs git status
abbr -a ll ls -la
abbr -a dc docker compose
```

The difference is what happens when you use them. Type `gs` and press space or enter, and Fish replaces `gs` with `git status` *on the command line* before running it. You see the full `git status` text, and that&apos;s what goes into your history.

This is a text-expansion system, not a command wrapper. Your abbreviation triggers, the text on your command line changes, and then whatever&apos;s on the line gets executed.

## Why abbreviations are usually better

### Your history stays readable

With aliases, your history is full of short codes. Someone looking at `gs` in your [history](/fish-shell-history-persistence/) (or you, six months later) has to remember what `gs` means. With abbreviations, the history shows `git status` because that&apos;s what actually ran.

This also means `Ctrl+R` history search works with the real commands. Search for &quot;git status&quot; and you&apos;ll find it, even though you typed `gs`.

### Other people can read your screen

If you share your terminal (pair programming, screen recordings, tutorials), abbreviations show the real commands. Nobody has to decode your personal shorthand.

### You can edit before running

After an abbreviation expands, the full text is on your command line and you can modify it. Type `gs`, press space (it expands to `git status`), then add ` --short` at the end. With aliases, you can&apos;t easily insert flags into the middle of the aliased command.

### No function overhead

Aliases create wrapper functions. Abbreviations don&apos;t, they&apos;re pure text replacement. The difference is tiny in practice, but abbreviations are conceptually simpler.

## When to use aliases instead

Aliases are still useful in a few cases:

**When you need to wrap command arguments.** If your shortcut needs to manipulate arguments in ways that go beyond text replacement, a [function](/fish-shell-functions-custom-commands/) makes more sense:

```fish
# This needs to be a function, not an abbreviation
function mkcd
    mkdir -p $argv[1] &amp;&amp; cd $argv[1]
end
```

**When you need default flags on existing commands.** If you want `ls` to always use `--color=auto`, an alias works:

```fish
alias ls=&quot;ls --color=auto&quot;
```

You could do this with an abbreviation, but the expansion would be visible every time, which gets noisy for commands you run constantly.

**When you want a completely different command name** that doesn&apos;t need to show its expansion. Some people prefer keeping their shortcuts opaque.

## Abbreviation features

Fish abbreviations have gotten more powerful over time, especially in Fish 4.x. Here are the options worth knowing.

### Position: command vs. anywhere

By default, abbreviations only expand when they&apos;re in command position (the first word on the line):

```fish
abbr -a gs git status        # only expands as a command
abbr -a -p anywhere L &quot;| less&quot;   # expands anywhere on the line
```

The `L` abbreviation lets you type `cat file.txt L` and it expands to `cat file.txt | less`.

### Command-specific abbreviations

Since Fish 4.0, abbreviations can be restricted to specific commands:

```fish
abbr -a --command git co checkout
abbr -a --command git br branch
abbr -a --command docker ps &quot;ps --format &apos;table {{.Names}}\t{{.Status}}&apos;&quot;
```

Now `co` only expands to `checkout` when the command on the line is `git`. Type `co` on its own and nothing happens.

This is great for git workflows. You can type `git co` and have it expand to `git checkout` without polluting the global abbreviation namespace.

### Regex abbreviations

You can use regular expressions to match abbreviation triggers:

```fish
abbr -a --regex &apos;.+\.txt&apos; --position command --function vim_edit
```

This would match any word ending in `.txt` and run it through a custom function. The Fish docs have more examples of this.

### Cursor placement with --set-cursor

You can control where the cursor ends up after expansion:

```fish
abbr -a gcm --set-cursor &quot;git commit -m &apos;%&apos;&quot;
```

Type `gcm`, press space, and it expands to `git commit -m &apos;&apos;` with your cursor between the quotes. The `%` marker (the default) gets removed and the cursor lands there.

### Function-based abbreviations

For dynamic expansions, you can point an abbreviation at a function:

```fish
function last_history_item
    echo $history[1]
end
abbr -a !! --position anywhere --function last_history_item
```

This recreates Bash&apos;s `!!` (last command) feature. Type `sudo !!` and it expands to `sudo &lt;your-last-command&gt;`.

## Managing abbreviations

```fish
abbr                   # list all abbreviations (output is re-usable as commands)
abbr --list            # list abbreviation names only
abbr --erase gs        # remove an abbreviation
abbr --rename gs gst   # rename an abbreviation
```

### Saving abbreviations

The recommended approach is to put your `abbr -a` commands in a config file:

```fish
# ~/.config/fish/conf.d/abbreviations.fish
abbr -a gs git status
abbr -a ga git add
abbr -a gc git commit
abbr -a gp git push
abbr -a gd git diff
abbr -a gl git log --oneline
abbr -a dc docker compose
abbr -a dcu docker compose up -d
abbr -a dcd docker compose down
abbr -a k kubectl
```

You can also dump your current abbreviations to a file:

```fish
abbr &gt; ~/.config/fish/conf.d/abbreviations.fish
```

This saves them in a format that Fish can source directly.

## My abbreviation setup

Here&apos;s what I actually use:

```fish
# Git
abbr -a gs git status
abbr -a ga git add
abbr -a gaa git add --all
abbr -a gc git commit
abbr -a gcm --set-cursor &quot;git commit -m &apos;%&apos;&quot;
abbr -a gp git push
abbr -a gl git log --oneline
abbr -a gco git checkout
abbr -a gd git diff
abbr -a gb git branch

# Docker
abbr -a dc docker compose
abbr -a dcu docker compose up -d
abbr -a dcd docker compose down
abbr -a dcl docker compose logs -f
abbr -a dps docker ps

# Navigation
abbr -a -p anywhere L &quot;| less&quot;
abbr -a -p anywhere G &quot;| grep&quot;

# System
abbr -a sa sudo apt
abbr -a sai sudo apt install
```

If you&apos;re setting up Fish for the first time, check my [Fish Shell installation guide](/install-fish-shell-ubuntu/). For plugins that complement abbreviations, see [best Fish Shell plugins and tools](/best-fish-shell-plugins/). And for a broader look at Fish compared to other shells, read [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) or the focused [Fish vs Zsh](/fish-shell-vs-zsh/) comparison. You might also want to explore [Fish&apos;s autocomplete and suggestions](/fish-shell-autocomplete-suggestions/), which pair well with abbreviations.</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell Autocomplete &amp; Suggestions Guide</title><link>https://www.bitdoze.com/fish-shell-autocomplete-suggestions/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-autocomplete-suggestions/</guid><description>How Fish Shell&apos;s autosuggestions and tab completions work, how to configure them, and how to write your own custom completions.</description><pubDate>Sun, 22 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

The first thing people notice when they open Fish is the autosuggestions. You start typing and gray text appears after your cursor, predicting what you want. It feels like the shell can read your mind. Compared to [Bash or Zsh](/fish-shell-vs-bash-vs-zsh/), where you need plugins for anything like this, Fish just does it from a fresh install.

This guide covers how both systems work — autosuggestions (the gray inline predictions) and tab completions (the menu that appears when you press Tab) — and how to configure and extend them.

## Autosuggestions

Autosuggestions are the grayed-out text that appears as you type. Fish pulls these from two sources: your command history and available completions (file paths, command names, etc.). History matches take priority, so the more you use a command, the faster it appears as a suggestion.

### How to accept suggestions

- **Right arrow** — accept the entire suggestion
- **Alt+Right arrow** (or `Alt+F`) — accept one word at a time
- **Ctrl+F** — same as right arrow, accept the full suggestion
- Keep typing — ignore the suggestion entirely

Accepting word by word is useful when the suggestion is close but not exactly what you want. If Fish suggests `git commit -m &quot;fix auth bug&quot;` and you want `git commit -m &quot;fix login flow&quot;`, press `Alt+Right` three times to accept `git commit -m` and then type your own message.

### How Fish ranks suggestions

Fish picks suggestions based on:

1. Commands from your history that match what you&apos;ve typed so far, most recent first
2. File paths and completions that match the current context

If you&apos;ve run `docker compose up -d` twenty times this week, typing `do` will almost certainly suggest that full command.

### Disabling autosuggestions

Some people find them distracting. Turn them off:

```fish
set -g fish_autosuggestion_enabled 0
```

Add that to `~/.config/fish/config.fish` to make it permanent.

## Tab completions

Press Tab and Fish shows possible completions in a pager-style menu below your command line. Each completion comes with a description, so you&apos;re not guessing what `-v` does for a particular command.

### How to navigate the completion menu

- **Tab** — open completions, or cycle forward through them
- **Shift+Tab** — cycle backward
- **Arrow keys** — navigate the completion list
- **Enter** — select the highlighted completion
- **Ctrl+S** — open a search prompt within the completion menu (useful when there are many results)

### Where completions come from

Fish pulls completions from several sources:

**Man pages.** Fish parses man pages in the background and generates completions for command flags and options. This is why `rsync --` followed by Tab shows you every `rsync` flag with descriptions, right out of the box.

**Built-in completions.** Fish ships with hand-written completion scripts for hundreds of commands: `git`, `docker`, `ssh`, `systemctl`, `apt`, `brew`, `npm`, and many more. These are stored in Fish&apos;s data directory (usually `/usr/share/fish/completions/`).

**Custom completions.** You can write your own. More on this below.

**File paths.** Fish completes file and directory names by default when no other completions match.

### Forcing file completion

Sometimes Fish&apos;s smart completions hide file paths. Press `Alt+E` (or `Alt+O` on some systems) to explicitly complete a file path, bypassing the programmed completions.

## Writing custom completions

This is where Fish&apos;s completion system gets interesting. You can add completions for your own scripts, internal tools, or commands that don&apos;t have good completions yet.

Completion files go in `~/.config/fish/completions/` and are named after the command: `mycommand.fish` for the command `mycommand`.

### Basic example

Say you have a script called `deploy` that takes subcommands `staging`, `production`, and `rollback`:

```fish
# ~/.config/fish/completions/deploy.fish

# Disable file completions (deploy doesn&apos;t take filenames)
complete -c deploy -f

# Add subcommands
complete -c deploy -a &quot;staging production rollback&quot; -d &quot;Deployment target&quot;

# Add flags
complete -c deploy -s v -l verbose -d &quot;Verbose output&quot;
complete -c deploy -s d -l dry-run -d &quot;Show what would happen without doing it&quot;
```

Now `deploy` followed by Tab shows your three subcommands, and `deploy --` shows the two flags.

### Context-aware completions

Completions can be conditional. Use `-n` (condition) to show certain completions only in certain contexts:

```fish
# Only show these when no subcommand has been given yet
complete -c deploy -n &quot;not __fish_seen_subcommand_from staging production rollback&quot; \
    -a &quot;staging production rollback&quot;

# Only show branch names after &quot;deploy staging&quot;
complete -c deploy -n &quot;__fish_seen_subcommand_from staging&quot; \
    -a &quot;(git branch --format=&apos;%(refname:short)&apos;)&quot; -d &quot;Git branch&quot;
```

The `__fish_seen_subcommand_from` helper function checks whether any of the given words have appeared in the command line. Fish ships with several helper functions like this — look through `/usr/share/fish/completions/git.fish` for a real-world example.

### Completions with dynamic data

The `-a` flag accepts command substitutions:

```fish
# Complete running Docker container names
complete -c myapp -l container \
    -a &quot;(docker ps --format &apos;{{.Names}}&apos;)&quot; \
    -d &quot;Container name&quot;

# Complete from a config file
complete -c myapp -l profile \
    -a &quot;(cat ~/.myapp/profiles | string split \\n)&quot; \
    -d &quot;Config profile&quot;
```

The command inside `()` runs every time you press Tab, so the completions stay up to date.

## Tuning completion behavior

### Completion colors

Fish colors the completion pager based on these variables:

```fish
set fish_color_search_match --background=yellow  # highlighted match
set fish_pager_color_completion normal             # regular completion text
set fish_pager_color_description grey              # description text
set fish_pager_color_prefix cyan --underline       # matched prefix
```

You can also set these through `fish_config` in the browser interface.

### Completion performance

Man page parsing happens in the background when Fish starts. If you install new programs and their completions don&apos;t appear, run:

```fish
fish_update_completions
```

This regenerates completions from man pages. It takes a few seconds.

### Case sensitivity

Tab completion in Fish is case-insensitive by default. Type `doc` and it matches both `Documents/` and `docker`. If you type an uppercase letter, Fish switches to case-sensitive matching for that completion.

## Autosuggestions vs tab completions

These are two separate features that people sometimes confuse:

| | Autosuggestions | Tab completions |
|---|---|---|
| Trigger | Automatic as you type | Press Tab |
| Display | Gray text after cursor | Menu below command line |
| Source | History + completions | Completions only |
| Accept | Right arrow / Ctrl+F | Enter / Tab |
| Shows options | One suggestion at a time | All matching options |

They work together. Autosuggestions give you quick history recall without stopping. Tab completions give you a browseable list when you need to explore options.

## Comparing with Zsh and Bash

In [Zsh](/fish-shell-vs-zsh/), you get similar autosuggestions with the `zsh-autosuggestions` plugin. Zsh&apos;s `compinit` completion system is powerful but needs configuration. Fish&apos;s advantage is that all of this works without setup.

Bash has basic tab completion through `readline` and `bash-completion`. It works for simple cases but doesn&apos;t generate completions from man pages and shows no descriptions.

If you want to set up Fish from scratch, see my [Ubuntu installation guide](/install-fish-shell-ubuntu/) or the [macOS setup guide](/fish-shell-macos-setup/). For plugins that improve Fish completions further, like fzf.fish for fuzzy searching, check [best Fish Shell plugins](/best-fish-shell-plugins/).</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>NanoClaw Deploy Guide: Container-Isolated Claude Agent on Your VPS</title><link>https://www.bitdoze.com/nanoclaw-deploy-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/nanoclaw-deploy-guide/</guid><description>Step-by-step guide to deploying NanoClaw on a Linux VPS with WhatsApp integration, agent swarms, and container isolation. Covers Docker setup, skills, scheduled tasks, and security.</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

[NanoClaw](https://github.com/qwibitai/nanoclaw) caught my attention because it does security differently from every other self-hosted bot I&apos;ve tried. Instead of application-level permission checks, it runs every agent in an actual Linux container. The agent can only see what&apos;s explicitly mounted. Bash commands run inside the container, not on your host. It also supports [Agent Swarms](https://code.claude.com/docs/en/agent-teams), where multiple agents work together on the same task.

This guide walks through deploying NanoClaw on a VPS with WhatsApp integration, scheduled tasks, and proper container isolation.

&lt;Button text=&quot;NanoClaw GitHub&quot; link=&quot;https://github.com/qwibitai/nanoclaw&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing NanoClaw via Claude Code&apos;s /setup command&lt;/li&gt;
&lt;li&gt;Docker container configuration for agent isolation&lt;/li&gt;
&lt;li&gt;WhatsApp channel setup with Baileys&lt;/li&gt;
&lt;li&gt;Configuring agent swarms for multi-agent tasks&lt;/li&gt;
&lt;li&gt;Skills system and customization&lt;/li&gt;
&lt;li&gt;Scheduled tasks and group context isolation&lt;/li&gt;
&lt;li&gt;Security model and best practices&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted bot options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup includes NanoClaw alongside ZeroClaw, nanobot, memU, and PicoClaw.

## What NanoClaw actually is

NanoClaw is a lightweight alternative to OpenClaw that runs on Claude&apos;s Agent SDK. The key difference from other bots is the isolation model. Agents execute in Linux containers (Docker on Linux/macOS, Apple Container on macOS) with explicitly mounted directories. The agent can&apos;t escape the container, and it can only access what you allow.

The architecture looks like this:

```
WhatsApp (Baileys) --&gt; SQLite --&gt; Polling loop --&gt; Container (Claude Agent SDK) --&gt; Response
```

Single Node.js process. Per-group message queues. IPC via filesystem. No microservices, no message queues, no abstraction layers. The whole codebase is small enough to read in a single sitting.

### How it compares

| | OpenClaw | nanobot | NanoClaw 🐾 |
|---|---|---|---|
| **Language** | TypeScript | Python | **TypeScript** |
| **Isolation** | App-level | App-level | **Container-level** |
| **Agent SDK** | Custom | LiteLLM | **Claude Agent SDK** |
| **Agent Swarms** | No | No | **Yes (first)** |
| **Channels** | 4 platforms | 9 platforms | **6+ (via skills)** |
| **Setup** | Custom script | `pip install` | **Claude Code /setup** |
| **Customization** | Config files | Config + files | **Skills + code** |

## Philosophy: Why NanoClaw exists

OpenClaw has 52+ modules, 8 config management files, 45+ dependencies, and abstractions for 15 channel providers. Everything runs in one Node process with shared memory. Security is application-level — allowlists and pairing codes, not OS isolation.

NanoClaw takes a different approach:

**Small enough to understand.** One process, a few source files. The codebase is small enough that Claude Code can walk you through it and safely modify it.

**Secure by isolation.** Agents run in Linux containers. They can only see what&apos;s explicitly mounted. Bash access is safe because commands run inside the container, not on your host.

**Built for one user.** This isn&apos;t a framework. It&apos;s working software that you fork and have Claude Code customize for your exact needs.

**AI-native.** No installation wizard. Claude Code guides setup. No monitoring dashboard. Ask Claude what&apos;s happening. No debugging tools. Describe the problem, Claude fixes it.

**Skills over features.** Contributors don&apos;t add features to the codebase. They contribute Claude Code skills like `/add-telegram` that transform your fork. You end up with clean code that does exactly what you need.

## Requirements

Before starting, make sure you have:

| Requirement | Why |
|-------------|-----|
| macOS or Linux | NanoClaw runs on Unix-like systems |
| Node.js 20+ | Runtime environment |
| [Claude Code](https://claude.ai/download) | Setup wizard and agent orchestration |
| Docker or Apple Container | Container runtime for agent isolation |
| Anthropic API key | Claude access via Agent SDK |

&lt;Button text=&quot;Try Hetzner Cloud&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;

For this VPS deployment guide, I&apos;m assuming Ubuntu 24.04 with Docker.

## Installation

NanoClaw uses Claude Code for setup. There&apos;s no `npm install` or `pip install` wizard. You clone the repo and let Claude handle everything.

```bash
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw
claude
```

Then run `/setup`. Claude Code handles:

1. Installing Node.js dependencies
2. WhatsApp authentication
3. Container runtime detection and configuration
4. Service configuration
5. Initial skills setup

The setup process is interactive. Claude will ask questions and configure everything based on your answers.

&lt;Notice type=&quot;info&quot; title=&quot;Claude Code setup&quot;&gt;
NanoClaw is designed around Claude Code as the configuration interface. If you haven&apos;t used Claude Code before, it&apos;s Anthropic&apos;s CLI tool that can execute commands and modify files. The `/setup` command is a skill that guides the entire installation.
&lt;/Notice&gt;

## Container isolation

This is the main thing that sets NanoClaw apart. Agents run in containers, not in the main process.

### How it works

When you send a message to NanoClaw:

1. Message arrives via WhatsApp (Baileys library)
2. Message stored in SQLite with group context
3. Polling loop picks up the message
4. Container runner spawns an isolated container
5. Claude Agent SDK processes the message inside the container
6. Response written to IPC filesystem
7. Main process sends response back to WhatsApp

The container has no network access by default. It can only see the directories you explicitly mount. Shell commands run inside the container, so even if the agent tries something dangerous, it&apos;s contained.

### Docker configuration

On Linux, NanoClaw uses Docker for container isolation. The container runner spawns a new container for each agent invocation:

```typescript
// src/container-runner.ts (simplified)
const container = await docker.createContainer({
  Image: &apos;nanoclaw-agent&apos;,
  Cmd: [&apos;node&apos;, &apos;agent-entrypoint.js&apos;],
  HostConfig: {
    Binds: [`${groupDir}:/workspace:rw`],
    NetworkMode: &apos;none&apos;, // No network by default
  },
});
```

Each group gets its own mounted directory. The container can only see that group&apos;s files.

### Apple Container (macOS)

On macOS, you can optionally switch to Apple Container for a lighter-weight native runtime:

```
/convert-to-apple-container
```

This modifies the configuration to use Apple&apos;s container runtime instead of Docker. The isolation model is the same, but the overhead is lower on macOS.

## WhatsApp setup

NanoClaw supports WhatsApp, Telegram, Discord, Slack, Signal, and headless operation. WhatsApp via the Baileys library is the default channel, with others available through skills.

### Authentication

Run the setup command in Claude Code:

```
/setup
```

Claude will guide you through WhatsApp authentication. The process generates a QR code that you scan with your phone:

1. Open WhatsApp on your phone
2. Go to Settings → Linked Devices
3. Tap &quot;Link a Device&quot;
4. Scan the QR code shown by NanoClaw

The authentication session is stored locally. You&apos;ll stay logged in until you explicitly log out or the session expires.

### Group isolation

Each WhatsApp group gets its own context:

| Isolation | What it means |
|-----------|---------------|
| `CLAUDE.md` | Each group has its own memory file |
| Filesystem | Each group&apos;s files are in separate directories |
| Container | Each group runs in its own container sandbox |
| History | Conversations are not shared between groups |

The main channel (your self-chat) is special. From there, you can manage groups and tasks:

```
@Andy list all scheduled tasks across groups
@Andy pause the Monday briefing task
@Andy join the Family Chat group
```

### Trigger word

By default, the bot responds to `@Andy`. Change it by telling Claude Code:

```
Change the trigger word to @Bob
```

Or run `/customize` for guided changes.

## Agent Swarms

NanoClaw supports [Agent Swarms](https://code.claude.com/docs/en/agent-teams), where multiple agents split up the work on a single request.

### What are Agent Swarms

Instead of one agent handling everything, you can spin up a team:

```
@Andy research the latest React patterns and have the code reviewer check if our codebase follows them
```

This might spawn:
- **Researcher agent**: Searches for latest React patterns
- **Code reviewer agent**: Analyzes your codebase against the patterns
- **Coordinator**: Synthesizes findings into a response

Each agent runs in its own container with access to the relevant tools and files.

### Configuring swarms

Agent swarms are configured through skills. Tell Claude Code what kind of team you want:

```
Create a swarm for daily news briefing with separate agents for:
- Tech news fetching
- AI news fetching  
- Summary writing
```

Claude will create the necessary skill files and configuration.

## Skills system

NanoClaw doesn&apos;t use configuration files for customization. It uses skills.

### What are skills

Skills are instructions that teach Claude Code how to transform your NanoClaw installation. They&apos;re Markdown files in `.claude/skills/`:

```
.claude/skills/
├── add-telegram/SKILL.md
├── customize/SKILL.md
├── setup/SKILL.md
└── ...
```

### Built-in skills

| Skill | What it does |
|-------|--------------|
| `/setup` | Initial installation and configuration |
| `/customize` | Guided customization of behavior |
| `/add-gmail` | Add Gmail integration |
| `/add-telegram` | Add Telegram as channel (contributed) |
| `/add-slack` | Add Slack integration (contributed) |
| `/create-skill` | Meta-skill for creating new skills |

### Customizing behavior

There are no configuration files to learn. Just tell Claude Code what you want:

```
Change the trigger word to @Bob
Remember to make responses shorter and more direct
Add a custom greeting when I say good morning
Store conversation summaries weekly
```

The codebase is small enough that Claude can safely modify it. Each customization becomes part of your fork.

### Contributing skills

**Don&apos;t add features. Add skills.**

If you want to add Telegram support, don&apos;t create a PR that adds Telegram alongside WhatsApp. Instead, contribute a skill file that teaches Claude Code how to transform a NanoClaw installation to use Telegram.

Users then run `/add-telegram` on their fork and get clean code that does exactly what they need.

## Scheduled tasks

NanoClaw has a built-in task scheduler for recurring jobs.

### Creating scheduled tasks

Tell the bot what you want scheduled:

```
@Andy send an overview of the sales pipeline every weekday morning at 9am
@Andy review the git history for the past week each Friday and update the README if there&apos;s drift
@Andy every Monday at 8am, compile news on AI developments from Hacker News and TechCrunch and message me a briefing
```

The bot creates scheduled tasks that run Claude and can message you back.

### Managing tasks

From the main channel (your self-chat):

```
@Andy list all scheduled tasks across groups
@Andy pause the Monday briefing task
@Andy resume the Friday git review
```

Tasks are stored in SQLite and persist across restarts.

## Memory system

Each group has its own `CLAUDE.md` file for memory:

```
groups/
├── main/
│   └── CLAUDE.md
├── family-chat/
│   └── CLAUDE.md
└── work-team/
    └── CLAUDE.md
```

The agent reads this file at the start of each conversation and can update it. Tell it to remember something and it writes to the file.

### Context files

Like other bots in this space, NanoClaw uses workspace files to shape behavior:

| File | Purpose |
|------|---------|
| `CLAUDE.md` | Per-group memory and context |
| `IDENTITY.md` | Who the agent is |
| `SOUL.md` | Core personality and values |

These are plain Markdown files that you can edit directly or have Claude modify.

## Security model

NanoClaw&apos;s security is based on OS-level isolation, not application-level checks.

### Container sandboxing

| Layer | Protection |
|-------|------------|
| Network | Containers have no network by default |
| Filesystem | Only explicitly mounted directories are visible |
| Process | Container process can&apos;t access host processes |
| User | Container runs as non-root user |

Even if the agent tries to run dangerous commands, it&apos;s contained. The host system is protected.

### What you should still review

Container isolation is strong, but you should still review:

1. **What directories you mount** — Only mount directories the agent actually needs
2. **What skills you install** — Skills can modify the codebase
3. **Scheduled tasks** — Tasks run automatically with whatever permissions the agent has

See `docs/SECURITY.md` in the repo for the full security model.

## VPS deployment

For production use on a VPS, here&apos;s a complete deployment setup.

### Server requirements

| Spec | Minimum | Recommended |
|------|---------|-------------|
| CPU | 1 core | 2+ cores |
| RAM | 1GB | 2GB+ |
| Storage | 10GB | 20GB+ |
| OS | Ubuntu 22.04 | Ubuntu 24.04 |

NanoClaw itself is lightweight. The main resource consumer is Docker for container isolation.

### Quick deployment

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER

# Install Node.js 20
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt install -y nodejs

# Install Claude Code
npm install -g @anthropic-ai/claude-code

# Clone NanoClaw
git clone https://github.com/qwibitai/NanoClaw.git
cd NanoClaw

# Run setup
claude
# Then type: /setup
```

### Systemd service

Create a systemd service for automatic startup:

```bash
cat &gt; /etc/systemd/system/nanoclaw.service &lt;&lt; &apos;EOF&apos;
[Unit]
Description=NanoClaw AI Assistant
After=docker.service network.target

[Service]
Type=simple
User=dragos
WorkingDirectory=/home/dragos/nanoclaw
ExecStart=/usr/bin/node dist/index.js
Restart=always
RestartSec=10
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
EOF

systemctl daemon-reload
systemctl enable nanoclaw
systemctl start nanoclaw
```

### Environment variables

Create a `.env` file with your API keys:

```bash
ANTHROPIC_API_KEY=sk-ant-...
```

The setup process will prompt for this, but you can also set it manually.

## Troubleshooting

NanoClaw is designed to be debugged through Claude Code. No log viewers or debug dashboards.

### Using /debug

If something isn&apos;t working:

```
/debug
```

Claude Code will analyze the logs, check the configuration, and suggest fixes. If it finds an issue that&apos;s likely affecting other users, you can open a PR to modify the setup skill.

### Common issues

&lt;Accordion label=&quot;Common issues&quot; group=&quot;troubleshooting&quot; expanded=&quot;false&quot;&gt;

**WhatsApp not connecting**

Run `/debug` and Claude will check:
- Session file validity
- Network connectivity
- Baileys library status

You may need to re-scan the QR code if the session expired.

**Container not starting**

Check that Docker is running:
```bash
docker ps
```

If Docker is running but containers aren&apos;t spawning, check the logs:
```bash
journalctl -u nanoclaw -f
```

**Scheduled tasks not running**

From the main channel:
```
@Andy why isn&apos;t the scheduler running?
```

The agent can diagnose its own scheduler issues.

**Agent responses are slow**

Container startup adds latency. For faster responses:
- Use a smaller base image
- Enable container reuse (advanced)
- Check Docker resource limits

&lt;/Accordion&gt;

## Architecture deep dive

For those who want to understand the codebase:

```
src/
├── index.ts           # Orchestrator: state, message loop, agent invocation
├── channels/
│   └── whatsapp.ts    # WhatsApp connection, auth, send/receive
├── ipc.ts             # IPC watcher and task processing
├── router.ts          # Message formatting and outbound routing
├── group-queue.ts     # Per-group queue with global concurrency limit
├── container-runner.ts # Spawns streaming agent containers
├── task-scheduler.ts  # Runs scheduled tasks
└── db.ts              # SQLite operations (messages, groups, sessions, state)
```

Key patterns:

- **Single process**: Everything runs in one Node.js process
- **Per-group queues**: Each group has its own message queue with concurrency control
- **IPC via filesystem**: Communication between main process and containers uses files
- **Streaming responses**: Container output streams back to WhatsApp in real-time

## NanoClaw vs ZeroClaw vs nanobot

I run all three, so here&apos;s a direct comparison:

| Aspect | NanoClaw 🐾 | ZeroClaw 🦀 | nanobot 🐍 |
|--------|------------|------------|-----------|
| Language | TypeScript | Rust | Python |
| Isolation | Containers | App-level | App-level |
| Agent SDK | Claude Agent | Custom | LiteLLM |
| Agent Swarms | Yes | No | No |
| Channels | 6+ (via skills) | 8+ | 9 |
| Setup method | Claude Code | `cargo install` | `pip install` |
| Customization | Skills | Config + code | Config + files |
| RAM usage | ~200MB | &lt; 5MB | ~100MB |

NanoClaw wins on isolation and agent capabilities. ZeroClaw is more resource-efficient. nanobot has broader channel support and the easiest setup.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Why WhatsApp only?**

The author uses WhatsApp. The whole point is to fork it and run a skill to change it if you want something else. That&apos;s the design philosophy.

**Can I run this without Docker?**

No. Container isolation is core to the security model. The agent runs inside containers, not as part of the main process.

**How is this different from just running Claude Code?**

Claude Code is an interactive CLI. NanoClaw is an always-on assistant that you message from your phone. It has memory, scheduled tasks, and group context that persists across conversations.

**Can multiple people use one NanoClaw instance?**

Yes, but each person should be in a different group. Groups are isolated from each other. The main channel (self-chat) is for admin control.

**What&apos;s the cost to run NanoClaw?**

VPS: ~$5/month at [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps). Claude API: depends on usage. The Agent SDK uses Claude tokens, so expect $10-50/month for personal use depending on how much you interact with it.

**Can I use a different LLM?**

NanoClaw is built for Claude&apos;s Agent SDK. It doesn&apos;t support other LLMs out of the box. If you want multi-model support with providers like [MiniMax M2.5 or GLM-5](/best-opensource-models-for-openclaw/), look at [nanobot](/nanobot-setup-guide/), [NullClaw](/nullclaw-deploy-guide/), or [ZeroClaw](/zeroclaw-setup-guide/) instead.

**How do I add Telegram/Discord/Slack?**

Run the appropriate skill:
```
/add-telegram
/add-slack
/add-discord
```

These are contributed skills that teach Claude Code how to transform your installation.

&lt;/Accordion&gt;

If you want a Zig-based alternative that runs on $5 hardware with 22+ providers, see our [NullClaw deploy guide](/nullclaw-deploy-guide/). For a Python-based option with MiniMax M2.5 and GLM-5 support, check the [nanobot setup guide](/nanobot-setup-guide/). For a self-improving assistant with voice mode and OpenClaw migration, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/). For model recommendations that work across these platforms, see [best open source models for OpenClaw](/best-opensource-models-for-openclaw/).

If you want to explore other AI coding tools, our [AI coding tools comparison](/ai-coading-tools/) covers the current landscape. For MCP basics that work across assistants, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

Este artículo también está disponible en español: [Guía de Despliegue de NanoClaw](/es/guia-despliegue-nanoclaw/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>NullClaw Deploy Guide: The Smallest AI Assistant Infrastructure in Zig</title><link>https://www.bitdoze.com/nullclaw-deploy-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/nullclaw-deploy-guide/</guid><description>Step-by-step guide to deploying NullClaw on a Linux VPS. Covers Zig binary, 22+ LLM providers, 17 chat channels, hybrid memory, sandboxing, and running on $5 hardware.</description><pubDate>Sun, 22 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

[NullClaw](https://github.com/nullclaw/nullclaw) is the smallest AI assistant I&apos;ve found that still does everything. A single 678 KB static binary written in Zig, about 1 MB of RAM at runtime. No runtime, no VM, no framework overhead. It boots in under 2 milliseconds and runs on anything with a CPU, including $5 ARM boards.

This guide walks through deploying NullClaw on a VPS with your choice of LLM provider, chat channels, hybrid memory, and proper sandboxing.

&lt;Button text=&quot;NullClaw GitHub&quot; link=&quot;https://github.com/nullclaw/nullclaw&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Building NullClaw from source or using Docker&lt;/li&gt;
&lt;li&gt;Configuring 22+ LLM providers via OpenAI-compatible endpoints&lt;/li&gt;
&lt;li&gt;Setting up Telegram, Discord, Slack, or 10 other channels&lt;/li&gt;
&lt;li&gt;Hybrid memory system with SQLite FTS5 + vector search&lt;/li&gt;
&lt;li&gt;Multi-layer sandboxing (Landlock, Firejail, Bubblewrap, Docker)&lt;/li&gt;
&lt;li&gt;MCP server integration&lt;/li&gt;
&lt;li&gt;Running on edge hardware and $5 boards&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted bot options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup includes NullClaw alongside ZeroClaw, nanobot, [NanoClaw](/nanoclaw-deploy-guide/), and PicoClaw.


## What NullClaw actually is

NullClaw is an AI assistant written entirely in Zig. The tagline is &quot;null overhead, null compromise&quot; — a static binary with zero runtime dependencies that runs on the cheapest hardware you can find.

The architecture uses vtable interfaces for every subsystem. Want to swap your LLM provider? Change one config line. Same for channels, memory backends, tools, tunnels, sandboxes, and peripherals.

```
678 KB binary · &lt;2 ms startup · 3,230+ tests · 22+ providers · 17 channels · Pluggable everything
```

### How it compares

| | [OpenClaw](https://github.com/openclaw/openclaw) | [nanobot](https://github.com/HKUDS/nanobot) | [ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) | **[NullClaw](https://github.com/nullclaw/nullclaw)** |
|---|---|---|---|---|
| **Language** | TypeScript | Python | Rust | **Zig** |
| **RAM** | &gt; 1 GB | &gt; 100 MB | &lt; 5 MB | **~1 MB** |
| **Startup** | &gt; 500 s | &gt; 30 s | &lt; 10 ms | **&lt; 2 ms** |
| **Binary** | ~28 MB | N/A | 3.4 MB | **678 KB** |
| **Tests** | — | — | 1,017 | **3,230+** |
| **Channels** | 4 | 9 | 8+ | **17** |
| **Providers** | Several | 13+ | 22+ | **22+** |
| **Min Hardware** | Mac Mini $599 | Linux SBC ~$50 | Any $10 hardware | **Any $5 hardware** |

The benchmark numbers are measured on 0.8 GHz edge hardware. NullClaw starts in under 8 milliseconds even on the slowest targets.

## Why Zig

Zig is a systems programming language that compiles to C-like performance with zero runtime overhead:

| Property | What it means for NullClaw |
|----------|---------------------------|
| No garbage collector | Deterministic memory, no pauses |
| No hidden allocations | You control every byte |
| Static binary | No dependencies, drop and run |
| Compile-time execution | Config validation at build time |
| Cross-compilation | Build for ARM from x86 |

That 678 KB binary covers the same feature set as alternatives that weigh in at multiple gigabytes.

## Installation

Two main ways to get NullClaw installed.

&lt;Tabs&gt;
&lt;Tab name=&quot;From source (recommended)&quot;&gt;

You need Zig 0.15.2 (exact version required):

```bash
# Install Zig (Linux)
curl -L https://ziglang.org/download/0.15.2/zig-linux-x86_64-0.15.2.tar.xz | tar -xJ
sudo mv zig-linux-x86_64-0.15.2 /usr/local/zig
sudo ln -s /usr/local/zig/zig /usr/local/bin/zig

# Clone and build
git clone https://github.com/nullclaw/nullclaw.git
cd nullclaw

# Release build (678 KB)
zig build -Doptimize=ReleaseSmall

# The binary is at zig-out/bin/nullclaw
ls -lh zig-out/bin/nullclaw
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

If you prefer containers:

```bash
# Build the image
docker build -t nullclaw .

# Run
docker run -d \
  -v ~/.nullclaw:/root/.nullclaw \
  -p 3000:3000 \
  --name nullclaw \
  nullclaw
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

After building, run the onboard wizard:

```bash
# Quick setup (non-interactive)
nullclaw onboard --api-key sk-... --provider openrouter

# Or interactive wizard
nullclaw onboard --interactive
```

This creates `~/.nullclaw/` with a `config.json` and workspace folder.

Check that everything&apos;s working:

```bash
nullclaw status
```

## Configuring LLM providers

NullClaw supports 22+ providers out of the box. Every provider uses the OpenAI-compatible interface, so switching is just a config change.

### Supported providers

| Provider | Use case |
|----------|----------|
| OpenRouter | Gateway to any model |
| Anthropic | Claude models |
| OpenAI | GPT models |
| Ollama | Local models |
| Venice | Privacy-focused |
| Groq | Fast inference |
| Mistral | Mistral models |
| xAI | Grok models |
| DeepSeek | DeepSeek models |
| Together | Open-source models |
| Fireworks | Fast open-source |
| Perplexity | Search-augmented |
| Cohere | Command models |
| Bedrock | AWS-hosted models |
| Gemini | Google Gemini |
| Custom | Any OpenAI-compatible endpoint |

### Get an API key

For OpenRouter (recommended for flexibility):

1. Go to [openrouter.ai](https://openrouter.ai)
2. Create an account and generate an API key
3. The key starts with `sk-or-`

&lt;Notice type=&quot;success&quot; title=&quot;MiniMax coding plan — 10% off&quot;&gt;
If you want to use MiniMax M2.5 or GLM-5 through OpenRouter, check our [referral links](/best-opensource-models-for-openclaw/) for discounts on coding plans.
&lt;/Notice&gt;

### Add to config

Edit `~/.nullclaw/config.json`:

```json
{
  &quot;default_provider&quot;: &quot;openrouter&quot;,
  &quot;default_temperature&quot;: 0.7,
  &quot;models&quot;: {
    &quot;providers&quot;: {
      &quot;openrouter&quot;: {
        &quot;api_key&quot;: &quot;sk-or-your-key&quot;
      }
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;anthropic/claude-sonnet-4&quot;
      }
    }
  }
}
```

### Using custom providers

Any OpenAI-compatible endpoint works:

```json
{
  &quot;models&quot;: {
    &quot;providers&quot;: {
      &quot;minimax&quot;: {
        &quot;api_key&quot;: &quot;your-minimax-key&quot;,
        &quot;base_url&quot;: &quot;https://api.minimax.chat/v1&quot;
      }
    }
  }
}
```

Then set `&quot;default_provider&quot;: &quot;minimax&quot;`.

### Recommended models

If you&apos;re looking for cost-effective models to pair with NullClaw, two open-source options work well:

**[MiniMax M2.5](/best-opensource-models-for-openclaw/)** — A 230B MoE model with 10B active parameters. Scores 80.2% on SWE-Bench Verified at a fraction of Claude&apos;s cost ($0.15/M input tokens). The Lightning variant runs at 100 tokens/sec. Available through OpenRouter or directly via the [MiniMax API](https://go.bitdoze.com/minimax).

**[GLM-5](/best-opensource-models-for-openclaw/)** — A 744B MoE model from Z.AI with 40B active parameters. 95.8% on SWE-bench Verified and near-zero hallucinations. Available through OpenRouter or [Z.AI coding plans](https://z.ai/subscribe?ic=NKNUNYDRZT).

Both are covered in detail in our [nanobot setup guide](/nanobot-setup-guide/), which walks through API key configuration and provider setup. The same models work with NullClaw through OpenRouter or direct provider endpoints.

### Test it

```bash
nullclaw agent -m &quot;What&apos;s 42 * 17?&quot;
```

## Channel setup

NullClaw supports 17 chat channels. Pick what you use.

### Telegram

1. Create a bot via [@BotFather](https://t.me/BotFather)
2. Copy the bot token
3. Get your Telegram user ID (message [@userinfobot](https://t.me/userinfobot))

Config:

```json
{
  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;accounts&quot;: {
        &quot;main&quot;: {
          &quot;bot_token&quot;: &quot;123456789:ABCdefGHIjklMNOpqrSTUvwxYZ&quot;,
          &quot;allow_from&quot;: [&quot;your_telegram_user_id&quot;],
          &quot;reply_in_private&quot;: true
        }
      }
    }
  }
}
```

&lt;Notice type=&quot;warning&quot; title=&quot;Allowlist behavior&quot;&gt;
Empty `allow_from` means **deny all**. Use `[&quot;*&quot;]` to allow everyone, or add specific user IDs to restrict access.
&lt;/Notice&gt;

### Discord

1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
2. Create a bot and copy the token
3. Enable **MESSAGE CONTENT INTENT** in the Bot settings

Config:

```json
{
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;accounts&quot;: {
        &quot;main&quot;: {
          &quot;token&quot;: &quot;your-discord-bot-token&quot;,
          &quot;guild_id&quot;: &quot;your-server-id&quot;,
          &quot;allow_from&quot;: [&quot;your_user_id&quot;],
          &quot;allow_bots&quot;: false
        }
      }
    }
  }
}
```

### Slack

Config:

```json
{
  &quot;channels&quot;: {
    &quot;slack&quot;: {
      &quot;accounts&quot;: {
        &quot;main&quot;: {
          &quot;bot_token&quot;: &quot;xoxb-your-bot-token&quot;,
          &quot;app_token&quot;: &quot;xapp-your-app-token&quot;,
          &quot;allow_from&quot;: [&quot;U1234567890&quot;]
        }
      }
    }
  }
}
```

### Other channels

| Channel | Config key | Notes |
|---------|-----------|-------|
| iMessage | `imessage` | macOS only |
| Matrix | `matrix` | Homeserver required |
| WhatsApp | `whatsapp` | Via Meta webhook |
| Signal | `signal` | Signal-cli required |
| Line | `line` | LINE Messaging API |
| Webhook | `webhook` | Custom HTTP endpoint |
| IRC | `irc` | Libera, OFTC, etc. |
| Lark/Feishu | `lark` | ByteDance workplace |
| DingTalk | `dingtalk` | Alibaba workplace |
| QQ | `qq` | Via go-cqhttp |
| OneBot | `onebot` | Universal bot protocol |
| Email | `email` | IMAP/SMTP |
| MaixCam | `maixcam` | Sipeed hardware |

## Full config example

Here&apos;s a complete `~/.nullclaw/config.json` with OpenRouter, Telegram, hybrid memory, and sandboxing:

```json
{
  &quot;default_provider&quot;: &quot;openrouter&quot;,
  &quot;default_temperature&quot;: 0.7,

  &quot;models&quot;: {
    &quot;providers&quot;: {
      &quot;openrouter&quot;: { &quot;api_key&quot;: &quot;sk-or-...&quot; }
    }
  },

  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: { &quot;primary&quot;: &quot;anthropic/claude-sonnet-4&quot; },
      &quot;heartbeat&quot;: { &quot;every&quot;: &quot;30m&quot; }
    }
  },

  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;accounts&quot;: {
        &quot;main&quot;: {
          &quot;bot_token&quot;: &quot;123:ABC&quot;,
          &quot;allow_from&quot;: [&quot;your_user_id&quot;]
        }
      }
    }
  },

  &quot;memory&quot;: {
    &quot;backend&quot;: &quot;sqlite&quot;,
    &quot;auto_save&quot;: true,
    &quot;embedding_provider&quot;: &quot;openai&quot;,
    &quot;vector_weight&quot;: 0.7,
    &quot;keyword_weight&quot;: 0.3,
    &quot;hygiene_enabled&quot;: true
  },

  &quot;gateway&quot;: {
    &quot;port&quot;: 3000,
    &quot;require_pairing&quot;: true,
    &quot;allow_public_bind&quot;: false
  },

  &quot;autonomy&quot;: {
    &quot;level&quot;: &quot;supervised&quot;,
    &quot;workspace_only&quot;: true,
    &quot;max_actions_per_hour&quot;: 20
  },

  &quot;runtime&quot;: {
    &quot;kind&quot;: &quot;native&quot;,
    &quot;docker&quot;: {
      &quot;image&quot;: &quot;alpine:3.20&quot;,
      &quot;network&quot;: &quot;none&quot;,
      &quot;memory_limit_mb&quot;: 512,
      &quot;read_only_rootfs&quot;: true
    }
  },

  &quot;security&quot;: {
    &quot;sandbox&quot;: { &quot;backend&quot;: &quot;auto&quot; },
    &quot;resources&quot;: { &quot;max_memory_mb&quot;: 512, &quot;max_cpu_percent&quot;: 80 },
    &quot;audit&quot;: { &quot;enabled&quot;: true, &quot;retention_days&quot;: 90 }
  },

  &quot;tunnel&quot;: { &quot;provider&quot;: &quot;none&quot; },
  &quot;secrets&quot;: { &quot;encrypt&quot;: true },
  &quot;identity&quot;: { &quot;format&quot;: &quot;openclaw&quot; }
}
```

## Memory system

NullClaw&apos;s memory is built on SQLite with no external dependencies:

| Layer | Implementation |
|-------|---------------|
| **Vector DB** | Embeddings stored as BLOB, cosine similarity search |
| **Keyword search** | FTS5 virtual tables with BM25 scoring |
| **Hybrid merge** | Configurable vector/keyword weights |
| **Embeddings** | OpenAI, custom URL, or noop |
| **Hygiene** | Automatic archival + purge of stale memories |
| **Snapshots** | Export/import for migration |

Config:

```json
{
  &quot;memory&quot;: {
    &quot;backend&quot;: &quot;sqlite&quot;,
    &quot;auto_save&quot;: true,
    &quot;embedding_provider&quot;: &quot;openai&quot;,
    &quot;vector_weight&quot;: 0.7,
    &quot;keyword_weight&quot;: 0.3,
    &quot;hygiene_enabled&quot;: true
  }
}
```

Set `embedding_provider` to `noop` if you don&apos;t want to pay for embeddings. FTS5 keyword search still works.

### Identity files

NullClaw supports two identity formats:

| Format | Files | Use case |
|--------|-------|----------|
| `openclaw` | `IDENTITY.md`, `SOUL.md`, `USER.md` | Markdown-based |
| `aieos` | Single JSON file | Portable AI personas |

Edit these in `~/.nullclaw/workspace/` to customize the bot&apos;s personality and knowledge about you.

## Security model

NullClaw locks things down at multiple levels, not just application-level allowlists.

### Gateway security

| Layer | Default | What it does |
|-------|---------|-------------|
| Localhost binding | `127.0.0.1` | Refuses public exposure |
| Pairing required | `true` | 6-digit code exchange for bearer token |
| Tunnel required | `false` | Refuses `0.0.0.0` without tunnel |

To expose the gateway, configure a tunnel:

```json
{
  &quot;tunnel&quot;: {
    &quot;provider&quot;: &quot;cloudflare&quot;
  }
}
```

Supported tunnels: Cloudflare, Tailscale, ngrok, or custom binary.

### Sandbox isolation

NullClaw auto-detects the best sandbox backend:

| Backend | Platform | Security level |
|---------|----------|---------------|
| Landlock | Linux 5.13+ | Kernel-level |
| Firejail | Linux | Namespaces |
| Bubblewrap | Linux | Lightweight containers |
| Docker | Any | Full container isolation |

Config:

```json
{
  &quot;security&quot;: {
    &quot;sandbox&quot;: { &quot;backend&quot;: &quot;auto&quot; }
  }
}
```

Set `&quot;backend&quot;: &quot;docker&quot;` for maximum isolation.

### Encrypted secrets

API keys are encrypted with ChaCha20-Poly1305:

```json
{
  &quot;secrets&quot;: { &quot;encrypt&quot;: true }
}
```

The encryption key is stored locally in `~/.nullclaw/`.

### Workspace scoping

With `workspace_only = true`, the bot can only access files inside its workspace. Symlink escape attempts are blocked through path canonicalization.

## MCP support

NullClaw supports Model Context Protocol servers:

```json
{
  &quot;mcp_servers&quot;: {
    &quot;filesystem&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@modelcontextprotocol/server-filesystem&quot;, &quot;/home/user/documents&quot;]
    }
  }
}
```

MCP tools are discovered and registered automatically. The bot can use them alongside built-in tools.

## Scheduled tasks

NullClaw has a built-in cron scheduler:

```bash
# List scheduled tasks
nullclaw cron list

# Add a recurring task
nullclaw cron add --name &quot;morning&quot; --message &quot;What&apos;s on my calendar today?&quot; --cron &quot;0 9 * * *&quot;

# Run a task once
nullclaw cron run &lt;task_id&gt;

# Pause/resume
nullclaw cron pause &lt;task_id&gt;
nullclaw cron resume &lt;task_id&gt;
```

Tasks are persisted to JSON and survive restarts.

## CLI reference

| Command | Description |
|---------|-------------|
| `nullclaw onboard --api-key sk-...` | Quick setup |
| `nullclaw onboard --interactive` | Full wizard |
| `nullclaw agent -m &quot;...&quot;` | Single message |
| `nullclaw agent` | Interactive chat |
| `nullclaw gateway` | Start webhook server |
| `nullclaw daemon` | Full autonomous runtime |
| `nullclaw status` | System status |
| `nullclaw doctor` | Diagnostics |
| `nullclaw channel doctor` | Channel health |
| `nullclaw service install` | Install as system service |
| `nullclaw cron list/add/remove` | Manage scheduled tasks |
| `nullclaw skills list/install` | Manage skill packs |
| `nullclaw hardware scan` | Detect peripherals |
| `nullclaw migrate openclaw` | Import from OpenClaw |

## VPS deployment

For production use, here&apos;s a complete VPS setup.

&lt;Button text=&quot;Try Hetzner Cloud&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;rocket-launch&quot; /&gt;

### Server requirements

| Spec | Minimum | Works with |
|------|---------|------------|
| CPU | Any | 0.8 GHz edge core |
| RAM | 256 MB | 1 GB+ recommended |
| Storage | 1 GB | 10 GB for logs |
| OS | Linux | Ubuntu 22.04+ |

NullClaw runs on $5 boards. I tested it on a Raspberry Pi Zero 2 W and it worked fine.

### Quick deployment

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Install Zig
curl -L https://ziglang.org/download/0.15.2/zig-linux-x86_64-0.15.2.tar.xz | tar -xJ
sudo mv zig-linux-x86_64-0.15.2 /usr/local/zig
sudo ln -s /usr/local/zig/zig /usr/local/bin/zig

# Clone and build
git clone https://github.com/nullclaw/nullclaw.git
cd nullclaw
zig build -Doptimize=ReleaseSmall

# Install binary
sudo cp zig-out/bin/nullclaw /usr/local/bin/

# Initialize
nullclaw onboard --interactive

# Edit config
nano ~/.nullclaw/config.json

# Start daemon
nullclaw daemon
```

### Systemd service

For automatic startup:

```bash
nullclaw service install
nullclaw service start
nullclaw service status
```

Or create manually:

```ini
[Unit]
Description=NullClaw AI Assistant
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/nullclaw daemon
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Save to `/etc/systemd/system/nullclaw.service`, then:

```bash
systemctl daemon-reload
systemctl enable nullclaw
systemctl start nullclaw
```

### Running on edge hardware

NullClaw&apos;s low resource usage makes it a good fit for edge deployment:

| Hardware | RAM | Status |
|----------|-----|--------|
| Raspberry Pi Zero 2 W | 512 MB | ✅ Works |
| Raspberry Pi 4 | 2-8 GB | ✅ Works |
| Orange Pi Zero | 256 MB | ✅ Works |
| $5 AliExpress SBC | 256 MB | ✅ Works |
| MaixCam (RISC-V) | 256 MB | ✅ Supported |

Build for ARM from x86:

```bash
zig build -Dtarget=aarch64-linux -Doptimize=ReleaseSmall
```

## Gateway API

| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/health` | GET | None | Health check |
| `/pair` | POST | `X-Pairing-Code` | Exchange code for token |
| `/webhook` | POST | Bearer token | Send message |
| `/whatsapp` | GET | Query params | Meta webhook verification |
| `/whatsapp` | POST | Meta signature | WhatsApp incoming |

Pairing flow:

```bash
# Start gateway (shows pairing code)
nullclaw gateway

# Exchange code for token
curl -X POST http://127.0.0.1:3000/pair \
  -H &quot;X-Pairing-Code: 123456&quot;

# Use token for requests
curl -X POST http://127.0.0.1:3000/webhook \
  -H &quot;Authorization: Bearer YOUR_TOKEN&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;message&quot;: &quot;Hello, nullclaw!&quot;}&apos;
```

## NullClaw vs ZeroClaw vs nanobot

I run all three, so here&apos;s a direct comparison:

| Aspect | NullClaw 🟠 | ZeroClaw 🦀 | nanobot 🐍 |
|--------|------------|------------|-----------|
| Language | Zig | Rust | Python |
| RAM usage | ~1 MB | &lt; 5 MB | ~100 MB |
| Startup | &lt; 2 ms | &lt; 10 ms | &gt; 2 s |
| Binary size | 678 KB | 3.4 MB | N/A |
| Channel count | 17 | 8+ | 9 |
| Provider count | 22+ | 22+ | 13+ |
| Memory | SQLite hybrid | SQLite hybrid | File-based |
| Security | Sandbox + pairing | Pairing + sandbox | Allowlists |
| Min hardware | $5 board | $10 board | $50 SBC |
| Setup method | `zig build` | `cargo install` | `pip install` |

NullClaw is the lightest option here. ZeroClaw is easier to set up if you&apos;re already in the Rust ecosystem. nanobot has the broadest channel support.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**How much does it cost to run NullClaw?**

Hardware: $5 for a cheap ARM board, or $0 if you already have a VPS from [Hetzner](https://go.bitdoze.com/hetzner) or [Hostinger](https://go.bitdoze.com/hostinger-vps). API costs depend on your provider — expect $5-50/month for personal use.

**Can I run NullClaw without any API costs?**

Yes. Configure Ollama as your provider and point it at a local model. You need hardware that can run inference, but there are no API bills.

**Does NullClaw work on a Raspberry Pi Zero?**

Yes. The 512 MB Pi Zero 2 W runs NullClaw without issues. The original Pi Zero (single-core) might struggle with inference but the assistant itself works.

**Can multiple people use one NullClaw instance?**

Yes. Add multiple user IDs to `allow_from`. Each person gets their own conversation context.

**What&apos;s the difference between gateway and daemon?**

`nullclaw gateway` starts the webhook server only. `nullclaw daemon` starts the full autonomous runtime including all channels, heartbeat tasks, and scheduler.

**Can I migrate from OpenClaw to NullClaw?**

Yes. NullClaw has a built-in migration command:

```bash
nullclaw migrate openclaw --dry-run
nullclaw migrate openclaw
```

**How do I add a new channel that&apos;s not supported?**

Implement the `Channel` vtable interface in `src/channels/` and submit a PR. The architecture is designed for extensions.

&lt;/Accordion&gt;

If you&apos;re comparing container-based isolation, see our [NanoClaw deploy guide](/nanoclaw-deploy-guide/) — it uses Docker containers with Claude&apos;s Agent SDK. For a Python-based alternative with MiniMax and GLM-5 setup walkthroughs, check the [nanobot setup guide](/nanobot-setup-guide/).

If you want to explore other AI coding tools, our [AI coding tools comparison](/ai-coading-tools/) covers the current landscape. For MCP basics that work across assistants, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

Este artículo también está disponible en español: [Guía de Despliegue de NullClaw](/es/guia-despliegue-nullclaw/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>Best Fish Shell Plugins &amp; Tools (Oh My Fish, Fisher)</title><link>https://www.bitdoze.com/best-fish-shell-plugins/</link><guid isPermaLink="true">https://www.bitdoze.com/best-fish-shell-plugins/</guid><description>A guide to the most useful Fish Shell plugins, plugin managers, and tools including Fisher, Tide, fzf.fish, and other extensions worth installing.</description><pubDate>Sat, 21 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Fish already does a lot without plugins. [Autosuggestions](/fish-shell-autocomplete-suggestions/), [syntax highlighting](/fish-shell-syntax-highlighting/), and man page completions are all built in. That means the plugin ecosystem is smaller than Zsh&apos;s, but more focused. You&apos;re adding specific tools rather than patching in missing features.

I&apos;ve been using Fish daily and tried quite a few plugins. Here&apos;s what I actually keep installed and recommend.

If you&apos;re new to Fish, start with my [installation guide for Ubuntu](/install-fish-shell-ubuntu/) first.

## Plugin managers: Fisher vs Oh My Fish

You need a plugin manager before you can install anything. There are two options, but the choice is straightforward.

### Fisher (recommended)

[Fisher](https://github.com/jorgebucaran/fisher) is a lightweight plugin manager written in pure Fish. It installs plugins by copying functions and completions directly into your Fish config directory. No framework, no startup overhead.

Install it:

```fish
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher
```

Basic usage:

```fish
fisher install author/plugin    # install a plugin
fisher update                   # update all plugins
fisher remove author/plugin     # remove a plugin
fisher list                     # list installed plugins
```

Fisher tracks installed plugins in `~/.config/fish/fish_plugins`. You can version-control this file and run `fisher update` on a new machine to recreate your setup.

### Oh My Fish

[Oh My Fish](https://github.com/oh-my-fish/oh-my-fish) (OMF) is a framework similar to Oh My Zsh. It has its own package repository with themes and plugins, and it provides a command-line tool (`omf`) for managing them.

Install it:

```fish
curl https://raw.githubusercontent.com/oh-my-fish/oh-my-fish/master/bin/install | fish
```

Usage:

```fish
omf install plugin-name    # install a plugin
omf theme theme-name       # apply a theme
omf update                 # update everything
omf list                   # list installed packages
```

**The honest take:** Oh My Fish has been unmaintained for a while. The GitHub repo carries a warning about it. Some packages work fine, others are broken. I wouldn&apos;t start a new Fish setup with OMF today. Fisher is actively maintained, faster, and can even install OMF-compatible plugins. If you still want to try it, I have a [complete Oh My Fish guide](/oh-my-fish-install-themes-plugins/).

&lt;Notice type=&quot;warning&quot; title=&quot;Oh My Fish status&quot;&gt;
Oh My Fish&apos;s GitHub page states it has been unmaintained for years and some packages are broken. For new setups, go with Fisher.
&lt;/Notice&gt;

## Prompt plugins

### Tide

[Tide](https://github.com/IlanCosman/tide) is the most popular Fish-native prompt. It renders asynchronously (so your prompt never lags), has a configuration wizard, and shows git status, current directory, runtime versions, and command duration.

```fish
fisher install IlanCosman/tide@v6
tide configure
```

The wizard walks you through style options: powerline vs. plain, icons vs. text, one-line vs. two-line. The result looks polished without any manual configuration.

Tide needs a [Nerd Font](https://www.nerdfonts.com/) for icons. I recommend MesloLGS NF, which Tide&apos;s documentation suggests. Install the font, set it in your terminal emulator, and you&apos;re good.

If you want a prompt that works across Fish, Zsh, and Bash, check out [Starship with Fish Shell](/fish-shell-starship-prompt/) instead. Tide is Fish-only but more deeply integrated with Fish&apos;s features.

### Hydro

[Hydro](https://github.com/jorgebucaran/hydro) is a minimal prompt made by Fisher&apos;s author. It shows git branch, command duration, and exit status with almost zero overhead. Good if you prefer something clean and fast over feature-rich.

```fish
fisher install jorgebucaran/hydro
```

No configuration needed. It just works with reasonable defaults.

For a full comparison of Fish prompt options including Tide, Hydro, Starship, and Pure, check my [Fish Shell themes and prompts guide](/fish-shell-themes-prompts/).

## Search and navigation plugins

### fzf.fish

[fzf.fish](https://github.com/PatrickF1/fzf.fish) adds fuzzy search to your Fish shell using [fzf](https://github.com/junegunn/fzf). You get interactive searching for command history, file paths, git log, git status, and processes.

Install fzf first, then the plugin:

```fish
# Install fzf (Ubuntu)
sudo apt install fzf

# Or on macOS
brew install fzf

# Install the Fish plugin
fisher install PatrickF1/fzf.fish
```

Default keybindings:
- `Ctrl+R` - search command history (replaces Fish&apos;s built-in search with a better one)
- `Ctrl+Alt+F` - search file paths
- `Ctrl+Alt+L` - search git log
- `Ctrl+Alt+S` - search git status
- `Ctrl+Alt+P` - search running processes

This is probably the single most useful Fish plugin. If you install nothing else, install this one.

### zoxide

[Zoxide](https://github.com/ajeetdsouza/zoxide) isn&apos;t a Fish plugin, it&apos;s a standalone tool that replaces `cd` with a smarter alternative. It learns which directories you visit and lets you jump to them with partial names. Type `z proj` instead of `cd ~/Documents/work/projects`.

```fish
# Install
sudo apt install zoxide  # or: brew install zoxide

# Add to your Fish config
# In ~/.config/fish/config.fish:
zoxide init fish | source
```

I have a full guide on [zoxide](/zoxide/) that covers setup and usage. It works with Fish, Zsh, and Bash.

## Git plugins

### fish-git-util

If you use Tide or Hydro, git information is already in your prompt. But if you want standalone git abbreviations and helpers, there are a few options.

The simplest approach is to define your own abbreviations:

```fish
# Add to ~/.config/fish/conf.d/git.fish
abbr -a gs git status
abbr -a ga git add
abbr -a gc git commit
abbr -a gp git push
abbr -a gl git log --oneline
abbr -a gco git checkout
abbr -a gb git branch
abbr -a gd git diff
```

Abbreviations expand when you press space or enter, so you see the full command in your history. I explain why this matters in [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/).

## Node and version management

### nvm.fish

[nvm.fish](https://github.com/jorgebucaran/nvm.fish) is a Node.js version manager built for Fish. It&apos;s lighter than the Bash-based nvm and starts faster because it loads lazily. I have a [detailed NVM with Fish Shell guide](/nvm-fish-shell/) covering setup and usage.

```fish
fisher install jorgebucaran/nvm.fish
```

Usage:

```fish
nvm install 22       # install Node 22
nvm use 22          # switch to Node 22
nvm list            # list installed versions
nvm current         # show active version
```

It also reads `.nvmrc` and `.node-version` files automatically when you enter a directory.

## Other useful plugins

### autopair.fish

[autopair.fish](https://github.com/jorgebucaran/autopair.fish) auto-closes brackets, quotes, and parentheses as you type. Press `(` and it inserts `()` with the cursor between them.

```fish
fisher install jorgebucaran/autopair.fish
```

Small quality-of-life improvement.

### sponge

[sponge](https://github.com/meaningful-ooo/sponge) automatically removes failed commands from your Fish history. If a command exits with an error, it doesn&apos;t pollute your [history and autosuggestions](/fish-shell-history-persistence/).

```fish
fisher install meaningful-ooo/sponge
```

### puffer-fish

[puffer-fish](https://github.com/nickeb96/puffer-fish) expands `..` to `../..` and `...` to `../../..` as you type. Press `.` twice and it keeps adding parent directory references.

```fish
fisher install nickeb96/puffer-fish
```

## My recommended setup

If I&apos;m setting up Fish on a new machine, here&apos;s what I install:

```fish
# Plugin manager
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher

# Prompt (pick one)
fisher install IlanCosman/tide@v6
# OR for cross-shell: install Starship separately

# Must-have plugins
fisher install PatrickF1/fzf.fish
fisher install jorgebucaran/autopair.fish
fisher install meaningful-ooo/sponge
fisher install nickeb96/puffer-fish
```

Plus zoxide installed separately. That gives me a fast, functional shell with fuzzy search, smart directory navigation, and a good prompt. The whole setup takes about five minutes.

## Comparison with Zsh plugins

If you&apos;re coming from Zsh and wondering what the equivalents are:

| Zsh plugin/tool | Fish equivalent |
|---|---|
| Oh My Zsh | Fisher (plugin manager) |
| Powerlevel10k | Tide |
| zsh-autosuggestions | Built into Fish |
| zsh-syntax-highlighting | Built into Fish |
| zsh-completions | Built into Fish (man page parsing) |
| fzf (Zsh integration) | fzf.fish |
| zoxide (Zsh) | zoxide (same tool, Fish init) |
| nvm | nvm.fish |

I have a detailed [Fish vs Zsh comparison](/fish-shell-vs-zsh/) and a broader [Fish vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) article if you want more context on the differences. For Zsh users, I also have a list of the [best Oh My Zsh plugins](/best-oh-my-zsh-plugins/) for comparison.</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell History &amp; Persistence Guide</title><link>https://www.bitdoze.com/fish-shell-history-persistence/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-history-persistence/</guid><description>How Fish Shell handles command history including searching, deleting, merging across sessions, the history file, and tips for keeping a clean history.</description><pubDate>Fri, 20 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

Fish&apos;s history is one of those features that works so well you rarely think about it. Commands are saved automatically, shared across sessions (with some caveats), and searchable through multiple methods. But there are things worth knowing about how it works under the hood, especially if you care about keeping your history clean or syncing it across machines.

## Where the history file lives

Fish stores history in:

```
~/.local/share/fish/fish_history
```

It&apos;s a plain text file with timestamps. Each entry looks something like:

```
- cmd: git commit -m &quot;fix auth&quot;
  when: 1708784400
```

The file grows over time. Fish doesn&apos;t have a hard limit on history size like Bash does (`HISTSIZE`). By default, Fish keeps 256,000 entries (or about 16 MB of history). In practice, you&apos;ll never hit this unless you script history additions.

## Searching history

### Arrow keys

The simplest method. Press the **up arrow** to cycle through previous commands. If you&apos;ve typed something first, the up arrow only shows commands that start with what you typed.

Type `git` and then press up arrow — Fish cycles through your git commands only. This is prefix-based history search.

### Ctrl+R (reverse search)

Press `Ctrl+R` and start typing. Fish searches your history for commands containing what you typed, using glob syntax. Type `git*fix` and it matches commands containing &quot;git&quot; followed later by &quot;fix&quot;.

Navigate results with the arrow keys or keep typing to narrow the search. Press Enter to run the selected command, or Escape to cancel.

If you have [fzf.fish](/best-fish-shell-plugins/) installed, `Ctrl+R` opens an fzf-powered interactive search instead, which is even better.

### The history command

For more precise searching:

```fish
# Search for commands containing &quot;docker&quot;
history search docker

# Search by prefix
history search --prefix &quot;git commit&quot;

# Exact match
history search --exact &quot;git push origin main&quot;

# Show timestamps
history search --show-time docker

# Limit results
history search --max 10 docker

# Case-sensitive search
history search --case-sensitive Docker
```

By default, `history search` is case-insensitive and sorts newest first. Add `--reverse` to flip the order.

## Deleting history entries

Sometimes you run a command with a typo, or accidentally put sensitive data on the command line. Fish lets you clean up.

### Interactive deletion

```fish
history delete docker
```

This searches for commands matching &quot;docker&quot; and shows you the matches. You can enter specific entry numbers to delete, a range, or `all` to remove every match.

### Exact deletion

```fish
history delete --exact --case-sensitive &quot;docker login -p my_secret_password&quot;
```

This removes that one specific command without prompting.

### Deleting with the sponge plugin

The [sponge plugin](/best-fish-shell-plugins/) automatically removes commands that fail (non-zero exit code) from your history. Install it with:

```fish
fisher install meaningful-ooo/sponge
```

I find this useful because it keeps mistyped commands and failed experiments out of my autosuggestions.

## Controlling what goes into history

### The fish_should_add_to_history function

Fish 4.0 added a hook that lets you control which commands get saved. Define this function to filter history entries:

```fish
# ~/.config/fish/functions/fish_should_add_to_history.fish
function fish_should_add_to_history
    # Don&apos;t save commands that start with a space
    string match -qr &apos;^\s&apos; -- $argv[1]; and return 1

    # Don&apos;t save short commands
    test (string length -- $argv[1]) -lt 3; and return 1

    # Don&apos;t save commands containing sensitive patterns
    string match -qr &apos;password|secret|token|api.key&apos; -- $argv[1]; and return 1

    return 0
end
```

Return 0 to save the command, 1 to skip it.

### Leading space to skip history

The function above demonstrates a common pattern: prefixing a command with a space to keep it out of history. This mimics the `HISTCONTROL=ignorespace` behavior from Bash. Fish doesn&apos;t do this by default — you need the `fish_should_add_to_history` function.

## History across multiple sessions

Fish handles multi-session history differently from Bash. Each Fish session keeps its own in-memory history and writes to the shared history file. Here&apos;s how it works:

- When you run a command, it&apos;s added to the current session&apos;s history and written to disk.
- Other running sessions don&apos;t see the new command immediately.
- New sessions that start after the command was saved will see it.
- `history merge` forces the current session to load commands from all other sessions.

### Merging history

```fish
history merge
```

This imports history from other Fish sessions into the current one. Useful if you ran something in another terminal tab and want to access it with Ctrl+R or arrow-key search.

Some people add this to a keybinding:

```fish
bind \em &apos;history merge; commandline -f repaint&apos;
```

Now `Alt+M` merges and repaints the prompt.

### Clearing history

```fish
# Clear everything (with confirmation)
history clear

# Clear only the current session&apos;s history
history clear-session
```

`clear-session` is useful when you&apos;ve been experimenting and don&apos;t want those commands polluting your history, but you want to keep everything from before.

## Saving history manually

```fish
history save
```

This forces an immediate write to disk. Fish auto-saves periodically, but this ensures nothing is lost if the shell crashes.

### Appending commands without running them

```fish
history append &quot;some command I want to remember&quot;
```

This adds the command to history without executing it. Useful for bookmarking commands you want to find later with `Ctrl+R`.

## History file format and backup

The history file is easy to back up and portable between machines:

```fish
cp ~/.local/share/fish/fish_history ~/fish_history_backup
```

To restore:

```fish
cp ~/fish_history_backup ~/.local/share/fish/fish_history
history merge
```

If you manage dotfiles across machines, you can symlink or sync the history file. Just be aware that concurrent writes from multiple machines could cause conflicts — merge manually if needed.

## Useful history tips

**Use abbreviations instead of relying on history.** If you use a command every day, add it as an [abbreviation](/fish-shell-abbreviations-vs-aliases/) instead of hunting for it in history every time.

**Search within results.** When the Ctrl+R search shows too many results, add more characters or use glob patterns. `git*main` narrows better than just `git`.

**Combine with fzf.** The [fzf.fish plugin](/best-fish-shell-plugins/) replaces the built-in Ctrl+R with a fuzzy search that&apos;s much faster to browse. It shows a preview and supports multi-select.

**Delete patterns.** If you changed a password and accidentally typed the old one in a command, `history delete --prefix &quot;mysql -p&quot;` cleans it up fast.

## Related guides

- [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) — history powers autosuggestions
- [Best Fish Shell plugins](/best-fish-shell-plugins/) — fzf.fish and sponge for better history
- [Fish Shell functions guide](/fish-shell-functions-custom-commands/) — create fish_should_add_to_history
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — getting started
- [Fish Shell on macOS](/fish-shell-macos-setup/) — Mac setup guide</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell on macOS - Complete Setup Guide</title><link>https://www.bitdoze.com/fish-shell-macos-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-macos-setup/</guid><description>How to install and configure Fish Shell on macOS with Homebrew, set it as default, configure iTerm2 or Ghostty, and set up essential tools.</description><pubDate>Thu, 19 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

macOS ships with Zsh as the default shell (Apple switched from Bash back in 2019). Fish isn&apos;t included, but it&apos;s a single Homebrew command away. I run Fish on both my Mac and Linux machines, and the experience is nearly identical on both — which is one of the things I like about it.

This guide covers the full macOS setup: installation, making Fish your default shell, terminal emulator configuration, and the essential tools I install alongside it.

## Install Fish with Homebrew

If you don&apos;t have Homebrew yet:

```bash
/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
```

Install Fish:

```bash
brew install fish
```

Check the version:

```bash
fish --version
```

You should see `fish, version 4.5.0` or newer. Homebrew keeps Fish up to date — run `brew upgrade fish` periodically.

## Try Fish without switching

Type `fish` in your current terminal to start a Fish session. Play around with autosuggestions (just start typing and watch the gray suggestions appear), tab completion, and syntax highlighting. Type `exit` to go back to Zsh.

## Set Fish as your default shell

macOS requires the shell to be listed in `/etc/shells` before you can set it as default. Homebrew installs Fish to `/opt/homebrew/bin/fish` (Apple Silicon) or `/usr/local/bin/fish` (Intel).

```bash
# Find your Fish path
which fish

# Add it to allowed shells
echo (which fish) | sudo tee -a /etc/shells

# Set as default
chsh -s (which fish)
```

Open a new terminal window. You should be in Fish.

To switch back to Zsh later: `chsh -s /bin/zsh`.

&lt;Notice type=&quot;info&quot; title=&quot;Keep Zsh around&quot;&gt;
macOS system scripts sometimes expect Zsh or Bash. Don&apos;t remove them. Fish replaces your interactive shell, not the system scripting layer.
&lt;/Notice&gt;

## Terminal emulator setup

Fish works in any terminal, but your font matters. Fish&apos;s completion pager and many prompt themes use special characters that need a Nerd Font.

### Install a Nerd Font

```bash
brew install font-meslo-lg-nerd-font
```

Then set it as your terminal&apos;s font. Here&apos;s how for the popular options:

&lt;Accordion label=&quot;Ghostty&quot; group=&quot;terminals&quot; expanded=&quot;true&quot;&gt;
Add to `~/.config/ghostty/config`:

```
font-family = MesloLGS Nerd Font
font-size = 14
```

Ghostty is a fast, GPU-accelerated terminal. I have a [full Ghostty setup guide](/ghostty-terminal/) if you want to explore its features.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;iTerm2&quot; group=&quot;terminals&quot;&gt;
Go to **Preferences → Profiles → Text → Font** and select &quot;MesloLGS NF&quot;. Set the size to your preference (I use 14).

Also worth enabling in iTerm2:
- **Preferences → Profiles → Terminal → Shell Integration** — gives you click-to-select command output
- **Preferences → General → Selection → Applications in terminal may access clipboard** — so Fish&apos;s copy operations work
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Alacritty&quot; group=&quot;terminals&quot;&gt;
In `~/.config/alacritty/alacritty.toml`:

```toml
[font]
size = 14

[font.normal]
family = &quot;MesloLGS Nerd Font&quot;
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;WezTerm&quot; group=&quot;terminals&quot;&gt;
In `~/.wezterm.lua`:

```lua
config.font = wezterm.font(&quot;MesloLGS Nerd Font&quot;)
config.font_size = 14.0
```

I have a [WezTerm setup guide](/install-wezterm-mac/) that covers integration with zoxide and tmux.
&lt;/Accordion&gt;

## Basic Fish configuration

Create your config file:

```fish
mkdir -p ~/.config/fish
nano ~/.config/fish/config.fish
```

A practical starting config for macOS:

```fish
# ~/.config/fish/config.fish

# Homebrew (Apple Silicon)
fish_add_path /opt/homebrew/bin
fish_add_path /opt/homebrew/sbin

# Common paths
fish_add_path ~/bin
fish_add_path ~/.local/bin

# Suppress the greeting
set -g fish_greeting

# Editor
set -gx EDITOR &quot;code --wait&quot;  # or vim, nvim, etc.

# Homebrew completions
if test -d (brew --prefix)&quot;/share/fish/completions&quot;
    set -p fish_complete_path (brew --prefix)/share/fish/completions
end
if test -d (brew --prefix)&quot;/share/fish/vendor_completions.d&quot;
    set -p fish_complete_path (brew --prefix)/share/fish/vendor_completions.d
end
```

The Homebrew completions block is important — without it, you miss tab completions for Homebrew-installed commands.

## Install essential tools

Here&apos;s my standard set of companion tools for Fish on macOS:

```bash
brew install fisher      # plugin manager (or install via curl)
brew install starship    # cross-shell prompt
brew install zoxide      # smarter cd
brew install eza         # modern ls replacement
brew install fzf         # fuzzy finder
brew install bat         # better cat with syntax highlighting
brew install fd          # better find
brew install ripgrep     # better grep
```

### Set up Fisher

If you installed Fisher via Homebrew, it&apos;s ready. Otherwise:

```fish
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher
```

### Install Fish plugins

```fish
fisher install PatrickF1/fzf.fish     # fuzzy search integration
fisher install jorgebucaran/autopair.fish  # auto-close brackets
fisher install meaningful-ooo/sponge  # remove failed commands from history
```

See my [best Fish Shell plugins guide](/best-fish-shell-plugins/) for the full list.

### Set up Starship (or Tide)

For Starship, add to `config.fish`:

```fish
starship init fish | source
```

For Tide instead:

```fish
fisher install IlanCosman/tide@v6
tide configure
```

I compared these and other prompts in [Fish Shell themes — best prompts](/fish-shell-themes-prompts/). I also have a dedicated [Starship + Fish guide](/fish-shell-starship-prompt/).

### Set up zoxide

Add to `config.fish`:

```fish
zoxide init fish | source
```

Now use `z` instead of `cd`: `z projects` jumps to your projects directory. Full guide in my [zoxide article](/zoxide/).

### Set up eza aliases

```fish
# ~/.config/fish/conf.d/eza.fish
abbr -a ls eza
abbr -a ll &quot;eza -la --icons --git&quot;
abbr -a lt &quot;eza -la --icons --tree --level=2&quot;
```

## macOS-specific abbreviations

```fish
# ~/.config/fish/conf.d/macos.fish

# Quick Look from terminal
abbr -a ql &quot;qlmanage -p&quot;

# Open current directory in Finder
abbr -a finder &quot;open .&quot;

# Flush DNS
abbr -a flushdns &quot;sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder&quot;

# Show/hide hidden files in Finder
abbr -a showhidden &quot;defaults write com.apple.finder AppleShowAllFiles -bool true &amp;&amp; killall Finder&quot;
abbr -a hidehidden &quot;defaults write com.apple.finder AppleShowAllFiles -bool false &amp;&amp; killall Finder&quot;
```

I cover abbreviations vs aliases in detail in [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/).

## Node.js version management

The Bash-based nvm doesn&apos;t work in Fish. Use nvm.fish instead:

```fish
fisher install jorgebucaran/nvm.fish
nvm install lts
set --universal nvm_default_version lts
```

Full walkthrough in my [NVM with Fish Shell guide](/nvm-fish-shell/).

## Common macOS issues

&lt;Accordion label=&quot;Homebrew PATH not working&quot; group=&quot;issues&quot; expanded=&quot;true&quot;&gt;
On Apple Silicon Macs, Homebrew installs to `/opt/homebrew/` instead of `/usr/local/`. Make sure your `config.fish` has:

```fish
fish_add_path /opt/homebrew/bin
fish_add_path /opt/homebrew/sbin
```

Put these at the top of your config file.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Environment variables from .zshrc missing&quot; group=&quot;issues&quot;&gt;
Fish doesn&apos;t read `.zshrc` or `.bash_profile`. Move your environment variables to `config.fish` using `set -gx`:

```fish
set -gx JAVA_HOME (/usr/libexec/java_home)
set -gx ANDROID_HOME ~/Library/Android/sdk
fish_add_path $ANDROID_HOME/platform-tools
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;SSH agent not forwarding&quot; group=&quot;issues&quot;&gt;
macOS&apos;s SSH agent works with Fish, but you may need to load your keys:

```fish
# ~/.config/fish/conf.d/ssh.fish
if status is-interactive
    ssh-add --apple-use-keychain ~/.ssh/id_ed25519 2&gt;/dev/null
end
```
&lt;/Accordion&gt;

## Related guides

- [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) — comparison of all three shells
- [Fish Shell vs Zsh](/fish-shell-vs-zsh/) — since you&apos;re probably switching from Zsh on Mac
- [Install Fish on Ubuntu](/install-fish-shell-ubuntu/) — if you also use Linux
- [Fish Shell history guide](/fish-shell-history-persistence/) — manage and search your command history
- [Starship and Ghostty setup](/starship-ghostty-terminal/) — modern terminal + prompt combo
- [Ghostty Terminal guide](/ghostty-terminal/) — full Ghostty setup for Mac</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>How to Set Up Starship Prompt with Fish Shell</title><link>https://www.bitdoze.com/fish-shell-starship-prompt/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-starship-prompt/</guid><description>A step-by-step guide to installing and configuring Starship prompt in Fish Shell, with preset themes, custom modules, and Nerd Font setup.</description><pubDate>Thu, 19 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

Starship is a cross-shell prompt written in Rust. It works with Fish, Zsh, Bash, PowerShell, and others, so you can use the same prompt configuration regardless of which shell you&apos;re in. I&apos;ve been running it with Fish Shell and it pairs well. Starship is fast, the configuration is a single TOML file, and it shows you the information you actually need (git branch, language versions, command duration) without cluttering the prompt.

I previously wrote a guide on [Starship with Ghostty and Zsh](/starship-ghostty-terminal/). The Fish setup follows the same principles but the init line goes in a different config file.

## Prerequisites

You need Fish Shell installed. If you don&apos;t have it yet, follow my [Fish Shell installation guide for Ubuntu](/install-fish-shell-ubuntu/).

You also need a Nerd Font. Starship uses special glyphs for icons, and without a Nerd Font they show up as broken boxes.

### Install a Nerd Font

I recommend **MesloLGS Nerd Font**. On Ubuntu:

```bash
# Download and install
mkdir -p ~/.local/share/fonts
cd ~/.local/share/fonts
curl -fLO https://github.com/ryanoasis/nerd-fonts/releases/latest/download/Meslo.tar.xz
tar -xf Meslo.tar.xz
fc-cache -fv
```

On macOS with Homebrew:

```bash
brew install font-meslo-lg-nerd-font
```

After installing, set the font in your terminal emulator&apos;s settings. In Ghostty, add this to your config:

```
font-family = MesloLGS Nerd Font
```

For other terminals ([WezTerm](/install-wezterm-mac/), Alacritty, kitty, etc.), check their docs for the font configuration option.

## Install Starship

The recommended install method:

```bash
curl -sS https://starship.rs/install.sh | sh
```

Or use a package manager:

```bash
# Ubuntu/Debian (via snap)
snap install starship

# macOS
brew install starship

# Cargo
cargo install starship --locked
```

Verify:

```bash
starship --version
```

## Add Starship to Fish

Add one line to your Fish config:

```fish
# ~/.config/fish/config.fish
starship init fish | source
```

That&apos;s it. Open a new terminal or run `source ~/.config/fish/config.fish` and you should see the Starship prompt.

&lt;Notice type=&quot;info&quot; title=&quot;Placement matters&quot;&gt;
Put the `starship init fish | source` line at the end of your `config.fish`, after any other configuration. This makes sure Starship loads last and can properly set up the prompt.
&lt;/Notice&gt;

## Choose a preset

Starship ships with several presets that change the look of your prompt. You can browse them at [starship.rs/presets](https://starship.rs/presets/).

Apply a preset:

```bash
starship preset tokyo-night -o ~/.config/starship.toml
```

Popular presets:

- **Tokyo Night** - dark theme with purple/blue tones
- **Catppuccin** - pastel-colored theme, several variants (Mocha, Macchiato, Latte)
- **Nerd Font Symbols** - replaces text labels with Nerd Font icons
- **Bracketed Segments** - wraps each module in brackets
- **Plain Text** - no special characters, works without Nerd Fonts

Try a few. The preset command overwrites your `starship.toml`, so you might want to back up any custom configuration first.

## Configure Starship

Starship reads its configuration from `~/.config/starship.toml`. If the file doesn&apos;t exist, Starship uses sensible defaults.

Here&apos;s a practical configuration that shows relevant information without clutter:

```toml
# ~/.config/starship.toml

# General settings
format = &quot;&quot;&quot;
$directory\
$git_branch\
$git_status\
$nodejs\
$python\
$rust\
$golang\
$docker_context\
$cmd_duration\
$line_break\
$character&quot;&quot;&quot;

# Don&apos;t add a blank line between prompts
add_newline = false

[directory]
truncation_length = 3
truncate_to_repo = true

[git_branch]
format = &quot;[$symbol$branch]($style) &quot;
symbol = &quot; &quot;

[git_status]
format = &apos;([$all_status$ahead_behind]($style) )&apos;

[nodejs]
format = &quot;[$symbol($version)]($style) &quot;
symbol = &quot; &quot;

[python]
format = &quot;[$symbol($version)]($style) &quot;
symbol = &quot; &quot;

[rust]
format = &quot;[$symbol($version)]($style) &quot;
symbol = &quot; &quot;

[golang]
format = &quot;[$symbol($version)]($style) &quot;
symbol = &quot; &quot;

[docker_context]
format = &quot;[$symbol$context]($style) &quot;
symbol = &quot; &quot;

[cmd_duration]
min_time = 2_000
format = &quot;[$duration]($style) &quot;

[character]
success_symbol = &quot;[❯](green)&quot;
error_symbol = &quot;[❯](red)&quot;
```

### What each section does

**format** controls which modules appear and in what order. Only modules listed here are shown.

**directory** shows the current path. `truncation_length = 3` means you see at most 3 parent directories. `truncate_to_repo = true` shows the full path from the git repo root.

**git_branch** and **git_status** show your branch name and whether you have uncommitted changes, ahead/behind counts, etc.

**Language modules** (nodejs, python, rust, golang) only appear when you&apos;re in a project that uses that language. They detect this by looking for `package.json`, `pyproject.toml`, `Cargo.toml`, and similar files.

**cmd_duration** shows how long the last command took, but only if it took more than 2 seconds. Useful for noticing slow commands.

**character** shows a green `❯` on success and a red `❯` after a failed command.

## Starship vs Tide

If you&apos;re using Fish, you have two solid prompt options:

| | Starship | Tide |
|---|---|---|
| Works with | Fish, Zsh, Bash, etc. | Fish only |
| Configuration | TOML file | Interactive wizard |
| Async rendering | No (but fast enough) | Yes |
| Installation | Separate binary | Fisher plugin |
| Nerd Font needed | Yes (for most presets) | Yes |
| Customization | Edit `starship.toml` | `tide configure` + variables |

**Pick Starship if** you use multiple shells and want one prompt config, or if you prefer editing a config file directly.

**Pick Tide if** you only use Fish and want async rendering with a wizard-based setup. Install Tide with Fisher:

```fish
fisher install IlanCosman/tide@v6
tide configure
```

I cover Tide and other Fish tools in [best Fish Shell plugins and tools](/best-fish-shell-plugins/). For a full comparison of all Fish prompt options including Tide, Pure, and Hydro, see my [Fish Shell themes and prompts guide](/fish-shell-themes-prompts/).

Both are good. I use Starship because I sometimes drop into Zsh or Bash on different machines and want the same prompt everywhere.

## Advanced configuration

&lt;Accordion label=&quot;Right-side prompt&quot; group=&quot;advanced&quot; expanded=&quot;true&quot;&gt;
Starship supports a right prompt that appears on the right edge of your terminal:

```toml
# In starship.toml
right_format = &quot;&quot;&quot;$time&quot;&quot;&quot;

[time]
disabled = false
format = &quot;[$time]($style)&quot;
time_format = &quot;%H:%M&quot;
```

This shows the current time on the right side. Fish&apos;s right prompt support works well with this.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Custom modules&quot; group=&quot;advanced&quot;&gt;
You can define custom modules that run shell commands:

```toml
[custom.fish_version]
command = &quot;fish --version | string split &apos; &apos; | tail -1&quot;
when = &quot;true&quot;
format = &quot;[fish $output]($style) &quot;
style = &quot;cyan&quot;
```

This would show your Fish version in the prompt. Useful for debugging or showing project-specific info.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Per-directory configuration&quot; group=&quot;advanced&quot;&gt;
Starship doesn&apos;t have per-directory config, but you can achieve something similar with Fish&apos;s `conf.d` system. Create a function that sets `STARSHIP_CONFIG` based on the current directory:

```fish
# ~/.config/fish/conf.d/starship-dir.fish
function __check_starship_config --on-variable PWD
    if test -f .starship.toml
        set -gx STARSHIP_CONFIG (pwd)/.starship.toml
    else
        set -e STARSHIP_CONFIG
    end
end
```

Now any directory with a `.starship.toml` file will use that configuration instead of the global one.
&lt;/Accordion&gt;

## Troubleshooting

**Icons show as boxes or question marks.** You need a Nerd Font installed and selected in your terminal. Check the Prerequisites section above.

**Prompt is slow.** Starship is generally fast, but some modules (particularly git status in very large repos) can be slow. Disable them:

```toml
[git_status]
disabled = true
```

**Starship not loading.** Make sure `starship init fish | source` is in your `config.fish` and that `starship` is in your `$PATH`. Run `which starship` to verify.

## Further reading

- [Fish Shell vs Bash vs Zsh](/fish-shell-vs-bash-vs-zsh/) - complete shell comparison
- [Fish Shell vs Zsh](/fish-shell-vs-zsh/) - focused comparison
- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) - getting Fish set up
- [Fish Shell macOS setup](/fish-shell-macos-setup/) - install and configure Fish on Mac
- [Best Fish Shell plugins and tools](/best-fish-shell-plugins/) - Fisher, Tide, fzf.fish, and more
- [Fish Shell abbreviations vs aliases](/fish-shell-abbreviations-vs-aliases/) - text expansion in Fish
- [Fish Shell themes and prompts](/fish-shell-themes-prompts/) - compare Tide, Starship, Pure, and Hydro
- [Fish Shell syntax highlighting](/fish-shell-syntax-highlighting/) - how Fish highlights your commands
- [Fish Shell autocomplete and suggestions](/fish-shell-autocomplete-suggestions/) - Fish&apos;s completion system
- [Starship and Ghostty setup guide](/starship-ghostty-terminal/) - if you also want a modern terminal emulator
- [Ghostty Terminal guide](/ghostty-terminal/) - full Ghostty setup and configuration</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>Fish Shell vs Zsh - Which Should You Choose?</title><link>https://www.bitdoze.com/fish-shell-vs-zsh/</link><guid isPermaLink="true">https://www.bitdoze.com/fish-shell-vs-zsh/</guid><description>A practical comparison of Fish Shell and Zsh for daily terminal use, covering autocompletion, plugins, scripting, configuration, and which shell fits your workflow.</description><pubDate>Thu, 19 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

I used Zsh with Oh My Zsh for about three years before switching to Fish. Both are big upgrades over Bash, but they take different approaches to getting there. Zsh gives you a POSIX-compatible shell that you build up with plugins. Fish gives you a polished experience from the first launch but breaks with POSIX conventions.

If you want to see how all three shells compare, check my [Fish vs Bash vs Zsh comparison](/fish-shell-vs-bash-vs-zsh/). This article goes deeper on just Fish and Zsh.

## Out-of-the-box experience

This is where Fish and Zsh differ most.

**Fish** gives you autosuggestions, [syntax highlighting](/fish-shell-syntax-highlighting/), rich tab completions, and man page parsing from a fresh install. You open a terminal and everything works. No configuration, no plugins, no framework.

**Zsh** out of the box is... plain. It&apos;s a step above Bash, with better globbing and some built-in features, but the experience most people associate with Zsh comes from Oh My Zsh and plugins. You need to install [zsh-autosuggestions](/enable-command-autocomplete-in-zsh/) for history-based suggestions and [zsh-syntax-highlighting](/enable-syntax-highlighting-zsh/) for command coloring.

If you care about time-to-productive, Fish wins by a wide margin. You trade configuration flexibility for a shell that just works.

## Autosuggestions

Both shells can show inline suggestions from your [command history](/fish-shell-history-persistence/) as you type. The difference is that Fish does it by default and Zsh needs a plugin.

In Fish, press the right arrow to accept the full suggestion or `Alt+Right` to accept one word at a time. Fish also blends history suggestions with file path and command completions. I go into more detail in my [Fish Shell autocomplete and suggestions guide](/fish-shell-autocomplete-suggestions/).

In Zsh with `zsh-autosuggestions`, the behavior is similar but can feel slightly less polished. The plugin sometimes conflicts with other completions or themes, and you may need to tweak your configuration to get it working smoothly.

## Tab completions

Fish&apos;s tab completion system is more sophisticated out of the box. It reads man pages and generates completions automatically, so commands you&apos;ve never configured still get useful tab results. Type `rsync --` and press tab, and you&apos;ll see all available flags with descriptions.

Fish also displays completions in a pager-style list with descriptions next to each option. You navigate with arrow keys and press enter to select.

Zsh has a powerful completion system too (`compinit` plus `zstyle` configuration), and it can match Fish once properly configured. But &quot;properly configured&quot; is the key phrase. Most Zsh users rely on Oh My Zsh or frameworks to handle this, and even then it&apos;s rarely as complete as what Fish generates from man pages.

## Syntax

This is the real decision point for many people.

**Zsh** is largely POSIX-compatible. Bash scripts run in Zsh with minimal changes, and Zsh scripts look almost identical to Bash:

```zsh
# Zsh
export MY_VAR=&quot;hello&quot;
if [[ -f &quot;$HOME/.zshrc&quot; ]]; then
    source &quot;$HOME/.zshrc&quot;
fi
my_list=(&quot;one&quot; &quot;two&quot; &quot;three&quot;)
for item in &quot;${my_list[@]}&quot;; do
    echo &quot;$item&quot;
done
```

**Fish** has its own syntax. It&apos;s arguably cleaner, but it&apos;s different:

```fish
# Fish
set -gx MY_VAR &quot;hello&quot;
if test -f ~/.config/fish/config.fish
    source ~/.config/fish/config.fish
end
set my_list one two three
for item in $my_list
    echo $item
end
```

The Fish version has less punctuation. No `[[ ]]`, no `do`/`done`, no semicolons, no `${}`-style variable expansion. Variables are just `$name`, lists don&apos;t need quotes around each element, and blocks end with `end` instead of `fi`/`done`/`esac`.

The trade-off: you can&apos;t paste Bash commands directly. Things like `export VAR=value` (use `set -gx VAR value`), `$(command)` for command substitution (use `(command)`), and `&amp;&amp;` between commands (use `; and` or `&amp;&amp;` since Fish 3.0 added support) need adjustment.

&lt;Notice type=&quot;info&quot; title=&quot;Fish does support &amp;&amp; now&quot;&gt;
Fish 3.0 added `&amp;&amp;` and `||` support. So `command1 &amp;&amp; command2` works. Older guides that say otherwise are outdated.
&lt;/Notice&gt;

## Plugin ecosystem

**Zsh** has the larger ecosystem thanks to Oh My Zsh, which has been around since 2009. There are 300+ bundled plugins, 150+ themes, and a massive community. Alternative managers like zinit, antigen, and zplug offer faster loading. I have a list of [the best Oh My Zsh plugins](/best-oh-my-zsh-plugins/) if you&apos;re interested.

**Fish** has a smaller but focused ecosystem. [Fisher](https://github.com/jorgebucaran/fisher) is the recommended plugin manager, it&apos;s fast and stays out of your way. Popular plugins include Tide (a prompt), fzf.fish (fuzzy search integration), and [nvm.fish](/nvm-fish-shell/) (Node version management). I cover these in [best Fish Shell plugins and tools](/best-fish-shell-plugins/).

Oh My Fish exists as an alternative framework, but it&apos;s been unmaintained for a while. Fisher is the way to go. If you&apos;re still curious about OMF, see my [Oh My Fish guide](/oh-my-fish-install-themes-plugins/).

One thing worth noting: Fish needs fewer plugins to begin with. Features that require a Zsh plugin (autosuggestions, syntax highlighting, good completions) are built into Fish. So while Zsh has more plugins available, Fish users need fewer of them.

## Configuration complexity

**Zsh configuration** can get involved. A typical `.zshrc` with Oh My Zsh includes theme selection, plugin lists, custom aliases, path configuration, and framework settings. It&apos;s not uncommon to end up with a 100-line `.zshrc`. Oh My Zsh also adds measurable startup time, especially with many plugins enabled.

**Fish configuration** tends to be shorter. Your `~/.config/fish/config.fish` might just have path additions and some abbreviations. Fish also supports `conf.d/` for splitting config into separate files, and the `fish_config` web interface lets you change colors and prompts without editing files.

Fish&apos;s [abbreviation system](/fish-shell-abbreviations-vs-aliases/) also reduces what you put in config files. Instead of defining shell functions for common commands, you add abbreviations that expand inline.

## Prompt customization

**Zsh** themes through Oh My Zsh give you quick prompt changes. Powerlevel10k is the most popular option, it&apos;s fast and has a configuration wizard. You can also use Starship, which I covered in my [Starship and Ghostty guide](/starship-ghostty-terminal/).

**Fish** has Tide as its native prompt option, with a similar configuration wizard to Powerlevel10k. Starship also works with Fish. I wrote a guide on [setting up Starship with Fish Shell](/fish-shell-starship-prompt/), and a broader [Fish Shell themes and prompts comparison](/fish-shell-themes-prompts/) covering Tide, Starship, Pure, and Hydro.

Both shells work well with Starship if you want a consistent prompt across shells.

## Scripting

If you write automation scripts that need to run on different systems, Zsh (or Bash) is the safer choice. Zsh scripts run on macOS (where Zsh is the default shell) and most Linux systems with Zsh installed. Bash scripts run everywhere.

Fish scripts only run in Fish. For automation, I keep my scripts as Bash files with `#!/bin/bash` shebangs and run them from Fish. This is the standard recommendation in the Fish community. Fish is meant to be your interactive shell, not necessarily your scripting language for portable automation.

That said, Fish&apos;s scripting syntax is pleasant for Fish-specific things like custom completions, prompt functions, and abbreviation handlers. See my [Fish Shell functions guide](/fish-shell-functions-custom-commands/) for examples.

## Performance comparison

| Metric | Fish 4.5 | Zsh (with Oh My Zsh) | Zsh (bare) |
|---|---|---|---|
| Startup time | ~30ms | 200-800ms | ~40ms |
| Interactive response | Instant | Depends on plugins | Fast |
| Memory usage | ~15MB | ~20-40MB | ~10MB |
| Written in | Rust | C | C |

Fish 4.0 was rewritten in Rust (February 2025), and the latest release is 4.5.0. Startup is fast and interactive response is immediate. Zsh with Oh My Zsh and several plugins can have noticeable startup delay, though this varies a lot depending on which plugins you enable.

## So which one should you pick?

**Go with Fish if:**
- You want things to work immediately without configuring them
- You spend most of your time running commands interactively, not writing shell scripts
- The POSIX syntax differences don&apos;t bother you
- You prefer a smaller, focused plugin ecosystem over a huge one

**Go with Zsh if:**
- You want Bash compatibility so you can paste commands from tutorials without changes
- You like having a massive plugin ecosystem with lots of themes
- You write shell scripts that need to be portable
- You&apos;re already comfortable with your Zsh setup and don&apos;t feel limited by it

If you&apos;re currently on Bash and looking to upgrade, I&apos;d suggest trying Fish first. The barrier to trying it is low. Just [install it on Ubuntu](/install-fish-shell-ubuntu/) (or [on macOS](/fish-shell-macos-setup/)), run `fish`, and use it for a day. You don&apos;t have to set it as your default shell to test it out. If the syntax differences bother you too much, switch to Zsh instead.</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>How to Use NVM with Fish Shell</title><link>https://www.bitdoze.com/nvm-fish-shell/</link><guid isPermaLink="true">https://www.bitdoze.com/nvm-fish-shell/</guid><description>Install and configure nvm.fish to manage multiple Node.js versions in Fish Shell. Covers installation, switching versions, .nvmrc support, and default versions.</description><pubDate>Thu, 19 Feb 2026 01:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

The original nvm (Node Version Manager) is a Bash script. It doesn&apos;t work in Fish. If you try sourcing it in Fish, you get syntax errors because nvm relies on POSIX shell features that [Fish deliberately doesn&apos;t support](/fish-shell-vs-bash-vs-zsh/).

The fix is **nvm.fish** — a separate Node version manager written entirely in Fish. Same concept (install and switch between Node versions), different implementation. It&apos;s made by the same person who built [Fisher](/best-fish-shell-plugins/), and it works well.

## Install nvm.fish

You need [Fisher](/best-fish-shell-plugins/) first. If you don&apos;t have it:

```fish
curl -sL https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish | source &amp;&amp; fisher install jorgebucaran/fisher
```

Then install nvm.fish:

```fish
fisher install jorgebucaran/nvm.fish
```

That&apos;s it. No additional configuration needed. Restart your shell or open a new terminal tab.

## Install Node versions

```fish
# Install the latest release
nvm install latest

# Install the latest LTS version
nvm install lts

# Install a specific version
nvm install 22
nvm install 20.11.0
nvm install v18.19.1

# Install an LTS line by codename
nvm install iron    # Node 20 LTS
```

nvm.fish downloads pre-built Node binaries and stores them in `~/.local/share/nvm/` (following the XDG Base Directory spec). Each version gets its own directory.

## Switch between versions

```fish
# Use a specific version (current session only)
nvm use 22
nvm use lts
nvm use latest

# Check which version is active
nvm current

# List installed versions
nvm list
```

`nvm use` changes the active Node version for your current shell session. Open a new terminal and it reverts to the default (or whatever `.nvmrc` specifies for that directory).

## Set a default Node version

Without a default, new shell sessions won&apos;t have any nvm-managed Node on the PATH. Set one with a universal variable:

```fish
set --universal nvm_default_version v22
```

Now every new Fish session starts with Node 22 active. You can use `lts`, `latest`, or a specific version number.

## Automatic version switching with .nvmrc

This is the feature I use most. Create a `.nvmrc` file in a project&apos;s root directory:

```bash
echo &quot;20&quot; &gt; ~/projects/legacy-app/.nvmrc
echo &quot;22&quot; &gt; ~/projects/new-app/.nvmrc
```

When you `cd` into a directory with a `.nvmrc` (or `.node-version`) file, `nvm use` reads it automatically. This works by traversing up the directory tree until it finds the file.

```fish
cd ~/projects/legacy-app
node --version  # v20.x.x

cd ~/projects/new-app
node --version  # v22.x.x
```

&lt;Notice type=&quot;info&quot; title=&quot;.nvmrc formats&quot;&gt;
nvm.fish supports the same `.nvmrc` formats as the original nvm: version numbers (`20`, `20.11`, `20.11.0`), `lts`, `latest`, and LTS codenames (`iron`, `hydrogen`). Files named `.node-version` work too.
&lt;/Notice&gt;

## Install default global packages

If you want certain npm packages installed every time you install a new Node version:

```fish
set --universal nvm_default_packages yarn typescript tsx
```

Now `nvm install 22` will also run `npm install -g yarn typescript tsx` after downloading Node.

## Uninstall Node versions

```fish
nvm uninstall v18
nvm uninstall 20.11.0
```

This removes the installed binaries from the nvm data directory.

## Change the install mirror

If you need to use a mirror (corporate proxy, China mirror, etc.):

```fish
set --universal nvm_mirror https://npmmirror.com/mirrors/node
```

Default is `https://nodejs.org/dist`.

## Change the data directory

By default, nvm.fish stores everything in `~/.local/share/nvm/`. Change it with:

```fish
set --global nvm_data ~/.nvm
```

If you previously used the Bash-based nvm and have Node versions in `~/.nvm/`, this lets nvm.fish use that same directory. Versions installed by either tool should work.

## nvm.fish vs nvm (the Bash version)

| | nvm.fish | nvm (Bash) |
|---|---|---|
| Shell support | Fish only | Bash, Zsh |
| Written in | Fish | Bash/POSIX sh |
| Installation | Fisher plugin | curl script |
| Startup impact | Minimal (lazy loading) | Can add 200-500ms |
| `.nvmrc` support | Yes | Yes |
| Tab completions | Yes (Fish native) | Basic |
| LTS codenames | Yes | Yes |

The performance difference is worth mentioning. The Bash-based nvm is notorious for slow shell startup because it needs to be sourced in every new session. nvm.fish loads lazily — it only activates when you actually run `nvm` or switch to a directory with `.nvmrc`. You won&apos;t notice it in your startup time.

## nvm.fish vs fnm vs Volta

There are other Node version managers that work with Fish:

**fnm** (Fast Node Manager) — written in Rust, works with any shell. It&apos;s faster than nvm.fish for installs because it uses parallel downloads. Install with `brew install fnm` or `cargo install fnm`, then add `fnm env --use-on-cd --shell fish | source` to your config.fish.

**Volta** — also Rust-based, also cross-shell. Its main differentiator is per-project global package management. Install with `curl https://get.volta.sh | bash`, then add `set -gx VOLTA_HOME ~/.volta; fish_add_path $VOLTA_HOME/bin` to config.fish.

I use nvm.fish because it&apos;s pure Fish, integrates with Fisher, and I don&apos;t need the extra features of fnm or Volta. If you use multiple shells, fnm or Volta are better choices since they work everywhere.

## Troubleshooting

**`nvm: command not found`** — Fisher didn&apos;t install correctly, or your shell hasn&apos;t reloaded. Run `fisher list` to check if `jorgebucaran/nvm.fish` appears. Try opening a new terminal.

**Node not found after install** — Make sure you ran `nvm use &lt;version&gt;` after installing. Or set a default version: `set --universal nvm_default_version lts`.

**`.nvmrc` not being read automatically** — nvm.fish reads `.nvmrc` when you run `nvm install` or `nvm use` without arguments. For automatic switching on `cd`, make sure you&apos;re using a recent version of nvm.fish (`fisher update jorgebucaran/nvm.fish`).

## Related guides

- [Install Fish Shell on Ubuntu](/install-fish-shell-ubuntu/) — get Fish set up first
- [Fish Shell on macOS](/fish-shell-macos-setup/) — Mac setup including Homebrew Node
- [Best Fish Shell plugins](/best-fish-shell-plugins/) — nvm.fish and other recommended plugins
- [Fish Shell functions guide](/fish-shell-functions-custom-commands/) — create your own Node-related helper functions</content:encoded><category>linux</category><category>fish-shell</category></item><item><title>PicoClaw Setup Guide: Go Binary AI Assistant on $10 Hardware</title><link>https://www.bitdoze.com/picoclaw-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/picoclaw-setup-guide/</guid><description>Step-by-step guide to installing PicoClaw on a Linux VPS or single-board computer with MiniMax M2.5, GLM-5, Discord integration, and Brave Search. Covers config, providers, memory, and Docker deployment.</description><pubDate>Thu, 19 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

[PicoClaw](https://github.com/sipeed/picoclaw) is a Go rewrite of nanobot that compiles down to a single binary, boots in under a second, and uses less than 10MB of RAM. Sipeed, the RISC-V hardware company behind NanoKVM and MaixCAM, built it to run on their $10 LicheeRV-Nano boards. I&apos;ve been running it alongside my [nanobot](/nanobot-setup-guide/) and [ZeroClaw](/zeroclaw-setup-guide/) setups for the past week, and the resource numbers are real. If you want an AI assistant on hardware that would choke on Python, this is the one to look at.

This guide walks through getting PicoClaw running on a VPS or SBC with MiniMax M2.5 and GLM-5 as your models, Brave Search for web access, and Discord as the chat channel.

&lt;Button text=&quot;PicoClaw GitHub&quot; link=&quot;https://github.com/sipeed/picoclaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing PicoClaw from source, prebuilt binary, or Docker&lt;/li&gt;
&lt;li&gt;Configuring MiniMax M2.5 and Zhipu GLM-5 as LLM providers&lt;/li&gt;
&lt;li&gt;Setting up Brave Search and DuckDuckGo for web access&lt;/li&gt;
&lt;li&gt;Discord channel integration&lt;/li&gt;
&lt;li&gt;Memory system, workspace files, and scheduled tasks&lt;/li&gt;
&lt;li&gt;Security sandbox and deployment options&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted bot options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup includes PicoClaw alongside nanobot, NanoClaw, memU, IronClaw, and ZeroClaw.

## What PicoClaw actually is

PicoClaw is an open-source AI assistant from Sipeed. The project started as a nanobot port to Go, and 95% of the migration was reportedly driven by the AI agent itself with human review. The result is a single binary that runs on RISC-V, ARM, and x86 without any runtime dependencies.

The architecture follows the same pattern as nanobot:

```
You (Discord / Telegram / DingTalk / LINE / QQ / CLI)
    ↓
PicoClaw Gateway (running on your VPS or SBC)
    ↓
LLM Provider (OpenRouter, Zhipu, Anthropic, OpenAI, Gemini, Groq, DeepSeek)
    ↓
Tools (shell, file access, web search, memory, scheduled tasks)
```

Messages come in from your chat app, PicoClaw routes them to whatever LLM you configured, and the model can use built-in tools to run shell commands, read and write files, and search the web. Everything except the LLM API calls stays on your machine.

### How it compares

| | OpenClaw | NanoBot | PicoClaw 🦐 |
|---|---|---|---|
| **Language** | TypeScript | Python | **Go** |
| **RAM** | &gt; 1GB | &gt; 100MB | **&lt; 10MB** |
| **Startup (0.8GHz)** | &gt; 500s | &gt; 30s | **&lt; 1s** |
| **Binary** | ~28MB (dist) | N/A (scripts) | **Single binary** |
| **Min hardware cost** | Mac Mini $599 | ~$50 SBC | **$10 board** |
| **Channels** | 4 platforms | 9 platforms | **5+ platforms** |
| **Providers** | Several | 13+ | **8+** |
| **Memory** | File + semantic | File-based | **File-based workspace** |

## Why MiniMax M2.5 and GLM-5

Both of these models came out in February 2026 and they work well with a lightweight bot like PicoClaw.

### MiniMax M2.5

MiniMax M2.5 is a 230B Mixture-of-Experts model with only 10B active parameters per pass. It runs fast and cheap:

| Spec | Value |
|------|-------|
| Architecture | 230B MoE, 10B active |
| Context window | 1M tokens |
| Speed (Lightning) | 100 tokens/sec |
| Cost (Lightning) | $0.30/M input, $2.40/M output |
| SWE-Bench Verified | 80.2% |
| License | Modified MIT (open-source) |

It scores 80.2% on SWE-Bench Verified, matching Claude Opus 4.6 at about 1/20th the cost. The 1M token context window means PicoClaw won&apos;t hit context limits even with long conversations.

### GLM-5

GLM-5 from Zhipu AI is a 744B MoE model with 40-44B active parameters:

| Spec | Value |
|------|-------|
| Architecture | 744B MoE, ~40B active |
| Context window | 200K tokens |
| SWE-Bench Verified | 77.8% |
| BrowseComp | #1 open-source |
| License | MIT |

GLM-5 ranks first among open-source models on BrowseComp (web search agent tasks), so it&apos;s a solid pick for a bot that does a lot of web lookups. Both models are available through their own APIs and through OpenRouter.

## Installation

Three ways to get PicoClaw installed. The prebuilt binary is the fastest path.

&lt;Tabs&gt;
&lt;Tab name=&quot;Prebuilt binary (fastest)&quot;&gt;

Download the binary for your platform from the [releases page](https://github.com/sipeed/picoclaw/releases):

```bash
# Example for Linux amd64 — check releases for latest version
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-amd64
chmod +x picoclaw-linux-amd64
sudo mv picoclaw-linux-amd64 /usr/local/bin/picoclaw
```

For ARM64 (Raspberry Pi, phone via Termux):

```bash
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
chmod +x picoclaw-linux-arm64
sudo mv picoclaw-linux-arm64 /usr/local/bin/picoclaw
```

&lt;/Tab&gt;
&lt;Tab name=&quot;From source (recommended for dev)&quot;&gt;

You need Go 1.21+ installed:

```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
make build
```

Or build and install in one step:

```bash
make install
```

To cross-compile for multiple platforms:

```bash
make build-all
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw

# Set up your config
cp config/config.example.json config/config.json
nano config/config.json

# Start the gateway
docker compose --profile gateway up -d

# Check logs
docker compose logs -f picoclaw-gateway
```

For a one-shot query without the gateway:

```bash
docker compose run --rm picoclaw-agent -m &quot;What is 2+2?&quot;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

After installing, initialize the workspace and config:

```bash
picoclaw onboard
```

This creates the `~/.picoclaw/` directory with a default `config.json` and a `workspace/` folder.

Check that everything&apos;s working:

```bash
picoclaw status
```

## Configuring MiniMax M2.5

PicoClaw uses a JSON config file at `~/.picoclaw/config.json`. The provider system routes models based on keywords in the model name, similar to how nanobot does it.

### Get an API key

1. Go to [platform.minimax.io](https://platform.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (mainland China)
2. Create an account and generate an API key

&lt;Notice type=&quot;success&quot; title=&quot;MiniMax coding plan — 10% off&quot;&gt;
MiniMax offers coding plans priced for developer workloads. [Get 10% off with our referral link](https://go.bitdoze.com/minimax). For details on how GLM-5 and MiniMax M2.5 compare for always-on bots, see our [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) breakdown.
&lt;/Notice&gt;

### Add to config

The simplest way is through OpenRouter, which gives you access to MiniMax alongside hundreds of other models:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;minimax/MiniMax-M2.5&quot;,
      &quot;max_tokens&quot;: 8192,
      &quot;temperature&quot;: 0.7
    }
  },
  &quot;providers&quot;: {
    &quot;openrouter&quot;: {
      &quot;api_key&quot;: &quot;sk-or-your-openrouter-key&quot;,
      &quot;api_base&quot;: &quot;https://openrouter.ai/api/v1&quot;
    }
  }
}
```

To hit MiniMax&apos;s API directly, configure a dedicated provider:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;MiniMax-M2.5&quot;
    }
  },
  &quot;providers&quot;: {
    &quot;minimax&quot;: {
      &quot;api_key&quot;: &quot;your-minimax-api-key&quot;
    }
  }
}
```

For the mainland China endpoint, add `&quot;api_base&quot;: &quot;https://api.minimaxi.com/v1&quot;` to the minimax provider.

### Test it

```bash
picoclaw agent -m &quot;What&apos;s 42 * 17?&quot;
```

If you get a response, MiniMax is working.

## Configuring GLM-5 (Zhipu)

PicoClaw has built-in support for Zhipu. The provider routes automatically when it detects `glm` in the model name.

### Get an API key

1. Go to [bigmodel.cn](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys)
2. Register and create an API key

&lt;Notice type=&quot;success&quot; title=&quot;Z.AI GLM coding plan — 10% off&quot;&gt;
Z.AI offers [GLM coding plans](https://z.ai/subscribe?ic=NKNUNYDRZT) designed for continuous developer workloads. Use our link for 10% off.
&lt;/Notice&gt;

### Add to config

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;glm-4.7&quot;,
      &quot;max_tokens&quot;: 8192,
      &quot;temperature&quot;: 0.7,
      &quot;max_tool_iterations&quot;: 20
    }
  },
  &quot;providers&quot;: {
    &quot;zhipu&quot;: {
      &quot;api_key&quot;: &quot;your-zhipu-api-key&quot;,
      &quot;api_base&quot;: &quot;https://open.bigmodel.cn/api/paas/v4&quot;
    }
  }
}
```

### Switching between models

Configure both providers and swap the default model whenever you want. Change `&quot;model&quot;` to `&quot;MiniMax-M2.5&quot;` or `&quot;glm-4.7&quot;` and PicoClaw picks the right provider automatically. No restart needed for CLI use. For the gateway, restart with `picoclaw gateway`.

## Setting up web search

PicoClaw supports both Brave Search and DuckDuckGo. Brave gives better results but needs an API key. DuckDuckGo works out of the box with no key required.

### Brave Search

1. Go to [brave.com/search/api](https://brave.com/search/api/)
2. Sign up for an account
3. The free tier gives you 2,000 searches per month
4. Generate an API key from the dashboard

### Add to config

```json
{
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;brave&quot;: {
        &quot;enabled&quot;: true,
        &quot;api_key&quot;: &quot;your-brave-search-api-key&quot;,
        &quot;max_results&quot;: 5
      },
      &quot;duckduckgo&quot;: {
        &quot;enabled&quot;: true,
        &quot;max_results&quot;: 5
      }
    }
  }
}
```

Keep both enabled. If Brave hits a rate limit or fails, PicoClaw falls back to DuckDuckGo automatically. The `max_results` setting controls how many results get pulled per search. Five is a reasonable default.

## Discord setup

Discord is one of the channels PicoClaw supports alongside Telegram, QQ, DingTalk, and LINE.

### Create a Discord bot

1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
2. Click **New Application**, give it a name
3. Go to **Bot** in the left sidebar, click **Add Bot**
4. Copy the bot token

### Enable intents

Still in the Bot settings page:

1. Scroll down to **Privileged Gateway Intents**
2. Enable **MESSAGE CONTENT INTENT** (required, or the bot can&apos;t read messages)
3. Optionally enable **SERVER MEMBERS INTENT** if you plan to use allow lists

### Get your user ID

1. Open Discord Settings → **Advanced** → enable **Developer Mode**
2. Right-click your avatar anywhere in Discord
3. Click **Copy User ID**

### Configure PicoClaw

Add the Discord channel to your `~/.picoclaw/config.json`:

```json
{
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_DISCORD_BOT_TOKEN&quot;,
      &quot;allow_from&quot;: [&quot;YOUR_USER_ID&quot;]
    }
  }
}
```

The `allow_from` array restricts who can talk to the bot. Leave it empty to let anyone in your server use it. For a personal bot, always lock this down to your user ID.

### Invite the bot to your server

1. In the Discord developer portal, go to **OAuth2** → **URL Generator**
2. Under **Scopes**, check `bot`
3. Under **Bot Permissions**, check `Send Messages` and `Read Message History`
4. Copy the generated URL and open it in your browser
5. Select the server to add the bot to

### Start the gateway

```bash
picoclaw gateway
```

Send a message in Discord. The bot should respond. If nothing happens, check `picoclaw status` and look at the gateway logs.

## Full config example

Here&apos;s what a complete `~/.picoclaw/config.json` looks like with OpenRouter, Zhipu, Brave Search, DuckDuckGo, and Discord:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;workspace&quot;: &quot;~/.picoclaw/workspace&quot;,
      &quot;model&quot;: &quot;minimax/MiniMax-M2.5&quot;,
      &quot;max_tokens&quot;: 8192,
      &quot;temperature&quot;: 0.7,
      &quot;max_tool_iterations&quot;: 20
    }
  },
  &quot;providers&quot;: {
    &quot;openrouter&quot;: {
      &quot;api_key&quot;: &quot;sk-or-your-openrouter-key&quot;,
      &quot;api_base&quot;: &quot;https://openrouter.ai/api/v1&quot;
    },
    &quot;zhipu&quot;: {
      &quot;api_key&quot;: &quot;your-zhipu-api-key&quot;,
      &quot;api_base&quot;: &quot;https://open.bigmodel.cn/api/paas/v4&quot;
    },
    &quot;groq&quot;: {
      &quot;api_key&quot;: &quot;gsk_your-groq-key&quot;
    }
  },
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_DISCORD_BOT_TOKEN&quot;,
      &quot;allow_from&quot;: [&quot;YOUR_USER_ID&quot;]
    },
    &quot;telegram&quot;: {
      &quot;enabled&quot;: false,
      &quot;token&quot;: &quot;&quot;,
      &quot;allow_from&quot;: []
    }
  },
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;brave&quot;: {
        &quot;enabled&quot;: true,
        &quot;api_key&quot;: &quot;your-brave-search-api-key&quot;,
        &quot;max_results&quot;: 5
      },
      &quot;duckduckgo&quot;: {
        &quot;enabled&quot;: true,
        &quot;max_results&quot;: 5
      }
    },
    &quot;cron&quot;: {
      &quot;exec_timeout_minutes&quot;: 5
    }
  },
  &quot;heartbeat&quot;: {
    &quot;enabled&quot;: true,
    &quot;interval&quot;: 30
  }
}
```

### Config settings explained

| Setting | Default | What it does |
|---------|---------|-------------|
| `agents.defaults.model` | `anthropic/claude-opus-4-5` | Which model handles your messages |
| `agents.defaults.max_tokens` | `8192` | Max tokens per LLM response |
| `agents.defaults.temperature` | `0.7` | Randomness (lower = more deterministic) |
| `agents.defaults.max_tool_iterations` | `20` | How many tool calls per turn before stopping |
| `agents.defaults.restrict_to_workspace` | `true` | Sandbox all file/shell access to workspace |
| `heartbeat.interval` | `30` | Minutes between periodic task checks |
| `tools.cron.exec_timeout_minutes` | `5` | Timeout for scheduled task execution |

## Provider system

PicoClaw routes providers by protocol family. OpenAI-compatible endpoints (OpenRouter, Groq, Zhipu) share one code path. Anthropic has its own. Adding a new OpenAI-compatible provider is mostly a config operation with `api_base` and `api_key`.

| Provider | Purpose | Get API key |
|----------|---------|-------------|
| OpenRouter | Gateway to any model | [openrouter.ai](https://openrouter.ai/keys) |
| Zhipu | GLM models (direct) | [bigmodel.cn](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| Gemini | Google Gemini (direct) | [aistudio.google.com](https://aistudio.google.com/api-keys) |
| Anthropic | Claude (direct) | [console.anthropic.com](https://console.anthropic.com) |
| OpenAI | GPT (direct) | [platform.openai.com](https://platform.openai.com) |
| DeepSeek | DeepSeek (direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| Groq | Fast inference + Whisper voice | [console.groq.com](https://console.groq.com) |

&lt;Notice type=&quot;info&quot; title=&quot;Free API tiers&quot;&gt;
OpenRouter gives 200K tokens/month free. Zhipu gives 200K tokens/month free. Brave Search gives 2,000 queries/month free. Groq has a free tier for fast inference. You can get started without spending anything.
&lt;/Notice&gt;

## Memory and workspace

PicoClaw stores memory and configuration in the workspace directory:

```
~/.picoclaw/workspace/
├── sessions/          # Conversation sessions and history
├── memory/            # Long-term memory (MEMORY.md)
├── state/             # Persistent state (last channel, etc.)
├── cron/              # Scheduled jobs database
├── skills/            # Custom skills
├── AGENTS.md          # Agent behavior guide
├── HEARTBEAT.md       # Periodic task prompts
├── IDENTITY.md        # Agent identity
├── SOUL.md            # Agent soul / personality
├── TOOLS.md           # Tool descriptions
└── USER.md            # Your personal info and preferences
```

Tell the bot to remember something and it writes to `memory/MEMORY.md`. Sessions are stored per conversation. The bootstrap files (`SOUL.md`, `USER.md`, etc.) get loaded into the system prompt every time the bot processes a message.

```bash
nano ~/.picoclaw/workspace/USER.md
```

Add whatever context you want the bot to always have — project details, communication preferences, technical background. This works the same way as [nanobot&apos;s workspace files](/nanobot-setup-guide/).

## Heartbeat and scheduled tasks

PicoClaw can run periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:

```markdown
# Periodic Tasks

- Check the weather forecast
- Search the web for AI news and summarize
```

The agent reads this file every 30 minutes (configurable) and runs each task using available tools. For long-running tasks, PicoClaw spawns a subagent that works independently without blocking the main heartbeat loop.

You can also manage one-off and recurring jobs from the CLI:

```bash
# Add a reminder
picoclaw cron add --name &quot;morning&quot; --message &quot;Good morning! What&apos;s in the news?&quot; --cron &quot;0 9 * * *&quot;

# List all jobs
picoclaw cron list
```

Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.

## Security sandbox

PicoClaw runs in a sandboxed environment by default. With `restrict_to_workspace` set to `true`, the agent can only access files and execute commands within the workspace directory.

### Protected tools

| Tool | Function | Restriction |
|------|----------|-------------|
| `read_file` | Read files | Workspace only |
| `write_file` | Write files | Workspace only |
| `list_dir` | List directories | Workspace only |
| `edit_file` | Edit files | Workspace only |
| `append_file` | Append to files | Workspace only |
| `exec` | Execute commands | Paths must be within workspace |

### Dangerous command blocking

Even with `restrict_to_workspace` set to `false`, PicoClaw blocks destructive commands:

- `rm -rf`, `del /f`, `rmdir /s` — bulk deletion
- `format`, `mkfs`, `diskpart` — disk formatting
- `dd if=` — disk imaging
- Writing to `/dev/sd[a-z]` — direct disk writes
- `shutdown`, `reboot`, `poweroff` — system shutdown
- Fork bombs

The sandbox boundary applies consistently across the main agent, subagents, and heartbeat tasks. There&apos;s no way to bypass it through subagents or scheduled jobs.

### Disabling restrictions

If you need the agent to access paths outside the workspace:

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;restrict_to_workspace&quot;: false
    }
  }
}
```

&lt;Notice type=&quot;warning&quot; title=&quot;Security risk&quot;&gt;
Disabling workspace restriction lets the agent access any path on your system. Only do this in controlled environments where you trust the model&apos;s output.
&lt;/Notice&gt;

## Docker deployment

PicoClaw has a Docker Compose setup with separate profiles for agent mode and gateway mode.

### Using Docker Compose

```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw

# Set up config
cp config/config.example.json config/config.json
nano config/config.json

# Start gateway (long-running)
docker compose --profile gateway up -d

# Check logs
docker compose logs -f picoclaw-gateway

# Stop
docker compose --profile gateway down
```

### Agent mode (one-shot queries)

```bash
# Ask a question
docker compose run --rm picoclaw-agent -m &quot;What is 2+2?&quot;

# Interactive mode
docker compose run --rm picoclaw-agent
```

### Rebuild after updates

```bash
docker compose --profile gateway build --no-cache
docker compose --profile gateway up -d
```

The compose file mounts `config.json` as read-only and uses a named volume for the workspace, so your data survives container restarts.

## CLI reference

| Command | What it does |
|---------|-------------|
| `picoclaw onboard` | Initialize config and workspace |
| `picoclaw agent -m &quot;...&quot;` | Send a single message |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway (connects to chat channels) |
| `picoclaw status` | Show current status |
| `picoclaw cron list` | List scheduled jobs |
| `picoclaw cron add` | Add a scheduled job |

In interactive mode, type `exit`, `quit`, or press `Ctrl+D` to leave.

## VPS hosting

PicoClaw runs on just about anything. The single binary with under 10MB RAM means you can deploy it on boards as cheap as $10. For a VPS, a [Hetzner CX22](/hetzner-cloud-review/) (2 vCPU, 4GB RAM) at €4.35/month is overkill, but it leaves room for other services.

&lt;Notice type=&quot;success&quot; title=&quot;Hetzner discount&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers around 4 months of a CX22.
&lt;/Notice&gt;

Quick setup on a fresh Ubuntu 24.04 VPS:

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Download binary (check releases for latest version)
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-amd64
chmod +x picoclaw-linux-amd64
mv picoclaw-linux-amd64 /usr/local/bin/picoclaw

# Initialize
picoclaw onboard

# Edit config
nano ~/.picoclaw/config.json

# Start gateway in background
nohup picoclaw gateway &gt; /var/log/picoclaw.log 2&gt;&amp;1 &amp;
```

For a proper daemon setup, create a systemd service:

```ini
[Unit]
Description=PicoClaw gateway
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/picoclaw gateway
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Save to `/etc/systemd/system/picoclaw.service`, then:

```bash
systemctl daemon-reload
systemctl enable picoclaw
systemctl start picoclaw
```

### Running on cheap hardware

PicoClaw was designed for this. A $10 LicheeRV-Nano with Ethernet or WiFi6 can serve as a minimal home assistant. A $30-50 NanoKVM works for automated server maintenance. A MaixCAM handles smart monitoring use cases.

For old Android phones, install Termux and run the ARM64 binary:

```bash
pkg install proot wget
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
chmod +x picoclaw-linux-arm64
termux-chroot ./picoclaw-linux-arm64 onboard
```

If you want to run local models alongside PicoClaw, check our guide on [installing Ollama with Docker](/ollama-docker-install/). PicoClaw works with any OpenAI-compatible endpoint, so pointing it at a local Ollama server is a one-line config change.

## PicoClaw vs nanobot vs ZeroClaw

I run all three at this point, so here&apos;s a direct comparison:

| Aspect | PicoClaw 🦐 | nanobot | ZeroClaw 🦀 |
|--------|------------|---------|------------|
| Language | Go | Python | Rust |
| RAM usage | &lt; 10MB | ~100MB | &lt; 5MB |
| Startup | &lt; 1s | &gt; 2s | &lt; 10ms |
| Binary | Single Go binary | N/A (scripts) | 3.4MB Rust binary |
| Config format | JSON | JSON | TOML |
| Channel count | 5+ | 9 | 8+ |
| Provider count | 8+ | 13+ | 22+ |
| Memory | File-based workspace | File-based | SQLite hybrid search |
| Security | Workspace sandbox + command blocking | Allowlists | Pairing + sandbox + allowlists |
| Install method | Binary download / `make install` | `pip install` | `cargo install` |
| Setup time | ~2 minutes (binary) | ~5 minutes | ~10 minutes (includes compile) |

PicoClaw is the easiest to deploy: download a binary, run it, done. nanobot has better channel coverage and a bigger community. ZeroClaw has the most capable memory system and the widest provider support. For the nanobot setup guide, see our [full walkthrough](/nanobot-setup-guide/). For ZeroClaw, see the [setup guide](/zeroclaw-setup-guide/).

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**How much does it cost to run PicoClaw?**

VPS: ~$5/month at Hetzner, though PicoClaw can run on hardware as cheap as $10 one-time. MiniMax M2.5 Lightning API costs roughly $1/hour of continuous use, but actual costs are much lower since the bot only calls the API when you message it. Expect $5-20/month for personal use. You can also start with free tiers from OpenRouter and Zhipu.

**Can I use PicoClaw without any API costs?**

Partially. DuckDuckGo web search works without an API key. For the LLM, you need either a paid API or a local model via an OpenAI-compatible endpoint. Point PicoClaw at a local Ollama server and there are no API bills.

**Does PicoClaw work on a Raspberry Pi?**

Yes. Download the ARM64 binary and it runs on any Pi with networking. The binary uses under 10MB of RAM at runtime, so even a Pi Zero 2W handles it. PicoClaw was literally designed for $10 single-board computers.

**Can multiple people use one PicoClaw instance?**

Yes. Add multiple user IDs to `allow_from` in your channel config. Each person gets their own conversation context through the session system.

**What&apos;s the difference between agent and gateway?**

`picoclaw agent` is for direct CLI chat. `picoclaw gateway` starts the background service that connects to Discord, Telegram, and other chat platforms. For 24/7 use, you want the gateway.

**Can I add Telegram alongside Discord?**

Yes. Configure both channels in the same config file and the gateway handles them at the same time. For Telegram, you need a bot token from @BotFather — the process is the same as described in the nanobot guide.

**Does PicoClaw support voice messages?**

Yes, through Groq&apos;s Whisper integration. Configure a Groq API key and Telegram voice messages get automatically transcribed.

&lt;/Accordion&gt;

If you want to explore other AI coding tools, our [AI coding tools comparison](/ai-coading-tools/) covers the current landscape. For a self-improving assistant with voice mode and OpenClaw migration, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/). For MCP basics that work across these assistants, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

This article is also available in Spanish: [Guía de Configuración de PicoClaw](/es/guia-configuracion-picoclaw/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>Best OpenClaw Command Centers &amp; Dashboards in 2026</title><link>https://www.bitdoze.com/best-openclaw-dashboards/</link><guid isPermaLink="true">https://www.bitdoze.com/best-openclaw-dashboards/</guid><description>A roundup of the best dashboards and command centers for OpenClaw — from full multi-agent orchestration platforms to lightweight single-file monitors. Covers Mission Control, LobsterBoard, AI Maestro, Clawe, Clawtrol, VidClaw, Clawd Control, OpenClaw Dashboard, and Claw Dashboard.</description><pubDate>Wed, 18 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

Once you have [OpenClaw running on a VPS or Mac Mini](/clawdbot-setup-guide/), the terminal gets old fast. You start wanting to see what your agent is actually doing — which sessions are active, what it&apos;s spending on API calls, which scheduled jobs ran last night. That&apos;s where dashboards come in.

Several have appeared over the last year. Some are full orchestration platforms that let you create tasks and dispatch multiple agents. Others are single-file monitors you drop on the same server and forget about. I went through nine of the most popular ones so you can pick the right fit without spending a day trying each.

&lt;Notice type=&quot;info&quot; title=&quot;What This Covers&quot;&gt;
Nine OpenClaw dashboard and command center projects, sorted by star count. Includes quick-start setup, key features, and which use case each one fits best.
&lt;/Notice&gt;

## Quick Comparison

| Project | Stars | Language | Best For |
|---------|-------|----------|----------|
| [Mission Control](https://github.com/crshdn/mission-control) | ⭐ 440 | TypeScript | Multi-agent orchestration, task planning |
| [LobsterBoard](https://github.com/Curbob/LobsterBoard) | ⭐ 382 | JavaScript | Build a custom dashboard with 50 widgets |
| [AI Maestro](https://github.com/23blocks-OS/ai-maestro) | ⭐ 324 | TypeScript | 80+ agents across multiple machines |
| [Clawe](https://github.com/getclawe/clawe) | ⭐ 261 | TypeScript | Squad of specialized agents, Trello-like |
| [Clawd Control](https://github.com/Temaki-AI/clawd-control) | ⭐ 101 | HTML | Lightweight fleet monitor, zero build step |
| [VidClaw](https://github.com/madrzak/vidclaw) | ⭐ 67 | JavaScript | Task board, soul editor, usage tracking |
| [OpenClaw Dashboard](https://github.com/tugcantopaloglu/openclaw-dashboard) | ⭐ 60 | HTML | Security-hardened, TOTP MFA, zero deps |
| [Claw Dashboard](https://github.com/spleck/claw-dashboard) | ⭐ 49 | JavaScript | Terminal-style btop/htop-inspired monitor |
| [Clawtrol](https://github.com/nachoiacovino/clawtrol) | ⭐ 18 | TypeScript | Remote screen, terminal, file browser, kanban |

---

## 1. Mission Control — Best Overall

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/crshdn/mission-control&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

Mission Control is the most popular OpenClaw dashboard by a margin. It&apos;s built around a Kanban board where tasks move through seven stages: Planning → Inbox → Assigned → In Progress → Testing → Review → Done. When you create a task, an AI planning flow asks clarifying questions before spinning up a specialized agent to execute it.

The project connects to the OpenClaw gateway over WebSocket, so tasks get dispatched in real time rather than polling. Security defaults include bearer token auth, Zod validation, and path traversal protection. There&apos;s a live demo you can try before installing anything.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Task planning flow**: AI asks questions before starting work, so agents get precise instructions&lt;/li&gt;
&lt;li&gt;**Auto-creates agents**: Spawns specialized agents per task rather than throwing everything at one&lt;/li&gt;
&lt;li&gt;**Live event feed**: Real-time stream of agent activity, task state changes, and system events&lt;/li&gt;
&lt;li&gt;**Multi-machine support**: Run the dashboard on one box, the agents on another, over Tailscale&lt;/li&gt;
&lt;li&gt;**SQLite storage**: No external database needed, resets with a single file delete&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
git clone https://github.com/crshdn/mission-control.git
cd mission-control
npm install
cp .env.example .env.local
# Add OPENCLAW_GATEWAY_URL and OPENCLAW_GATEWAY_TOKEN to .env.local
npm run dev
```

Open `http://localhost:4000`. The gateway token lives in `~/.openclaw/openclaw.json` under `gateway.token`.

**What it lacks**: No cost tracking, no memory file viewer. It&apos;s purely task-focused.

---

## 2. LobsterBoard — Best Custom Dashboard

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/Curbob/LobsterBoard&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

LobsterBoard takes a different approach from everything else on this list. Instead of a fixed layout, it&apos;s a drag-and-drop dashboard builder with 50 widgets. You decide what goes where. System stats, weather, RSS feeds, stock tickers, Claude API cost tracking, active session monitors. Pick what matters and arrange them however you want.

It runs as a single Node.js server with no framework and no build step. Drop it on a server, run `node server.cjs`, and you have a real-time dashboard that auto-refreshes via Server-Sent Events.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**50 widgets**: System monitoring, AI cost tracking, smart home, finance, media, and more&lt;/li&gt;
&lt;li&gt;**Template gallery**: Export your layout, import others, share configurations&lt;/li&gt;
&lt;li&gt;**Custom pages**: Add full pages beyond the widget grid: notes, kanban boards, anything&lt;/li&gt;
&lt;li&gt;**OpenClaw-specific widgets**: Auth status, cron job status, active session monitor, token gauge&lt;/li&gt;
&lt;li&gt;**No cloud**: Everything runs locally, config saves to a single JSON file&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
npm install lobsterboard
cd node_modules/lobsterboard
node server.cjs
```

Open `http://localhost:8080`, press **Ctrl+E** for edit mode, drag widgets from the sidebar, save.

**License note**: LobsterBoard uses Business Source License 1.1. Free for personal and non-commercial use, but commercial use needs a separate license from the author. Everything else on this list is MIT.

---

## 3. AI Maestro — Best for Large Agent Teams

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/23blocks-OS/ai-maestro&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; /&gt;

AI Maestro comes from someone who was running 35 agents across multiple terminals and got tired of manually copying context between them. It&apos;s the most ambitious project here, built as a peer mesh network where multiple machines join as equal nodes with every agent visible from one dashboard.

The standout feature is the Agent Messaging Protocol (AMP), which lets agents send messages to each other directly rather than relying on you to relay context. It also includes a code graph (interactive codebase visualization), persistent memory across sessions, and Kanban-based work coordination.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Multi-machine mesh**: No central server; machines connect as peers and a new computer joins automatically&lt;/li&gt;
&lt;li&gt;**Agent-to-agent messaging**: Agents coordinate directly using AMP, with priority levels and cryptographic signatures&lt;/li&gt;
&lt;li&gt;**Works with any agent**: Claude Code, Aider, Cursor, Copilot — not locked to OpenClaw&lt;/li&gt;
&lt;li&gt;**Gateway integrations**: Route agent interactions through Slack, Discord, Email, WhatsApp&lt;/li&gt;
&lt;li&gt;**Three memory layers**: Conversation memory, code graph, auto-generated documentation&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
curl -fsSL https://raw.githubusercontent.com/23blocks-OS/ai-maestro/main/scripts/remote-install.sh | sh
```

Dashboard opens at `http://localhost:23000`. Requires Node.js 18+ and tmux.

**When to use it**: If you&apos;re running 10+ agents and need them to coordinate without you acting as the go-between. Overkill for a single-agent setup.

---

## 4. Clawe — Best Multi-Agent Coordination

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/getclawe/clawe&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

Clawe ships with a pre-configured squad of four agents: Clawe (Squad Lead), Inky (Content Editor), Pixel (Designer), and Scout (SEO). Each agent wakes on a 15-minute cron schedule, checks for assigned tasks, and reports back. A Convex backend stores tasks, notifications, and activity. The web dashboard gives you the squad status, task board, and direct chat with any agent.

It&apos;s more opinionated than the others. You&apos;re working within a defined team structure rather than building your own. Setup is faster, but customizing the squad takes more work.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Pre-configured squad**: Four agents with distinct roles, ready to work out of the box&lt;/li&gt;
&lt;li&gt;**Heartbeat scheduling**: Agents wake every 15 minutes to check for work, staggered to avoid rate limits&lt;/li&gt;
&lt;li&gt;**Kanban task board**: Assign tasks to specific agents, track subtasks and deliverables&lt;/li&gt;
&lt;li&gt;**@mention notifications**: Tag an agent in a task and it gets notified on next wake&lt;/li&gt;
&lt;li&gt;**Shared workspace files**: Agents coordinate through shared context files&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
git clone https://github.com/getclawe/clawe.git
cd clawe
cp .env.example .env
# Add ANTHROPIC_API_KEY, SQUADHUB_TOKEN, CONVEX_URL
pnpm install
cd packages/backend &amp;&amp; npx convex deploy
./scripts/start.sh
```

Dashboard runs at `http://localhost:3000`.

**What it requires**: A free Convex account for the backend. More moving parts than the other options, but the Convex free tier covers a personal setup comfortably.

---

## 5. Clawd Control — Best Lightweight Fleet Monitor

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/Temaki-AI/clawd-control&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

Clawd Control is deliberately minimal. Single Node.js server, no build step, no framework, vanilla HTML/JS throughout. It auto-discovers local OpenClaw agents, shows real-time health metrics via SSE, and lets you drill into any agent&apos;s sessions, channels, and config. Password auth with a randomly generated key on first run.

If you want something running in five minutes without thinking about it, this is it.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Auto-discovery**: Finds local OpenClaw agents without manual configuration&lt;/li&gt;
&lt;li&gt;**Agent creation wizard**: Guided setup to spin up new agents from the dashboard&lt;/li&gt;
&lt;li&gt;**Host metrics**: CPU, RAM, disk usage for the machine running OpenClaw&lt;/li&gt;
&lt;li&gt;**Fleet overview**: All agents in one view with health indicators&lt;/li&gt;
&lt;li&gt;**One dependency**: Only `ws` (WebSocket client), no framework or bundler&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
git clone https://github.com/Temaki-AI/clawd-control.git
cd clawd-control
npm install
npm start
```

Open `http://localhost:3100` and log in with the generated password printed to console.

---

## 6. VidClaw — Best Task + Soul Management

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/madrzak/vidclaw&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; /&gt;

VidClaw focuses on what your agent is working on rather than the infrastructure around it. It has a Kanban board where tasks get assigned to specific OpenClaw skills, a soul editor for SOUL.md, IDENTITY.md, and USER.md with version history, and usage tracking parsed directly from session transcripts.

The security model is simple: it binds to localhost only, and you access it over SSH tunnel. No auth layer needed because SSH is the auth layer.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Kanban board**: Tasks execute automatically via cron (every 2 min) or heartbeat (every 30 min)&lt;/li&gt;
&lt;li&gt;**Soul editor**: Edit SOUL.md and other identity files with persona templates and version history&lt;/li&gt;
&lt;li&gt;**Model switching**: Change Claude models directly from the dashboard, hot-reloads via config watcher&lt;/li&gt;
&lt;li&gt;**Usage tracking**: Token usage and cost estimates parsed from actual session transcripts&lt;/li&gt;
&lt;li&gt;**Skills manager**: View, enable/disable, and create custom skills from the UI&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
cd ~/.openclaw/workspace
git clone https://github.com/madrzak/vidclaw.git dashboard
cd dashboard
./setup.sh
```

Access via SSH tunnel: `ssh -L 3333:localhost:3333 root@your-server`, then open `http://localhost:3333`.

---

## 7. OpenClaw Dashboard — Best Security-Hardened Option

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/tugcantopaloglu/openclaw-dashboard&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

This is the most security-conscious dashboard on the list. It has PBKDF2 password hashing with 100,000 iterations, optional TOTP MFA (Google Authenticator compatible), rate limiting, HSTS, CSP headers, path traversal protection, and a full audit log. All of that with zero external dependencies: pure Node.js, no database, no framework.

Beyond security, it covers a lot of ground: session management, cost analysis, rate limit monitoring, memory file viewer, cron job management, Tailscale integration, and a live feed of agent messages.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**TOTP MFA**: Optional two-factor auth compatible with Google Authenticator, Authy, 1Password&lt;/li&gt;
&lt;li&gt;**Zero dependencies**: Pure Node.js with no npm packages required&lt;/li&gt;
&lt;li&gt;**Cost analysis**: Spending breakdowns by model, session, and time period&lt;/li&gt;
&lt;li&gt;**Memory viewer**: Browse MEMORY.md, HEARTBEAT.md, and daily memory notes&lt;/li&gt;
&lt;li&gt;**Cron management**: View, enable/disable, and manually trigger scheduled jobs&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
git clone https://github.com/tugcantopaloglu/openclaw-dashboard.git
cd openclaw-dashboard
node server.js
```

Visit `http://localhost:7000` and register an account on first launch.

&lt;Notice type=&quot;info&quot; title=&quot;Security Setup&quot;&gt;
The first time you run it, a recovery token prints to the console. Save it — you&apos;ll need it if you forget your password. To enable MFA, go to Settings → Security → Enable MFA after logging in.
&lt;/Notice&gt;

---

## 8. Claw Dashboard — Best Terminal-Style Monitor

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/spleck/claw-dashboard&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;md&quot; /&gt;

If you live in the terminal and want something that looks like btop or htop rather than a web app, Claw Dashboard is the one. ASCII art logo, gradient colors, donut charts, progress bars, all rendered in your terminal using the `blessed` library. It auto-refreshes every two seconds and shows per-core CPU usage, memory gauges, GPU stats (Apple Silicon), top processes, and active OpenClaw sessions.

```bash
npm install -g claw-dashboard
clawdash
```

Supports macOS out of the box. Works on Linux. Keyboard controls: `q` to quit, `r` to refresh, `s` for settings.

**Best for**: Quick monitoring without opening a browser. Great for Mac Mini setups where you&apos;d SSH in anyway.

---

## 9. Clawtrol — Best All-in-One Panel

&lt;Button text=&quot;GitHub Repository&quot; link=&quot;https://github.com/nachoiacovino/clawtrol&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; /&gt;

Clawtrol packs the most features into a single dashboard: system overview, remote screen viewer with click interaction (useful for headless Mac Minis), full web terminal via ttyd, file browser, session viewer with live chat, Kanban board, memory browser, cron manager, and sub-agent monitor. Each module is optional; enable only what you need.

Four themes out of the box: Nova (cyberpunk), Midnight (minimal dark), Catppuccin (warm pastel), Solar (light).

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Remote screen**: View and click your headless Mac&apos;s screen from a browser tab&lt;/li&gt;
&lt;li&gt;**Web terminal**: Full shell access via ttyd, no SSH client needed&lt;/li&gt;
&lt;li&gt;**Modular**: Enable only the panels you actually use; unused modules don&apos;t load&lt;/li&gt;
&lt;li&gt;**Four themes**: Proper theming with font choices, not just a color swap&lt;/li&gt;
&lt;li&gt;**pm2 integration**: `clawtrol start/stop/status` commands for daemon management&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

```bash
npm install -g clawtrol
clawtrol init
cd clawtrol
clawtrol start
```

Opens at `http://localhost:4781`.

&lt;Notice type=&quot;warning&quot; title=&quot;No Built-in Auth&quot;&gt;
Clawtrol has no authentication. The assumption is you&apos;ll access it over Tailscale or a trusted network. Don&apos;t expose port 4781 to the public internet.
&lt;/Notice&gt;

---

## Best Picks by Use Case

&lt;Tabs&gt;
&lt;Tab name=&quot;Full Control&quot;&gt;

**Mission Control** is the pick if you want proper multi-agent orchestration with task planning. The AI clarification flow before task dispatch is genuinely useful. Agents get clearer instructions and make fewer wrong assumptions. It&apos;s actively maintained, MIT-licensed, and well-documented.

**AI Maestro** is the pick if you&apos;re running many agents across multiple machines and need them to communicate with each other. The AMP messaging protocol solves a real problem once you&apos;re past single-agent setups.

&lt;/Tab&gt;

&lt;Tab name=&quot;Custom Layout&quot;&gt;

**LobsterBoard** if you want to build exactly the dashboard you need. The 50-widget library covers system stats, AI cost tracking, weather, smart home sensors, finance, and more. The template gallery means you can import a community layout if you don&apos;t want to start from scratch.

Keep the BSL-1.1 license in mind if this is for anything commercial.

&lt;/Tab&gt;

&lt;Tab name=&quot;Simple Deploy&quot;&gt;

**Clawd Control** — clone, `npm install`, `npm start`, done. Auto-discovers your local agents, no config required for a standard setup.

**OpenClaw Dashboard** — single `node server.js` command, zero npm dependencies. TOTP MFA if you need to expose it over the network. The most security-complete option with the least setup friction.

&lt;/Tab&gt;

&lt;Tab name=&quot;Terminal Only&quot;&gt;

**Claw Dashboard** — install with `npm install -g claw-dashboard`, run with `clawdash`. Looks like btop. Works where you&apos;re already SSH&apos;d in.

&lt;/Tab&gt;
&lt;/Tabs&gt;

---

## Pairing dashboards with the right setup

A dashboard is only as useful as what it&apos;s monitoring. A few things worth having sorted before you install one.

If you&apos;re watching costs, an efficient model matters more than the dashboard UI. The [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) guide covers GLM-5 and MiniMax M2.5, which together cost a fraction of Claude API. Most dashboards make the difference obvious once you switch.

Before installing any dashboard that connects to your gateway, make sure your instance is locked down. The [OpenClaw security guide](/openclaw-security-guide/) covers CVE-2026-25253, the ClawHub supply chain attack, and step-by-step hardening procedures.

Several dashboards show active tool calls in real time, which makes search integration worth setting up. The [DuckDuckGo OpenClaw search guide](/duckduckgo-openclaw-search/) walks through adding free web search with no API key.

If you&apos;re evaluating dashboards as part of deciding which agent platform to use, the [OpenClaw alternatives](/openclaw-alternatives/) roundup covers NanoClaw, IronClaw, NullClaw, and others that bring their own dashboard approaches. For NanoClaw&apos;s container-based approach, see the [NanoClaw deploy guide](/nanoclaw-deploy-guide/). For the smallest possible footprint with a Zig binary, see the [NullClaw deploy guide](/nullclaw-deploy-guide/).

If you haven&apos;t installed OpenClaw yet, the [OpenClaw setup guide](/clawdbot-setup-guide/) covers the full installation on Hetzner VPS or Mac Mini, including the gateway token most of these dashboards need.

For giving your OpenClaw agents persistent memory across sessions, [Hindsight](/hindsight-docker-deploy/) has a direct integration with server-side access control and auto-managed embeddings. It&apos;s a step up from the basic file-based memory most agents ship with.

---

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Do I need to pick just one dashboard?**

No. Several people run Mission Control for task creation alongside something lighter like Claw Dashboard for quick system checks. They access different parts of the OpenClaw gateway independently.

**Which dashboard works best on a Mac Mini?**

Clawtrol stands out here because of the remote screen viewer. You can see and click your headless Mac&apos;s display from a browser without setting up VNC. LobsterBoard is also good on Mac Mini since it runs as a background service and shows system stats in a browser tab.

**Are any of these dashboards official OpenClaw projects?**

No. All of them are community-built. Mission Control is the most widely used and most actively maintained, but none of them are endorsed or maintained by the OpenClaw team.

**What&apos;s the minimum setup for just monitoring costs?**

OpenClaw Dashboard (`tugcantopaloglu/openclaw-dashboard`) — single `node server.js`, no dependencies, cost analysis built in. Or LobsterBoard if you want cost tracking alongside other widgets.

**Can I use these dashboards with alternative agent platforms?**

AI Maestro is explicitly designed to work with any terminal-based AI agent, not just OpenClaw. Mission Control and the others are built specifically for OpenClaw&apos;s gateway API. If you&apos;re running something from the [OpenClaw alternatives](/openclaw-alternatives/) list, check whether the gateway protocol is compatible before installing a dashboard.

**Is LobsterBoard free for personal use?**

Yes. Business Source License 1.1 permits personal and non-commercial self-hosting. Commercial deployments need a separate license from the author.

**Which one should I try first?**

If you want to try one today: Clawd Control. Clone it, run `npm start`, it finds your agents automatically. If you want the best long-term option for managing work: Mission Control.

&lt;/Accordion&gt;

Nine dashboards in roughly two years. The terminal still works fine, but having actual visibility into what your agent is doing overnight is better once you have it. You start noticing things you&apos;d otherwise miss.

Start with whatever fits your scale. Most of these can run alongside each other without conflicts, so switching later isn&apos;t a big deal.

For everything else in the OpenClaw ecosystem: [setup guide](/clawdbot-setup-guide/), [model recommendations](/best-opensource-models-for-openclaw/), [free web search](/duckduckgo-openclaw-search/), [security hardening](/openclaw-security-guide/), [local models with Ollama](/openclaw-ollama-local-models/), and [alternative platforms](/openclaw-alternatives/).</content:encoded><category>ai</category><category>ai-tools</category><category>openclaw</category><category>self-hosted</category></item><item><title>Pinchtab: Browser Control via HTTP for AI Agents</title><link>https://www.bitdoze.com/pinchtab-browser-ai-agents/</link><guid isPermaLink="true">https://www.bitdoze.com/pinchtab-browser-ai-agents/</guid><description>Pinchtab is a 12MB Go binary that gives any AI agent browser control over a plain HTTP API using accessibility trees. Zero config, framework-agnostic, and far cheaper than screenshots.</description><pubDate>Tue, 17 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;

If you&apos;ve tried giving an AI agent browser access, you already know the problem. Playwright MCP ties you to Node. Browser Use needs Python. OpenClaw&apos;s browser backend only works inside its own ecosystem. Switch agents, or try to fire off a quick curl request to inspect a page, and you&apos;re rewriting the integration from scratch.

[Pinchtab](https://github.com/pinchtab/pinchtab) takes a different approach: it&apos;s just an HTTP server. A 12MB Go binary with no Node, no Python, no dependencies. It launches its own Chrome and exposes everything—navigation, clicks, form fills, screenshots, accessibility snapshots—through a plain REST API. Whatever agent you&apos;re using speaks HTTP, and that&apos;s the whole integration story.

## Why accessibility trees over screenshots

Most people reach for screenshots first because it&apos;s obvious: take a picture, send it to a vision model, done. The problem is cost. A 10-step task using screenshots runs about $0.06. The same task with accessibility trees costs around $0.015.

The real difference shows up at scale. Run that same task 1,000 times and screenshots cost $60; accessibility trees cost $15. Run a 50-page monitoring job:

| Method | ~Tokens | Est. cost |
|--------|---------|-----------|
| Screenshots (vision) | ~100,000 | $0.30 |
| Full a11y snapshot | ~525,000 | $0.16 |
| Pinchtab `?filter=interactive` | ~180,000 | $0.05 |
| Pinchtab `/text` | ~40,000 | $0.01 |

Pinchtab&apos;s `/text` endpoint pulls readable content at around 800 tokens per page using Mozilla&apos;s Readability library (the same thing behind Firefox Reader View). That&apos;s 5x cheaper than a full accessibility snapshot and 13x cheaper than screenshots. For read-heavy work, the difference compounds quickly.

There&apos;s also a reliability argument. Vision models guess coordinates from pixels. Accessibility trees give you stable node refs (`e0`, `e1`, `e2`...) tied to the actual DOM elements. Click `e5` and you hit the right button, regardless of how the page renders.

## The full API

Pinchtab covers more than basic navigation and clicking:

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/health` | Check if the server and Chrome are responsive |
| `GET` | `/tabs` | List all open tabs with their IDs |
| `GET` | `/snapshot` | Accessibility tree as JSON or text |
| `GET` | `/screenshot` | JPEG screenshot with quality control |
| `GET` | `/text` | Readable page text (Readability or raw innerText) |
| `POST` | `/navigate` | Go to a URL in a tab |
| `POST` | `/action` | Click, type, fill, press, focus, hover, select, scroll |
| `POST` | `/evaluate` | Run arbitrary JavaScript |
| `POST` | `/tab` | Open or close tabs |
| `POST` | `/tab/lock` | Lock a tab for exclusive agent access |
| `POST` | `/tab/unlock` | Release a tab lock |
| `POST` | `/cookies` | Inject session cookies programmatically |

The `/tab/lock` endpoint is worth noting if you run multiple agents at once. One agent locks a tab, does its work, unlocks it. No competing writes to the same browser context.

### Snapshot query parameters

The snapshot endpoint has several options that cut token usage significantly:

```bash
# Only interactive elements — ~75% fewer nodes
curl &quot;localhost:9867/snapshot?filter=interactive&quot;

# Compact one-line-per-node format — 56-64% fewer tokens than JSON
curl &quot;localhost:9867/snapshot?format=compact&quot;

# Only changes since the last snapshot
curl &quot;localhost:9867/snapshot?diff=true&quot;

# Limit to a specific section of the page
curl &quot;localhost:9867/snapshot?selector=main&quot;

# Cap output at roughly N tokens
curl &quot;localhost:9867/snapshot?maxTokens=2000&quot;
```

Combining `filter=interactive` with `format=compact` gives you the smallest possible payload for action-oriented tasks. Use `diff=true` for pages that update incrementally—polling a live dashboard, for example—so you&apos;re only sending what actually changed.

### Human-like actions

Beyond standard click and type, Pinchtab has `humanClick` and `humanType` actions that add realistic delays and movement patterns. Useful when you&apos;re hitting sites with behavioral bot detection that watches interaction timing.

```bash
curl -X POST localhost:9867/action \
  -d &apos;{&quot;kind&quot;:&quot;humanType&quot;,&quot;ref&quot;:&quot;e12&quot;,&quot;text&quot;:&quot;hello world&quot;}&apos;
```

## Configuration

All configuration comes through environment variables:

| Variable | Default | Description |
|----------|---------|-------------|
| `BRIDGE_PORT` | `9867` | HTTP port |
| `BRIDGE_TOKEN` | *(none)* | Bearer token for auth |
| `BRIDGE_HEADLESS` | `false` | Run Chrome without a window |
| `BRIDGE_STEALTH` | `light` | `light` (webdriver patch) or `full` (canvas/WebGL/font spoofing) |
| `BRIDGE_PROFILE` | `~/.pinchtab/chrome-profile` | Chrome profile directory |
| `BRIDGE_STATE_DIR` | `~/.pinchtab` | State and session storage |
| `BRIDGE_BLOCK_IMAGES` | `false` | Skip image downloads |
| `BRIDGE_BLOCK_MEDIA` | `false` | Block images, fonts, CSS, video |
| `BRIDGE_NO_ANIMATIONS` | `false` | Freeze CSS animations globally |
| `BRIDGE_TIMEOUT` | `15` | Action timeout in seconds |
| `BRIDGE_NAV_TIMEOUT` | `30` | Navigation timeout in seconds |
| `BRIDGE_TIMEZONE` | *(system)* | Chrome timezone (e.g. `America/New_York`) |
| `CDP_URL` | *(none)* | Connect to an existing Chrome instead of launching one |
| `CHROME_BINARY` | *(auto)* | Path to Chrome or Chromium |
| `CHROME_FLAGS` | *(none)* | Extra Chrome launch flags |

`BRIDGE_BLOCK_MEDIA` is the aggressive version—it skips everything except HTML and JavaScript. Useful for bulk scraping where page fidelity doesn&apos;t matter. `BRIDGE_NO_ANIMATIONS` helps if you&apos;re snapshotting pages mid-animation and getting inconsistent results.

You can also generate a config file if you prefer JSON over environment variables:

```bash
pinchtab config init   # creates ~/.pinchtab/config.json
pinchtab config show   # shows current effective config
```

Environment variables override the config file, so it works fine alongside Docker secrets or `.env` files.

## Docker deployment

The simplest way to run Pinchtab on a server. Chrome needs `seccomp=unconfined` in a container, which is the main reason you&apos;d want to isolate it from the rest of your stack.

### Basic docker-compose setup

```yaml
# docker-compose.yml
services:
  pinchtab:
    image: pinchtab/pinchtab:latest
    container_name: pinchtab
    restart: unless-stopped
    security_opt:
      - seccomp:unconfined
    mem_limit: 2g
    cpus: &quot;2.0&quot;
    environment:
      - BRIDGE_PORT=9867
      - BRIDGE_HEADLESS=true
      - BRIDGE_STEALTH=full
      - BRIDGE_TOKEN=${PINCHTAB_TOKEN}
      - BRIDGE_BLOCK_IMAGES=true
      - BRIDGE_NO_ANIMATIONS=true
    volumes:
      - pinchtab-data:/data
    ports:
      - &quot;127.0.0.1:9867:9867&quot;

volumes:
  pinchtab-data:
```

A few things to note here. The port binding `127.0.0.1:9867:9867` only exposes the service on localhost, not to the network. The memory limit matters: Chrome with a few tabs open will easily use 1-1.5GB. Setting `BRIDGE_BLOCK_IMAGES=true` helps keep memory usage lower if you&apos;re doing content-only tasks.

### Behind a Caddy reverse proxy

If you want to expose Pinchtab over HTTPS with a domain:

```yaml
# docker-compose.yml
services:
  pinchtab:
    image: pinchtab/pinchtab:latest
    container_name: pinchtab
    restart: unless-stopped
    security_opt:
      - seccomp:unconfined
    mem_limit: 2g
    cpus: &quot;2.0&quot;
    environment:
      - BRIDGE_PORT=9867
      - BRIDGE_HEADLESS=true
      - BRIDGE_STEALTH=full
      - BRIDGE_TOKEN=${PINCHTAB_TOKEN}
      - BRIDGE_BLOCK_IMAGES=true
    volumes:
      - pinchtab-data:/data
    networks:
      - proxy

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy-data:/data
      - caddy-config:/config
    networks:
      - proxy

networks:
  proxy:
    driver: bridge

volumes:
  pinchtab-data:
  caddy-data:
  caddy-config:
```

```
# Caddyfile
pinchtab.yourdomain.com {
    reverse_proxy pinchtab:9867
}
```

Caddy handles TLS automatically. With `BRIDGE_TOKEN` set, requests need an `Authorization: Bearer &lt;token&gt;` header, so the API isn&apos;t open to the public even with HTTPS.

```bash
curl -H &quot;Authorization: Bearer $PINCHTAB_TOKEN&quot; \
  https://pinchtab.yourdomain.com/health
```

&lt;Notice type=&quot;info&quot; title=&quot;Build from source vs prebuilt image&quot;&gt;
The official `docker-compose.yml` in the repo uses `build: .` which compiles from source. If you want the prebuilt image, use `image: pinchtab/pinchtab:latest` instead. Check the [releases page](https://github.com/pinchtab/pinchtab/releases) for available tags.
&lt;/Notice&gt;

## Security concerns and mitigations

Pinchtab gives an AI agent full control of a real Chrome browser, including any accounts you&apos;ve logged into through that browser. The README is direct about this: &quot;Think of Pinchtab like giving someone your unlocked laptop.&quot; Here&apos;s what that means in practice and how to handle it.

### No auth by default

Out of the box, Pinchtab accepts requests from anyone who can reach port 9867. On a shared network or a server with a public IP, that means anyone.

**Mitigation:** Always set `BRIDGE_TOKEN`. Once set, every request needs `Authorization: Bearer &lt;token&gt;` or it gets a 401. Treat this token like a password—generate something long and random, store it in an environment variable or secret manager, never hardcode it.

```bash
# Generate a token
openssl rand -hex 32
```

### Pinchtab binds to all interfaces

By default, the server listens on `0.0.0.0`, not just localhost. On a cloud server this means port 9867 is reachable from anywhere if your firewall allows it.

**Mitigation:** Either bind the port to localhost only (as shown in the docker-compose above with `127.0.0.1:9867:9867`), or set a firewall rule that blocks external access to port 9867. Using a reverse proxy like Caddy or Nginx adds another layer and lets you handle TLS properly.

### The Chrome profile holds live sessions

When you log into a site through Pinchtab&apos;s Chrome window, that session persists in `~/.pinchtab/chrome-profile/`. Cookies, saved passwords, auth tokens. An agent with API access can use those sessions to act as you on any site you&apos;re logged into.

**Mitigation:**
- Use a dedicated Chrome profile with only the accounts your agents actually need
- Don&apos;t log personal accounts (email, banking, social) into the Pinchtab profile unless you specifically need them automated
- Treat `~/.pinchtab/` as sensitive and restrict file permissions: `chmod 700 ~/.pinchtab`
- In Docker, use a named volume and avoid mounting it read-only (Pinchtab needs to write state), but don&apos;t mount it somewhere accessible to other containers

### The `seccomp=unconfined` requirement

Chrome needs a relaxed seccomp profile to run in a container. This is a real privilege: it removes a layer of kernel syscall filtering.

**Mitigation:** This is harder to fully mitigate without building a custom seccomp profile for Chromium. The practical approach is to isolate the Pinchtab container—don&apos;t run it on the same network as containers with database access or other sensitive services. Keep it in its own network segment that only your agent service can reach.

### Agent trust model

An agent with Pinchtab access can do anything a human with that browser can do. If your agent takes arbitrary instructions from users or external inputs, prompt injection is a real concern: a malicious website could include instructions in its content that trick the agent into taking unintended actions.

**Mitigation:**
- Scope what your agent is allowed to do. If it only needs to read pages, don&apos;t give it action capabilities
- Log all `/action` and `/navigate` calls so you can audit what happened
- Consider running agents against a sandboxed profile with no real accounts for untrusted inputs
- Rate-limit your Pinchtab endpoint—add rate limiting in Caddy or Nginx if you&apos;re exposing it to external agents

&lt;Notice type=&quot;warning&quot; title=&quot;Don&apos;t skip the token&quot;&gt;
Running Pinchtab without `BRIDGE_TOKEN` on any internet-connected server is a serious risk. Anyone who finds the port has full browser control. Set the token before anything else.
&lt;/Notice&gt;

## A typical agent workflow

```python
import httpx

BASE = &quot;http://localhost:9867&quot;
HEADERS = {&quot;Authorization&quot;: &quot;Bearer your-token&quot;}

# Navigate to a page
httpx.post(f&quot;{BASE}/navigate&quot;, json={&quot;url&quot;: &quot;https://example.com&quot;}, headers=HEADERS)

# Get only interactive elements to keep tokens low
snapshot = httpx.get(f&quot;{BASE}/snapshot?filter=interactive&amp;format=compact&quot;, headers=HEADERS)
refs = snapshot.json()

# Click a button by its ref
httpx.post(f&quot;{BASE}/action&quot;, json={&quot;kind&quot;: &quot;click&quot;, &quot;ref&quot;: &quot;e5&quot;}, headers=HEADERS)

# Read the result
text = httpx.get(f&quot;{BASE}/text&quot;, headers=HEADERS)
print(text.text)
```

Node refs are stable within a snapshot. After a click that loads a new page, take a fresh snapshot—refs on the new page are independent. For pages that update without a full load (React, Vue SPAs), use `?diff=true` to see what changed rather than re-fetching the whole tree.

For multi-agent setups where several agents share a browser instance, use tab locking:

```bash
# Lock tab 1 for exclusive use
curl -X POST localhost:9867/tab/lock -d &apos;{&quot;tabId&quot;: 1}&apos;

# ... do your work ...

# Release it
curl -X POST localhost:9867/tab/unlock -d &apos;{&quot;tabId&quot;: 1}&apos;
```

## When it fits well

Pinchtab is a good fit for agents that need to browse the web but don&apos;t need to be tightly coupled to a browser testing framework. Specifically:

- Web scraping and content monitoring at any scale
- Form automation (sign-ups, data entry, multi-step workflows)
- Authenticated scraping after a one-time login setup
- Agents running in bash scripts, Go programs, or any language with HTTP support
- Setups where you want to switch between different agent frameworks without changing browser integration

It&apos;s not the right tool for pixel-accurate visual testing (Playwright is better there), or for sites where the accessibility tree is sparse or unreliable. Some single-page apps built with custom components don&apos;t expose much useful ARIA data, and a full screenshot becomes the more practical option.

## Getting started

```bash
# Docker (easiest, no Chrome install needed)
docker run -d \
  -p 127.0.0.1:9867:9867 \
  --security-opt seccomp=unconfined \
  -e BRIDGE_TOKEN=your-secret-token \
  -e BRIDGE_HEADLESS=true \
  pinchtab/pinchtab:latest

curl -H &quot;Authorization: Bearer your-secret-token&quot; http://localhost:9867/health

# Build from source (requires Go 1.25+ and Chrome installed)
git clone https://github.com/pinchtab/pinchtab.git
cd pinchtab
go build -o pinchtab .
BRIDGE_HEADLESS=true BRIDGE_TOKEN=your-secret-token ./pinchtab
```

The GitHub repo has an [OpenClaw skill](https://github.com/pinchtab/pinchtab/tree/main/skill/pinchtab) that can install and configure Pinchtab automatically if you&apos;re using an agent that supports skills.

---

*Pinchtab is MIT licensed. Source on [GitHub](https://github.com/pinchtab/pinchtab).*</content:encoded><category>ai</category><category>ai-agents</category><category>browser-automation</category><category>pinchtab</category></item><item><title>NanoBot Setup Guide: MiniMax M2.5, GLM-5, and Brave Search on Your VPS</title><link>https://www.bitdoze.com/nanobot-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/nanobot-setup-guide/</guid><description>Step-by-step guide to installing nanobot on a Linux VPS with MiniMax M2.5, GLM-5 (Zhipu), Discord integration, and Brave Search. Covers config, providers, memory, and Docker deployment.</description><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I&apos;ve been testing [nanobot](https://github.com/HKUDS/nanobot) for the past week alongside my [OpenClaw setup](/clawdbot-setup-guide/). The pitch is simple: a personal AI assistant in about 3,700 lines of Python that connects to Telegram, Discord, WhatsApp, Slack, and a bunch of other chat platforms. What got me interested was how quick the setup is compared to OpenClaw, and how well it works with cheaper LLM providers like MiniMax and Zhipu&apos;s GLM-5.

This guide walks through getting nanobot running on a VPS with MiniMax M2.5 and GLM-5 as your models, Brave Search for web access, and Discord as the chat channel.

&lt;Button text=&quot;NanoBot GitHub&quot; link=&quot;https://github.com/HKUDS/nanobot&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing nanobot via pip, uv, or Docker&lt;/li&gt;
&lt;li&gt;Configuring MiniMax M2.5 and Zhipu GLM-5 as LLM providers&lt;/li&gt;
&lt;li&gt;Setting up Brave Search for web access&lt;/li&gt;
&lt;li&gt;Discord channel integration&lt;/li&gt;
&lt;li&gt;Memory system, workspace files, and MCP support&lt;/li&gt;
&lt;li&gt;Security settings and scheduled tasks&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re looking at multiple self-hosted bot options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup compares nanobot against NanoClaw, memU, PicoClaw, and others. For MCP basics, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/UaWygl82pwQ&quot;
  label=&quot;The OpenClaw Killer? Nanobot Full Setup &amp; Review
&quot;
/&gt;

## What nanobot actually is

nanobot is an open-source project from HKUDS (University of Hong Kong). It&apos;s an AI assistant that runs on your server and talks to you through whatever chat app you prefer. The whole thing is about 3,700 lines of Python, compared to OpenClaw&apos;s 430k+.

The architecture is straightforward:

```
You (Discord / Telegram / WhatsApp / Slack / etc.)
    ↓
nanobot Gateway (running on your VPS)
    ↓
LLM Provider (MiniMax, Zhipu, OpenRouter, Anthropic, etc.)
    ↓
Tools (file access, shell commands, web search, MCP servers)
```

Messages come in from your chat app, nanobot sends them to whatever LLM you configured, and the model can use tools (run shell commands, read/write files, search the web) to get things done. Everything except the LLM API calls stays on your machine.

## Why MiniMax M2.5 and GLM-5

Both of these models dropped in February 2026 and they&apos;re worth paying attention to for self-hosted bot setups.

### MiniMax M2.5

MiniMax M2.5 is a 230B Mixture-of-Experts model with only 10B active parameters per pass. In practice, that means it runs fast and cheap:

| Spec | Value |
|------|-------|
| Architecture | 230B MoE, 10B active |
| Context window | 1M tokens |
| Speed (Lightning) | 100 tokens/sec |
| Cost (Lightning) | $0.30/M input, $2.40/M output |
| SWE-Bench Verified | 80.2% |
| License | Modified MIT (open-source) |

It scores 80.2% on SWE-Bench Verified, matching Claude Opus 4.6 at roughly 1/20th the cost. The 1M token context window is overkill for most chat interactions, but it means nanobot won&apos;t run into context limits even with long conversations and large files.

MiniMax has two API platforms. The global one at `platform.minimax.io` and a mainland China one at `minimaxi.com`. nanobot supports both.

### GLM-5

GLM-5 from Zhipu AI is a 744B MoE model with 40-44B active parameters. It&apos;s beefier than MiniMax but still efficient because of the sparse architecture:

| Spec | Value |
|------|-------|
| Architecture | 744B MoE, ~40B active |
| Context window | 200K tokens |
| SWE-Bench Verified | 77.8% |
| BrowseComp | #1 open-source |
| License | MIT |

GLM-5 ranks first among open-source models on BrowseComp (web search agent tasks), which makes it a solid choice for a bot that needs to find things online. The 200K context window handles most workloads comfortably.

Both models are available through their respective APIs and through OpenRouter if you want a single gateway.

## Installation

Three ways to get nanobot installed. Pick whatever fits your workflow.

&lt;Tabs&gt;
&lt;Tab name=&quot;pip&quot;&gt;

The simplest path. Requires Python 3.11+.

```bash
pip install nanobot-ai
```

&lt;/Tab&gt;
&lt;Tab name=&quot;uv (recommended)&quot;&gt;

Faster package management with [uv](/uv-get-start/). This is what I use.

```bash
uv tool install nanobot-ai
```

&lt;/Tab&gt;
&lt;Tab name=&quot;From source&quot;&gt;

If you want the latest features or plan to modify the code:

```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
pip install -e .
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

After installing, initialize the workspace and config:

```bash
nanobot onboard
```

This creates the `~/.nanobot/` directory with a default `config.json` and a `workspace/` folder for memory, skills, and bootstrap files.

Check that everything&apos;s working:

```bash
nanobot status
```

## Configuring MiniMax M2.5

The config lives at `~/.nanobot/config.json`. All changes go there. nanobot uses a provider registry system internally, so you just need to set your API key and model name.

### Get an API key

1. Go to [platform.minimax.io](https://platform.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (mainland China)
2. Create an account and generate an API key
3. Note which platform you&apos;re on, because the API base URL differs

&lt;Notice type=&quot;success&quot; title=&quot;MiniMax coding plan — 10% off&quot;&gt;
MiniMax offers coding plans priced for developer workloads. [Get 10% off with our referral link](https://go.bitdoze.com/minimax). For details on how GLM-5 and MiniMax M2.5 compare for always-on bots, see our [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) breakdown.
&lt;/Notice&gt;

### Add to config

For the global platform:

```json
{
  &quot;providers&quot;: {
    &quot;minimax&quot;: {
      &quot;apiKey&quot;: &quot;your-minimax-api-key&quot;
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;MiniMax-M2.5&quot;
    }
  }
}
```

For the mainland China platform, add the `apiBase` override:

```json
{
  &quot;providers&quot;: {
    &quot;minimax&quot;: {
      &quot;apiKey&quot;: &quot;your-minimax-api-key&quot;,
      &quot;apiBase&quot;: &quot;https://api.minimaxi.com/v1&quot;
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;MiniMax-M2.5&quot;
    }
  }
}
```

nanobot automatically prefixes the model name for LiteLLM routing. When you set `&quot;model&quot;: &quot;MiniMax-M2.5&quot;`, it becomes `minimax/MiniMax-M2.5` internally. You don&apos;t need to add the prefix yourself.

### Test it

```bash
nanobot agent -m &quot;What&apos;s 42 * 17?&quot;
```

If you get a response, MiniMax is wired up correctly.

## Configuring GLM-5 (Zhipu)

GLM-5 goes through Zhipu&apos;s API. nanobot has built-in support for it.

### Get an API key

1. Go to [z.ai](https://z.ai/manage-apikey/apikey-list)
2. Register and create an API key

&lt;Notice type=&quot;success&quot; title=&quot;Z.AI GLM coding plan — 10% off&quot;&gt;
Z.AI offers [GLM coding plans](https://z.ai/subscribe?ic=NKNUNYDRZT) designed for continuous developer workloads. Use our link for 10% off.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Zhipu coding plan endpoint&quot;&gt;
If you&apos;re on Zhipu&apos;s coding plan, set `&quot;apiBase&quot;: &quot;https://api.z.ai/api/coding/paas/v4&quot;` in your zhipu provider config. This routes through their coding-optimized endpoint.
&lt;/Notice&gt;

### Add to config

```json
{
  &quot;providers&quot;: {
    &quot;zhipu&quot;: {
      &quot;apiKey&quot;: &quot;your-zhipu-api-key&quot;,
       &quot;apiBase&quot;: &quot;https://api.z.ai/api/coding/paas/v4&quot;
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;glm-5&quot;
    }
  }
}
```

nanobot detects the `glm` keyword in the model name and routes it to Zhipu automatically. Internally it adds the `zai/` prefix for LiteLLM, so `glm-5` becomes `zai/glm-5`.

### Switching between models

You don&apos;t have to pick one. Configure both providers and swap the default model whenever you want:

```json
{
  &quot;providers&quot;: {
    &quot;minimax&quot;: {
      &quot;apiKey&quot;: &quot;your-minimax-key&quot;
    },
    &quot;zhipu&quot;: {
      &quot;apiKey&quot;: &quot;your-zhipu-key&quot;
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;glm-5&quot;
    }
  }
}
```

Change `&quot;model&quot;` to `&quot;MiniMax-M2.5&quot;` when you want to switch. No restart needed if you&apos;re using the CLI. For the gateway, restart with `nanobot gateway`.

## Setting up Brave Search

Without web search, your bot can only work with what the model already knows and whatever&apos;s on your server. Brave Search gives it access to the live web.

### Get a Brave API key

1. Go to [brave.com/search/api](https://brave.com/search/api/)
2. Sign up for an account
3. The free tier gives you about 1,000 searches per month ($5 in monthly credits)
4. Generate an API key from the dashboard

### Add to config

```json
{
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;search&quot;: {
        &quot;apiKey&quot;: &quot;your-brave-search-api-key&quot;,
        &quot;maxResults&quot;: 5
      }
    }
  }
}
```

The `maxResults` setting controls how many results nanobot pulls per search. Five is a reasonable default. Lower it to 3 if you want faster responses, bump it to 10 if you need more thorough research.

### How it works

Once configured, nanobot&apos;s LLM can call the web search tool whenever it needs current information. Ask your bot something like &quot;what happened in tech news today&quot; and it&apos;ll hit Brave&apos;s API, pull results, and summarize them.

The free tier&apos;s 1,000 queries per month is enough for casual use. If you&apos;re running the bot for a team or heavy daily use, the paid plans start at $5/month for 2,000 queries.

## Discord setup

Discord works well for personal and team bot setups. Here&apos;s how to connect nanobot to it.

### Create a Discord bot

1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
2. Click **New Application**, give it a name
3. Go to **Bot** in the left sidebar, click **Add Bot**
4. Copy the bot token

### Enable intents

Still in the Bot settings page:

1. Scroll down to **Privileged Gateway Intents**
2. Enable **MESSAGE CONTENT INTENT** (required, or the bot can&apos;t read messages)
3. Optionally enable **SERVER MEMBERS INTENT** if you plan to use allow lists

### Get your user ID

1. Open Discord Settings, go to **Advanced**, enable **Developer Mode**
2. Right-click your avatar anywhere in Discord
3. Click **Copy User ID**

### Configure nanobot

```json
{
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_DISCORD_BOT_TOKEN&quot;,
      &quot;allowFrom&quot;: [&quot;YOUR_USER_ID&quot;]
    }
  }
}
```

The `allowFrom` array restricts who can talk to the bot. Leave it empty to let anyone in your server use it, or add specific user IDs to lock it down. I&apos;d keep it restricted unless you want every server member chatting with your bot.

### Invite the bot to your server

1. In the Discord developer portal, go to **OAuth2** then **URL Generator**
2. Under **Scopes**, check `bot`
3. Under **Bot Permissions**, check `Send Messages` and `Read Message History`
4. Copy the generated URL and open it in your browser
5. Select the server you want to add the bot to

### Start the gateway

```bash
nanobot gateway
```

Send a message in Discord. The bot should respond. If nothing happens, check `nanobot status` and look at the gateway logs.

## Full config example

Here&apos;s what a complete `~/.nanobot/config.json` looks like with MiniMax M2.5, GLM-5 as a second provider, Brave Search, and Discord:

```json
{
  &quot;providers&quot;: {
    &quot;minimax&quot;: {
      &quot;apiKey&quot;: &quot;your-minimax-api-key&quot;
    },
    &quot;zhipu&quot;: {
      &quot;apiKey&quot;: &quot;your-zhipu-api-key&quot;
    }
  },
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: &quot;MiniMax-M2.5&quot;,
      &quot;workspace&quot;: &quot;~/.nanobot/workspace&quot;,
      &quot;maxTokens&quot;: 8192,
      &quot;temperature&quot;: 0.7,
      &quot;maxToolIterations&quot;: 20,
      &quot;memoryWindow&quot;: 50
    }
  },
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_DISCORD_BOT_TOKEN&quot;,
      &quot;allowFrom&quot;: [&quot;YOUR_USER_ID&quot;]
    }
  },
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;search&quot;: {
        &quot;apiKey&quot;: &quot;your-brave-search-api-key&quot;,
        &quot;maxResults&quot;: 5
      }
    },
    &quot;exec&quot;: {
      &quot;timeout&quot;: 60
    },
    &quot;restrictToWorkspace&quot;: false
  },
  &quot;gateway&quot;: {
    &quot;host&quot;: &quot;0.0.0.0&quot;,
    &quot;port&quot;: 18790
  }
}
```

### Config settings explained

| Setting | Default | What it does |
|---------|---------|-------------|
| `agents.defaults.model` | `anthropic/claude-opus-4-5` | Which model handles your messages |
| `agents.defaults.maxTokens` | `8192` | Max tokens per LLM response |
| `agents.defaults.temperature` | `0.7` | Randomness (lower = more deterministic) |
| `agents.defaults.maxToolIterations` | `20` | How many tool calls per turn before stopping |
| `agents.defaults.memoryWindow` | `50` | Number of past messages kept in context |
| `tools.exec.timeout` | `60` | Shell command timeout in seconds |
| `tools.restrictToWorkspace` | `false` | When true, all file/shell access is sandboxed to workspace |
| `gateway.port` | `18790` | Port the gateway listens on |

## How the provider system works

nanobot uses a provider registry that automatically routes your model name to the right API. When you set a model like `glm-5`, nanobot:

1. Scans the model name for keywords (`glm` matches `zhipu`)
2. Checks if the matched provider has an API key configured
3. Adds the correct prefix for LiteLLM routing (`zai/glm-5`)
4. Sets environment variables the LLM library expects

If the model name doesn&apos;t match any provider, nanobot falls back to the first provider that has an API key. Gateways (like OpenRouter) get fallback priority since they can route any model.

Here are the providers nanobot supports out of the box:

| Provider | Keyword match | Use case |
|----------|--------------|----------|
| `openrouter` | `openrouter` | Gateway to any model |
| `anthropic` | `anthropic`, `claude` | Claude models |
| `openai` | `openai`, `gpt` | GPT models |
| `deepseek` | `deepseek` | DeepSeek models |
| `gemini` | `gemini` | Google Gemini |
| `zhipu` | `zhipu`, `glm`, `zai` | GLM models |
| `minimax` | `minimax` | MiniMax models |
| `moonshot` | `moonshot`, `kimi` | Kimi models |
| `dashscope` | `qwen`, `dashscope` | Qwen models |
| `groq` | `groq` | Groq (also handles Whisper voice transcription) |
| `vllm` | `vllm` | Local models via vLLM |
| `openai_codex` | `openai-codex`, `codex` | Codex via OAuth |
| `custom` | — | Any OpenAI-compatible endpoint |

You can configure multiple providers at once. nanobot picks the right one based on the model name you set.

## Memory system

nanobot stores memory in two files inside the workspace:

| File | Purpose |
|------|---------|
| `~/.nanobot/workspace/memory/MEMORY.md` | Long-term facts the bot remembers |
| `~/.nanobot/workspace/memory/HISTORY.md` | Searchable log of past interactions |

Tell the bot to remember something and it writes to `MEMORY.md`. It can also grep through `HISTORY.md` to find past conversations. Both files are plain Markdown, so you can edit them directly.

### Workspace bootstrap files

The workspace also has bootstrap files that shape how the bot behaves:

| File | Purpose |
|------|---------|
| `AGENTS.md` | Agent configuration and instructions |
| `SOUL.md` | Personality and behavior rules |
| `USER.md` | Your personal info and preferences |
| `TOOLS.md` | Tool usage instructions |
| `IDENTITY.md` | Bot identity overrides |

These get loaded into the system prompt every time the bot processes a message. Edit `USER.md` to tell the bot about yourself, your work, your preferences. Edit `SOUL.md` to change how it communicates.

```bash
nano ~/.nanobot/workspace/USER.md
```

Add whatever context you want the bot to always have. For me, that&apos;s project details, preferred communication style, and a few technical preferences.

## MCP support

nanobot supports [Model Context Protocol](/mcp-introduction-beginners/) for connecting external tool servers. The config format is the same as Claude Desktop and Cursor, so you can copy MCP server configs from any MCP server&apos;s README.

### Adding an MCP server

```json
{
  &quot;tools&quot;: {
    &quot;mcpServers&quot;: {
      &quot;filesystem&quot;: {
        &quot;command&quot;: &quot;npx&quot;,
        &quot;args&quot;: [&quot;-y&quot;, &quot;@modelcontextprotocol/server-filesystem&quot;, &quot;/home/user/documents&quot;]
      }
    }
  }
}
```

Two transport modes work:

| Mode | Config fields | Example |
|------|--------------|---------|
| Stdio | `command` + `args` | Local process via `npx` or `uvx` |
| HTTP | `url` | Remote endpoint like `https://mcp.example.com/sse` |

MCP tools get discovered and registered automatically when nanobot starts. The LLM can use them alongside built-in tools without any extra setup.

## Docker deployment

If you prefer containers, nanobot has a Dockerfile that bundles Python 3.12 and Node.js 20 (needed for the WhatsApp bridge).

```bash
# Build
docker build -t nanobot .

# Initialize config (first time)
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard

# Edit config on host
nano ~/.nanobot/config.json

# Run gateway
docker run -d \
  -v ~/.nanobot:/root/.nanobot \
  -p 18790:18790 \
  --name nanobot \
  nanobot gateway
```

The `-v ~/.nanobot:/root/.nanobot` mount keeps your config and workspace data on the host, so it survives container restarts.

For a quick test without the gateway:

```bash
docker run -v ~/.nanobot:/root/.nanobot --rm nanobot agent -m &quot;Hello!&quot;
```

## Scheduled tasks

nanobot has a cron system for recurring tasks. You manage jobs from the CLI:

```bash
# Add a job that runs every morning at 9am
nanobot cron add --name &quot;morning&quot; --message &quot;Good morning! What&apos;s on my calendar today?&quot; --cron &quot;0 9 * * *&quot;

# Add a job that runs every hour
nanobot cron add --name &quot;check&quot; --message &quot;Check server disk usage&quot; --every 3600

# List all jobs
nanobot cron list

# Remove a job
nanobot cron remove &lt;job_id&gt;
```

The bot processes these messages through the same LLM pipeline as regular chat. If you have Brave Search configured, your morning briefing can include live news and weather.

## Security settings

Two settings to pay attention to for production use:

### Workspace restriction

```json
{
  &quot;tools&quot;: {
    &quot;restrictToWorkspace&quot;: true
  }
}
```

When enabled, the bot can only read and write files inside `~/.nanobot/workspace/`, and shell commands are limited to that directory. This prevents the LLM from wandering around your server. I&apos;d turn this on if the bot is accessible to multiple people.

### Channel allowlists

Every channel config has an `allowFrom` field:

```json
{
  &quot;channels&quot;: {
    &quot;discord&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;...&quot;,
      &quot;allowFrom&quot;: [&quot;123456789&quot;]
    }
  }
}
```

Empty `allowFrom` means anyone can interact. Add user IDs to restrict access. For a personal bot, always set this.

## CLI reference

| Command | What it does |
|---------|-------------|
| `nanobot onboard` | Initialize config and workspace |
| `nanobot agent -m &quot;...&quot;` | Send a single message |
| `nanobot agent` | Interactive chat mode |
| `nanobot agent --no-markdown` | Plain-text output |
| `nanobot agent --logs` | Show runtime logs during chat |
| `nanobot gateway` | Start the gateway (connects to chat channels) |
| `nanobot status` | Show current status |
| `nanobot provider login openai-codex` | OAuth login for Codex |
| `nanobot channels login` | Link WhatsApp (QR scan) |
| `nanobot channels status` | Show channel connection status |
| `nanobot cron list` | List scheduled jobs |
| `nanobot cron add` | Add a scheduled job |
| `nanobot cron remove &lt;id&gt;` | Remove a scheduled job |

In interactive mode, type `exit`, `quit`, `/exit`, `/quit`, `:q`, or press `Ctrl+D` to leave.

## VPS hosting

A small VPS handles nanobot without issues. I&apos;m running it on a [Hetzner CX22](/hetzner-cloud-review/) (2 vCPU, 4GB RAM) at €4.35/month. Python memory usage is modest compared to OpenClaw&apos;s Node.js stack.

&lt;Notice type=&quot;success&quot; title=&quot;Hetzner discount&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers around 4 months of a CX22.
&lt;/Notice&gt;

Quick setup on a fresh Ubuntu 24.04 VPS:

&lt;Tabs&gt;
&lt;Tab name=&quot;uv (recommended)&quot;&gt;

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env

# Install nanobot
uv tool install nanobot-ai

# Initialize
nanobot onboard

# Edit config
nano ~/.nanobot/config.json

# Start gateway in background
nohup nanobot gateway &gt; /var/log/nanobot.log 2&gt;&amp;1 &amp;
```

&lt;/Tab&gt;
&lt;Tab name=&quot;pip&quot;&gt;

```bash
ssh root@YOUR_SERVER_IP

# Install Python 3.12 and pip
apt update &amp;&amp; apt upgrade -y
apt install -y python3 python3-pip python3-venv

# Install nanobot
pip install nanobot-ai

# Initialize
nanobot onboard

# Edit config
nano ~/.nanobot/config.json

# Start gateway in background
nohup nanobot gateway &gt; /var/log/nanobot.log 2&gt;&amp;1 &amp;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

For a proper daemon setup, create a systemd service:

```ini
[Unit]
Description=nanobot gateway
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/nanobot gateway
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
```

Save that to `/etc/systemd/system/nanobot.service`, then:

```bash
systemctl daemon-reload
systemctl enable nanobot
systemctl start nanobot
```

If you want to run local models alongside nanobot, check our guide on [installing Ollama with Docker](/ollama-docker-install/). nanobot&apos;s vLLM provider works with any OpenAI-compatible endpoint, so you can point it at a local Ollama or vLLM server.

## nanobot vs OpenClaw

I run both, so here&apos;s a frank comparison:

| Aspect | nanobot | OpenClaw |
|--------|---------|----------|
| Codebase size | ~3,700 lines | 430k+ lines |
| Install method | `pip install` | Custom installer script |
| Setup time | ~5 minutes | ~20 minutes |
| Channel support | 9 platforms | 4 platforms |
| Memory system | File-based (MEMORY.md) | File-based + semantic search |
| Provider support | 13+ built-in | Several with OAuth options |
| Skills system | Markdown-based, loaded from workspace | Registry with community sharing |
| MCP support | Yes | Not yet |
| Resource usage | Lower (Python, ~100MB RAM) | Higher (Node.js, &gt;1GB RAM) |

OpenClaw has a more polished setup wizard and the OAuth flow for using existing Claude/ChatGPT subscriptions is nice. nanobot is leaner, installs faster, and supports more chat platforms. For details on OpenClaw, see our [full setup guide](/clawdbot-setup-guide/). If you want container-level isolation with Claude&apos;s Agent SDK, see our [NanoClaw deploy guide](/nanoclaw-deploy-guide/). If you want a Go binary that runs on even cheaper hardware, see our [PicoClaw setup guide](/picoclaw-setup-guide/). For the smallest possible footprint (678 KB Zig binary, ~1 MB RAM), see our [NullClaw deploy guide](/nullclaw-deploy-guide/).

For other alternatives, check our [OpenClaw alternatives](/openclaw-alternatives/) roundup. If you want to build something more custom with multi-agent teams, look at our [AI agent Discord bot guide](/create-your-own-ai-agent/) using the Agno framework.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**How much does it cost to run nanobot?**

VPS: ~$5/month at Hetzner. MiniMax M2.5 Lightning API: roughly $1/hour of continuous use, but real-world costs are much lower since the bot only calls the API when you message it. Expect $5-20/month for personal use depending on how chatty you are.

**Can I use nanobot without any API costs?**

Yes. Configure the vLLM provider and point it at a local model server running Ollama or vLLM. You&apos;ll need hardware that can run inference, but there are no API bills.

**Does nanobot work on a Raspberry Pi?**

It runs, but performance depends on your model choice. With a remote API provider (MiniMax, Zhipu), a Pi 4 with 4GB RAM handles the gateway fine. Running local models on a Pi is a different story.

**Can multiple people use one nanobot instance?**

Yes. Add multiple user IDs to `allowFrom` in your channel config. Each person gets their own conversation context through the session system.

**What&apos;s the difference between the agent and gateway commands?**

`nanobot agent` is for direct CLI chat. `nanobot gateway` starts the background service that connects to Discord, Telegram, and other chat platforms. For 24/7 use, you want the gateway.

**Can I add Telegram alongside Discord?**

Absolutely. Configure both channels in the same config file and the gateway handles them simultaneously. See the [OpenClaw setup guide](/clawdbot-setup-guide/) for detailed Telegram bot creation steps since the BotFather flow is identical.

&lt;/Accordion&gt;

If you want to explore other AI coding tools that pair well with self-hosted bots, our [AI coding tools comparison](/ai-coading-tools/) covers the current landscape. For running agents with Python frameworks, check the [Agno getting started guide](/agno-get-start/).

Este artículo también está disponible en español: [Guía de Configuración de NanoBot](/es/guia-configuracion-nanobot/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>ZeroClaw Setup Guide: MiniMax M2.5, GLM-5, and Discord on Your VPS</title><link>https://www.bitdoze.com/zeroclaw-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/zeroclaw-setup-guide/</guid><description>Step-by-step guide to installing ZeroClaw on a Linux VPS with MiniMax M2.5, GLM-5, Discord integration, and Brave Search. Covers config, providers, memory, and Docker deployment.</description><pubDate>Mon, 16 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

[ZeroClaw](https://github.com/zeroclaw-labs/zeroclaw) showed up on my radar last week and I had to try it. It&apos;s a self-hosted AI assistant written entirely in Rust that compiles down to a 3.4MB binary and uses less than 5MB of RAM at runtime. For context, that&apos;s about 200x less memory than OpenClaw and roughly 20x less than nanobot. If you&apos;ve been running [nanobot](/nanobot-setup-guide/) or [OpenClaw](/clawdbot-setup-guide/) and wanted something leaner, this is worth looking at.

This guide walks through getting ZeroClaw running on a VPS with MiniMax M2.5 and GLM-5 as your models, Brave Search for web access, and Discord as the chat channel.

&lt;Button text=&quot;ZeroClaw GitHub&quot; link=&quot;https://github.com/zeroclaw-labs/zeroclaw&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What this guide covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Installing ZeroClaw from source or via Docker&lt;/li&gt;
&lt;li&gt;Configuring MiniMax M2.5 and Zhipu GLM-5 as LLM providers&lt;/li&gt;
&lt;li&gt;Setting up Brave Search for web access&lt;/li&gt;
&lt;li&gt;Discord channel integration&lt;/li&gt;
&lt;li&gt;Memory system with SQLite hybrid search&lt;/li&gt;
&lt;li&gt;Security settings, sandboxing, and gateway pairing&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re comparing self-hosted bot options, our [OpenClaw alternatives](/openclaw-alternatives/) roundup includes ZeroClaw alongside nanobot, NanoClaw, memU, and PicoClaw.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/8zuIJWGp2ko&quot;
  label=&quot;ZeroClaw Setup&quot;
/&gt;

## What ZeroClaw actually is

ZeroClaw is a Rust-based AI assistant from zeroclaw-labs. It boots in under 10ms, uses less than 5MB of RAM, and the release binary is about 3.4MB. Everything is built around traits, which is Rust&apos;s version of interfaces. Want to swap your LLM provider? Change one line in a TOML file. Same goes for channels, memory backends, and tools.

The architecture looks like this:

```
You (Discord / Telegram / Slack / iMessage / Matrix / etc.)
    ↓
ZeroClaw Gateway (running on your VPS, 127.0.0.1:8080)
    ↓
LLM Provider (OpenRouter, Anthropic, OpenAI, Ollama, MiniMax, Zhipu, etc.)
    ↓
Tools (shell, file access, web search, memory, Composio integrations)
```

Messages come in from your chat app, ZeroClaw routes them to whatever LLM you configured, and the model can use built-in tools to get work done. The gateway binds to localhost by default and requires a pairing code before accepting any webhook requests. That&apos;s a nice security default that most alternatives skip.

### How it compares

| | OpenClaw | NanoBot | ZeroClaw 🦀 |
|---|---|---|---|
| **Language** | TypeScript | Python | **Rust** |
| **RAM** | &gt; 1GB | &gt; 100MB | **&lt; 5MB** |
| **Startup** | &gt; 10s | &gt; 2s | **&lt; 10ms** |
| **Binary** | ~28MB (dist) | N/A (scripts) | **3.4MB** |
| **Channels** | 4 platforms | 9 platforms | **8+ platforms** |
| **Providers** | Several | 13+ | **22+** |
| **Memory** | File + semantic | File-based | **SQLite hybrid search** |

## Why MiniMax M2.5 and GLM-5

Both of these models came out in February 2026 and they pair well with a lightweight bot like ZeroClaw.

### MiniMax M2.5

MiniMax M2.5 is a 230B Mixture-of-Experts model with only 10B active parameters per pass. It runs fast and cheap:

| Spec | Value |
|------|-------|
| Architecture | 230B MoE, 10B active |
| Context window | 1M tokens |
| Speed (Lightning) | 100 tokens/sec |
| Cost (Lightning) | $0.30/M input, $2.40/M output |
| SWE-Bench Verified | 80.2% |
| License | Modified MIT (open-source) |

It scores 80.2% on SWE-Bench Verified, matching Claude Opus 4.6 at roughly 1/20th the cost. The 1M token context window means ZeroClaw won&apos;t hit context limits even with long conversations.

### GLM-5

GLM-5 from Zhipu AI is a 744B MoE model with 40-44B active parameters:

| Spec | Value |
|------|-------|
| Architecture | 744B MoE, ~40B active |
| Context window | 200K tokens |
| SWE-Bench Verified | 77.8% |
| BrowseComp | #1 open-source |
| License | MIT |

GLM-5 ranks first among open-source models on BrowseComp (web search agent tasks), so it&apos;s a solid pick for a bot that does a lot of web lookups.

Both models are available through their own APIs and through OpenRouter if you want a single gateway.

## Installation

Two main ways to get ZeroClaw installed: from source or via Docker.

&lt;Tabs&gt;
&lt;Tab name=&quot;From source (recommended)&quot;&gt;

You need Rust installed. If you don&apos;t have it:

```bash
sudo apt update
sudo apt install build-essential pkg-config
curl --proto &apos;=https&apos; --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
```

Then build and install ZeroClaw:

```bash
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release --locked
cargo install --path . --force --locked
```

The release build produces a ~3.4MB binary. On a 2-core VPS this takes a few minutes. On a Raspberry Pi with 1GB RAM, use `CARGO_BUILD_JOBS=1 cargo build --release` to avoid the kernel killing rustc.

&lt;/Tab&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

Pull the pre-built image or build locally:

```bash
# Using docker-compose
curl -O https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/docker-compose.yml

# Set your API key
export API_KEY=&quot;sk-your-key-here&quot;

# Start
docker-compose up -d
```

Or build from the repo:

```bash
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
docker build -t zeroclaw .
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

After installing, run the onboard wizard:

```bash
# Quick setup (non-interactive)
zeroclaw onboard --api-key sk-... --provider openrouter

# Or interactive wizard
zeroclaw onboard --interactive
```

This creates the `~/.zeroclaw/` directory with a `config.toml` and a workspace folder.

Check that everything&apos;s working:

```bash
zeroclaw status
```

## Configuring MiniMax M2.5

ZeroClaw uses TOML for configuration instead of JSON. The config lives at `~/.zeroclaw/config.toml`.

### Get an API key

1. Go to [platform.minimax.io](https://platform.minimax.io) (global) or [minimaxi.com](https://www.minimaxi.com) (mainland China)
2. Create an account and generate an API key

&lt;Notice type=&quot;success&quot; title=&quot;MiniMax coding plan — 10% off&quot;&gt;
MiniMax offers coding plans priced for developer workloads. [Get 10% off with our referral link](https://go.bitdoze.com/minimax). For details on how GLM-5 and MiniMax M2.5 compare for always-on bots, see our [best open source models for OpenClaw](/best-opensource-models-for-openclaw/) breakdown.
&lt;/Notice&gt;

### Add to config

ZeroClaw supports MiniMax through OpenRouter or as a custom OpenAI-compatible endpoint. The simplest way is via OpenRouter:

```toml
api_key = &quot;sk-or-your-openrouter-key&quot;
default_provider = &quot;openrouter&quot;
default_model = &quot;minimax/MiniMax-M2.5&quot;
default_temperature = 0.7
```

If you want to hit MiniMax&apos;s API directly, use a custom provider:

```toml
api_key = &quot;your-minimax-api-key&quot;
default_provider = &quot;custom:https://api.minimax.chat/v1&quot;
default_model = &quot;MiniMax-M2.5&quot;
default_temperature = 0.7
```

For the mainland China endpoint, swap the URL to `https://api.minimaxi.com/v1`.

### Test it

```bash
zeroclaw agent -m &quot;What&apos;s 42 * 17?&quot;
```

If you get a response, MiniMax is working.

## Configuring GLM-5 (Zhipu)

GLM-5 works through OpenRouter or directly via Zhipu&apos;s API.

### Get an API key

1. Go to [z.ai](https://z.ai/manage-apikey/apikey-list)
2. Register and create an API key

&lt;Notice type=&quot;success&quot; title=&quot;Z.AI GLM coding plan — 10% off&quot;&gt;
Z.AI offers [GLM coding plans](https://z.ai/subscribe?ic=NKNUNYDRZT) designed for continuous developer workloads. Use our link for 10% off.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Zhipu coding plan endpoint&quot;&gt;
If you&apos;re on Zhipu&apos;s coding plan, use `custom:https://api.z.ai/api/coding/paas/v4` as your provider. This routes through their coding-optimized endpoint.
&lt;/Notice&gt;

### Add to config

Via OpenRouter:

```toml
api_key = &quot;sk-or-your-openrouter-key&quot;
default_provider = &quot;openrouter&quot;
default_model = &quot;zhipu/glm-5&quot;
default_temperature = 0.7
```

Or directly via Zhipu&apos;s API:

```toml
api_key = &quot;your-zhipu-api-key&quot;
default_provider = &quot;custom:https://api.z.ai/api/coding/paas/v4&quot;
default_model = &quot;glm-5&quot;
default_temperature = 0.7
```

### Switching between models

ZeroClaw reads the model from `config.toml`, so switching is just editing one line. No restart needed if you&apos;re using the CLI. For the gateway, restart with `zeroclaw gateway`.

## Setting up Brave Search

Without web search, your bot is limited to the model&apos;s training data and whatever files are on your server. Brave Search gives it live web access.

### Get a Brave API key

1. Go to [brave.com/search/api](https://brave.com/search/api/)
2. Sign up for an account
3. The free tier gives you about 1,000 searches per month
4. Generate an API key from the dashboard

### Add to config

```toml
[browser]
enabled = true
allowed_domains = [&quot;*&quot;]
```

ZeroClaw&apos;s browser tool supports Brave Search through the `browser_open` tool. Set the Brave API key as an environment variable:

```bash
export BRAVE_API_KEY=&quot;your-brave-search-api-key&quot;
```

Or add it to the config if you prefer everything in one place. The `allowed_domains` array controls which domains the bot can browse. Use `[&quot;*&quot;]` during setup, then lock it down to specific domains later.

## Discord setup

Discord is one of the 8+ channels ZeroClaw supports. The integration uses Discord&apos;s WebSocket gateway directly, so you don&apos;t need a webhook URL or public endpoint.

### Create a Discord bot

1. Go to [discord.com/developers/applications](https://discord.com/developers/applications)
2. Click **New Application**, give it a name
3. Go to **Bot** in the left sidebar, click **Add Bot**
4. Copy the bot token

### Enable intents

Still in the Bot settings page:

1. Scroll down to **Privileged Gateway Intents**
2. Enable **MESSAGE CONTENT INTENT** (required, or the bot can&apos;t read messages)
3. Optionally enable **SERVER MEMBERS INTENT** if you want to use allowlists by username

### Get your user ID

1. Open Discord Settings → **Advanced** → enable **Developer Mode**
2. Right-click your avatar anywhere in Discord
3. Click **Copy User ID**

### Configure ZeroClaw

Add the Discord channel config to your `~/.zeroclaw/config.toml`:

```toml
[channels_config.discord]
token = &quot;YOUR_DISCORD_BOT_TOKEN&quot;
allowed_users = [&quot;YOUR_USER_ID&quot;]
```

&lt;Notice type=&quot;warning&quot; title=&quot;Allowlist behavior&quot;&gt;
In ZeroClaw, an empty allowlist means **deny all inbound messages** by default. This is the opposite of most other bots. Add your user ID or use `[&quot;*&quot;]` for open access. If you&apos;re not sure what your sender identity looks like, start the bot, send it a message, and check the warning log for the exact value.
&lt;/Notice&gt;

### Invite the bot to your server

1. In the Discord developer portal, go to **OAuth2** → **URL Generator**
2. Under **Scopes**, check `bot`
3. Under **Bot Permissions**, check `Send Messages` and `Read Message History`
4. Copy the generated URL and open it in your browser
5. Select the server to add the bot to

### Start the daemon

```bash
zeroclaw daemon
```

This starts the full autonomous runtime including all configured channels. For just the gateway:

```bash
zeroclaw gateway
```

Send a message in Discord. The bot should respond. If nothing happens, run `zeroclaw doctor` to diagnose channel issues, or `zeroclaw channel doctor` for targeted channel health checks.

## Full config example

Here&apos;s what a complete `~/.zeroclaw/config.toml` looks like with OpenRouter (for MiniMax M2.5), Brave Search, Discord, and SQLite memory:

```toml
api_key = &quot;sk-or-your-openrouter-key&quot;
default_provider = &quot;openrouter&quot;
default_model = &quot;minimax/MiniMax-M2.5&quot;
default_temperature = 0.7

[memory]
backend = &quot;sqlite&quot;
auto_save = true
embedding_provider = &quot;openai&quot;
vector_weight = 0.7
keyword_weight = 0.3

[gateway]
require_pairing = true
allow_public_bind = false

[autonomy]
level = &quot;supervised&quot;
workspace_only = true
allowed_commands = [&quot;git&quot;, &quot;npm&quot;, &quot;cargo&quot;, &quot;ls&quot;, &quot;cat&quot;, &quot;grep&quot;]
forbidden_paths = [&quot;/etc&quot;, &quot;/root&quot;, &quot;/proc&quot;, &quot;/sys&quot;, &quot;~/.ssh&quot;, &quot;~/.gnupg&quot;, &quot;~/.aws&quot;]

[channels_config.discord]
token = &quot;YOUR_DISCORD_BOT_TOKEN&quot;
allowed_users = [&quot;YOUR_USER_ID&quot;]

[browser]
enabled = true
allowed_domains = [&quot;docs.rs&quot;, &quot;github.com&quot;, &quot;stackoverflow.com&quot;]

[heartbeat]
enabled = false
interval_minutes = 30

[tunnel]
provider = &quot;none&quot;

[secrets]
encrypt = true
```

### Config settings explained

| Setting | Default | What it does |
|---------|---------|-------------|
| `default_model` | `anthropic/claude-sonnet-4-20250514` | Which model handles your messages |
| `default_temperature` | `0.7` | Randomness (lower = more deterministic) |
| `autonomy.level` | `supervised` | `readonly`, `supervised`, or `full` |
| `autonomy.workspace_only` | `true` | Scopes all file/shell access to workspace |
| `memory.backend` | `sqlite` | `sqlite`, `lucid`, `markdown`, or `none` |
| `gateway.require_pairing` | `true` | Requires 6-digit code to connect |
| `gateway.allow_public_bind` | `false` | Refuses 0.0.0.0 without a tunnel |
| `secrets.encrypt` | `true` | API keys encrypted with ChaCha20-Poly1305 |

## Provider system

ZeroClaw ships with 22+ built-in providers. Every provider implements the same `Provider` trait, so swapping between them is a config change.

| Provider | API endpoint | Notes |
|----------|-------------|-------|
| OpenRouter | `https://openrouter.ai/api/v1` | Gateway to any model |
| Anthropic | Direct API | Claude models |
| OpenAI | Direct API | GPT models |
| Ollama | Local | Self-hosted models |
| Gemini | Direct API | Google Gemini (native, not OpenAI-compat) |
| Venice | Direct API | Privacy-focused |
| Groq | Direct API | Fast inference |
| Mistral | Direct API | Mistral models |
| xAI | Direct API | Grok models |
| DeepSeek | Direct API | DeepSeek models |
| Together | Direct API | Open-source models |
| Fireworks | Direct API | Fast open-source |
| Perplexity | Direct API | Search-augmented |
| Cohere | Direct API | Command models |
| Bedrock | AWS API | AWS-hosted models |
| Custom | Any URL | Any OpenAI-compatible API |

For MiniMax and Zhipu specifically, use either OpenRouter or the `custom:` provider with their API URLs. ZeroClaw&apos;s custom provider works with any OpenAI-compatible endpoint, which covers both.

## Memory system

ZeroClaw&apos;s memory goes further than what nanobot or OpenClaw offer. It&apos;s a hybrid search engine built on SQLite with no external dependencies:

| Layer | What it does |
|-------|-------------|
| Vector DB | Embeddings stored as BLOB in SQLite, cosine similarity search |
| Keyword search | FTS5 virtual tables with BM25 scoring |
| Hybrid merge | Weighted merge of vector and keyword results |
| Embeddings | OpenAI embeddings by default, or noop for no embeddings |
| Chunking | Line-based markdown chunker with heading preservation |
| Caching | SQLite embedding cache with LRU eviction |

The bot automatically stores, recalls, and manages memory through built-in tools. No external services needed. No Pinecone, no Elasticsearch.

```toml
[memory]
backend = &quot;sqlite&quot;
auto_save = true
embedding_provider = &quot;openai&quot;
vector_weight = 0.7
keyword_weight = 0.3
```

Set `embedding_provider = &quot;noop&quot;` if you don&apos;t want to pay for OpenAI embeddings. You&apos;ll lose vector search but keyword search (FTS5) still works well on its own.

### Identity files

Like nanobot, ZeroClaw uses workspace files to shape the bot&apos;s personality:

| File | Purpose |
|------|---------|
| `IDENTITY.md` | Who the agent is |
| `SOUL.md` | Core personality and values |
| `USER.md` | Your personal info and preferences |
| `AGENTS.md` | Behavior guidelines |
| `TOOLS.md` | Tool usage instructions |

Edit these in `~/.zeroclaw/workspace/`. ZeroClaw also supports AIEOS (AI Entity Object Specification) as an alternative identity format if you want portable AI personas.

## Docker deployment

For Docker, ZeroClaw has a multi-stage Dockerfile with both dev and production targets. The production image uses Google&apos;s distroless base, so it&apos;s tiny.

### Using docker-compose

```bash
# Download the compose file
curl -O https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main/docker-compose.yml

# Create .env file
cat &gt; .env &lt;&lt; &apos;EOF&apos;
API_KEY=sk-or-your-openrouter-key
PROVIDER=openrouter
ZEROCLAW_MODEL=minimax/MiniMax-M2.5
HOST_PORT=3000
EOF

# Start
docker-compose up -d
```

### Manual Docker run

```bash
# Build
docker build -t zeroclaw --target release .

# Run
docker run -d \
  -e API_KEY=&quot;sk-or-your-openrouter-key&quot; \
  -e PROVIDER=&quot;openrouter&quot; \
  -e ZEROCLAW_MODEL=&quot;minimax/MiniMax-M2.5&quot; \
  -v zeroclaw-data:/zeroclaw-data \
  -p 3000:3000 \
  --name zeroclaw \
  zeroclaw
```

The container runs as a non-root user (UID 65534) by default.

### Health checks

The compose file includes a health check using `zeroclaw doctor`:

```yaml
healthcheck:
  test: [&quot;CMD&quot;, &quot;zeroclaw&quot;, &quot;doctor&quot;]
  interval: 30s
  timeout: 10s
  retries: 3
```

## Security settings

ZeroClaw&apos;s security defaults are stricter than most alternatives. A few things worth knowing:

### Gateway pairing

When you start the gateway, ZeroClaw generates a 6-digit one-time pairing code. You need to exchange this code for a bearer token before the gateway accepts any webhook requests:

```bash
# Start gateway (shows pairing code in logs)
zeroclaw gateway

# Exchange code for token
curl -X POST http://127.0.0.1:8080/pair \
  -H &quot;X-Pairing-Code: 123456&quot;
```

All subsequent `/webhook` requests need the `Authorization: Bearer &lt;token&gt;` header.

### Workspace sandboxing

```toml
[autonomy]
workspace_only = true
allowed_commands = [&quot;git&quot;, &quot;npm&quot;, &quot;cargo&quot;, &quot;ls&quot;, &quot;cat&quot;, &quot;grep&quot;]
forbidden_paths = [&quot;/etc&quot;, &quot;/root&quot;, &quot;/proc&quot;, &quot;/sys&quot;, &quot;~/.ssh&quot;, &quot;~/.gnupg&quot;, &quot;~/.aws&quot;]
```

With `workspace_only = true`, the bot can only access files inside its workspace. 14 system directories and 4 sensitive dotfiles are blocked by default. Symlink escape attempts are caught through path canonicalization.

### Localhost-only binding

The gateway binds to `127.0.0.1` by default and refuses to bind to `0.0.0.0` unless you either configure a tunnel (Cloudflare, Tailscale, ngrok) or explicitly set `allow_public_bind = true`.

## CLI reference

| Command | What it does |
|---------|-------------|
| `zeroclaw onboard` | Quick setup |
| `zeroclaw onboard --interactive` | Full 7-step wizard |
| `zeroclaw onboard --channels-only` | Reconfigure channels only |
| `zeroclaw agent -m &quot;...&quot;` | Send a single message |
| `zeroclaw agent` | Interactive chat mode |
| `zeroclaw gateway` | Start webhook server |
| `zeroclaw daemon` | Start full autonomous runtime |
| `zeroclaw status` | Show system status |
| `zeroclaw doctor` | Run diagnostics |
| `zeroclaw channel doctor` | Check channel health |
| `zeroclaw service install` | Install as system service |
| `zeroclaw service status` | Check service status |
| `zeroclaw migrate openclaw` | Migrate memory from OpenClaw |

The `migrate openclaw` command is handy if you&apos;re switching from OpenClaw. Run `--dry-run` first to preview what gets imported.

## VPS hosting

ZeroClaw runs on just about anything. I tested it on a [Hetzner CX22](/hetzner-cloud-review/) (2 vCPU, 4GB RAM) at €4.35/month, but honestly it would run fine on much cheaper hardware. The project claims it works on $10 single-board computers and I believe it given the memory footprint.

&lt;Notice type=&quot;success&quot; title=&quot;Hetzner discount&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers around 4 months of a CX22.
&lt;/Notice&gt;

Quick setup on a fresh Ubuntu 24.04 VPS:

```bash
ssh root@YOUR_SERVER_IP

# Update system
apt update &amp;&amp; apt upgrade -y

# Install Rust
curl --proto &apos;=https&apos; --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env

# Clone and build
git clone https://github.com/zeroclaw-labs/zeroclaw.git
cd zeroclaw
cargo build --release --locked
cargo install --path . --force --locked

# Initialize
zeroclaw onboard --interactive

# Edit config
nano ~/.zeroclaw/config.toml

# Start
zeroclaw daemon
```

For a proper daemon setup, install ZeroClaw as a system service:

```bash
zeroclaw service install
zeroclaw service start
zeroclaw service status
```

This creates a systemd user service that starts on boot and restarts on failure.

If you want to run local models alongside ZeroClaw, check our guide on [installing Ollama with Docker](/ollama-docker-install/). ZeroClaw&apos;s Ollama provider connects to any local Ollama instance without extra config.

## ZeroClaw vs nanobot vs OpenClaw

I run all three at this point, so here&apos;s a direct comparison:

| Aspect | ZeroClaw 🦀 | nanobot | OpenClaw |
|--------|------------|---------|----------|
| Language | Rust | Python | TypeScript |
| RAM usage | &lt; 5MB | ~100MB | &gt; 1GB |
| Startup | &lt; 10ms | &gt; 2s | &gt; 10s |
| Binary size | 3.4MB | N/A (scripts) | ~28MB |
| Config format | TOML | JSON | Custom |
| Channel count | 8+ | 9 | 4 |
| Provider count | 22+ | 13+ | Several |
| Memory | SQLite hybrid search | File-based | File + semantic |
| Security | Pairing + sandbox + allowlists | Allowlists | Allowlists |
| Install method | `cargo install` | `pip install` | Custom script |
| Setup time | ~10 minutes (includes compile) | ~5 minutes | ~20 minutes |

ZeroClaw wins on resource usage and security. nanobot wins on setup speed and channel count. OpenClaw has the most polished onboarding experience with its OAuth flow for existing Claude/ChatGPT subscriptions.

For the nanobot setup guide, see our [full walkthrough](/nanobot-setup-guide/). For OpenClaw, see the [setup guide](/clawdbot-setup-guide/). For a Go-based alternative that runs on $10 hardware, check out our [PicoClaw setup guide](/picoclaw-setup-guide/).

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**How much does it cost to run ZeroClaw?**

VPS: ~$5/month at Hetzner. MiniMax M2.5 Lightning API: roughly $1/hour of continuous use, but actual costs are much lower since the bot only calls the API when you message it. Expect $5-20/month for personal use.

**Can I use ZeroClaw without any API costs?**

Yes. Set `default_provider = &quot;ollama&quot;` and point it at a local Ollama instance. You need hardware that can run inference, but there are no API bills.

**Does ZeroClaw work on a Raspberry Pi?**

Yes. The project explicitly supports ARM targets. On a Pi with 1GB RAM, compile with `CARGO_BUILD_JOBS=1` to avoid running out of memory during the build. Once compiled, ZeroClaw uses less than 5MB of RAM at runtime.

**Can multiple people use one ZeroClaw instance?**

Yes. Add multiple user IDs to the channel allowlist. Each person gets their own conversation context.

**What&apos;s the difference between gateway and daemon?**

`zeroclaw gateway` starts the webhook server only. `zeroclaw daemon` starts the full autonomous runtime including all channels, heartbeat tasks, and the scheduler. For 24/7 use with Discord, you want the daemon.

**Can I migrate from OpenClaw to ZeroClaw?**

Yes. ZeroClaw has a built-in migration command: `zeroclaw migrate openclaw`. Run `--dry-run` first to preview what gets imported, then run it for real.

**Can I add Telegram alongside Discord?**

Yes. Configure both channels in the same config file. ZeroClaw handles them simultaneously. See the nanobot guide&apos;s Telegram section for the BotFather flow since it&apos;s the same process.

&lt;/Accordion&gt;

If you want to explore other AI coding tools, our [AI coding tools comparison](/ai-coading-tools/) covers the current landscape. For a self-improving assistant with voice mode and OpenClaw migration, see the [Hermes Agent setup guide](/hermes-agent-setup-guide/). For MCP basics that work across all these assistants, check the [MCP introduction for beginners](/mcp-introduction-beginners/).

Este artículo también está disponible en español: [Guía de Configuración de ZeroClaw](/es/guia-configuracion-zeroclaw/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>Add DuckDuckGo Search to OpenClaw: Free Web Search Without API Keys</title><link>https://www.bitdoze.com/duckduckgo-openclaw-search/</link><guid isPermaLink="true">https://www.bitdoze.com/duckduckgo-openclaw-search/</guid><description>Step-by-step guide to adding DuckDuckGo search to OpenClaw (formerly Clawdbot/Moltbot). Replace Brave Search with a free alternative that doesn&apos;t require an API key.</description><pubDate>Sun, 15 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

OpenClaw comes with Brave Search as the default web search provider. It works well, but you need an API key. If you want a free alternative that just works without signups, DuckDuckGo is the answer.

&lt;Notice type=&quot;info&quot; title=&quot;What You&apos;ll Need&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;OpenClaw installed on your server&lt;/li&gt;
&lt;li&gt;Basic familiarity with editing TypeScript files&lt;/li&gt;
&lt;li&gt;curl installed (available by default on most Linux systems)&lt;/li&gt;
&lt;li&gt;No API key required for DuckDuckGo!&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re new to OpenClaw, check out our complete [OpenClaw Setup Guide](https://www.bitdoze.com/clawdbot-setup-guide/) first.

## Why DuckDuckGo Over Brave?

| Feature | Brave Search | DuckDuckGo |
|---------|--------------|------------|
| **API Key** | Required (free tier available) | Not needed |
| **Monthly Cost** | Free tier + paid plans | $0 forever |
| **Rate Limits** | Per-plan limits | Anti-bot risk |
| **Setup Complexity** | Low | Medium (code modification) |
| **Freshness Filters** | Yes (pd, pw, pm, py) | No |
| **Reliability** | High | Medium (HTML scraping) |

DuckDuckGo uses HTML scraping, which means it can break if they change their page structure. But for personal use on OpenClaw, it&apos;s a solid free option.

## Overview of the Implementation

The approach uses DuckDuckGo&apos;s HTML search endpoint (`https://html.duckduckgo.com/html/`) and parses results from the raw HTML. This is the same method used by many privacy-focused search tools.

Here&apos;s what we&apos;ll do:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Add DuckDuckGo to the list of search providers&lt;/li&gt;
&lt;li&gt;Create a function to call DuckDuckGo HTML search via curl&lt;/li&gt;
&lt;li&gt;Add an HTML parser to extract results&lt;/li&gt;
&lt;li&gt;Update the provider resolution logic&lt;/li&gt;
&lt;li&gt;Configure OpenClaw to use DuckDuckGo&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Step 1: Add DuckDuckGo to Search Providers

Open the web search tool file:

```bash
nano ~/.openclaw/openclawd/src/agents/tools/web-search.ts
```

Find the `SEARCH_PROVIDERS` array near the top and add `duckduckgo`:

```typescript
const SEARCH_PROVIDERS = [&quot;brave&quot;, &quot;perplexity&quot;, &quot;grok&quot;, &quot;duckduckgo&quot;] as const;
```

## Step 2: Add the DuckDuckGo Endpoint

Add the HTML search endpoint constant after the Brave endpoint:

```typescript
const BRAVE_SEARCH_ENDPOINT = &quot;https://api.search.brave.com/res/v1/web/search&quot;;
const DUCKDUCKGO_HTML_ENDPOINT = &quot;https://html.duckduckgo.com/html/&quot;;
```

## Step 3: Add Type Definitions

Add the DuckDuckGo result type before the parsing function:

```typescript
type DuckDuckGoSearchResult = {
  title: string;
  url: string;
  description: string;
  siteName?: string;
};
```

## Step 4: Create the HTML Parser

Add this function to parse DuckDuckGo&apos;s HTML response:

```typescript
function parseDuckDuckGoHtml(html: string): DuckDuckGoSearchResult[] {
  const results: DuckDuckGoSearchResult[] = [];
  const linkRegex = /&lt;a[^&gt;]*class=&quot;result__a&quot;[^&gt;]*href=&quot;([^&quot;]*)&quot;[^&gt;]*&gt;([^&lt;]*)&lt;\/a&gt;/gi;
  const snippetRegex = /&lt;a[^&gt;]*class=&quot;result__snippet&quot;[^&gt;]*&gt;([^&lt;]*(?:&lt;[^&gt;]*&gt;[^&lt;]*)*)&lt;\/a&gt;/gi;

  const links: { url: string; title: string }[] = [];
  let match;

  // Extract links and titles
  while ((match = linkRegex.exec(html)) !== null) {
    let url = match[1] ?? &quot;&quot;;
    const title = (match[2] ?? &quot;&quot;).trim();

    // DuckDuckGo redirects through their own URL - extract the real URL
    if (url.includes(&quot;uddg=&quot;)) {
      try {
        const parsed = new URL(url, &quot;https://duckduckgo.com&quot;);
        const realUrl = parsed.searchParams.get(&quot;uddg&quot;);
        if (realUrl) {
          url = decodeURIComponent(realUrl);
        }
      } catch {
        // Keep original URL if parsing fails
      }
    }

    if (url &amp;&amp; title &amp;&amp; url.startsWith(&quot;http&quot;)) {
      links.push({ url, title });
    }
  }

  // Extract snippets
  const snippets: string[] = [];
  while ((match = snippetRegex.exec(html)) !== null) {
    const snippet = (match[1] ?? &quot;&quot;).replace(/&lt;[^&gt;]*&gt;/g, &quot;&quot;).trim();
    snippets.push(snippet);
  }

  // Combine links with snippets
  for (let i = 0; i &lt; links.length; i++) {
    const link = links[i];
    if (!link) {
      continue;
    }
    results.push({
      title: link.title,
      url: link.url,
      description: snippets[i] ?? &quot;&quot;,
      siteName: resolveSiteName(link.url),
    });
  }

  return results;
}
```

## Step 5: Create the Search Function

Add a function that performs the actual DuckDuckGo search using curl:

```typescript
async function runDuckDuckGoSearch(params: {
  query: string;
  count: number;
  timeoutSeconds: number;
}): Promise&lt;DuckDuckGoSearchResult[]&gt; {
  const { execFileSync } = await import(&quot;child_process&quot;);

  const curlArgs = [
    &quot;-s&quot;,
    &quot;--max-time&quot;,
    String(params.timeoutSeconds),
    &quot;-X&quot;,
    &quot;POST&quot;,
    &quot;-H&quot;,
    &quot;Content-Type: application/x-www-form-urlencoded&quot;,
    &quot;-H&quot;,
    &quot;User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36&quot;,
    &quot;-H&quot;,
    &quot;Accept: text/html&quot;,
    &quot;-d&quot;,
    `q=${encodeURIComponent(params.query)}`,
    DUCKDUCKGO_HTML_ENDPOINT,
  ];

  try {
    const html = execFileSync(&quot;curl&quot;, curlArgs, {
      encoding: &quot;utf-8&quot;,
      maxBuffer: 2 * 1024 * 1024,
      timeout: params.timeoutSeconds * 1000,
    });

    const allResults = parseDuckDuckGoHtml(html);

    // Check if we got results or just the homepage (anti-bot detection)
    if (allResults.length === 0 &amp;&amp; html.includes(&quot;&lt;title&gt;&quot;) &amp;&amp; !html.includes(&quot;at DuckDuckGo&quot;)) {
      throw new Error(
        &quot;DuckDuckGo returned homepage instead of search results (possible anti-bot detection)&quot;
      );
    }

    return allResults.slice(0, params.count);
  } catch (err) {
    const message = err instanceof Error ? err.message : String(err);
    throw new Error(`DuckDuckGo search failed: ${message}`, { cause: err });
  }
}
```

## Step 6: Update Provider Resolution

Find the `resolveSearchProvider` function and add DuckDuckGo support:

```typescript
function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDERS)[number] {
  const raw =
    search &amp;&amp; &quot;provider&quot; in search &amp;&amp; typeof search.provider === &quot;string&quot;
      ? search.provider.trim().toLowerCase()
      : &quot;&quot;;
  if (raw === &quot;perplexity&quot;) {
    return &quot;perplexity&quot;;
  }
  if (raw === &quot;grok&quot;) {
    return &quot;grok&quot;;
  }
  if (raw === &quot;duckduckgo&quot; || raw === &quot;ddg&quot;) {
    return &quot;duckduckgo&quot;;
  }
  if (raw === &quot;brave&quot;) {
    return &quot;brave&quot;;
  }
  return &quot;brave&quot;; // Default
}
```

## Step 7: Add DuckDuckGo to the Main Search Function

In the `runWebSearch` function, add a case for DuckDuckGo before the Brave fallback:

```typescript
async function runWebSearch(params: {
  // ... existing params
}): Promise&lt;Record&lt;string, unknown&gt;&gt; {
  // ... existing cache logic

  const start = Date.now();

  // ... existing Perplexity code...

  if (params.provider === &quot;duckduckgo&quot;) {
    const ddgResults = await runDuckDuckGoSearch({
      query: params.query,
      count: params.count,
      timeoutSeconds: params.timeoutSeconds,
    });
    const payload = {
      query: params.query,
      provider: params.provider,
      count: ddgResults.length,
      tookMs: Date.now() - start,
      results: ddgResults,
    };
    writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
    return payload;
  }

  // ... existing Brave code...
}
```

## Step 8: Update Tool Description

Modify the `createWebSearchTool` function to include DuckDuckGo in the description:

```typescript
const description =
  provider === &quot;perplexity&quot;
    ? &quot;Search the web using Perplexity Sonar (direct or via OpenRouter). Returns AI-synthesized answers with citations from real-time web search.&quot;
    : provider === &quot;grok&quot;
      ? &quot;Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.&quot;
      : provider === &quot;duckduckgo&quot;
        ? &quot;Search the web using DuckDuckGo. Free search without API key requirements. Returns titles, URLs, and snippets.&quot;
        : &quot;Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.&quot;;
```

## Step 9: Skip API Key Check for DuckDuckGo

In the execute function, modify the API key check:

```typescript
execute: async (_toolCallId, args) =&gt; {
  // ... existing code...

  if (!apiKey &amp;&amp; provider !== &quot;duckduckgo&quot;) {
    return jsonResult(missingSearchKeyPayload(provider));
  }

  // ... rest of the code...
}
```

## Step 10: Configure OpenClaw

Edit your OpenClaw configuration:

```bash
nano ~/.openclaw/config.json
```

Set DuckDuckGo as your search provider:

```json
{
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;search&quot;: {
        &quot;provider&quot;: &quot;duckduckgo&quot;
      }
    }
  }
}
```

Or use the CLI:

```bash
openclaw configure --section web
```

## Step 11: Rebuild and Restart

After making all the code changes, rebuild OpenClaw:

```bash
cd ~/.openclaw/openclawd
npm run build
```

Restart the gateway:

```bash
openclaw gateway restart
```

## Testing Your Setup

Send a search query to OpenClaw through your messaging channel:

&gt; &quot;Search for the latest Node.js release&quot;

You should see results from DuckDuckGo without any API key configuration.

## Alternative: Use Community Patch

If you don&apos;t want to manually modify the code, there&apos;s a community fork with DuckDuckGo already implemented:

```bash
git clone https://github.com/jokelord/openclaw-local-model-tool-calling-patch.git
cd openclaw-local-model-tool-calling-patch
```

The DuckDuckGo implementation is in `openclawd-2026.2.3/src/agents/tools/web-search.ts`.

&lt;Button text=&quot;View Community Patch&quot; link=&quot;https://github.com/jokelord/openclaw-local-model-tool-calling-patch&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

## Limitations to Know

&lt;Notice type=&quot;warning&quot; title=&quot;DuckDuckGo Limitations&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**No freshness filters**: Unlike Brave, you can&apos;t filter by past day/week/month&lt;/li&gt;
&lt;li&gt;**Anti-bot detection**: Heavy usage may trigger blocks&lt;/li&gt;
&lt;li&gt;**HTML parsing**: Could break if DuckDuckGo changes their page structure&lt;/li&gt;
&lt;li&gt;**No country/language filters**: Less granular control than Brave API&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

For production use or heavy search loads, consider using Brave Search with an API key instead. The free tier is generous enough for personal OpenClaw use.

## Comparing Search Providers

&lt;Tabs&gt;
&lt;Tab name=&quot;Brave Search&quot;&gt;

**Pros:**
- Official API with guaranteed stability
- Freshness filters (past day, week, month, year)
- Country and language targeting
- Higher rate limits on paid plans

**Cons:**
- Requires API key signup
- Free tier has limits
- Paid plans for heavy usage

**Best for:** Production use, teams, heavy search needs
&lt;/Tab&gt;

&lt;Tab name=&quot;DuckDuckGo&quot;&gt;

**Pros:**
- No API key required
- Free forever
- Privacy-focused
- Easy setup (once code is modified)

**Cons:**
- HTML scraping can break
- Anti-bot detection risk
- No freshness filters
- Less reliable for heavy use

**Best for:** Personal use, testing, privacy enthusiasts
&lt;/Tab&gt;

&lt;Tab name=&quot;Perplexity&quot;&gt;

**Pros:**
- AI-synthesized answers
- Built-in citations
- Real-time web access
- Direct answers, not just links

**Cons:**
- Requires API key (more expensive)
- Different output format
- May be overkill for simple searches

**Best for:** Research tasks, comprehensive answers
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Troubleshooting

### &quot;DuckDuckGo returned homepage instead of search results&quot;

This means anti-bot detection kicked in. Try:

1. Reduce search frequency
2. Add delays between searches
3. Consider rotating user agents

### &quot;curl: command not found&quot;

Install curl:

```bash
apt install curl -y  # Ubuntu/Debian
```

### Results Are Empty

Check the HTML structure - DuckDuckGo may have changed their page layout. The parser looks for:
- Links with class `result__a`
- Snippets with class `result__snippet`

### TypeScript Compilation Errors

Make sure you&apos;ve added all the type definitions and imported `execFileSync` correctly:

```typescript
const { execFileSync } = await import(&quot;child_process&quot;);
```

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Why use curl instead of fetch?**

DuckDuckGo&apos;s HTML endpoint works better with curl&apos;s default headers and behavior. Node&apos;s fetch can trigger different responses. Curl is also more likely to be cached and efficient.

**Can I use both Brave and DuckDuckGo?**

Yes! Switch providers in your config anytime, or modify the code to support fallback providers.

**Is this against DuckDuckGo&apos;s Terms of Service?**

This uses their public HTML search, which is accessible to anyone. For heavy commercial use, consider their official API or use Brave Search instead.

**How do I switch back to Brave?**

Just change the provider in your config:

```json
{
  &quot;tools&quot;: {
    &quot;web&quot;: {
      &quot;search&quot;: {
        &quot;provider&quot;: &quot;brave&quot;,
        &quot;apiKey&quot;: &quot;YOUR_BRAVE_API_KEY&quot;
      }
    }
  }
}
```

**Will this work with future OpenClaw versions?**

The implementation may need updates if OpenClaw changes their web search architecture. Watch the official repo for changes to `web-search.ts`.

&lt;/Accordion&gt;

Adding DuckDuckGo to OpenClaw gives you a free, privacy-focused search option without API key management. It&apos;s perfect for personal use and testing. For production or team deployments, Brave Search with its official API is still the recommended choice.

If you&apos;re exploring alternatives to OpenClaw that also support web search, check our [NanoClaw deploy guide](/nanoclaw-deploy-guide/) (container-isolated Claude agents) and [NullClaw deploy guide](/nullclaw-deploy-guide/) (678 KB Zig binary with 22+ providers).

For more OpenClaw tips, see our complete [OpenClaw Setup Guide](https://www.bitdoze.com/clawdbot-setup-guide/), [OpenClaw alternatives](https://www.bitdoze.com/openclaw-alternatives/), [best OpenClaw dashboards](https://www.bitdoze.com/best-openclaw-dashboards/) if you want a UI for monitoring sessions and costs, the [OpenClaw security guide](/openclaw-security-guide/) for hardening your instance against CVE-2026-25253 and other vulnerabilities, and [running OpenClaw with Ollama](/openclaw-ollama-local-models/) if you want to pair free local models with DuckDuckGo for a fully self-hosted setup.</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>openclaw</category></item><item><title>Dockge Install - Docker Compose Manager for Self-Hosting</title><link>https://www.bitdoze.com/dockge-install/</link><guid isPermaLink="true">https://www.bitdoze.com/dockge-install/</guid><description>How to install Dockge with Docker Compose. A lightweight web UI for managing docker-compose stacks, with multi-agent support, interactive editor, and web terminal.</description><pubDate>Fri, 13 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/01/dockge-main.png&quot;;
import imag2 from &quot;../../assets/images/24/01/dockge-container.png&quot;;
import imag3 from &quot;../../assets/images/24/01/dockge-add.png&quot;;

I first wrote about Dockge back in 2024, and two years later it&apos;s still on every server I run. Where other Docker management tools keep adding features and complexity, Dockge stayed focused on one thing: making docker-compose stacks easy to manage from a browser.

[Dockge](https://github.com/louislam/dockge) was built by Louis Lam, the same developer behind [Uptime Kuma](https://www.bitdoze.com/uptime-kuma-tool/). It has nearly 22,000 GitHub stars and a community that keeps growing. The project hit v1.5 with multi-agent support, and now you can manage stacks across multiple Docker hosts from one interface.

If you&apos;re looking at Docker management UIs in general, I wrote a [comparison of Portainer alternatives](/portainer-alternatives/) that covers Dockge alongside Arcane, Dockhand, UsulNet, and Komodo.

## What Dockge does (and doesn&apos;t do)

Dockge is a compose-file manager. You write or paste a docker-compose YAML, click deploy, and that&apos;s it. All your compose files stay on disk in the directory you choose. Nothing gets locked in a database. You can still run `docker compose up -d` from the terminal and Dockge picks up the changes.

&lt;Picture
  src={imag1}
  alt=&quot;Dockge UI showing compose stack list&quot;
/&gt;

Here&apos;s what you get:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Create, edit, start, stop, restart, and delete compose stacks from a web UI&lt;/li&gt;
&lt;li&gt;Interactive compose.yaml editor with real-time validation&lt;/li&gt;
&lt;li&gt;Web terminal for exec-ing into running containers&lt;/li&gt;
&lt;li&gt;Real-time progress tracking for image pulls and stack operations&lt;/li&gt;
&lt;li&gt;Convert docker run commands into compose YAML&lt;/li&gt;
&lt;li&gt;Multi-agent support for managing stacks on remote Docker hosts&lt;/li&gt;
&lt;li&gt;File-based storage, your compose files live on disk in standard format&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

What Dockge doesn&apos;t have: vulnerability scanning, RBAC, OIDC/SSO, REST API, GitOps, or auto-updates. If you need any of those, check out [Arcane](/arcane-docker-install/) (GitOps + API), [Dockhand](/dockhand-docker-install/) (security scanning + auto-updates), or [UsulNet](/usulnet-docker-install/) (everything in one binary). My [Arcane vs Dockhand](/arcane-vs-dockhand/) comparison covers the differences between those two.

&lt;Picture
  src={imag2}
  alt=&quot;Dockge container details and logs view&quot;
/&gt;

For monitoring your server resources alongside Dockge, take a look at [server monitoring tools](https://www.bitdoze.com/sever-monitoring/).

&lt;YouTubeEmbed url=&quot;https://www.youtube.com/embed/ouyOyAqRDyI&quot; label=&quot;Dockge Install&quot; /&gt;

## Prerequisites

Before you start:

- A Linux server (VPS or local). I use [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting
- Docker 20+ and Docker Compose v2 installed
- Works on amd64, arm64, and armv7 (Raspberry Pi included)

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

Or use a [Mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/).

### Install Docker

If Docker isn&apos;t installed yet:

```sh
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  jammy stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose
```

Full walkthrough: [Install Docker &amp; Docker-compose for Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/).

## Install Dockge with Docker Compose

### Quick install

The fastest way to get Dockge running:

```sh
mkdir -p /opt/stacks /opt/dockge
cd /opt/dockge

curl https://raw.githubusercontent.com/louislam/dockge/master/compose.yaml --output compose.yaml

docker compose up -d
```

That downloads the compose file, creates the stack directory, and starts Dockge. Open `http://your-server-ip:5001` to set up your admin account.

### Custom install

If you want to change the port or stacks directory, use the generator URL:

```sh
mkdir -p /opt/stacks /opt/dockge
cd /opt/dockge

curl &quot;https://dockge.kuma.pet/compose.yaml?port=5001&amp;stacksPath=/opt/stacks&quot; --output compose.yaml

docker compose up -d
```

Change `port` and `stacksPath` to whatever you need. Or just create the compose file manually:

```yaml
services:
  dockge:
    image: louislam/dockge:1
    restart: unless-stopped
    ports:
      - 5001:5001
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/app/data
      - /opt/stacks:/opt/stacks
    environment:
      - DOCKGE_STACKS_DIR=/opt/stacks
```

&lt;Notice type=&quot;warning&quot; title=&quot;Stacks path must match&quot;&gt;
The left and right side of the stacks volume mount must be identical. If your stacks live at `/opt/stacks`, mount it as `/opt/stacks:/opt/stacks`, not `/opt/stacks:/some/other/path`. Compose files use relative paths, and they break if the paths don&apos;t match inside and outside the container.
&lt;/Notice&gt;

### First login

Open `http://your-server-ip:5001` in your browser. Dockge prompts you to create your admin account on first visit. After that, you land on the main dashboard where you can start deploying compose stacks.

&lt;Picture
  src={imag3}
  alt=&quot;Dockge create new compose stack&quot;
/&gt;

## Updating Dockge

Pull the latest image and restart:

```sh
cd /opt/dockge
docker compose pull &amp;&amp; docker compose up -d
```

Your data and stacks are preserved since they live in mounted volumes.

## Reverse proxy setup

For SSL and a proper domain, put a reverse proxy in front of Dockge. WebSocket support is needed for the real-time updates and web terminal.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Nginx&quot;&gt;

```nginx
server {
    listen 443 ssl http2;
    server_name dockge.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:5001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Traefik&quot;&gt;
    Add labels to the Dockge service in your compose file:

    ```yaml
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.dockge.rule=Host(`dockge.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.dockge.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.dockge.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.dockge.loadbalancer.server.port=5001&quot;
    ```

    Full Traefik setup: [How to use Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/). For wildcard SSL certificates with Dockge and Traefik, see [Traefik wildcard certificate setup](/traefik-wildcard-certificate/).
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Cloudflare Tunnels&quot;&gt;
    Point a tunnel at `http://localhost:5001`. SSL and WebSocket handling are automatic. No ports to open on your server. This is my preferred setup for homelab use.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

I also have a guide on [setting up Dockge with CloudPanel](/cloudpanel-setup-dockge/) if you&apos;re using that panel.

## Multi-agent setup

Since v1.4, Dockge can manage stacks on remote Docker hosts. You run a Dockge agent on each remote machine, and they all show up in the main Dockge UI.

On the remote machine, deploy the agent:

```yaml
services:
  dockge-agent:
    image: louislam/dockge:1
    restart: unless-stopped
    ports:
      - 5001:5001
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./data:/app/data
      - /opt/stacks:/opt/stacks
    environment:
      - DOCKGE_STACKS_DIR=/opt/stacks
```

Then in your main Dockge instance, go to the agents page and add the remote host by its address and port. Stacks from that host appear in your dashboard alongside your local stacks.

## Dockge vs other Docker managers

I&apos;ve tested a lot of Docker management tools. Here&apos;s where Dockge fits:

| | Dockge | Arcane | Dockhand |
|---|---|---|---|
| Focus | Compose UI | Full Docker mgmt | Security-focused |
| Complexity | Minimal | Medium | Medium |
| Vuln scanning | No | No | Yes |
| OIDC/SSO | No | Yes | Yes |
| GitOps | No | Yes | Webhooks |
| Auto-updates | No | No | Yes + rollback |
| API | No | REST | REST |
| License | MIT | BSD-3-Clause | BSL 1.1 |

Dockge is the right tool when you don&apos;t need all the extras. I use it on my homelab where I just want to see my stacks, check logs, and restart things when they break. On production servers where I need GitOps and API access, I run [Arcane](/arcane-docker-install/) instead.

For a full comparison of all the options, see my [Portainer alternatives](/portainer-alternatives/) article.

## Troubleshooting

&lt;Accordion label=&quot;Dockge can&apos;t see existing compose stacks&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

Your stacks need to be in the directory you configured with `DOCKGE_STACKS_DIR`. Each stack should be in its own subdirectory with a `compose.yaml` file. The directory structure should look like:

```
/opt/stacks/
├── myapp/
│   └── compose.yaml
├── another-app/
│   └── compose.yaml
```

Also make sure the volume mount paths match on both sides, as mentioned in the install section.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;WebSocket errors or UI not updating&quot; group=&quot;troubleshoot&quot;&gt;

Your reverse proxy isn&apos;t forwarding WebSocket connections. For Nginx, you need:

```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection &quot;upgrade&quot;;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission denied on Docker socket&quot; group=&quot;troubleshoot&quot;&gt;

The container needs access to the Docker socket. Make sure the user running Docker has the right permissions:

```bash
sudo usermod -aG docker $USER
```

Then log out and back in, or restart the Docker daemon.

&lt;/Accordion&gt;

## Related articles

- [Best Portainer alternatives in 2026](/portainer-alternatives/) - five Docker management UIs compared
- [Install Arcane](/arcane-docker-install/) - Docker manager with GitOps and REST API
- [Install Dockhand](/dockhand-docker-install/) - security-focused Docker manager with scanning
- [Arcane vs Dockhand](/arcane-vs-dockhand/) - side-by-side comparison
- [Install UsulNet](/usulnet-docker-install/) - all-in-one Docker management platform
- [Dockge with CloudPanel](/cloudpanel-setup-dockge/) - using both tools together
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run with Dockge
- [Mount an S3 bucket as a filesystem](/s3-bucket-filesystem-vps/) - cheap bucket-backed volumes for your stacks
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Traefik reverse proxy for Docker](/traefik-proxy-docker/) - reverse proxy setup
- [Traefik wildcard certificate](/traefik-wildcard-certificate/) - wildcard SSL with Traefik and Dockge
- [Server monitoring tools](/sever-monitoring/) - monitoring your Docker host</content:encoded><category>hosting</category><category>docker</category><category>self-hosted</category></item><item><title>Best Portainer Alternatives in 2026</title><link>https://www.bitdoze.com/portainer-alternatives/</link><guid isPermaLink="true">https://www.bitdoze.com/portainer-alternatives/</guid><description>Five Docker management UIs that can replace Portainer in 2026. Hands-on look at Arcane, Dockhand, UsulNet, Dockge, and Komodo with feature comparisons and UI screenshots.</description><pubDate>Fri, 13 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import arcaneUi from &quot;../../assets/images/26/02/arcane-ui.webp&quot;;
import dockhandUi from &quot;../../assets/images/26/02/dockerhand-ui.webp&quot;;
import usulnetDashboard from &quot;../../assets/images/26/02/usulnet-dashboard.png&quot;;
import dockgeUi from &quot;../../assets/images/24/01/dockge-main.png&quot;;
import komodoUi from &quot;../../assets/images/26/02/komodo-ui.png&quot;;

Portainer has been the go-to Docker GUI for years. It got the job done, but the licensing changes over time pushed useful features behind paid tiers. OIDC/SSO, RBAC, and other things that used to be free now require a business license. That shift drove a lot of people to look for alternatives, myself included.

I run Docker on multiple servers, both VPS and homelab. Over the past few months, I tested five tools that work as Portainer replacements. I installed all of these, ran them for weeks, and have individual install guides for four of them.

## Quick comparison

| | Arcane | Dockhand | UsulNet | Dockge | Komodo |
|---|---|---|---|---|---|
| License | BSD-3-Clause | BSL 1.1 | AGPL-3.0 | MIT | GPL-3.0 |
| Backend | Go | Bun + SvelteKit | Go | Node.js | Rust |
| Vuln scanning | No | Grype/Trivy | Trivy | No | No |
| Multi-node | Yes | Yes (Hawser) | Yes (NATS) | Yes (agents) | Yes (Periphery) |
| OIDC/SSO | Free | Free | Business tier | No | OAuth (GitHub/Google) |
| GitOps | Built-in | Webhooks | Auto-deploy on push | No | Auto-deploy on push |
| Auto-updates | No | Yes + rollback | No | No | Yes |
| RBAC | No | Enterprise | 44+ permissions | No | Granular |
| API | REST | REST | REST + WebSocket | No | REST + WebSocket |
| Pricing | Free | Free / $499 SMB | Free CE / EUR79 Biz | Free | Free |

## 1. Arcane

[Arcane](/arcane-docker-install/) is written in Go and runs as a single container. It&apos;s the tool I reach for first when setting up a new server because it&apos;s fast, has a small memory footprint, and the GitOps integration works without any external dependencies.

&lt;Picture
  src={arcaneUi}
  alt=&quot;Arcane Docker Manager UI showing container list and compose editor&quot;
/&gt;

Arcane has been around since 2022 with 4,400+ GitHub stars, 35 contributors, and 1,800+ commits. When something breaks at 2am, that contributor count matters.

What I like about it:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;GitOps that actually works. Point it at a repo, push changes, stacks redeploy automatically&lt;/li&gt;
&lt;li&gt;REST API you can script against, plus a CLI tool for terminal-based management&lt;/li&gt;
&lt;li&gt;OIDC/SSO support included for free&lt;/li&gt;
&lt;li&gt;Remote host management via the arcane-headless agent&lt;/li&gt;
&lt;li&gt;BSD-3-Clause license. No paid tiers, no feature locks&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

What it&apos;s missing: vulnerability scanning and scheduled auto-updates with rollback. If those matter to you, Dockhand fills that gap.

For a comparison between these two, check out my [Arcane vs Dockhand](/arcane-vs-dockhand/) article. And for a full setup walkthrough, here&apos;s the [Arcane install guide](/arcane-docker-install/).

## 2. Dockhand

[Dockhand](/dockhand-docker-install/) has better security tooling than anything else on this list, and it&apos;s free. It shipped its first release in December 2025 and has been putting out updates at an unusual pace since then.

&lt;Picture
  src={dockhandUi}
  alt=&quot;Dockhand Docker Manager UI showing container dashboard with security scanning&quot;
/&gt;

The best part is safe-pull protection. When Dockhand auto-updates a container, it pulls the new image, scans it for vulnerabilities using Grype or Trivy, and only swaps it in if the scan passes. If the new image has problems, your running container stays untouched. I had this catch two bad updates in three weeks of testing.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Vulnerability scanning built into the UI, free tier&lt;/li&gt;
&lt;li&gt;Scheduled auto-updates with automatic rollback on failure&lt;/li&gt;
&lt;li&gt;OIDC/SSO included in the free tier (Portainer charges for this)&lt;/li&gt;
&lt;li&gt;Container file browser and web terminal&lt;/li&gt;
&lt;li&gt;Hawser agent for remote hosts with NAT traversal&lt;/li&gt;
&lt;li&gt;Zero telemetry&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The BSL 1.1 license means you can use it freely for personal and internal business purposes. You can&apos;t resell it as a hosted service.

Dockhand is newer than Arcane, with fewer commits and contributors. It&apos;s polished for a v1, but there will be edge cases it hasn&apos;t hit yet. Full setup walkthrough: [Dockhand install guide](/dockhand-docker-install/).

## 3. UsulNet

[UsulNet](/usulnet-docker-install/) tries to be the entire management stack in one binary. Container management, security scanning, reverse proxy config, backups, monitoring, multi-node orchestration. It&apos;s ambitious for a project that just shipped its first public beta in February 2026.

&lt;Picture
  src={usulnetDashboard}
  alt=&quot;UsulNet dashboard showing container status, resource utilization, and security score&quot;
/&gt;

The feature list is long:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Trivy scanning with per-container security scores (0-100) and SBOM generation&lt;/li&gt;
&lt;li&gt;RBAC with 44+ granular permissions and custom roles&lt;/li&gt;
&lt;li&gt;Scheduled backups to S3, Azure Blob, GCS, Backblaze B2, SFTP&lt;/li&gt;
&lt;li&gt;Caddy and Nginx Proxy Manager integration for reverse proxy&lt;/li&gt;
&lt;li&gt;Multi-node with NATS messaging and mTLS&lt;/li&gt;
&lt;li&gt;Monaco code editor and Neovim running in the browser&lt;/li&gt;
&lt;li&gt;Database browser for PostgreSQL, MySQL, MongoDB, Redis, SQLite&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The trade-off is maturity. One developer, eleven commits, first release. The four-container stack (PostgreSQL, Redis, NATS, plus the app) is heavier than the single-container approach of Arcane or Dockhand. On a small VPS with 2 GB RAM, that overhead is noticeable.

UsulNet&apos;s Community Edition is limited to 2 nodes and 3 users. OIDC and LDAP require the Business license (EUR79/node/year). Full setup walkthrough: [UsulNet install guide](/usulnet-docker-install/).

## 4. Dockge

[Dockge](/dockge-install/) comes from Louis Lam, the same developer behind Uptime Kuma. It takes a fundamentally different approach from everything else on this list. Dockge is not trying to replace Portainer feature-for-feature. It&apos;s a compose-file manager with a clean UI.

&lt;Picture
  src={dockgeUi}
  alt=&quot;Dockge UI showing compose stack management&quot;
/&gt;

If all you need is a visual way to manage docker-compose stacks, deploy new ones, view logs, and use a web terminal, Dockge does that well without the complexity of the other options. It stores your compose files on disk in standard format, so you can still manage them with normal Docker Compose commands. Nothing gets locked inside a proprietary database.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Clean, single-page UI for compose management&lt;/li&gt;
&lt;li&gt;Interactive compose editor with real-time progress tracking&lt;/li&gt;
&lt;li&gt;Convert docker run commands to compose YAML&lt;/li&gt;
&lt;li&gt;Multi-agent support for managing stacks across servers&lt;/li&gt;
&lt;li&gt;MIT licensed, fully open source&lt;/li&gt;
&lt;li&gt;Lightweight Node.js backend&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Dockge doesn&apos;t have vulnerability scanning, RBAC, OIDC, or a REST API. And that&apos;s fine. Not every server needs a full-featured management platform. Sometimes you just want to see your stacks, restart a container, and check the logs.

With nearly 15,000 GitHub stars, Dockge has one of the largest communities in this space. Full setup walkthrough: [Dockge install guide](/dockge-install/).

## 5. Komodo

[Komodo](https://github.com/moghtech/komodo) has the most engineering behind it on this list. Written in Rust, it has 2,800+ commits, 10,200+ GitHub stars, and a core/periphery architecture designed for managing Docker across many servers.

&lt;Picture
  src={komodoUi}
  alt=&quot;Komodo dashboard showing server status and deployment overview&quot;
/&gt;

Komodo is not just a container manager, it&apos;s a build and deployment system. You can build auto-versioned Docker images from Git repos, trigger builds on push, deploy containers and compose stacks across your servers, and manage environment variables and secrets with shared interpolation.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Rust backend with a Periphery agent on each server&lt;/li&gt;
&lt;li&gt;Build Docker images from Git repos with auto-versioning&lt;/li&gt;
&lt;li&gt;Deploy compose stacks from UI or Git with auto-deploy on push&lt;/li&gt;
&lt;li&gt;Granular permissioning system for multi-user teams&lt;/li&gt;
&lt;li&gt;REST and WebSocket API with Rust and npm client libraries&lt;/li&gt;
&lt;li&gt;OAuth sign-on with GitHub and Google&lt;/li&gt;
&lt;li&gt;Server resource monitoring with CPU, memory, and disk alerts&lt;/li&gt;
&lt;li&gt;GPL-3.0 license, no paid tiers, no limits on servers&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

There is no limit to the number of servers you can connect, and the developers have stated there never will be. No business edition, no feature gating. Everything is free.

Komodo uses MongoDB (or FerretDB with Postgres) as its database. The Periphery agent runs on each connected server and exposes a local API that only the Core can call, with address whitelisting for security.

The project has an active Discord community and a demo you can try at [demo.komo.do](https://demo.komo.do). If you manage multiple servers and need build pipelines alongside deployment, Komodo fits that use case better than anything else here.

## Which one should you pick?

&lt;Accordion label=&quot;You want the most mature, no-strings-attached option&quot; group=&quot;pick&quot; expanded=&quot;true&quot;&gt;

Go with **Arcane**. BSD-3-Clause license, been around since 2022, active community, GitOps built in. It does container management well and doesn&apos;t try to be everything. [Install Arcane](/arcane-docker-install/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Security scanning is a requirement&quot; group=&quot;pick&quot;&gt;

**Dockhand** is the pick. Vulnerability scanning with Grype/Trivy, safe-pull protection for auto-updates, OIDC free. The BSL license is fine for self-hosting. [Install Dockhand](/dockhand-docker-install/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;You want everything in one tool&quot; group=&quot;pick&quot;&gt;

**UsulNet** bundles the most features: scanning, backups, reverse proxy, monitoring, RBAC, multi-node. It&apos;s beta software from a solo developer, so set expectations accordingly. [Install UsulNet](/usulnet-docker-install/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;You just need a simple compose UI&quot; group=&quot;pick&quot;&gt;

**Dockge** is lightweight, easy to install, and does compose management without complexity. No scanning, no RBAC, no OIDC. Just a clean interface for your stacks. [Install Dockge](/dockge-install/).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;You manage many servers and need build pipelines&quot; group=&quot;pick&quot;&gt;

**Komodo** is built for fleet management. Rust backend, Periphery agents, build pipelines from Git, granular permissions, unlimited servers. [See the Komodo docs](https://komo.do).

&lt;/Accordion&gt;

I run Arcane on my production servers and Dockge on my homelab. Arcane for the GitOps workflow and API access, Dockge for quick visual management when I just want to spin something up. Both have been reliable.

## Related articles

- [Install Arcane](/arcane-docker-install/) - full setup guide with socket proxy and OIDC
- [Install Dockhand](/dockhand-docker-install/) - security-focused Docker manager
- [Arcane vs Dockhand](/arcane-vs-dockhand/) - side-by-side comparison
- [Install UsulNet](/usulnet-docker-install/) - all-in-one Docker management platform
- [Install Dockge](/dockge-install/) - lightweight compose management UI
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run once your manager is set up
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Traefik reverse proxy for Docker](/traefik-proxy-docker/) - proper reverse proxy setup</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>UsulNet Docker Install: All-in-One Container Management Platform</title><link>https://www.bitdoze.com/usulnet-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/usulnet-docker-install/</guid><description>Step-by-step guide to install UsulNet with Docker Compose. Covers production setup with PostgreSQL, Redis, NATS, security scanning, multi-node deployment, reverse proxy, and RBAC configuration.</description><pubDate>Wed, 11 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import usulnetDashboard from &quot;../../assets/images/26/02/usulnet-dashboard.png&quot;;
import usulnetContainers from &quot;../../assets/images/26/02/usulnet-containers.png&quot;;
import usulnetSecurity from &quot;../../assets/images/26/02/usulnet-security.png&quot;;
import usulnetStacks from &quot;../../assets/images/26/02/usulnet-stacks.png&quot;;
import usulnetNodes from &quot;../../assets/images/26/02/usulnet-nodes.png&quot;;

I stumbled on [UsulNet](https://usulnet.com/) while browsing GitHub for Docker management tools. It caught my eye because it tries to be everything in one binary: container management, security scanning, reverse proxy config, backups, monitoring, multi-node orchestration. That&apos;s a long feature list for a project that just shipped its first public beta (v26.2.0) in February 2026.

I&apos;ve been writing about tools in this space recently, including [Arcane](/arcane-docker-install/) and [Dockhand](/dockhand-docker-install/). UsulNet takes a different approach from both. Where Arcane focuses on being a clean, open-source Docker UI with GitOps, and Dockhand leans into security scanning and auto-updates, UsulNet wants to replace your entire stack of management tools. Whether it actually pulls that off is a fair question, but the ambition is real.

## What UsulNet does

UsulNet is a self-hosted Docker management platform written in Go. It compiles down to a single ~50 MB binary with no runtime dependencies. No Node.js, no Python, no heavy frontend framework. The UI is server-rendered HTML using Templ templates, Tailwind CSS, Alpine.js, and HTMX.

&lt;Picture
  src={usulnetDashboard}
  alt=&quot;UsulNet dashboard showing container status, resource utilization, and security score&quot;
/&gt;

Here&apos;s what you get out of the box:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Full container lifecycle management with bulk operations, stats, exec, filesystem browser&lt;/li&gt;
&lt;li&gt;Docker Compose stack deployment with a built-in template catalog&lt;/li&gt;
&lt;li&gt;Trivy vulnerability scanning with security scoring (0-100 per container)&lt;/li&gt;
&lt;li&gt;SBOM generation in CycloneDX and SPDX formats&lt;/li&gt;
&lt;li&gt;RBAC with 44+ granular permissions and custom roles&lt;/li&gt;
&lt;li&gt;2FA/TOTP, LDAP, OIDC authentication&lt;/li&gt;
&lt;li&gt;Monitoring with alert rules and 11 notification channels&lt;/li&gt;
&lt;li&gt;Scheduled backups to S3, local, Azure Blob, GCS, Backblaze B2, SFTP&lt;/li&gt;
&lt;li&gt;Caddy and Nginx Proxy Manager integration for reverse proxy&lt;/li&gt;
&lt;li&gt;Multi-node master/agent architecture with NATS messaging and mTLS&lt;/li&gt;
&lt;li&gt;Monaco code editor and Neovim running in the browser&lt;/li&gt;
&lt;li&gt;SSH connections, RDP, database browser, LDAP browser, Git integration&lt;/li&gt;
&lt;li&gt;REST API with OpenAPI 3.0 docs and WebSocket streams&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

That list is long, and I&apos;ll be honest, I haven&apos;t tested every single feature. The core container management, stack deployment, and security scanning work well. Some of the more niche features like the in-browser Neovim and RDP connections feel like bonus items that may or may not matter to you.

## How UsulNet compares to other Docker managers

If you&apos;re coming from [Arcane](/arcane-docker-install/) or [Dockhand](/dockhand-docker-install/), or looking at the [Arcane vs Dockhand comparison](/arcane-vs-dockhand/), here&apos;s how UsulNet fits in:

| | UsulNet | Arcane | Dockhand |
|---|---|---|---|
| License | AGPL-3.0 | BSD-3-Clause | BSL 1.1 |
| Backend | Go (single binary) | Go | Bun + SvelteKit |
| Frontend | Templ + HTMX + Alpine.js | SvelteKit | SvelteKit 2 / Svelte 5 |
| Vuln scanning | Trivy (built-in) | No | Grype/Trivy |
| RBAC | Yes (44+ permissions) | No | Enterprise tier only |
| Multi-node | NATS + mTLS agents | arcane-headless | Hawser |
| Reverse proxy | Caddy + NPM integration | No | No |
| Backups | S3, local, Azure, GCS, B2, SFTP | No | No |
| Monitoring/alerts | Built-in with 11 notification channels | No | No |
| Database browser | PostgreSQL, MySQL, MongoDB, Redis, SQLite | No | No |
| Code editor | Monaco + Neovim | No | No |
| GitOps | Auto-deploy on Git push | Built-in GitOps | Git + webhooks |
| Docker Swarm | Yes | No | No |
| Status | Beta (v26.2.0, first release) | Stable (since 2022) | v1 (since Dec 2025) |
| Pricing | Free CE (2 nodes, 3 users), Business from EUR79/node/yr | Free | Free homelab, SMB $499/host/yr |

UsulNet has the widest feature set of the three, but it&apos;s also the newest. Arcane has been around since 2022 with 1,800+ commits and 35 contributors. UsulNet has 11 commits and one developer. That gap matters when you hit an edge case at 2am.

&lt;Notice type=&quot;info&quot; title=&quot;Beta software&quot;&gt;
UsulNet is in public beta. It&apos;s functional, but expect rough edges. The developer is actively shipping updates, but this is a solo project right now. If you need something battle-tested, look at [Arcane](/arcane-docker-install/) first.
&lt;/Notice&gt;

## Prerequisites

Before you start, you need:

- A Linux server (VPS or local machine). I recommend [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting
- Docker and Docker Compose v2 installed
- At least 2 GB RAM (4 GB recommended)
- Ports 8080 (HTTP) and 7443 (HTTPS) available

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

Or use a [Mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/).

### Install Docker

If you don&apos;t have Docker yet:

```sh
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  jammy stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose
```

Full walkthrough: [Install Docker &amp; Docker-compose for Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/).

## Install UsulNet with Docker Compose

UsulNet needs PostgreSQL, Redis, and NATS alongside the main application. That&apos;s more moving parts than Arcane (which runs as a single container) or Dockhand (single container with optional PostgreSQL). The trade-off is that you get proper session management, caching, and inter-node messaging baked in.

### Quick install (one command)

The fastest way to get running:

```bash
curl -fsSL https://raw.githubusercontent.com/fr4nsys/usulnet/main/deploy/install.sh | bash
```

This downloads the production compose file, generates all the secrets automatically, and starts everything. You&apos;ll be up in about 60 seconds. Access it at `https://your-server-ip:7443` with default credentials `admin` / `usulnet`.

I&apos;d still recommend the manual method below so you know what&apos;s in the compose file and can customize it.

### Manual Docker Compose install (recommended)

Create a directory for UsulNet:

```bash
mkdir -p /opt/usulnet &amp;&amp; cd /opt/usulnet
```

Download the production compose file and environment template:

```bash
curl -fsSL https://raw.githubusercontent.com/fr4nsys/usulnet/main/deploy/docker-compose.prod.yml -o docker-compose.yml
curl -fsSL https://raw.githubusercontent.com/fr4nsys/usulnet/main/deploy/.env.example -o .env
```

Generate the secrets. You need a database password, a JWT secret, and an encryption key:

```bash
sed -i &quot;s|CHANGE_ME_GENERATE_RANDOM_PASSWORD|$(openssl rand -base64 24 | tr -dc &apos;a-zA-Z0-9&apos; | head -c 32)|&quot; .env
sed -i &quot;s|CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_HEX_32|$(openssl rand -hex 32)|&quot; .env
sed -i &quot;s|CHANGE_ME_GENERATE_WITH_OPENSSL_RAND_HEX_32|$(openssl rand -hex 32)|&quot; .env
```

Or open the `.env` file and fill in the values manually. Your choice.

Start everything:

```bash
docker compose up -d
```

Open `https://your-server-ip:7443` in your browser. UsulNet generates a self-signed TLS certificate on first start, so you&apos;ll get a browser warning. Log in with `admin` / `usulnet` and change the password right away.

&lt;Notice type=&quot;warning&quot; title=&quot;Change default credentials&quot;&gt;
The default login is `admin` / `usulnet`. Change the password immediately after first login. Go to your profile settings to update it. Also enable 2FA while you&apos;re there.
&lt;/Notice&gt;

### What the compose file includes

Here&apos;s what gets deployed:

```yaml
services:
  usulnet:
    image: ghcr.io/fr4nsys/usulnet:latest
    ports:
      - &quot;8080:8080&quot;    # HTTP
      - &quot;7443:7443&quot;    # HTTPS (auto-TLS)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - usulnet-data:/var/lib/usulnet
    environment:
      - USULNET_DATABASE_URL=postgres://usulnet:secret@postgres:5432/usulnet?sslmode=disable
      - USULNET_REDIS_URL=redis://redis:6379/0
      - USULNET_NATS_URL=nats://nats:4222
      - USULNET_SECURITY_JWT_SECRET=your-secret-key-min-32-chars-long
      - USULNET_SECURITY_CONFIG_ENCRYPTION_KEY=your-64-hex-char-aes-256-key-here
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
      nats:
        condition: service_started
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: usulnet
      POSTGRES_USER: usulnet
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U usulnet&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
    volumes:
      - redis-data:/data
    restart: unless-stopped

  nats:
    image: nats:2.10-alpine
    command: [&quot;--jetstream&quot;, &quot;--store_dir&quot;, &quot;/data&quot;]
    volumes:
      - nats-data:/data
    restart: unless-stopped

volumes:
  usulnet-data:
  postgres-data:
  redis-data:
  nats-data:
```

Four containers total. PostgreSQL stores all the application data, Redis handles sessions and caching, NATS provides the messaging layer for multi-node communication (even in standalone mode, UsulNet expects it to be there).

&lt;Notice type=&quot;info&quot; title=&quot;Docker socket is read-only&quot;&gt;
Notice the Docker socket is mounted as `:ro` (read-only). UsulNet still works fine for container management with a read-only socket mount because the Docker API handles write operations through the socket regardless of the mount flag. The `:ro` flag just prevents UsulNet from modifying the socket file itself.
&lt;/Notice&gt;

## Container and stack management

The container management works about how you&apos;d expect. You get a list of all running containers with real-time CPU and memory stats, and you can start, stop, restart, pause, kill, or remove them individually or in bulk.

&lt;Picture
  src={usulnetContainers}
  alt=&quot;UsulNet container management interface showing container list with real-time stats&quot;
/&gt;

What I found more interesting is the stack deployment. You can deploy compose stacks three ways:

&lt;Tabs&gt;
  &lt;Tab name=&quot;From YAML&quot;&gt;
    Paste or write a compose file directly in the web editor. UsulNet validates the YAML before deploying. The editor is Monaco (same one VS Code uses), so you get syntax highlighting and autocomplete.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;From Git&quot;&gt;
    Connect a Git repository (Gitea, GitHub, or GitLab) and deploy stacks from compose files in the repo. You can set up auto-deploy rules that redeploy when you push changes to a specific branch.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;From catalog&quot;&gt;
    UsulNet has a built-in stack catalog with pre-configured templates for common applications. Pick one, adjust the settings, and deploy. Good for quickly spinning up something you want to test.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Picture
  src={usulnetStacks}
  alt=&quot;UsulNet stack deployment interface with compose editor and template catalog&quot;
/&gt;

The container filesystem browser and web terminal both work. You can browse files inside running containers, edit them with the Monaco editor, and open exec sessions. The terminal uses xterm.js, same library most web-based terminals use. It&apos;s responsive enough for interactive work.

## Security scanning with Trivy

UsulNet integrates Trivy for vulnerability scanning. This runs locally on your server and doesn&apos;t send data to external services.

&lt;Picture
  src={usulnetSecurity}
  alt=&quot;UsulNet security scanning interface showing vulnerability results and security score&quot;
/&gt;

To enable it, make sure Trivy is configured in your `config.yaml` or via environment variables:

```yaml
trivy:
  enabled: true
  cache_dir: /var/lib/usulnet/trivy
  timeout: 5m
  severity: CRITICAL,HIGH,MEDIUM
  ignore_unfixed: false
  update_db_on_start: true
```

Or set `USULNET_TRIVY_ENABLED=true` in your environment.

What sets this apart from Dockhand&apos;s scanning is the security scoring. Each container gets a 0-100 score based on the scan results, and you get an aggregate score across your entire infrastructure. There&apos;s trend tracking too, so you can see if your security posture is improving or degrading over time.

UsulNet also generates SBOMs (Software Bill of Materials) in CycloneDX and SPDX formats. If you need to document what&apos;s running in your containers for compliance purposes, that&apos;s built in.

&lt;Accordion label=&quot;First scan takes a while&quot; group=&quot;scanning&quot; expanded=&quot;true&quot;&gt;

The first vulnerability scan downloads the Trivy database, which is a few hundred MB. Subsequent scans are faster because the database is cached locally. If the first scan times out, increase the `timeout` value in the Trivy config.

&lt;/Accordion&gt;

## Setting up authentication

UsulNet supports multiple authentication methods. The free Community Edition includes TOTP 2FA. OIDC and LDAP require the Business license.

### 2FA/TOTP (free)

Every user can enable TOTP-based two-factor authentication from their profile settings. It works with any authenticator app (Google Authenticator, Authy, etc.). Backup codes are generated so you don&apos;t get locked out.

### OIDC/OAuth2 (Business license)

If you run an identity provider like Authentik, Keycloak, or want to use GitHub/Google/Microsoft login, configure OIDC in the settings. You&apos;ll need:

- Client ID
- Client secret
- Issuer URL / Discovery endpoint

Users are auto-provisioned on first OIDC login. You can map OIDC groups to UsulNet roles for automatic permission assignment.

### LDAP/Active Directory (Business license)

For enterprise environments, UsulNet can authenticate against LDAP directories. Configure the LDAP provider with your bind DN, search base, and attribute mappings. There&apos;s also a built-in LDAP browser for testing and debugging your directory setup.

## Multi-node management

UsulNet uses a master/agent architecture for managing Docker across multiple hosts. The communication layer is NATS with JetStream for persistence, and all agent-master traffic is encrypted with mTLS.

&lt;Picture
  src={usulnetNodes}
  alt=&quot;UsulNet multi-node management showing connected agent nodes&quot;
/&gt;

There are three modes:

- **standalone**: single-node, the default
- **master**: control plane that manages agents
- **agent**: worker node that connects to a master

### Deploying agents

You can deploy agents to remote hosts directly from the web UI. Go to **Nodes &gt; Add Node**, enter the SSH credentials for the remote machine, and click **Deploy Agent**. UsulNet SSHs into the remote host, installs the agent container, and configures it automatically.

Or deploy manually on the remote machine:

```yaml
# config.yaml on the agent
mode: agent
agent:
  master_url: nats://master-nats:4222
  name: worker-01
  token: your-auth-token
  heartbeat_interval: 30s
  metrics_interval: 1m
```

The agent sends heartbeats and metrics at configurable intervals. If an agent goes offline, the dashboard shows the status change. You can switch between managed hosts from any page in the UI.

This is different from how Arcane and Dockhand handle multi-node. Arcane uses `arcane-headless`, a lightweight agent that connects outbound. Dockhand uses Hawser, which has NAT traversal. UsulNet&apos;s NATS-based approach is more involved to set up but gives you persistent messaging and better reliability for larger deployments.

## Reverse proxy integration

This is something neither Arcane nor Dockhand offer. UsulNet can configure Caddy or Nginx Proxy Manager directly from its UI.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Caddy&quot;&gt;
    If you run Caddy, enable the integration in your config:

    ```yaml
    caddy:
      enabled: true
      admin_url: http://caddy:2019
      acme_email: admin@example.com
    ```

    You can then create proxy hosts, manage certificates, and configure routes from the UsulNet dashboard. Caddy handles automatic HTTPS with Let&apos;s Encrypt.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Nginx Proxy Manager&quot;&gt;
    Full Nginx Proxy Manager integration is available too. Manage proxy hosts, SSL certificates, redirections, TCP/UDP streams, and access lists, all from within UsulNet.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

If you want to put UsulNet itself behind a reverse proxy, it serves on port 8080 (HTTP) and 7443 (HTTPS) by default. WebSocket support is required for live logs, terminal, and real-time metrics.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Nginx&quot;&gt;

```nginx
server {
    listen 443 ssl http2;
    server_name usulnet.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Traefik&quot;&gt;
    Add labels to the UsulNet service:

    ```yaml
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.usulnet.rule=Host(`usulnet.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.usulnet.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.usulnet.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.usulnet.loadbalancer.server.port=8080&quot;
    ```

    Full Traefik setup: [How to use Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Cloudflare Tunnels&quot;&gt;
    Point a tunnel at `http://localhost:8080`. SSL and WebSocket support are handled automatically. No ports to open.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Backup configuration

UsulNet can back up individual containers, volumes, or entire stacks on a schedule. Storage backends include local filesystem, AWS S3, MinIO, Azure Blob, Google Cloud Storage, Backblaze B2, and SFTP.

Configure storage in your `config.yaml`:

```yaml
storage:
  type: s3
  s3:
    endpoint: s3.amazonaws.com
    bucket: usulnet-backups
    region: us-east-1
    access_key: YOUR_KEY
    secret_key: YOUR_SECRET
  backup:
    compression: zstd
    compression_level: 3
    default_retention_days: 30
```

Or use local storage:

```yaml
storage:
  type: local
  path: /var/lib/usulnet/backups
```

Backups are compressed with gzip or zstd (your choice) and can be restored with one click from the UI.

## Environment variables reference

All config values can be set via environment variables with the `USULNET_` prefix:

| Variable | Default | What it does |
|---|---|---|
| `USULNET_SERVER_PORT` | `8080` | HTTP port |
| `USULNET_SERVER_HTTPS_PORT` | `7443` | HTTPS port |
| `USULNET_DATABASE_URL` | none | PostgreSQL connection string (required) |
| `USULNET_REDIS_URL` | none | Redis connection string (required) |
| `USULNET_NATS_URL` | none | NATS connection string (required) |
| `USULNET_SECURITY_JWT_SECRET` | none | JWT signing secret, min 32 chars (required) |
| `USULNET_SECURITY_CONFIG_ENCRYPTION_KEY` | none | AES-256 key, 64 hex chars (required) |
| `USULNET_TRIVY_ENABLED` | `false` | Enable Trivy vulnerability scanning |
| `USULNET_MODE` | `standalone` | Operation mode: standalone, master, agent |
| `USULNET_SERVER_RATE_LIMIT_RPS` | `100` | Rate limit per second |

## Licensing and pricing

UsulNet is AGPL-3.0 licensed. The source code is on [GitHub](https://github.com/fr4nsys/usulnet) and you can use it, modify it, and distribute it freely. The catch with AGPL is that if you modify UsulNet and make it available over a network, you have to release your changes under the same license.

| Tier | Cost | What you get |
|---|---|---|
| Community (CE) | Free | Full Docker management, scanning, monitoring, 2 nodes, 3 users, 1 team |
| Business | EUR79/node/year | Unlimited nodes, OIDC, LDAP, custom roles, audit log export, API keys |
| Enterprise | Custom | Unlimited everything, SSO SAML, HA mode, white label, dedicated support |

The Community Edition is limited to 2 nodes, 3 users, and 1 team. OIDC and LDAP authentication require a Business license. That&apos;s more restrictive than Arcane (fully free, no limits) or Dockhand (free tier with OIDC included), but you also get a lot more features in the CE than either of those offer.

## Troubleshooting

&lt;Accordion label=&quot;UsulNet won&apos;t start - PostgreSQL connection errors&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

Make sure the database container is healthy before UsulNet tries to connect. The `depends_on` with `condition: service_healthy` in the compose file should handle this, but if you&apos;re starting services manually, PostgreSQL needs to be fully ready first.

Check PostgreSQL health:

```bash
docker exec usulnet-postgres pg_isready -U usulnet
```

If it returns &quot;accepting connections&quot;, the database is fine and the issue is likely in your connection string.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Self-signed certificate warnings&quot; group=&quot;troubleshoot&quot;&gt;

UsulNet generates a self-signed TLS certificate on first start for HTTPS on port 7443. Your browser will show a warning. For production, put a proper reverse proxy with Let&apos;s Encrypt certificates in front of it, or configure custom TLS certificates in the config:

```yaml
server:
  tls:
    enabled: true
    cert_file: /path/to/cert.pem
    key_file: /path/to/key.pem
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Trivy scans fail or timeout&quot; group=&quot;troubleshoot&quot;&gt;

The first scan downloads the vulnerability database, which can be large. Increase the timeout:

```yaml
trivy:
  timeout: 10m
```

Also verify the container has outbound internet access for downloading the database. If you&apos;re behind a proxy, configure the `HTTP_PROXY` and `HTTPS_PROXY` environment variables on the UsulNet container.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;NATS connection issues&quot; group=&quot;troubleshoot&quot;&gt;

If you see NATS connection errors in the logs, make sure the NATS container is running and accessible. The default JetStream data directory needs write permissions:

```bash
docker logs usulnet-nats
```

If NATS is running but UsulNet can&apos;t connect, check that both containers are on the same Docker network.

&lt;/Accordion&gt;

## My take after testing

UsulNet is ambitious. One developer, first public beta, and the feature list reads like a product that&apos;s been in development for years. The container management, stack deployment, and Trivy scanning work. The UI is clean and responsive, and the Go backend is fast.

What concerns me is the project&apos;s maturity. Eleven commits, one contributor, first release. Compare that to Arcane (1,800+ commits, 35 contributors, stable since 2022) and you see the gap. The AGPL license is also worth thinking about if you plan to modify the source, as you&apos;re obligated to share your changes.

The four-container stack (PostgreSQL, Redis, NATS, plus the app itself) is heavier than Arcane&apos;s single container or Dockhand&apos;s single container approach. On a small VPS with 2 GB RAM, that overhead is noticeable. On a machine with 4+ GB, it&apos;s not a big deal.

If you want the widest feature set in a Docker management tool and you&apos;re comfortable running beta software, UsulNet is worth trying. If you want stability and a proven track record, [Arcane](/arcane-docker-install/) is the safer pick. If security scanning is your priority and you want something more lightweight, check out [Dockhand](/dockhand-docker-install/).

## Related articles

- [Best Portainer alternatives in 2026](/portainer-alternatives/) - five Docker management UIs compared
- [Install Arcane](/arcane-docker-install/) - open-source Docker manager with GitOps
- [Install Dockhand](/dockhand-docker-install/) - security-focused Docker manager
- [Arcane vs Dockhand](/arcane-vs-dockhand/) - comparison of two other Docker management UIs
- [Install Dockge](/dockge-install/) - another Docker management UI
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run once your manager is set up
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Traefik reverse proxy for Docker](/traefik-proxy-docker/) - proper reverse proxy setup
- [Docker auto-update with Tugtainer](/tugtainer-docker-autoupdate/) - keep containers updated
- [Server monitoring tools](/sever-monitoring/) - monitoring your Docker host</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>How I Let My AI Assistant Deploy Docker Apps With a Single Message</title><link>https://www.bitdoze.com/ai-docker-deploy-skill/</link><guid isPermaLink="true">https://www.bitdoze.com/ai-docker-deploy-skill/</guid><description>How to create a docker-deploy skill for your AI assistant so it can install and configure Docker apps on your VPS using Caddy and Docker Compose, all from a chat message.</description><pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I got tired of SSH-ing into my VPS every time I wanted to try a new Docker app. Install this, write a compose file, add the Caddy config, restart, check if it works. The same steps every single time.

So I built a skill for my AI assistant. Now I open Telegram (or Discord, or Slack) and type something like &quot;install Plausible Analytics on my server.&quot; The assistant creates the folder, writes the compose file, updates Caddy for HTTPS, starts everything, and checks if the app responds. I don&apos;t touch the terminal at all.

This article walks through the setup that makes this work and gives you the skill file you can drop into your own AI assistant.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ty836MWWSak&quot;
  label=&quot;24/7 AI Deploys ANY Docker App While You Sleep&quot;
/&gt;


## What you need before starting

This setup has five pieces that need to be in place before the skill does anything useful:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;A VPS (I use [Hetzner CX22](https://go.bitdoze.com/hetzner) at about $4.50/month)&lt;/li&gt;
&lt;li&gt;Docker and Docker Compose installed on the VPS&lt;/li&gt;
&lt;li&gt;Caddy running as a Docker container for reverse proxy and automatic HTTPS&lt;/li&gt;
&lt;li&gt;A wildcard DNS record pointing `*.yourdomain.com` to your server IP&lt;/li&gt;
&lt;li&gt;A directory structure for your Docker stacks (like `/home/user/docker-apps`)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you already have all five, skip ahead to the skill file. If not, I&apos;ll cover each one.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

## The VPS

Any Linux VPS works. I&apos;ve been using Hetzner for years because it&apos;s cheap and the network is solid. A CX22 with 2 vCPUs and 4GB RAM handles Caddy plus a dozen Docker apps without issues. Ubuntu 24.04 is my go-to.

If you&apos;re new to VPS hosting, the [Hetzner cloud review](https://www.bitdoze.com/hetzner-cloud-review/) covers what you get and how to set one up. Or use a [mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/) if you&apos;d rather keep everything local.

## Install Docker

If Docker isn&apos;t on your server yet:

```sh
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
```

Full walkthrough: [Install Docker &amp; Docker-compose for Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/).

## Set up Caddy as your reverse proxy

Caddy handles HTTPS automatically. No certbot, no renewal crons, no nginx config files. It gets certificates from Let&apos;s Encrypt on its own and renews them before they expire.

I run Caddy as a Docker container inside the same directory structure where all my apps live.

### Create the directory structure

```sh
mkdir -p /home/$USER/docker-apps/caddy
cd /home/$USER/docker-apps/caddy
```

### Docker Compose for Caddy

Create a `docker-compose.yml` in the caddy directory:

```yaml
services:
  caddy:
    image: caddy:2-alpine
    container_name: caddy
    restart: unless-stopped
    ports:
      - &quot;80:80&quot;
      - &quot;443:443&quot;
      - &quot;443:443/udp&quot;
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - ./data:/data
      - ./config:/config
    networks:
      - caddy

networks:
  caddy:
    external: true
```

### Create the Docker network

All your apps will connect to this shared network so Caddy can reach them:

```sh
sudo docker network create caddy
```

### Create the Caddyfile

Create a `Caddyfile` in the same folder. Start with a placeholder:

```
# Apps will be added here by the AI assistant or manually
```

### Start Caddy

```sh
cd /home/$USER/docker-apps/caddy
sudo docker compose up -d
```

Caddy is running. Any app you add later just needs an entry in this Caddyfile and a connection to the `caddy` network.

## Set up wildcard DNS

You need a DNS record that sends all subdomains to your server. In your domain registrar or DNS provider (Cloudflare, etc.), add an A record:

| Type | Name | Value | TTL |
|------|------|-------|-----|
| A | `*` | `YOUR_SERVER_IP` | Auto |

If your domain is `example.com`, this means `anything.example.com` will resolve to your VPS. Caddy then handles routing each subdomain to the right container.

&lt;Notice type=&quot;info&quot; title=&quot;Cloudflare users&quot;&gt;
If you use Cloudflare, you can proxy the wildcard record (orange cloud) but you&apos;ll need to configure Caddy to work with Cloudflare&apos;s SSL mode. The simplest setup is to use &quot;DNS only&quot; (grey cloud) and let Caddy handle certificates directly.
&lt;/Notice&gt;

## The directory convention

Every app gets its own folder under `/home/$USER/docker-apps/`. The structure looks like this after a few apps:

```
/home/user/docker-apps/
  caddy/
    docker-compose.yml
    Caddyfile
    data/
    config/
  plausible/
    docker-compose.yml
    .env
  arcane/
    docker-compose.yml
    .env
  dockhand/
    docker-compose.yml
    .env
```

This keeps everything predictable. The AI assistant knows where to put files, and you know where to find them when you need to look at something manually.

## What the AI assistant does with all this

With the infrastructure in place, you can tell your AI assistant to deploy apps. I&apos;ve installed [Arcane](/arcane-docker-install/) and [Dockhand](/dockhand-docker-install/) this way. For context on those, I wrote a [comparison of the two](/arcane-vs-dockhand/).

The assistant follows the same workflow every time:

1. Creates a new folder under `/home/user/docker-apps/app-name/`
2. Writes a `docker-compose.yml` with local `./` volume paths (no named volumes)
3. Puts passwords and API keys in a `.env` file, not hardcoded in the compose file
4. Updates the Caddyfile in `/home/user/docker-apps/caddy/` with a new subdomain block
5. Starts the containers with `sudo docker compose up -d`
6. Reloads Caddy to pick up the new config
7. Runs a curl against the subdomain to confirm the app responds

It works because there&apos;s a skill file that tells the assistant exactly how this server is set up and what rules to follow. Without the skill, the assistant would guess at paths, might use named volumes, might skip the reverse proxy, or put secrets directly in the compose file.

## The skill file

This is the file that teaches your AI assistant how your server works. You need to change two things in it before using it:

1. **The path** - replace `/home/dragos/docker-apps` with your actual path
2. **The domain** - replace `*.ai.bitdoze.com` with your wildcard domain

&lt;Notice type=&quot;warning&quot; title=&quot;Change the path and domain&quot;&gt;
The skill below uses my setup as an example. You need to replace `/home/dragos/docker-apps` with your own path (like `/home/youruser/docker-apps`) and `*.ai.bitdoze.com` with your actual wildcard domain.
&lt;/Notice&gt;

Create the skill file in your assistant&apos;s skills directory. The location depends on which AI assistant you use:

- **OpenClaw**: `~/.openclaw/workspace/skills/docker-deploy/SKILL.md`
- **Custom Agno bot**: `workspace/skills/docker-deploy/SKILL.md`
- **OpenCode/Claude Code**: `.agents/skills/docker-deploy/SKILL.md`

The skill file itself:

&lt;Tabs&gt;
&lt;Tab name=&quot;Skill file&quot;&gt;

```markdown
---
name: docker-deploy
description: &gt;-
  Use this skill when deploying or modifying Dockerized apps on this server.
  It enforces server-specific constraints: Docker is installed with Caddy,
  commands that change system/runtime state should be run with sudo, app
  stacks live in /home/dragos/docker-apps, Caddy lives in
  /home/dragos/docker-apps/caddy, wildcard DNS *.ai.bitdoze.com points to this
  server for subdomain routing, and Docker Compose files should use local ./
  paths instead of named volumes.
---

# Docker + Caddy Server Rules

Apply these rules for all deployment, operations, and setup tasks on this server.

## Environment facts
- Docker is installed and available.
- Caddy is installed and managed from `/home/dragos/docker-apps/caddy`.
- Docker app projects must be placed under `/home/dragos/docker-apps`.
- Wildcard DNS `*.ai.bitdoze.com` points to this server and should be used for app subdomains.

## Permission model
- Use `sudo` for commands that manage Docker runtime/services, networking, filesystem locations requiring elevation, or system-level setup.
- Prefer explicit commands that can be audited and repeated.

## Compose placement and style
- Prefer Docker Compose-based deployments.
- Place each app in its own folder under `/home/dragos/docker-apps/&lt;app-name&gt;`.
- Keep compose and related app files together in the app folder.
- Do not use named volumes for app data/config in compose files.
- Use local relative paths (`./...`) in the location where the compose file is created.

## Caddy integration
- Route apps through Caddy using subdomains under `*.ai.bitdoze.com`.
- Keep Caddy config changes under `/home/dragos/docker-apps/caddy`.
- When adding a new app, include the target hostname and upstream container/service mapping.

## Expected workflow
1. Create app directory in `/home/dragos/docker-apps/&lt;app-name&gt;`.
2. Add `docker-compose.yml` with services and `./...` path mappings.
3. Add/update Caddy config in `/home/dragos/docker-apps/caddy` for `&lt;app&gt;.ai.bitdoze.com`.
4. Run required Docker/Caddy commands with `sudo`.
5. Validate service health and public routing.
6. Run curl to see if the new app is up on port or subdomain.
7. Place sensitive data into `.env` file not in compose directly like passwords and hashes.

## Guardrails
- Reject plans that place apps outside `/home/dragos/docker-apps`.
- Reject plans that use named Docker volumes for persistent files.
- Reject plans that bypass Caddy when public HTTP(S) access is required.
- Place sensitive data into `.env` file not in compose directly like passwords and hashes.
```

&lt;/Tab&gt;
&lt;Tab name=&quot;What to change&quot;&gt;

Replace these values with your own:

| Placeholder | Your value |
|-------------|-----------|
| `/home/dragos/docker-apps` | Your apps directory path |
| `*.ai.bitdoze.com` | Your wildcard domain |
| `dragos` | Your username |

For example, if your user is `john` and your domain is `apps.johndoe.com`:

- `/home/dragos/docker-apps` becomes `/home/john/docker-apps`
- `*.ai.bitdoze.com` becomes `*.apps.johndoe.com`

&lt;/Tab&gt;
&lt;/Tabs&gt;

## How the skill works in practice

Once the skill file is in place, the assistant reads it whenever you ask about deploying something. Here&apos;s what a real conversation looks like:

**Me:** &quot;Install Uptime Kuma on my server&quot;

The assistant then:

```sh
# 1. Creates the directory
sudo mkdir -p /home/dragos/docker-apps/uptime-kuma

# 2. Writes the .env file (passwords go here, not in compose)
# Creates /home/dragos/docker-apps/uptime-kuma/.env

# 3. Writes docker-compose.yml with local paths
# Creates /home/dragos/docker-apps/uptime-kuma/docker-compose.yml
```

The compose file looks something like:

```yaml
services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    volumes:
      - ./data:/app/data
    networks:
      - caddy

networks:
  caddy:
    external: true
```

Notice `./data` instead of a named volume. That&apos;s what the skill enforces.

Then it updates the Caddyfile:

```
uptime-kuma.ai.bitdoze.com {
    reverse_proxy uptime-kuma:3001
}
```

And starts everything:

```sh
cd /home/dragos/docker-apps/uptime-kuma
sudo docker compose up -d
cd /home/dragos/docker-apps/caddy
sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
curl -I https://uptime-kuma.ai.bitdoze.com
```

The whole thing takes maybe 30 seconds. I just watch the messages roll in.

## Why the guardrails matter

Without the skill, AI assistants make reasonable but inconsistent choices. They might:

- Put app data in `/opt/` or `/var/` or wherever they feel like
- Use Docker named volumes, which makes backups harder (you have to dig around in `/var/lib/docker/volumes/`)
- Skip the reverse proxy and expose ports directly
- Hardcode passwords in the compose file where they show up in `docker inspect`
- Forget to check if the app actually started

The guardrails in the skill prevent all of this. If the assistant tries to create a compose file with a named volume, the skill tells it not to. If it tries to expose a port directly without going through Caddy, the skill says no.

This is the difference between having an assistant that can deploy apps and having one that deploys apps the way you want.

## Managing deployed apps

After a few deployments, you&apos;ll want to check on things. Some useful commands:

```sh
# See all running containers
sudo docker ps

# Check logs for a specific app
sudo docker logs uptime-kuma --tail 50

# Restart an app
cd /home/$USER/docker-apps/uptime-kuma
sudo docker compose restart

# Update an app to latest image
cd /home/$USER/docker-apps/uptime-kuma
sudo docker compose pull
sudo docker compose up -d

# Remove an app completely
cd /home/$USER/docker-apps/uptime-kuma
sudo docker compose down
cd ..
rm -rf uptime-kuma
# Also remove the Caddyfile entry
```

For managing containers through a web UI instead of terminal, check out [Arcane](/arcane-docker-install/) or [Dockhand](/dockhand-docker-install/). I wrote about [which one to choose](/arcane-vs-dockhand/) if you&apos;re deciding between them. You can even ask your AI assistant to install either one using this same skill.

If you want automatic container updates, [Tugtainer](/tugtainer-docker-autoupdate/) handles that without needing to rebuild compose stacks.

## Which AI assistant to use

Any AI assistant that supports skills or system prompts can use this file. I&apos;ve tested it with:

- **[OpenClaw](/clawdbot-setup-guide/)** running on the same VPS, chatting through Telegram. It has direct shell access to the server, so it runs the Docker commands itself.
- **A custom [Agno bot](/create-your-own-ai-agent/)** on Discord with shell tools enabled. Same idea, different chat platform.
- **OpenCode/Claude Code** connected via SSH. Works if you prefer coding tools over chat bots.

The OpenClaw setup is the most hands-off. You message it on Telegram from your phone, and it does everything on the server without you opening a terminal. The [setup guide](/clawdbot-setup-guide/) covers installation.

For building your own bot from scratch, the [Agno bot guide](/create-your-own-ai-agent/) walks through the whole thing, including Discord integration, memory, and team agents.

## Extending the skill

The skill file is just markdown. You can add rules as your setup grows. Some things I&apos;ve added to mine over time:

- A backup section that tells the assistant to create a `backup.sh` script in each app folder
- Database defaults (use PostgreSQL over MySQL when the app supports both)
- Resource limits for containers (`deploy.resources.limits` in compose)
- A monitoring rule that adds all new apps to my Uptime Kuma instance automatically

You can also ask the assistant to update the skill itself. &quot;Add a rule that all new apps should include a healthcheck in the compose file&quot; works fine. It reads the skill, adds the rule, and follows it from then on.

## The bigger picture

This setup turns a VPS into something you manage through conversation. The server still runs Docker and Caddy like any normal setup. The difference is that your AI assistant knows the rules and follows them consistently.

I&apos;ve deployed about 15 apps this way over the past few weeks. Some I kept, some I tried for an hour and removed. The speed of trying things is what changed. When installing an app takes 30 seconds of typing a message instead of 10 minutes of writing compose files, you try more things.

The prerequisites take maybe an hour to set up if you&apos;re starting from zero. After that, every deployment is a chat message.

&lt;Accordion label=&quot;Frequently asked questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Does the assistant need root access?**

It needs to run Docker commands with `sudo`. If your user is in the `docker` group, you can modify the skill to drop the `sudo` requirement, but I prefer keeping it explicit.

**Can I use Nginx or Traefik instead of Caddy?**

Yes, but you&apos;d need to rewrite the Caddy-specific parts of the skill. Caddy is the simplest option because it handles certificates automatically without extra configuration. If you already run [Traefik](/traefik-proxy-docker/), adapt the skill to use labels instead of Caddyfile entries.

**What if the assistant messes something up?**

Every app is isolated in its own folder. If something goes wrong, `docker compose down` in that folder stops it, and removing the folder cleans it up. The worst case is a broken Caddyfile entry, which you fix by editing one file.

**Can multiple assistants share the same skill?**

Yes. The skill describes server conventions, not assistant-specific behavior. Put the same file in each assistant&apos;s skill directory and they&apos;ll all follow the same rules.

**Does this work on ARM servers (Raspberry Pi)?**

The skill itself is architecture-agnostic. Whether the Docker images you deploy have ARM builds is a separate question. Most popular self-hosted apps publish multi-arch images now.

&lt;/Accordion&gt;</content:encoded><category>ai</category><category>docker</category><category>self-hosted</category><category>ai-tools</category></item><item><title>Arcane Docker Install: Self-Hosted Container Manager</title><link>https://www.bitdoze.com/arcane-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/arcane-docker-install/</guid><description>Step-by-step guide to install Arcane with Docker Compose. Covers basic setup, socket proxy security, OIDC authentication, reverse proxy config, and remote host management.</description><pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import arcaneUi from &quot;../../assets/images/26/02/arcane-ui.webp&quot;;

I&apos;ve been running [Arcane](https://getarcane.app/) on two of my servers for a few weeks now. It replaced Portainer on both, and I haven&apos;t looked back. The whole thing runs on Go, which means it&apos;s fast, light on memory, and doesn&apos;t need a complicated runtime.

If you&apos;re looking for a comparison with another newer Docker manager, check out my [Arcane vs Dockhand](/arcane-vs-dockhand/) article. But if you&apos;ve already decided on Arcane and just want it running, this guide covers everything from basic install to socket proxy security.

## What Arcane actually does

Arcane is a web-based Docker management UI. You get container management, compose stack editing, real-time logs, a web terminal, and GitOps all in one interface. It runs as a single container.

&lt;Picture
  src={arcaneUi}
  alt=&quot;Arcane Docker Manager UI showing container list and compose editor&quot;
/&gt;

A few things that stood out to me after using it:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;REST API that you can script against directly&lt;/li&gt;
&lt;li&gt;CLI tool for terminal-based management alongside the web UI&lt;/li&gt;
&lt;li&gt;GitOps built in, not as an afterthought. Point it at a repo and your stacks sync automatically&lt;/li&gt;
&lt;li&gt;OIDC/SSO support for single sign-on&lt;/li&gt;
&lt;li&gt;Remote host management via the arcane-headless agent&lt;/li&gt;
&lt;li&gt;BSD-3-Clause license. Free, no paid tiers, no feature locks&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The project has around 4,400 GitHub stars, 35 contributors, and has been actively developed since 2022. It&apos;s not new software.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Ew0pn9Djf-0&quot;
  label=&quot;Arcane vs Dockhand&quot;
/&gt;


## Prerequisites

Before you start, you need:

- A Linux server (VPS or local machine). I recommend [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting
- Docker and Docker Compose installed
- Basic terminal knowledge

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

Or use a [Mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/).

### Install Docker

If you don&apos;t have Docker yet:

```sh
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  jammy stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose
```

Full walkthrough: [Install Docker &amp; Docker-compose for Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/).

## Install Arcane with Docker Compose

There are two ways to install Arcane. The convenience script is the fastest, but I prefer the compose method because you can see and control exactly what&apos;s happening.

### Quick install (convenience script)

If you want to get running in 30 seconds:

```bash
curl -fsSL https://getarcane.app/install.sh | bash
```

This pulls the image, generates secrets, and starts the container. Good for testing, but I wouldn&apos;t use it for a permanent setup because you don&apos;t control the compose file.

### Docker Compose install (recommended)

First, create a directory for Arcane:

```bash
mkdir -p /opt/arcane
cd /opt/arcane
```

Generate the secrets you&apos;ll need. Run this command twice, once for `ENCRYPTION_KEY` and once for `JWT_SECRET`:

```bash
docker run --rm ghcr.io/getarcaneapp/arcane:latest /app/arcane generate secret
```

Copy both values. Now create your `compose.yaml`:

```yaml
services:
  arcane:
    image: ghcr.io/getarcaneapp/arcane:latest
    container_name: arcane
    ports:
      - &quot;3552:3552&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - arcane-data:/app/data
      - /opt/stacks:/opt/stacks
    environment:
      - APP_URL=http://localhost:3552
      - PUID=1000
      - PGID=1000
      - ENCRYPTION_KEY=your-generated-encryption-key
      - JWT_SECRET=your-generated-jwt-secret
    restart: unless-stopped
volumes:
  arcane-data:
```

Replace the `ENCRYPTION_KEY` and `JWT_SECRET` values with the ones you generated. Set `APP_URL` to your actual server address if you&apos;re accessing it remotely.

Start it up:

```bash
docker compose up -d
```

Open `http://your-server-ip:3552` in your browser. Default login credentials are `arcane` / `arcane-admin`. Change the password immediately.

&lt;Notice type=&quot;warning&quot; title=&quot;Volume paths matter&quot;&gt;
If you want Arcane to manage existing compose projects on your server, mount the directory with matching paths inside and outside the container. For example, if your stacks live at `/opt/stacks`, mount it as `/opt/stacks:/opt/stacks`, NOT as `/opt/stacks:/app/data/projects`. Compose files use relative paths, and they&apos;ll break if the mount point doesn&apos;t match. You can also set the `PROJECTS_DIRECTORY` environment variable to tell Arcane where to look.
&lt;/Notice&gt;

## Hardening: Docker socket proxy

Mounting the Docker socket directly gives Arcane full control over your Docker daemon. That&apos;s a security risk. If Arcane gets compromised somehow, an attacker has root-equivalent access to your host.

The fix is a socket proxy that filters which Docker API calls Arcane can make.

&lt;Accordion label=&quot;Why use a socket proxy?&quot; group=&quot;security&quot; expanded=&quot;true&quot;&gt;

The Docker socket (`/var/run/docker.sock`) is essentially a root-level API. Any container with access to it can create privileged containers, mount the host filesystem, or run arbitrary commands as root on the host.

A socket proxy sits between Arcane and the Docker daemon. It intercepts API calls and only allows the ones you&apos;ve explicitly permitted. You get the management functionality without handing over the keys to the kingdom.

This setup is recommended for any internet-facing server. For a homelab behind a firewall, direct socket mounting is probably fine.

&lt;/Accordion&gt;

Here&apos;s a compose setup with [Tecnativa&apos;s docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy):

```yaml
services:
  arcane:
    image: ghcr.io/getarcaneapp/arcane:latest
    container_name: arcane
    ports:
      - &quot;3552:3552&quot;
    volumes:
      - arcane-data:/app/data
      - /opt/stacks:/opt/stacks
    environment:
      - APP_URL=http://localhost:3552
      - PUID=1000
      - PGID=1000
      - ENCRYPTION_KEY=your-generated-encryption-key
      - JWT_SECRET=your-generated-jwt-secret
      - DOCKER_HOST=tcp://docker-socket-proxy:2375
    depends_on:
      - docker-socket-proxy
    networks:
      - arcane-net
    restart: unless-stopped

  docker-socket-proxy:
    image: tecnativa/docker-socket-proxy
    container_name: arcane-socket-proxy
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      - CONTAINERS=1
      - IMAGES=1
      - NETWORKS=1
      - VOLUMES=1
      - SERVICES=1
      - TASKS=1
      - NODES=1
      - BUILD=1
      - EXEC=1
      - SYSTEM=1
      - INFO=1
      - VERSION=1
      - POST=1
      - DELETE=1
    networks:
      - arcane-net
    restart: unless-stopped

networks:
  arcane-net:
    driver: bridge

volumes:
  arcane-data:
```

Notice that Arcane no longer mounts the Docker socket directly. Instead, it connects to the proxy via `DOCKER_HOST=tcp://docker-socket-proxy:2375`. The socket is mounted read-only on the proxy container, and both containers sit on an internal bridge network.

The environment variables on the proxy control which Docker API endpoints are accessible. The list above is fairly permissive since Arcane needs most of them to function. You can tighten it further if you don&apos;t need certain features.

## Setting up OIDC/SSO

If you&apos;re already running an identity provider like Authentik, Keycloak, or Authelia, you can hook Arcane into it for single sign-on.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Via the web UI&quot;&gt;
    Go to **Settings &gt; Security &gt; OIDC Authentication** in Arcane. Fill in your client ID, client secret, and issuer URL. The redirect URI is:

    ```
    https://your-arcane-url/auth/oidc/callback
    ```

    Save and test the connection. Users who authenticate via OIDC are auto-provisioned on first login.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Via environment variables&quot;&gt;
    Add these to your compose file:

    ```yaml
    environment:
      - OIDC_ENABLED=true
      - OIDC_CLIENT_ID=your-client-id
      - OIDC_CLIENT_SECRET=your-client-secret
      - OIDC_ISSUER_URL=https://your-idp.example.com
      - OIDC_ADMIN_CLAIM=groups
      - OIDC_ADMIN_VALUE=arcane-admins
    ```

    `OIDC_ADMIN_CLAIM` and `OIDC_ADMIN_VALUE` let you auto-assign admin privileges based on a claim from your identity provider. So if a user belongs to the `arcane-admins` group, they get admin access automatically.

    If you want to disable local password login entirely once OIDC is working, that&apos;s configurable too.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Reverse proxy setup

You&apos;ll want a reverse proxy in front of Arcane for SSL and a clean domain name. Arcane uses WebSockets for real-time updates, so your proxy config needs to support that.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Nginx&quot;&gt;

```nginx
server {
    listen 443 ssl http2;
    server_name arcane.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:3552;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

The key lines are `proxy_http_version 1.1` and the `Upgrade` / `Connection` headers. Without them, WebSocket connections fail and the UI won&apos;t get live updates.

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Traefik&quot;&gt;
    If you&apos;re already running Traefik (and you probably should be for Docker setups), add labels to your Arcane service:

    ```yaml
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.arcane.rule=Host(`arcane.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.arcane.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.arcane.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.arcane.loadbalancer.server.port=3552&quot;
    ```

    Traefik handles WebSocket upgrade automatically. Full Traefik setup guide: [How to use Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Cloudflare Tunnels&quot;&gt;
    If you don&apos;t want to expose ports at all, Cloudflare Tunnels work well. Point a tunnel at `http://localhost:3552` and Cloudflare handles SSL and routing. WebSocket support is automatic.

    This is what I use on my homelab since I don&apos;t want to open any ports on my router.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

Update the `APP_URL` environment variable in your compose file to match your actual domain (e.g., `https://arcane.yourdomain.com`).

## Managing remote hosts

Arcane can manage Docker on other machines through the `arcane-headless` agent. This is useful if you have multiple servers but want one dashboard.

On the remote machine, deploy the agent:

```yaml
services:
  arcane-agent:
    image: ghcr.io/getarcaneapp/arcane:latest
    container_name: arcane-agent
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - AGENT_MODE=true
      - AGENT_TOKEN=your-agent-token
      - MANAGER_API_URL=https://arcane.yourdomain.com
    restart: unless-stopped
```

Generate the `AGENT_TOKEN` on your main Arcane instance, then paste it here. The agent connects outbound to your main instance, so you don&apos;t need to open any ports on the remote machine.

## Environment variables reference

Here are the most useful environment variables you can set:

| Variable | Default | What it does |
|---|---|---|
| `APP_URL` | `http://localhost:3552` | Public URL for the instance |
| `PUID` / `PGID` | `1000` | User/group ID for file permissions |
| `ENCRYPTION_KEY` | none | Required. 32-byte key for encrypting sensitive data |
| `JWT_SECRET` | none | Required. Secret for signing auth tokens |
| `DOCKER_HOST` | `unix:///var/run/docker.sock` | Docker connection. Use `tcp://` for socket proxy |
| `DATABASE_URL` | SQLite | External PostgreSQL connection string |
| `GPU_MONITORING_ENABLED` | `false` | Enable NVIDIA/AMD GPU stats |
| `GPU_TYPE` | none | `nvidia` or `amd` |
| `LOG_LEVEL` | `info` | Logging verbosity |
| `UI_CONFIGURATION_DISABLED` | `false` | Force config via env vars only |

## Troubleshooting

&lt;Accordion label=&quot;Arcane can&apos;t see my existing compose stacks&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

This is almost always a volume mount path mismatch. If your compose files live at `/opt/stacks/myapp/compose.yaml`, mount that exact path:

```yaml
volumes:
  - /opt/stacks:/opt/stacks
```

Not `/opt/stacks:/some/other/path`. The paths inside and outside the container must match. Also set `PROJECTS_DIRECTORY=/opt/stacks` in the environment.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;WebSocket errors or UI not updating live&quot; group=&quot;troubleshoot&quot;&gt;

Your reverse proxy isn&apos;t forwarding WebSocket connections. Make sure you have the upgrade headers set. For Nginx, you need:

```nginx
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection &quot;upgrade&quot;;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission denied on Docker socket&quot; group=&quot;troubleshoot&quot;&gt;

Either add your user to the `docker` group (`sudo usermod -aG docker $USER`) or make sure the `PUID`/`PGID` values in the compose file match a user with Docker access.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can&apos;t generate secrets&quot; group=&quot;troubleshoot&quot;&gt;

The secret generation command requires pulling the Arcane image first. If it fails, pull manually:

```bash
docker pull ghcr.io/getarcaneapp/arcane:latest
docker run --rm ghcr.io/getarcaneapp/arcane:latest /app/arcane generate secret
```

&lt;/Accordion&gt;

## What I like and what&apos;s missing

After a few weeks with Arcane, here&apos;s where I&apos;ve landed.

The GitOps integration is genuinely good. I keep my compose files in a private Git repo, and when I push changes, Arcane picks them up and redeploys. No webhook config, no CI pipeline needed. It just works.

The REST API is another strong point. I wrote a small script that checks container health and restarts anything that&apos;s unhealthy. Took about 20 minutes because the API is straightforward.

What I wish it had: vulnerability scanning (Dockhand has this), scheduled auto-updates with rollback protection, and a file browser for containers. These aren&apos;t dealbreakers, but they&apos;d make Arcane the complete package.

If you want vulnerability scanning and some of those missing features, take a look at [how to install Dockhand](/dockhand-docker-install/). There&apos;s also [UsulNet](/usulnet-docker-install/), a newer all-in-one Docker management platform that bundles Trivy scanning, backups, reverse proxy config, and multi-node orchestration into a single Go binary.

## Related articles

- [Best Portainer alternatives in 2026](/portainer-alternatives/) - five Docker management UIs compared
- [Arcane vs Dockhand](/arcane-vs-dockhand/) - side-by-side comparison of both tools
- [Install Dockhand](/dockhand-docker-install/) - the other Docker manager worth trying
- [Install UsulNet](/usulnet-docker-install/) - all-in-one Docker management platform with scanning, backups, and multi-node
- [Install Dockge](/dockge-install/) - another Docker management UI
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run once your manager is set up
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Traefik reverse proxy for Docker](/traefik-proxy-docker/) - proper reverse proxy setup
- [Docker auto-update with Tugtainer](/tugtainer-docker-autoupdate/) - keep containers updated
- [Server monitoring tools](/sever-monitoring/) - monitoring your Docker host</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>Arcane vs Dockhand: Which Docker Manager Should You Choose?</title><link>https://www.bitdoze.com/arcane-vs-dockhand/</link><guid isPermaLink="true">https://www.bitdoze.com/arcane-vs-dockhand/</guid><description>A hands-on comparison of Arcane and Dockhand, two new Docker management UIs. Licensing, features, security, and which one fits your setup.</description><pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import arcaneUi from &quot;../../assets/images/26/02/arcane-ui.webp&quot;;
import dockhandUi from &quot;../../assets/images/26/02/dockerhand-ui.webp&quot;;

Managing Docker containers doesn&apos;t have to mean Portainer. For years, Portainer was the only serious web UI for Docker, and it worked fine until the licensing changes started pushing people toward paid tiers for basic features. That opened the door for alternatives, and two of them have been getting real attention lately: [Arcane](https://getarcane.app/) and [Dockhand](https://dockhand.pro/).

I deployed both on the same VPS running a mix of compose stacks. This comparison comes from actually using them, not from reading feature lists. Both are solid, but they target different users and make different trade-offs that matter.

## Quick comparison

| | Arcane | Dockhand |
|---|---|---|
| License | BSD-3-Clause (fully open source) | BSL 1.1 (converts to Apache 2.0 in 2029) |
| Backend | Go | Bun + SvelteKit |
| Frontend | SvelteKit / TypeScript | SvelteKit 2 / Svelte 5 |
| GitHub stars | ~4.4k | ~2.4k |
| First release | 2022 | December 2025 |
| Contributors | 35 | 7 |
| Commits | 1,829 | 74 |
| Pricing | Free, always | Free (homelab), SMB $499/host/yr, Enterprise $1,499/host/yr |
| GitOps | Built-in | Git integration + webhooks |
| Vuln scanning | No | Yes (Grype/Trivy) |
| SSO/OIDC | Yes | Yes, free tier |
| CLI tool | Yes | No |
| Multi-env agent | arcane-headless | Hawser (NAT traversal) |


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Ew0pn9Djf-0&quot;
  label=&quot;Arcane vs Dockhand&quot;
/&gt;

## Architecture

These two took very different paths under the hood.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Arcane&quot;&gt;
    Arcane is written in Go. That matters because Go compiles to a single binary, so the whole thing runs lean. Memory footprint is small. The backend handles API requests, container management, and serves the SvelteKit frontend.

    There&apos;s a REST API you can hit directly, which is handy for scripting. Arcane also ships a CLI tool for people who want terminal access alongside the web UI.

    For managing remote hosts, there&apos;s `arcane-headless`, an agent you install on other machines. It connects back to your main instance.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Dockhand&quot;&gt;
    Dockhand runs on Bun (not Node) with SvelteKit handling both the API routes and the frontend. The database is SQLite by default, with PostgreSQL as an option if you need it.

    What&apos;s unusual is the OS layer. Dockhand builds its container image from scratch using Wolfi packages via apko. No base image with leftover packages you don&apos;t need. The attack surface is smaller because of this.

    For remote hosts, there&apos;s Hawser, an agent that handles NAT and firewall traversal. You don&apos;t need to open ports on the remote machine.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

### My take on architecture

Here&apos;s what each UI looks like in practice:

&lt;Picture
  src={arcaneUi}
  alt=&quot;Arcane Docker Manager UI&quot;
/&gt;

&lt;Picture
  src={dockhandUi}
  alt=&quot;Dockhand Docker Manager UI&quot;
/&gt;

Arcane&apos;s Go backend feels snappier for basic container operations. Page loads are fast, API responses come back quickly. Dockhand&apos;s Bun-based stack is no slouch either, but you can feel the difference when clicking through lots of containers. It&apos;s marginal, though. Both are faster than Portainer.

## Licensing

This is where you really need to pay attention.

&lt;Notice type=&quot;warning&quot; title=&quot;Licensing matters&quot;&gt;
Arcane is BSD-3-Clause. Fork it, modify it, use it commercially, sell it. No restrictions beyond keeping the copyright notice.

Dockhand uses BSL 1.1. You can use it freely for personal and internal business purposes. You cannot offer it as a commercial hosted service. The license converts to Apache 2.0 in 2029.
&lt;/Notice&gt;

For most self-hosters, both licenses work fine. You&apos;re running it on your own hardware for your own use. The BSL restriction on Dockhand only kicks in if you try to resell it as a service.

But if you&apos;re building something on top of a Docker management UI, or you want to embed it in a product, Arcane gives you more freedom.

## Features: where each one wins

### Container and compose management

Both handle the basics well. You can start, stop, restart, and remove containers. You can view logs, inspect settings, and manage networks and volumes. Compose stack management works in both. You can deploy, update, and edit compose files from the web UI.

&lt;Accordion label=&quot;Where Arcane does it better&quot; group=&quot;features&quot; expanded=&quot;true&quot;&gt;

- The REST API is well-documented and easy to script against
- CLI tool gives you terminal access to everything the UI does
- GitOps is built in, not bolted on. Point it at a git repo and it syncs your stacks automatically
- SBOM (Software Bill of Materials) transparency for supply chain visibility

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Where Dockhand does it better&quot; group=&quot;features&quot;&gt;

- File browser inside containers. Browse the filesystem without exec-ing in
- Scheduled auto-updates with safe-pull protection (pulls new image, checks it works, rolls back if not)
- Activity logging for everything. You can see who did what and when
- Notifications via SMTP and Apprise (supports dozens of notification services)

&lt;/Accordion&gt;

### Security features

This is where Dockhand pulls ahead.

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Dockhand includes vulnerability scanning with Grype and Trivy, built right into the UI&lt;/li&gt;
&lt;li&gt;OIDC/SSO is free in Dockhand, not locked behind a paid tier&lt;/li&gt;
&lt;li&gt;The custom Wolfi-based OS layer means fewer packages, fewer CVEs&lt;/li&gt;
&lt;li&gt;Zero telemetry. Dockhand doesn&apos;t phone home at all&lt;/li&gt;
&lt;li&gt;Enterprise tier adds RBAC, LDAP, and audit logging&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Arcane doesn&apos;t have vulnerability scanning, though it does support OIDC/SSO. For a homelab, the security gap between these two is small. For a small business running production workloads, Dockhand&apos;s built-in scanning and hardened OS layer are worth the trade-off of a more restrictive license.

### GitOps and automation

&lt;Tabs&gt;
  &lt;Tab name=&quot;Arcane&apos;s approach&quot;&gt;
    GitOps is a first-class feature. You connect a git repository, and Arcane watches it for changes. When you push an updated compose file, Arcane pulls it and redeploys. No webhooks to configure, no external CI needed.

    This works well if your workflow is already git-based. Push a change, see it deploy.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Dockhand&apos;s approach&quot;&gt;
    Git integration exists but works differently. You connect repos and set up webhooks. When a push happens, Dockhand receives the webhook and acts on it.

    The scheduled auto-update feature is separate and arguably more practical for most people. Set a schedule, and Dockhand checks for new images, pulls them, verifies they work, and rolls back if something breaks. I&apos;ve had it catch a bad image update twice already.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

### Multi-environment management

Both let you manage containers on remote hosts, but the mechanisms differ.

Arcane uses `arcane-headless`, a lightweight agent. Install it on a remote machine, point it at your main Arcane instance, and you can manage that host from the same dashboard.

Dockhand uses Hawser, which has a trick up its sleeve: NAT traversal. If your remote machine is behind a firewall or NAT, Hawser can still connect without opening ports. That&apos;s genuinely useful for managing machines in different networks.

## Maturity and stability

I have to be honest here. Arcane has a significant head start.

| Metric | Arcane | Dockhand |
|---|---|---|
| First release | 2022 | December 2025 |
| Total commits | 1,829 | 74 |
| Releases | 56 | 3 |
| Contributors | 35 | 7 |

Arcane has been around for years. It&apos;s been through many release cycles, edge cases have been found and fixed, and there&apos;s a real community around it. Dockhand shipped its first release two months ago. It&apos;s polished for a v1, but it&apos;s still a v1.

&lt;Notice type=&quot;info&quot; title=&quot;Stability in practice&quot;&gt;
I ran both for three weeks. Arcane didn&apos;t crash once. Dockhand had one instance where the UI froze after a bulk container restart, and I had to refresh the page. Not a big deal, but worth mentioning.
&lt;/Notice&gt;

## Pricing

Arcane is free. No tiers, no paid features, no &quot;community edition&quot; with missing parts. BSD-3-Clause means you get everything.

Dockhand has a tiered model:

| Tier | Cost | What you get |
|---|---|---|
| Free | $0 | Full features for homelab use |
| SMB | $499/host/year | Commercial support, priority updates |
| Enterprise | $1,499/host/year | RBAC, LDAP, audit logging, dedicated support |

The free tier is genuinely usable. OIDC/SSO is included even at the free level, which is better than what Portainer offers. But if you need RBAC or LDAP, you&apos;re paying enterprise prices.

## Community and support

Arcane has the bigger community. More GitHub issues, more discussions, more people contributing. Thirty-five contributors versus seven tells you something about how many people are invested in each project.

Dockhand has gotten press coverage from XDA Developers, The New Stack, and Lawrence Systems. That&apos;s impressive for a project that&apos;s only been public for two months. But coverage and community are different things. When you hit a weird bug at 2am, the size of the community matters more than press mentions.

Both have active Discord/GitHub channels. Response times have been reasonable from both teams in my experience.

## Installation

Both are Docker-based installs, which is appropriate for Docker management tools. I&apos;ve written full install guides for both: [install Arcane](/arcane-docker-install/) and [install Dockhand](/dockhand-docker-install/).

&lt;Tabs&gt;
  &lt;Tab name=&quot;Arcane&quot;&gt;
    Create a `compose.yaml`:

    ```yaml
    services:
      arcane:
        image: ghcr.io/getarcaneapp/arcane:latest
        container_name: arcane
        ports:
          - &quot;3552:3552&quot;
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
          - arcane-data:/app/data
          - /opt/docker:/opt/docker
        environment:
          - APP_URL=http://localhost:3552
          - PUID=1000
          - PGID=1000
          - ENCRYPTION_KEY=your-32-byte-encryption-key
          - JWT_SECRET=your-jwt-secret
        restart: unless-stopped
    volumes:
      arcane-data:
    ```

    Generate the secrets with:

    ```bash
    docker run --rm ghcr.io/getarcaneapp/arcane:latest /app/arcane generate secret
    ```

    Run that twice - once for `ENCRYPTION_KEY`, once for `JWT_SECRET`. Then `docker compose up -d` and open `localhost:3552`. Default login is `arcane` / `arcane-admin`.

    One thing to watch: if you want Arcane to manage existing compose projects, mount the projects folder with matching paths inside and outside the container. So if your stacks live at `/opt/docker`, mount it as `/opt/docker:/opt/docker`, not something like `/opt/docker:/app/data/projects`. Relative paths in compose files break otherwise.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Dockhand&quot;&gt;
    ```yaml
    services:
      dockhand:
        image: finsys/dockhand:latest
        ports:
          - &quot;3000:3000&quot;
        volumes:
          - /var/run/docker.sock:/var/run/docker.sock
          - dockhand_data:/app/data
        environment:
          - SECRET_KEY=change-this-to-something-random
    volumes:
      dockhand_data:
    ```

    Also quick. Dockhand asks you to set a secret key, which is good practice. First-run setup walks you through creating an admin account.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

Both require mounting the Docker socket, which is standard for these tools. If that makes you uncomfortable from a security perspective, both support connecting to remote Docker hosts instead.

## Who should pick which

&lt;Accordion label=&quot;Pick Arcane if...&quot; group=&quot;pick&quot; expanded=&quot;true&quot;&gt;

- You want something truly open source with no licensing concerns
- GitOps is part of your workflow
- You need a REST API or CLI tool
- Community size and project maturity matter to you
- Budget is zero, including for support

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Pick Dockhand if...&quot; group=&quot;pick&quot;&gt;

- Security features like vulnerability scanning and SSO are requirements
- You manage hosts behind NATs and firewalls
- Auto-updates with rollback protection sound appealing
- You want activity logging and audit trails
- You&apos;re okay with a newer project that&apos;s still finding its footing

&lt;/Accordion&gt;

## My honest take

If someone asked me which one to install today on a homelab server, I&apos;d say Arcane. It&apos;s more mature, fully open source, and the GitOps integration is excellent. The community is bigger, so when things go wrong, you&apos;ll find help faster.

But I&apos;m watching Dockhand closely. The security features are ahead of anything else in this space at the free tier. OIDC without paying, vulnerability scanning built in, that hardened OS layer. If the team keeps shipping at this pace, and if the project grows its contributor base, it could become the better choice for production use within a year.

For now, Arcane for reliability. Dockhand if you need those security features and can tolerate some rough edges. And if you want something that tries to be the full package, with container management, backups, reverse proxy, monitoring, and multi-node all in one tool, check out [UsulNet](/usulnet-docker-install/).

## Related articles

- [Best Portainer alternatives in 2026](/portainer-alternatives/) - five Docker management UIs compared
- [Install Arcane](/arcane-docker-install/) - full Arcane setup guide with socket proxy and OIDC
- [Install Dockhand](/dockhand-docker-install/) - full Dockhand setup guide with vulnerability scanning and Hawser
- [Install UsulNet](/usulnet-docker-install/) - all-in-one Docker management platform with scanning, backups, and multi-node
- [Install Dockge](/dockge-install/) - another Docker management UI worth trying
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run once you&apos;ve picked a manager
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Podman vs Docker](/podman-vs-docker/) - the container engine comparison
- [Docker auto-update with Tugtainer](/tugtainer-docker-autoupdate/) - keep containers updated automatically
- [Server monitoring tools](/sever-monitoring/) - monitoring your Docker host</content:encoded><category>self-hosting</category><category>docker</category><category>self-hosted</category></item><item><title>Dockhand Docker Install: Security-Focused Container Manager</title><link>https://www.bitdoze.com/dockhand-docker-install/</link><guid isPermaLink="true">https://www.bitdoze.com/dockhand-docker-install/</guid><description>Step-by-step guide to install Dockhand with Docker Compose. Covers SQLite and PostgreSQL setups, OIDC/SSO, vulnerability scanning, auto-updates, and remote host management with Hawser.</description><pubDate>Mon, 09 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import dockhandUi from &quot;../../assets/images/26/02/dockerhand-ui.webp&quot;;

[Dockhand](https://dockhand.pro/) shipped its first release in December 2025 and has been putting out updates at a pace I rarely see from new projects. Sixteen releases in about two months. I installed it alongside [Arcane](/arcane-docker-install/) on the same server to see how they compare, and while Arcane has the maturity edge, Dockhand&apos;s security features caught my attention.

If you&apos;re trying to decide between the two, read my [Arcane vs Dockhand comparison](/arcane-vs-dockhand/) first. This guide is for people who&apos;ve already decided on Dockhand and want it running properly.

## What Dockhand brings to the table

Dockhand is a Docker management UI built on Bun and SvelteKit. The security angle is what makes it different from other options in this space.

&lt;Picture
  src={dockhandUi}
  alt=&quot;Dockhand Docker Manager UI showing container dashboard&quot;
/&gt;

Here&apos;s what stood out to me:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Vulnerability scanning with Grype and Trivy built into the UI&lt;/li&gt;
&lt;li&gt;Safe-pull protection: scans new images before replacing running containers&lt;/li&gt;
&lt;li&gt;OIDC/SSO included in the free tier (Portainer charges for this)&lt;/li&gt;
&lt;li&gt;MFA/TOTP support for local accounts&lt;/li&gt;
&lt;li&gt;Container file browser and web terminal&lt;/li&gt;
&lt;li&gt;Scheduled auto-updates with automatic rollback if something breaks&lt;/li&gt;
&lt;li&gt;Activity logging for every action&lt;/li&gt;
&lt;li&gt;Notifications via SMTP and Apprise&lt;/li&gt;
&lt;li&gt;Zero telemetry. Nothing phones home&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Ew0pn9Djf-0&quot;
  label=&quot;Arcane vs Dockhand&quot;
/&gt;

The container image is built from scratch using Wolfi packages via apko. No Alpine or Debian base layer with packages you don&apos;t need. Smaller attack surface by design.

One thing to know: Dockhand uses a BSL 1.1 license. Free for personal and internal business use. You can&apos;t resell it as a hosted service. The license converts to Apache 2.0 in 2029. For self-hosting, this doesn&apos;t matter.

## Prerequisites

You need:

- A Linux server (VPS or local). I use [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting
- Docker and Docker Compose installed
- Works on both amd64 and arm64 (Raspberry Pi 4 included)

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;

Or use a [Mini PC as home server](https://www.bitdoze.com/best-mini-pc-home-server/).

### Install Docker

If Docker isn&apos;t set up yet:

```sh
sudo apt-get update
sudo apt-get install ca-certificates curl gnupg lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  jammy stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose
```

Full walkthrough: [Install Docker &amp; Docker-compose for Ubuntu](https://www.bitdoze.com/install-docker-ubuntu-arm/).

## Install Dockhand with Docker Compose

You have three options depending on your needs: a quick single command, SQLite-based compose, or PostgreSQL-backed compose. I&apos;ll cover all three.

### Quick install (single command)

If you just want to kick the tires:

```bash
docker run -d \
  --name dockhand \
  --restart unless-stopped \
  -p 3000:3000 \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v dockhand_data:/app/data \
  fnsys/dockhand:latest
```

That&apos;s it. Open `http://your-server-ip:3000` and create your admin account. For a permanent setup, I&apos;d use one of the compose methods below instead.

### Docker Compose with SQLite (recommended for most users)

Create a directory and compose file:

```bash
mkdir -p /opt/dockhand
cd /opt/dockhand
```

Create `compose.yaml`:

```yaml
services:
  dockhand:
    image: fnsys/dockhand:latest
    container_name: dockhand
    restart: unless-stopped
    ports:
      - &quot;3000:3000&quot;
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockhand_data:/app/data
volumes:
  dockhand_data:
```

Start it:

```bash
docker compose up -d
```

Open `http://your-server-ip:3000`. The first-run wizard walks you through creating an admin account. SQLite is the default database and works well for single-server setups. The data lives in the `dockhand_data` volume.

### Docker Compose with PostgreSQL

If you&apos;re managing a lot of containers or want the database running separately for backup purposes, use PostgreSQL:

```yaml
services:
  postgres:
    image: postgres:16-alpine
    container_name: dockhand-db
    restart: unless-stopped
    environment:
      POSTGRES_USER: dockhand
      POSTGRES_PASSWORD: change-this-password
      POSTGRES_DB: dockhand
    volumes:
      - postgres_data:/var/lib/postgresql/data

  dockhand:
    image: fnsys/dockhand:latest
    container_name: dockhand
    restart: unless-stopped
    ports:
      - &quot;3000:3000&quot;
    environment:
      DATABASE_URL: postgres://dockhand:change-this-password@postgres:5432/dockhand
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - dockhand_data:/app/data
    depends_on:
      - postgres

volumes:
  postgres_data:
  dockhand_data:
```

&lt;Notice type=&quot;warning&quot; title=&quot;Change the database password&quot;&gt;
Replace `change-this-password` in both the `POSTGRES_PASSWORD` and `DATABASE_URL` fields with an actual strong password. Use the same password in both places.
&lt;/Notice&gt;

## Setting up vulnerability scanning

This is one of Dockhand&apos;s best features and it&apos;s included in the free tier. You can scan your container images for known vulnerabilities using either Grype or Trivy.

After logging in, go to the settings page and enable vulnerability scanning. Choose your scanner:

&lt;Tabs&gt;
  &lt;Tab name=&quot;Grype&quot;&gt;
    Grype is the default option. It&apos;s fast, developed by Anchore, and pulls vulnerability data from multiple sources. Scans run against your local images without sending anything to external servers.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Trivy&quot;&gt;
    Trivy by Aqua Security is the other option. It supports more scan targets (OS packages, language-specific dependencies, IaC files) but is slightly slower. If you need deeper scanning beyond container images, Trivy is the better pick.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

The safe-pull feature ties into scanning. When Dockhand auto-updates a container, it pulls the new image, scans it for vulnerabilities, and only switches over if the scan passes. If the new image has problems, your running container stays untouched. I&apos;ve had this catch a bad image update twice in three weeks.

## Scheduled auto-updates

Dockhand can check for new container images on a schedule and update them automatically. Combined with the safe-pull protection mentioned above, this is actually usable in practice. I say &quot;actually usable&quot; because most auto-update tools don&apos;t verify the new image works before swapping it in.

Set this up per container or per stack in the UI. You pick a schedule (e.g., daily at 3am), and Dockhand handles the rest. If an update fails or the new container doesn&apos;t start properly, it rolls back.

## Configuring OIDC/SSO

OIDC support is free in Dockhand. No paid tier needed. If you run Authentik, Keycloak, or any other OIDC provider, you can set up single sign-on.

In the Dockhand UI, go to Settings and configure your OIDC provider with:

- Client ID
- Client secret
- Issuer URL / Discovery URL

Users are auto-provisioned on first login. Once OIDC is working, you can hide the local password login by setting the `DISABLE_LOCAL_LOGIN` environment variable. That way users can only authenticate through your identity provider.

&lt;Notice type=&quot;info&quot; title=&quot;MFA is separate from OIDC&quot;&gt;
If you&apos;re using local accounts instead of OIDC, Dockhand supports TOTP-based MFA. Each user can enable it from their profile settings. If you&apos;re using OIDC, MFA is handled by your identity provider instead.
&lt;/Notice&gt;

## Reverse proxy setup

Dockhand runs on port 3000 by default. For SSL and a proper domain, put a reverse proxy in front of it. WebSocket support is needed for live container logs and metrics.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Nginx&quot;&gt;

```nginx
server {
    listen 443 ssl http2;
    server_name dockhand.yourdomain.com;

    ssl_certificate /path/to/cert.pem;
    ssl_certificate_key /path/to/key.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &quot;upgrade&quot;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

  &lt;/Tab&gt;

  &lt;Tab name=&quot;Traefik&quot;&gt;
    Add labels to the Dockhand service in your compose file:

    ```yaml
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.dockhand.rule=Host(`dockhand.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.dockhand.entrypoints=websecure&quot;
      - &quot;traefik.http.routers.dockhand.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.dockhand.loadbalancer.server.port=3000&quot;
    ```

    Full Traefik setup: [How to use Traefik as a reverse proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Cloudflare Tunnels&quot;&gt;
    Point a tunnel at `http://localhost:3000`. SSL and WebSocket handling are automatic. This is the easiest option if you don&apos;t want to manage certificates or open ports.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Managing remote hosts with Hawser

Dockhand can manage Docker on remote machines through the Hawser agent. What makes Hawser interesting is NAT traversal. The agent makes outbound-only connections, so you don&apos;t need to open any ports on the remote machine. It works behind firewalls and NAT without any special network configuration.

[Hawser](https://github.com/Finsys/hawser) is open source (Go), separate from Dockhand itself.

To set it up:

1. In Dockhand, go to Environments and create a new remote host. This generates a token
2. On the remote machine, deploy Hawser:

```bash
docker run -d \
  --name hawser \
  --restart unless-stopped \
  -v /var/run/docker.sock:/var/run/docker.sock \
  fnsys/hawser:latest \
  --token YOUR_TOKEN \
  --server https://dockhand.yourdomain.com
```

The agent connects to your Dockhand instance and the remote host shows up in your dashboard. Token-based auth, auto-reconnect on network issues. There are Standard and Edge modes depending on whether the remote machine has consistent connectivity.

## Environment variables reference

| Variable | Default | What it does |
|---|---|---|
| `DATABASE_URL` | SQLite | PostgreSQL connection string |
| `PUID` / `PGID` | `1000` | File ownership user/group |
| `DISABLE_LOCAL_LOGIN` | `false` | Hide password login when SSO is active |
| `SKIP_DF_COLLECTION` | `false` | Skip disk usage collection (useful on NAS devices) |
| `DATA_DIR` | `/app/data` | Custom data directory path |

## Troubleshooting

&lt;Accordion label=&quot;Container logs show permission denied&quot; group=&quot;troubleshoot&quot; expanded=&quot;true&quot;&gt;

Check that the `PUID` and `PGID` values match a user with access to the Docker socket. On most systems, the Docker socket belongs to the `docker` group. Find the group ID with:

```bash
getent group docker
```

Use that GID as your `PGID` value.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Vulnerability scans fail or time out&quot; group=&quot;troubleshoot&quot;&gt;

The first scan takes longer because Grype or Trivy needs to download the vulnerability database. Subsequent scans are faster. If it keeps timing out, check that the container has internet access (DNS resolution, outbound HTTPS).

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Dockhand uses too much CPU on my NAS&quot; group=&quot;troubleshoot&quot;&gt;

Set `SKIP_DF_COLLECTION=true` in your environment variables. Disk usage collection can be heavy on certain NAS devices with many mount points.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Hawser agent won&apos;t connect&quot; group=&quot;troubleshoot&quot;&gt;

Verify the token is correct and the Dockhand URL is reachable from the remote machine. Hawser makes outbound HTTPS connections, so the remote machine needs to be able to reach your Dockhand instance on port 443. Check firewall rules on the remote machine&apos;s outbound traffic.

&lt;/Accordion&gt;

## Pricing

Worth being clear about this. Dockhand has a tiered pricing model:

| Tier | Cost | What you get |
|---|---|---|
| Free | $0 | Full features for homelab use, OIDC/SSO included |
| SMB | $499/host/year | Commercial support, priority updates |
| Enterprise | $1,499/host/year | RBAC, LDAP/AD, audit logging, dedicated support |

The free tier is legitimately full-featured. OIDC, vulnerability scanning, auto-updates, multi-host, all included. You only need to pay if you need RBAC, LDAP, or commercial support. That&apos;s a better deal than what Portainer offers at the free level.

## My take after three weeks

Dockhand is a v1 product that doesn&apos;t feel like one. The UI is polished, the security features work well, and the update pace shows the team is actively shipping.

The safe-pull feature is my favorite thing about it. I set auto-updates on all my non-critical containers and let it run. It caught two bad image updates by scanning them before deploying. Both times, my running containers were left untouched while the new images got flagged.

What gives me pause is the project&apos;s age. Seventy-four commits, seven contributors, first release two months ago. Arcane has been around since 2022 with 1,800+ commits and 35 contributors. That kind of maturity takes time to build, and there will be edge cases Dockhand hasn&apos;t hit yet.

If security features are your priority and you can tolerate a newer project, Dockhand is worth installing. If you want something more battle-tested, check out [how to install Arcane](/arcane-docker-install/) instead. And if you want an all-in-one platform that bundles container management with backups, reverse proxy config, and monitoring, take a look at [UsulNet](/usulnet-docker-install/).

## Related articles

- [Best Portainer alternatives in 2026](/portainer-alternatives/) - five Docker management UIs compared
- [Arcane vs Dockhand](/arcane-vs-dockhand/) - side-by-side comparison of both tools
- [Install Arcane](/arcane-docker-install/) - the other Docker manager covered in this series
- [Install UsulNet](/usulnet-docker-install/) - all-in-one Docker management platform with scanning, backups, and multi-node
- [Install Dockge](/dockge-install/) - another Docker management UI
- [Best Docker containers for home server](/docker-containers-home-server/) - what to run once your manager is set up
- [Best self-hosted panels](/best-self-hosted-panels/) - server management panels compared
- [Traefik reverse proxy for Docker](/traefik-proxy-docker/) - proper reverse proxy setup
- [Docker auto-update with Tugtainer](/tugtainer-docker-autoupdate/) - keep containers updated
- [Server monitoring tools](/sever-monitoring/) - monitoring your Docker host</content:encoded><category>linux</category><category>docker</category><category>self-hosted</category></item><item><title>Build a Discord AI Bot with Agno (Teams, Memory, Knowledge Base)</title><link>https://www.bitdoze.com/create-your-own-ai-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/create-your-own-ai-agent/</guid><description>A production-ready Discord AI bot with streaming responses, vector knowledge base, Cognee memory, team orchestration, and self-improvement tools.</description><pubDate>Fri, 06 Feb 2026 00:00:00 GMT</pubDate><content:encoded>import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

This guide walks through building a Discord AI bot using [Agno](https://github.com/agno-agi/agno). The bot streams responses in real-time, remembers conversations, searches a knowledge base, and can delegate work to specialized agents.

&lt;Button text=&quot;View on GitHub&quot; link=&quot;https://github.com/bitdoze/bitdoze_bot&quot; variant=&quot;solid&quot; color=&quot;purple&quot; size=&quot;md&quot; icon=&quot;github&quot; /&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/yoWGFO7tvpc&quot;
  label=&quot;I Built a Personal AI Assistant in 90 Minutes&quot;
/&gt;

## What This Bot Does

- Responds when you `@mention` it in Discord
- Streams responses (you see the message update as it generates)
- Stores memories and learns your preferences over time
- Searches a knowledge base (LanceDb or PgVector)
- Runs scheduled tasks via cron
- Delegates complex work to agent teams

## Prerequisites

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Python 3.12+&lt;/li&gt;
&lt;li&gt;Discord bot token&lt;/li&gt;
&lt;li&gt;Model API key (any OpenAI-compatible provider)&lt;/li&gt;
&lt;li&gt;Optional: GitHub token for GitHub tools&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

If you&apos;re new to Python packaging, check out [UV](/uv-get-start/).

## Quick Start

```bash
git clone https://github.com/bitdoze/bitdoze_bot.git
cd bitdoze_bot
uv sync
python scripts/setup_bot.py
```

The setup wizard asks for your Discord token, API keys, and optional settings. It creates `~/.bitdoze-bot/` with everything you need.

## Discord Bot Setup

1. Go to [discord.com/developers](https://discord.com/developers)
2. Create an application, add a bot
3. Copy the token
4. Enable **Message Content Intent** (required)
5. Invite to your server with appropriate permissions

## Project Structure

```text
bitdoze-bot/
  main.py
  config.example.yaml
  docker-compose.yml
  bitdoze_bot/
    agents.py
    discord_bot.py
    cron.py
    heartbeat.py
    discovery_tools.py
    tool_permissions.py
    run_monitor.py
  scripts/
    setup_bot.py
    generate_soul.py
    setup_knowledge.py
  tests/
```

## Core Features

**Streaming**: Messages update in Discord as the agent generates content. You see progress instead of waiting for a complete response.

**Memory**: SQLite-backed storage for conversations. Optional Cognee integration for long-term memory with semantic search.

**Knowledge Base**: Vector search using LanceDb (no setup) or PgVector (requires PostgreSQL). Add documents to `workspace/knowledge/` and the bot can reference them.

**Teams**: Multiple agents that can collaborate. The delivery team might have an architect plan and a software engineer implement.

**Self-improvement**: Discovery tools let the bot save and search its own learnings. Combined with `learned_knowledge: agentic`, it decides when to store new insights.

**Automation**: Heartbeat checks every 30 minutes. Cron jobs run on a schedule from `workspace/CRON.yaml`.

**Tool permissions**: Runtime allow/deny rules with audit logging. Block shell access in public channels, allow it in private ones.

## Configuration

### Environment Variables

```env
DISCORD_BOT_TOKEN=your_discord_token
OPENAI_API_KEY=your_api_key
GITHUB_ACCESS_TOKEN=optional_github_token
```

### Basic Config

```yaml
model:
  provider: openai_like
  id: stepfun/step-3.5-flash:free
  base_url: https://openrouter.ai/api/v1
  api_key_env: OPENAI_API_KEY
  structured_outputs: false

discord:
  token_env: DISCORD_BOT_TOKEN

runtime:
  streaming_enabled: true
  streaming_edit_interval: 1.5
  agent_timeout: 600

memory:
  mode: automatic
  db_file: data/bitdoze.db
  enable_session_summaries: true

learning:
  enabled: true
  mode: always
  stores:
    user_profile: true
    user_memory: true
    learned_knowledge: agentic
```

### Streaming

Set `runtime.streaming_enabled: true` to see responses update in real-time. The bot edits the Discord message as content arrives. Falls back to non-streaming for team runs.

### Knowledge Base

```yaml
knowledge:
  enabled: true
  backend: lancedb  # or pgvector
  embedder: text-embedding-3-small
  lance_uri: data/lancedb
  table_name: bitdoze_knowledge
```

Run `python scripts/setup_knowledge.py` after adding documents to `workspace/knowledge/`.

### Cognee Memory (Optional)

```yaml
memory:
  cognee:
    enabled: true
    base_url: http://localhost:8000
    auto_sync_conversations: true
    auto_recall_enabled: true
    auto_recall_limit: 5
```

Start Cognee with `docker compose up -d`.

&lt;Notice type=&quot;info&quot; title=&quot;Alternative: Hindsight&quot;&gt;
If you want a more capable memory system, [Hindsight](/hindsight-docker-deploy/) has a direct Agno integration. It replaces Cognee with vector-based memory that includes entity extraction, mental models, and four parallel search strategies. Better for agents that need to learn and improve over time.
&lt;/Notice&gt;

### Agents and Routing

```yaml
agents:
  default: main
  workspace_dir: workspace/agents
  definitions:
    - name: main
      tools: [web_search, website, github, file, discoveries]
    - name: research
      tools: [web_search, website, github]
      skills: [web-research]
  routing:
    rules:
      - agent: delivery-team
        starts_with: [&quot;team:&quot;]
      - agent: research
        contains: [&quot;research:&quot;]
```

### Teams

```yaml
teams:
  definitions:
    - name: delivery-team
      members: [architect, software-engineer]
      respond_directly: true
      determine_input_for_members: true
      delegate_to_all_members: false
      add_team_history_to_members: true
      num_team_history_runs: 5
```

&lt;Notice type=&quot;warning&quot; title=&quot;Team Configuration&quot;&gt;
Avoid setting both `delegate_to_all_members: true` and `respond_directly: true` unless you want broadcast behavior. The bot will warn you.
&lt;/Notice&gt;

## Add New Agents

Create a folder in `workspace/agents/&lt;name&gt;/`:

**agent.yaml**
```yaml
name: product-manager
enabled: true
model:
  id: glm-4.7
  base_url: https://api.z.ai/api/coding/paas/v4
  api_key_env: GLM_API_KEY
tools: []
skills: []
```

**AGENTS.md**
```md
# Product Manager Agent

Focus on scope, priorities, and delivery risk.
```

Add to a team in `config.yaml`:
```yaml
teams:
  definitions:
    - name: delivery-team
      members: [architect, software-engineer, product-manager]
```

## Use in Discord

Normal mention:
```text
@YourBot help me design a migration plan
```

Force a specific agent or team:
```text
@YourBot agent:research find papers on distributed systems
@YourBot agent:delivery-team Build and validate this feature
@YourBot team: implement the API with tests
```

## Heartbeat and Cron

**workspace/CRON.yaml**
```yaml
enabled: true
timezone: Europe/Bucharest
channel_id: 123456789012345678
jobs:
  - name: daily-status
    cron: &quot;0 9 * * *&quot;
    agent: main
    message: &quot;Send a daily status update.&quot;
    session_scope: isolated
```

Heartbeat runs every 30 minutes using `workspace/HEARTBEAT.md`. If it returns `HEARTBEAT_OK`, the message is suppressed.

## Docker (PgVector + Cognee)

Optional PostgreSQL with pgvector and Cognee API:

```bash
docker compose up -d
```

This starts PostgreSQL on port 5532 and Cognee on `127.0.0.1:8000`.

## Tool Permissions

Control which tools can run where:

```yaml
tool_permissions:
  enabled: true
  default_effect: deny
  rules:
    - effect: allow
      tools: [shell]
      role_ids: [123456789012345678]
  audit:
    enabled: true
    path: logs/tool-audit.jsonl
```

## Production (systemd)

`~/.config/systemd/user/bitdoze-bot.service`
```ini
[Unit]
Description=Bitdoze Bot
After=network.target

[Service]
WorkingDirectory=/home/you/.bitdoze-bot
ExecStart=/home/you/.local/bin/uv run main.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
EnvironmentFile=/home/you/.bitdoze-bot/.env

[Install]
WantedBy=default.target
```

```bash
systemctl --user daemon-reload
systemctl --user enable --now bitdoze-bot
journalctl --user -u bitdoze-bot -f
```

## Scripts

| Script | Purpose |
|--------|---------|
| `scripts/setup_bot.py` | Interactive setup wizard |
| `scripts/generate_soul.py` | Generate SOUL.md personality |
| `scripts/setup_knowledge.py` | Initialize knowledge base |

## Tests

```bash
uv run pytest -q
```

## Related

- [UV Python Package Manager](/uv-get-start/)
- [Agno Getting Started](/agno-get-start/)
- [Top AI GitHub repos](/top-ai-github-repos/) — Agno, Mastra, OpenClaw, Hermes, memory, and more

&lt;Notice type=&quot;success&quot; title=&quot;Ready to Run&quot;&gt;
You now have a Discord AI bot with memory, knowledge search, team orchestration, and scheduled tasks. Clone the repo, run the setup wizard, and start chatting.
&lt;/Notice&gt;</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>discord</category></item><item><title>How to Update Docker Compose Stacks in Dokploy</title><link>https://www.bitdoze.com/dokploy-update-docker-compose/</link><guid isPermaLink="true">https://www.bitdoze.com/dokploy-update-docker-compose/</guid><description>Learn how to update Docker Compose applications in Dokploy using the latest tag or pinned versions. Manual updates and automated solutions with Tugtainer.</description><pubDate>Thu, 29 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import ListCheck from &quot;../../components/widgets/ListCheck.astro&quot;;

When you deploy Docker Compose apps in Dokploy, you eventually need to update them. Whether it&apos;s for security patches, bug fixes, or new features, knowing the right way to update matters. This guide covers the two main approaches—manual SSH updates and automated solutions.

New to Dokploy? Start with our [installation guide](https://www.bitdoze.com/dokploy-install/). For deploying your first app, see [How To Deploy A Docker Compose App in Dokploy](https://www.bitdoze.com/dokploy-docker-compose-app/).

## Why bother with updates?

Old container images sit there with known vulnerabilities. Regular updates give you:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Security patches** - CVE fixes for discovered vulnerabilities&lt;/li&gt;
&lt;li&gt;**Bug fixes** - Issues the maintainers finally resolved&lt;/li&gt;
&lt;li&gt;**New features** - Capabilities added since your last deployment&lt;/li&gt;
&lt;li&gt;**Compatibility** - Keeping up with API changes and dependencies&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Backup before updates&quot;&gt;
Have backups ready before you start. Our [Dokploy backups guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/) shows how to set them up.
&lt;/Notice&gt;

## Option A: Using the latest tag

When your compose file uses `latest` tags, you have two update methods.

### Method 1: Manual SSH update

Connect to your server and pull the new images manually.

**Step 1: SSH into your server**

```bash
ssh username@your-vps-ip
```

**Step 2: Find your application directory**

Dokploy stores compose files here:

```bash
cd /etc/dokploy/compose/[app-name]-[random-suffix]/code
```

For example:

```bash
cd /etc/dokploy/compose/myapp-a1b2c3d/code
```

**Step 3: Pull the latest images**

```bash
docker compose pull
```

This grabs the newest versions of all images using the `latest` tag.

**Step 4: Reload in Dokploy**

1. Open Dokploy dashboard
2. Go to your project
3. Click **General** tab
4. Hit **Reload**

![Dokploy reload](../../assets/images/26/01/dokploy-compose.webp)

The reload recreates containers with fresh images while keeping your volumes intact.

### Method 2: Automated updates with Tugtainer

[Tugtainer](https://www.bitdoze.com/tugtainer-docker-autoupdate/) is a self-hosted tool that watches your containers and handles updates automatically. No more SSH sessions just to pull new images.

**What Tugtainer gives you:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Web interface** - Manage everything from a browser&lt;/li&gt;
&lt;li&gt;**Scheduled checks** - Automatically polls for new images&lt;/li&gt;
&lt;li&gt;**Smart ordering** - Updates containers in the right sequence&lt;/li&gt;
&lt;li&gt;**Notifications** - Alerts via Discord, Telegram, Slack, or email&lt;/li&gt;
&lt;li&gt;**Multi-server** - One interface for multiple hosts&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

![Tugtainer containers list view showing detailed container information](../../assets/images/26/01/tugtainer-all-containers.webp)

Set it up once, and Tugtainer handles the rest. You can enable full auto-updates or just get notified when new versions appear. I use it on my own servers—it beats SSHing in every time an update drops.

&lt;Button link=&quot;https://www.bitdoze.com/tugtainer-docker-autoupdate/&quot; text=&quot;Tugtainer setup guide&quot; /&gt;

## Option B: Using pinned versions

If you specify exact versions (like `image: postgres:16-alpine` instead of `latest`), the update workflow changes slightly.

### Updating pinned versions

**Step 1: Edit your compose file in Dokploy**

1. Go to your project in the Dokploy UI
2. Click **General** tab
3. Find the **Raw** section
4. Change the image version

Example:
```yaml
image: flowiseai/flowise:1.0.0
```

Becomes:
```yaml
image: flowiseai/flowise:1.1.0
```

![Dokploy reload](../../assets/images/26/01/dokploy-compose.webp)

**Step 2: Save**

Click **Save** to store the change.

**Step 3: Reload**

Click **Reload** to apply it. Dokploy pulls the new version and recreates the container.

&lt;Notice type=&quot;info&quot; title=&quot;Why pin versions?&quot;&gt;
Pinned versions let you control exactly what runs in production. You decide when to upgrade, and you know what changed. `latest` tags can surprise you with breaking changes.
&lt;/Notice&gt;

## Best practices for updates

### Test before production

Never push updates straight to production without checking them first:

1. **Use a staging environment** - Mirror your production setup somewhere safe
2. **Read the changelogs** - Know what you&apos;re getting into
3. **Verify compatibility** - Make sure your config still works

### When to update

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Security patches** - Apply these as soon as you&apos;ve tested them&lt;/li&gt;
&lt;li&gt;**Feature updates** - Schedule during your maintenance windows&lt;/li&gt;
&lt;li&gt;**Major versions** - Plan these carefully, test thoroughly&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Rolling back

When an update breaks something:

1. **Revert the compose file** (for pinned versions) or pull the old image
2. **Click Reload** in Dokploy to go back
3. **Check the logs** to see what went wrong

If you followed our [backup guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/), you can restore data if needed.

## Related guides

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-install/&quot; text=&quot;Dokploy installation&quot; /&gt;

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-docker-compose-app/&quot; text=&quot;Deploy Docker Compose apps&quot; /&gt;

&lt;Button link=&quot;https://www.bitdoze.com/tanstack-start-dokploy-deploy/&quot; text=&quot;Deploy TanStack Start&quot; /&gt;

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-python-railpack-uv/&quot; text=&quot;Deploy Python with uv&quot; /&gt;

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-backups-cloudflare-r2/&quot; text=&quot;Configure backups&quot; /&gt;

&lt;Button link=&quot;https://www.bitdoze.com/tugtainer-docker-autoupdate/&quot; text=&quot;Tugtainer auto-updater&quot; /&gt;

## Final thoughts

There are two main ways to handle updates in Dokploy. `latest` tags are convenient but need either manual SSH work or a tool like Tugtainer. Pinned versions give you control—you decide exactly when and what to update.

Pick what works for you:

- **Manual SSH** for simple setups with occasional updates
- **Tugtainer** for hands-off automation across multiple apps
- **Pinned versions** when stability matters most

Keep your apps updated, keep backups ready, and you&apos;ll avoid most self-hosting headaches.</content:encoded><category>self-hosting</category><category>dokploy</category><category>docker</category><category>devops</category></item><item><title>OpenClaw (Clawdbot) Setup Guide: Your 24/7 AI Assistant on VPS or Mac Mini</title><link>https://www.bitdoze.com/clawdbot-setup-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/clawdbot-setup-guide/</guid><description>Complete guide to setting up OpenClaw (formerly Moltbot/Clawdbot) on Hetzner VPS or Mac Mini. Covers installation, Telegram integration, LLM providers, skills, memory, and configuration.</description><pubDate>Mon, 26 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import Tabs from &quot;@components/widgets/Tabs.astro&quot;;
import Tab from &quot;@components/widgets/Tab.astro&quot;;

I&apos;ve been running OpenClaw on a Hetzner VPS for over a week now. It&apos;s hooked up to Slack and I&apos;ve been using GLM 4.7 with the coding plan. Recently switched to Gemini CLI with Gemini 3 Flash instead. Figured I&apos;d share what I learned getting it set up and actually useful.

&lt;Notice type=&quot;warning&quot; title=&quot;Name Evolution: From Clawdbot to Moltbot to OpenClaw&quot;&gt;
Yeah, this thing has had a bit of an identity crisis. Started as **Clawdbot**, became **Moltbot** after some trademark pressure, and now it&apos;s settled on **OpenClaw**. Install URL moved to `openclaw.ai` and all the commands are `openclaw` now. I might mention the old names here and there for context, but everything current points to OpenClaw.
&lt;/Notice&gt;

OpenClaw is an open-source AI assistant that lives on a server 24/7 and chats with you through Telegram, WhatsApp, Slack, or Discord. Unlike ChatGPT or Claude where you go to their site, this comes to you. It remembers stuff across conversations and can ping you when something needs attention.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/9Txk3SpB6fg&quot;
  label=&quot;I Built a 24/7 Personal AI Assistant (And It&apos;s Why Mac Minis are Sold Out!)&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;What This Guide Covers&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Mac Mini vs VPS hosting — which makes sense for you&lt;/li&gt;
&lt;li&gt;Step-by-step installation on Hetzner or any Linux VPS&lt;/li&gt;
&lt;li&gt;Telegram, WhatsApp, Slack, and Discord integration&lt;/li&gt;
&lt;li&gt;LLM provider options (Anthropic, OpenAI, Gemini, local models)&lt;/li&gt;
&lt;li&gt;Memory system and how to make things stick&lt;/li&gt;
&lt;li&gt;Skills and config files for customization&lt;/li&gt;
&lt;li&gt;Commands you&apos;ll actually use&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

If you&apos;re new to AI coding tools, our [AI Programming Beginners Guide](https://www.bitdoze.com/ai-programming-beginners-guide/) covers the basics. For running local models, see [how to set up Ollama with Docker](https://www.bitdoze.com/ollama-docker-install/). If you want something lighter, our [nanobot setup guide](/nanobot-setup-guide/) covers a 3,700-line alternative that installs with pip.

## What OpenClaw Actually Does

Most AI tools wait for you to ask something. OpenClaw actually does stuff — clears your inbox, schedules meetings, researches companies, follows up on tasks, runs automations on your server.

The architecture looks like this:

```
You (Telegram/WhatsApp/Slack)
    ↓
OpenClaw Gateway (running on VPS)
    ↓
LLM Provider (Anthropic, OpenAI, Gemini, etc.)
    ↓
Tools and Skills (file access, web search, calendar, etc.)
```

The Gateway just runs in the background on your server. Messages come in from your messaging app, get routed to whatever AI model you picked, and the model can actually run commands on your server — move files, run scripts, browse the web.

Everything stays on your machine except the actual AI calls. Your data isn&apos;t living on some company server somewhere.

## Mac Mini vs VPS: Which One to Choose

Kind of a funny situation — the OpenClaw community caused a minor run on Mac Mini M4s. People were buying them just to run this thing. Here&apos;s how to decide which route makes sense for you.

### Mac Mini M4 Advantages

| Feature | Mac Mini | VPS |
|---------|----------|-----|
| iMessage support | Yes | No |
| Native macOS apps | Yes | No |
| Local model inference | Excellent (M4 chip) | Limited |
| Power consumption | ~10W idle | N/A (hosted) |
| Upfront cost | $599-1299 | $0 |
| Monthly cost | ~$3 electricity | $5-50 |
| Physical access required | Yes | No |

If you want iMessage, Mac Mini is your only choice. The M4 chip also handles local models well if you&apos;re trying to avoid API bills. But you need solid home internet and power — no outages.

### VPS Advantages

For most people, a VPS is the better call:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;No hardware to maintain or keep running&lt;/li&gt;
&lt;li&gt;Redundant power and network&lt;/li&gt;
&lt;li&gt;Can access from anywhere without port forwarding&lt;/li&gt;
&lt;li&gt;Easier to set up and migrate&lt;/li&gt;
&lt;li&gt;Lower entry cost — $5.49/month at Hetzner gets you started&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

I&apos;m using a [Hetzner CX33](https://www.bitdoze.com/hetzner-cloud-review/) (4 vCPU, 8GB RAM) for €5.49/month. It handles OpenClaw comfortably with plenty of headroom for other services.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started with Hetzner&quot;&gt;
[Get €20 credit](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) when you sign up through our referral link. That covers nearly 4 months of running OpenClaw.
&lt;/Notice&gt;

### When to Choose Mac Mini

- You need iMessage as your primary channel
- You want to run local models (Llama, Mistral) to avoid API costs
- You prefer hardware you physically control
- You already have a Mac Mini sitting around

### When to Choose VPS

- Telegram, WhatsApp, Slack, or Discord are your primary channels
- You don&apos;t want to deal with home networking
- You need 24/7 uptime without worrying about power outages
- You&apos;re okay paying for API calls

## Installation on Hetzner VPS

Takes about 20 minutes start to finish. I&apos;m using Hetzner, but these steps work on pretty much any Ubuntu VPS.

### Create the Server

1. Go to [Hetzner Cloud Console](https://console.hetzner.cloud/)
2. Create a new project named `openclaw`
3. Click **Create Server**
4. Select:
   - **Location**: Germany (cheapest) or your nearest region
   - **Image**: Ubuntu 24.04
   - **Type**: CX33 (4 vCPU, 8GB RAM) — €5.49/mo
   - **IPv4**: Keep enabled
5. Add your SSH key (generate one with `ssh-keygen -t ed25519` if you don&apos;t have one)
6. Name it `openclaw` and create

Wait 30 seconds for the server to boot, then grab the IP address.

### Install OpenClaw

SSH into your server:

```bash
ssh root@YOUR_SERVER_IP
```

Update the system and install Node.js 22:

```bash
apt update &amp;&amp; apt upgrade -y
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
```

Install OpenClaw and run the onboarding wizard:

```bash
curl -fsSL https://openclaw.ai/install.sh | bash
```

The wizard walks you through everything:
- Local vs Remote gateway selection
- Model authentication (API keys or OAuth)
- Channel setup (Telegram, WhatsApp, etc.)
- Background service installation

## LLM Provider Options

OpenClaw works with several AI providers. Here&apos;s the breakdown:

### Anthropic (Claude)

This is what I&apos;d recommend for most people. Two ways to connect:

**API Key** (recommended):
1. Go to [console.anthropic.com](https://console.anthropic.com/)
2. Create an API key (starts with `sk-ant-`)
3. Paste it when the wizard asks

**Claude Code OAuth** (uses your subscription):
If you have Claude Pro/Max, you can use your subscription instead of paying for API credits:
```bash
claude setup-token
```
This generates a token you paste into the wizard. Works with Claude Code CLI.

### OpenAI

For GPT-5 and Codex models:
1. Go to [platform.openai.com](https://platform.openai.com/)
2. Create an API key
3. Enter it during onboarding

If you have a ChatGPT Plus or Codex subscription, you can use OAuth similar to Claude Code.

### Google Gemini

I&apos;ve been using Gemini 3 Flash recently. It&apos;s fast and cheap:

```bash
openclaw configure --section models
```

For Gemini CLI OAuth setup, the wizard will prompt you to authenticate through Google.

### Local Models (Ollama)

If you want to avoid API costs entirely, OpenClaw works with [Ollama](https://www.bitdoze.com/ollama-docker-install/). Install Ollama on your server:

```bash
curl -fsSL https://ollama.ai/install.sh | sh
ollama pull llama3.2
```

Then configure OpenClaw to use local models in your config.

### Cost Comparison

| Provider | Model | Typical Monthly Cost |
|----------|-------|---------------------|
| Anthropic | Claude Sonnet 4.5 | $15-50 |
| Anthropic | Claude Opus 4.5 | $50-150 |
| OpenAI | GPT-5 | $20-70 |
| Google | Gemini 3 Flash | $10-30 |
| Local | Llama 3.2 (Ollama) | $0 (compute only) |

Costs bounce around a lot depending on how much you use it. Keep an eye on your first month&apos;s bill. For cheaper alternatives like MiniMax M2.5 and GLM-5, see our [nanobot setup guide](/nanobot-setup-guide/) which covers providers that cost a fraction of Anthropic and OpenAI.

If you want to use your existing Claude or ChatGPT subscription with other tools, check out [VibeProxy](https://www.bitdoze.com/vibeproxy-ai-subscriptions-guide/) for routing subscription access.

## Channel Setup

### Telegram (Easiest)

1. Open Telegram and search for **@BotFather**
2. Send `/newbot`
3. Choose a name and username for your bot
4. Copy the bot token BotFather gives you
5. Search for **@userinfobot** and send any message to get your user ID
6. Paste both into the OpenClaw wizard

The user ID goes in `allowFrom` to restrict who can talk to your bot. Without this, anyone who finds your bot can chat with it.

### WhatsApp

WhatsApp uses QR login:

```bash
openclaw channels login
```

Scan the QR code from WhatsApp &gt; Settings &gt; Linked Devices. Your personal WhatsApp number becomes the bot&apos;s number — messages you send to yourself go to OpenClaw.

### Slack

For teams:
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps)
2. Add Bot Token Scopes: `chat:write`, `app_mentions:read`, `im:history`, `im:read`, `im:write`
3. Install to your workspace
4. Copy the Bot User OAuth Token
5. Paste into OpenClaw config

I run OpenClaw in a dedicated Slack channel. Mention @openclaw to interact.

### Discord

1. Create a Discord application at [discord.com/developers](https://discord.com/developers/applications)
2. Create a bot user and copy the token
3. Invite the bot to your server with appropriate permissions
4. Add the token to OpenClaw config

## Memory System

OpenClaw remembers stuff from previous chats. The memory setup has a few parts:

### How Memory Works

- **Short-term**: Current conversation context
- **Long-term**: Facts about you stored in `~/.openclaw/workspace/MEMORY.md` and `~/.openclaw/workspace/USER.md`
- **Semantic search**: Finds relevant past conversations (requires OpenAI API for embeddings)

### Making Things Stick

If OpenClaw keeps forgetting something, just tell it straight up:

&gt; &quot;Remember: I prefer meeting invites on Google Calendar, not Outlook.&quot;

It stores this in your memory files. You can also edit them directly:

```bash
nano ~/.openclaw/workspace/USER.md
```

Add your preferences, work context, project details — anything you want it to always know.

### Memory and Config Files

Workspace files live in `~/.openclaw/workspace/`. For the full list of configuration options, see the [AGENTS.default reference](https://docs.openclaw.ai/reference/AGENTS.default).

| File | Purpose |
|------|---------|
| `~/.openclaw/workspace/USER.md` | Your personal info and preferences |
| `~/.openclaw/workspace/SOUL.md` | Bot personality and behavior rules |
| `~/.openclaw/workspace/MEMORY.md` | Long-term memories it accumulates |
| `~/.openclaw/workspace/AGENTS.md` | Agent configuration for specialized tasks |

## Config Files

The main config lives at `~/.openclaw/config.json` (note: this is directly in `~/.openclaw/`, not in the workspace subfolder). Key sections:

### Model Configuration

```json
{
  &quot;agents&quot;: {
    &quot;defaults&quot;: {
      &quot;model&quot;: {
        &quot;primary&quot;: &quot;anthropic/claude-sonnet-4-5&quot;,
        &quot;fallback&quot;: [&quot;google/gemini-3-flash&quot;]
      }
    }
  }
}
```

### Channel Settings

```json
{
  &quot;channels&quot;: {
    &quot;telegram&quot;: {
      &quot;enabled&quot;: true,
      &quot;token&quot;: &quot;YOUR_BOT_TOKEN&quot;,
      &quot;allowFrom&quot;: [&quot;YOUR_USER_ID&quot;]
    }
  }
}
```

### Editing Config

Use the built-in configure command:

```bash
openclaw configure --section channels
openclaw configure --section models
```

Or edit directly and restart:

```bash
nano ~/.openclaw/config.json
openclaw gateway restart
```

For MCP (Model Context Protocol) integration, see our [MCP Introduction for Beginners](https://www.bitdoze.com/mcp-introduction-beginners/).

## Skills

Skills are basically reusable workflows OpenClaw can execute. They add capabilities beyond just chatting.

### Built-in Skills

Some capabilities work immediately:
- File management (organize folders, find files)
- Web search (requires Brave API key)
- Calendar and email (with proper authentication)
- Running scripts and commands

### Adding Skills

Browse community skills at the OpenClaw registry. Install with:

```bash
openclaw skill install skill-name
```

### Creating Custom Skills

Skills are defined in `~/.openclaw/workspace/skills/`. A skill is basically a prompt template with parameters:

```markdown
# Research Skill

Research {topic} and provide:
- 3 key findings
- Relevant sources
- Next steps
```

You can also ask OpenClaw to create skills for you:

&gt; &quot;Create a skill that checks my GitHub repos for new issues every morning.&quot;

### Custom Skill Folders

Load skills from a custom location:

```json
{
  &quot;skills&quot;: {
    &quot;paths&quot;: [&quot;~/.openclaw/workspace/skills&quot;, &quot;~/my-custom-skills&quot;]
  }
}
```

### Coding Tool Skills (Claude Code, Codex, Gemini CLI)

One of OpenClaw&apos;s most powerful skills lets it control AI coding tools directly. If you have Claude Code, OpenAI Codex CLI, or Gemini CLI installed on your server, OpenClaw can orchestrate them to build entire applications.

This means you can message OpenClaw from your phone:

&gt; &quot;Use Claude Code to create a FastAPI app with user authentication and deploy it to my server.&quot;

And OpenClaw will:
1. Launch Claude Code (or Codex/Gemini CLI)
2. Pass your requirements as prompts
3. Monitor the coding session
4. Report back when it&apos;s done or if it hits issues

**Setting it up:**

Make sure your coding tool CLI is installed and authenticated on the server:

```bash
# For Claude Code
claude --version
claude login

# For Gemini CLI
gemini --version

# For OpenAI Codex
codex --version
```

Then tell OpenClaw what&apos;s available:

&gt; &quot;I have Claude Code installed. When I ask you to build something, use it to write the code.&quot;

OpenClaw stores this in memory and uses the appropriate tool when you request coding tasks. This effectively turns your $20/month Claude subscription into an on-demand development team you can command from Telegram.

For more on these coding tools, see our [AI coding tools comparison](https://www.bitdoze.com/ai-coading-tools/).

## Scheduled Jobs and Reminders

OpenClaw can run cron jobs for scheduled stuff. This is where it stops being just a chatbot and starts actually automating things.

### Setting Up Reminders

Simple reminders work out of the box:

&gt; &quot;Remind me to check server logs every Monday at 9am.&quot;

&gt; &quot;In 3 hours, remind me to review the pull request.&quot;

&gt; &quot;Every Friday at 5pm, send me a summary of what I accomplished this week.&quot;

OpenClaw stores these and proactively messages you at the scheduled time.

### Cron-style Scheduling

For more complex schedules, OpenClaw supports cron expressions:

&gt; &quot;Run a disk space check every day at midnight.&quot;

&gt; &quot;Every 6 hours, check if my website is responding and alert me if it&apos;s down.&quot;

Behind the scenes, OpenClaw uses the server&apos;s cron system. You can view scheduled jobs:

```bash
openclaw cron list
```

### Proactive Notifications

Unlike ChatGPT or Claude web, OpenClaw messages you first. Set up monitoring:

&gt; &quot;Monitor my server&apos;s CPU usage. If it goes above 80% for more than 5 minutes, message me.&quot;

&gt; &quot;Watch my inbox for emails from [client]. When one arrives, summarize it and send to me immediately.&quot;

This &quot;heartbeat&quot; functionality runs periodic checks and surfaces relevant updates without you asking.

### Example: Daily Standup Assistant

Here&apos;s a practical automation I use:

&gt; &quot;Every weekday at 8:30am:
&gt; 1. Check my calendar for today&apos;s meetings
&gt; 2. Scan my email for urgent items
&gt; 3. Look at my GitHub notifications
&gt; 4. Send me a single message summarizing all of this&quot;

One message in Telegram and I know exactly what needs attention before I open my laptop.

## Enhancing OpenClaw with Agno and Python Scripts

This is where it gets fun. [Agno](https://www.bitdoze.com/agno-get-start/) is a Python framework for building AI agents. Pair it with [uv](https://www.bitdoze.com/uv-get-start/) for fast package management and you can build scripts that OpenClaw runs for you.

### Why Agno + OpenClaw?

OpenClaw handles the messaging side. Agno handles the complex AI work — multi-agent setups, RAG pipelines, tool use. The flow goes:

- You message OpenClaw on Telegram
- OpenClaw runs your Agno script
- Agno does the actual work — research, analysis, whatever
- Results come back through OpenClaw

### Setting Up the Environment

Install uv on your server (it&apos;s much faster than pip):

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

Create a scripts directory for OpenClaw:

```bash
mkdir -p ~/openclaw-scripts
cd ~/openclaw-scripts
uv init
uv add agno openai duckduckgo-search
```

### Example: Research Agent Script

Create a research agent that OpenClaw can invoke:

```python
#!/usr/bin/env python3
# ~/openclaw-scripts/research_agent.py
# /// script
# requires-python = &quot;&gt;=3.11&quot;
# dependencies = [&quot;agno&quot;, &quot;openai&quot;, &quot;duckduckgo-search&quot;]
# ///

import sys
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from agno.tools.duckduckgo import DuckDuckGoTools

def research(topic: str) -&gt; str:
    agent = Agent(
        model=OpenAIChat(id=&quot;gpt-4o&quot;),
        tools=[DuckDuckGoTools()],
        instructions=[
            &quot;You are a research assistant.&quot;,
            &quot;Search the web for current information.&quot;,
            &quot;Provide concise, factual summaries.&quot;,
        ],
        markdown=True,
    )
    
    response = agent.run(f&quot;Research this topic and provide key findings: {topic}&quot;)
    return response.content

if __name__ == &quot;__main__&quot;:
    topic = &quot; &quot;.join(sys.argv[1:]) if len(sys.argv) &gt; 1 else &quot;AI agents&quot;
    print(research(topic))
```

Make it executable:

```bash
chmod +x ~/openclaw-scripts/research_agent.py
```

### Connecting Scripts to OpenClaw

Tell OpenClaw about your scripts:

&gt; &quot;I have a Python script at ~/openclaw-scripts/research_agent.py that does web research. When I ask you to research something complex, run it with uv like this: `uv run ~/openclaw-scripts/research_agent.py [topic]`&quot;

Now you can message:

&gt; &quot;Research the latest developments in autonomous AI agents&quot;

OpenClaw runs your Agno script, which spawns its own AI agent to do web searches, and returns the results.

### Example: Multi-Agent Team

For complex tasks, create an Agno squad:

```python
#!/usr/bin/env python3
# ~/openclaw-scripts/content_team.py
# /// script
# requires-python = &quot;&gt;=3.11&quot;
# dependencies = [&quot;agno&quot;, &quot;openai&quot;, &quot;duckduckgo-search&quot;]
# ///

import sys
from agno.agent import Agent
from agno.models.openai import OpenAIChat
from agno.tools.duckduckgo import DuckDuckGoTools

def create_content(topic: str) -&gt; str:
    researcher = Agent(
        name=&quot;Researcher&quot;,
        model=OpenAIChat(id=&quot;gpt-4o&quot;),
        tools=[DuckDuckGoTools()],
        instructions=[&quot;Research topics thoroughly&quot;, &quot;Find current data and trends&quot;],
    )
    
    writer = Agent(
        name=&quot;Writer&quot;, 
        model=OpenAIChat(id=&quot;gpt-4o&quot;),
        instructions=[&quot;Write clear, engaging content&quot;, &quot;Use the research provided&quot;],
    )
    
    # Researcher gathers info
    research = researcher.run(f&quot;Research: {topic}&quot;)
    
    # Writer creates content based on research
    article = writer.run(
        f&quot;Write a blog post about {topic} using this research:\n\n{research.content}&quot;
    )
    
    return article.content

if __name__ == &quot;__main__&quot;:
    topic = &quot; &quot;.join(sys.argv[1:]) if len(sys.argv) &gt; 1 else &quot;AI trends&quot;
    print(create_content(topic))
```

Message OpenClaw:

&gt; &quot;Run the content team script for &apos;best practices for Docker security&apos;&quot;

For more Agno examples, check out our [Agno multi-agent guide](https://www.bitdoze.com/agno-squad/) and [Agno with MCP tools](https://www.bitdoze.com/agno-mcp-tools-context7/).

### Practical Script Ideas

Scripts you might build:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Market research bot**: Agno agent that searches news, analyzes sentiment, summarizes findings&lt;/li&gt;
&lt;li&gt;**Code reviewer**: Script that reads a GitHub PR and provides feedback&lt;/li&gt;
&lt;li&gt;**Meeting prep**: Agent that researches attendees before a call&lt;/li&gt;
&lt;li&gt;**Competitor monitor**: Daily check of competitor websites and social media&lt;/li&gt;
&lt;li&gt;**Content repurposer**: Turn a blog post into social media threads&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The pattern is always:
1. Write an Agno script for the complex task
2. Store it in your scripts directory
3. Tell OpenClaw how to run it
4. Trigger it via message or schedule

## Commands Reference

### Gateway Management

| Command | Description |
|---------|-------------|
| `openclaw status` | Check if everything is running |
| `openclaw gateway start` | Start the gateway in foreground |
| `openclaw gateway restart` | Restart the background service |
| `openclaw gateway stop` | Stop the service |
| `openclaw logs --follow` | View live logs |
| `openclaw health` | Run health checks |

### Chat Commands

Send these in your messaging app:

| Command | Description |
|---------|-------------|
| `/new` | Start a fresh conversation |
| `/model` | Switch AI models |
| `/compact` | Compress long conversations |
| `/status` | Bot status info |
| `/verbose on` | Show detailed responses |
| `/verbose off` | Hide internal messages |
| `stop` or `abort` | Cancel a running task |

### Configuration

| Command | Description |
|---------|-------------|
| `openclaw configure` | Interactive config editor |
| `openclaw onboard` | Re-run setup wizard |
| `openclaw reset` | Reset to defaults |
| `openclaw doctor` | Diagnose common issues |

## Practical Uses

Stuff that actually works:

### Morning Briefings

&gt; &quot;Every weekday at 7am, check my calendar and email, then send me a summary of what needs attention today.&quot;

### Research Tasks

&gt; &quot;Research the top 5 competitors to [company]. Give me a one-page summary with strengths and weaknesses.&quot;

### File Management

&gt; &quot;Organize my Downloads folder. Put PDFs in Documents/PDFs, images in Pictures, and delete anything older than 30 days.&quot;

### Code Assistance

&gt; &quot;Review the latest commit in my repo and flag any security concerns.&quot;

### Reminders and Follow-ups

&gt; &quot;Remind me to follow up with [person] about [topic] in 3 days.&quot;

### What Requires More Setup

These work but need custom skills or integrations:
- Full email management with auto-responses
- Trading alerts and market monitoring
- Multi-platform social media posting
- CRM integration

See our [best AI coding tools](https://www.bitdoze.com/ai-coading-tools/) article for tools that handle specific developer workflows.

## Troubleshooting

### Bot Not Responding

```bash
openclaw status --all
openclaw logs --follow
```

Usually one of these:
- Gateway crashed or isn&apos;t running
- API key expired or got rotated
- Channel token is wrong or expired

### Pairing Code Required

Default security requires approving new senders:

```bash
openclaw pairing list telegram
openclaw pairing approve telegram &lt;code&gt;
```

### Context Too Large

Long conversations hit token limits:

```
/compact
```

Or start fresh:

```
/new
```

### SSH Connection Issues

If SSH asks for a password when you have a key:

```bash
ssh -i ~/.ssh/id_ed25519 root@YOUR_SERVER_IP
```

### Need to Redo Setup

```bash
openclaw reset
openclaw onboard --install-daemon
```

## Security Considerations

OpenClaw has access to your server. Some things to think about:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Use `allowFrom` to restrict who can message your bot&lt;/li&gt;
&lt;li&gt;Create a separate email/GitHub for the bot if it needs account access&lt;/li&gt;
&lt;li&gt;Review the sandbox settings if running on a machine with sensitive data&lt;/li&gt;
&lt;li&gt;Keep API keys out of memory files and conversations&lt;/li&gt;
&lt;li&gt;Monitor API usage — runaway tasks can burn through credits&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The default pairing mode means unknown senders get a code instead of bot access. Don&apos;t disable this unless you understand the implications.

## What&apos;s Next

Once you have OpenClaw running:

1. **Customize personality**: Edit `~/.openclaw/workspace/SOUL.md` to change how it communicates
2. **Add your context**: Fill in `~/.openclaw/workspace/USER.md` with your work and preferences
3. **Check all config options**: See the [AGENTS.default reference](https://docs.openclaw.ai/reference/AGENTS.default) for the full list of settings
4. **Install useful skills**: Browse the OpenClaw skill registry
5. **Add web search**: Get a [Brave API key](https://brave.com/search/api/) for web browsing capability
6. **Connect more tools**: GitHub, Google Drive, calendar integrations

The [official docs](https://docs.openclaw.ai/) have detailed setup guides for specific integrations.

&lt;Accordion label=&quot;Frequently Asked Questions&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;

**Do I need technical skills to set up OpenClaw?**

Basic command line comfort helps. If you can SSH into a server and copy-paste commands, you can set it up. The wizard handles most complexity.

**How much does it cost to run?**

VPS: ~$5.50/month at Hetzner. API costs: $15-100/month depending on usage and model choice. Total: $20-110/month for most users.

**Can multiple people use one OpenClaw?**

Yes. Add multiple user IDs to `allowFrom`. Each person gets their own conversation context. For teams, Slack or Discord work better than individual Telegram bots.

**Does it work on Windows?**

Use WSL2 (Windows Subsystem for Linux). Native Windows support is untested and has compatibility issues.

**Can I run it on a Raspberry Pi?**

Yes, but it&apos;s borderline. Pi 4/5 with 4GB+ RAM works. Expect slower performance and occasional memory issues with larger conversations.

**What&apos;s the difference between stable and beta?**

Stable gets tested features. Beta gets new stuff first but may have bugs. For production use, stick with stable. To try new features: `openclaw update --beta`

&lt;/Accordion&gt;

OpenClaw isn&apos;t magic. It&apos;s a tool for running AI assistants that fit into how you already work. Setup is a bit of work, no doubt. But having an assistant that&apos;s actually available 24/7 and does stuff — not just chats about doing stuff — has been worth the hassle for me.

Before exposing your instance to the internet or installing skills from ClawHub, read the [OpenClaw security guide](/openclaw-security-guide/) covering CVE-2026-25253 and the 40+ vulnerability fixes shipped in recent releases.

If you want to explore what else is out there, check out our [OpenClaw alternatives](/openclaw-alternatives/) roundup covering NanoClaw, nanobot, memU, and bitdoze-bot. For a detailed walkthrough of nanobot with MiniMax M2.5 and GLM-5, see our [nanobot setup guide](/nanobot-setup-guide/). If you&apos;d rather run models locally instead of paying for APIs, see our [OpenClaw with Ollama guide](/openclaw-ollama-local-models/) for hardware recommendations and configuration. Once OpenClaw is running, you might also want a proper UI for it — our [best OpenClaw dashboards](/best-openclaw-dashboards/) guide covers nine community-built options from full multi-agent orchestration to lightweight terminal monitors. For the wider map of AI tools on GitHub (Hermes, Pi, OpenCode, memory, gateways), see [top AI GitHub repos](/top-ai-github-repos/).</content:encoded><category>ai</category><category>ai-tools</category><category>self-hosted</category><category>vps</category></item><item><title>How to Self-Host Cloudreve with Docker and Cloudflare Tunnels</title><link>https://www.bitdoze.com/cloudreve-docker-setup/</link><guid isPermaLink="true">https://www.bitdoze.com/cloudreve-docker-setup/</guid><description>Learn how to deploy Cloudreve on your home server using Docker Compose and Dockge, then expose it securely with Cloudflare Tunnels for external access.</description><pubDate>Fri, 23 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Tabs from &quot;../../components/widgets/Tabs.astro&quot;;
import Tab from &quot;../../components/widgets/Tab.astro&quot;;
import Notice from &quot;../../components/widgets/Notice.astro&quot;;
import Accordion from &quot;../../components/widgets/Accordion.astro&quot;;
import imag1 from &quot;../../assets/images/24/01/cloudflare-tunel-setup.png&quot;;

Cloudreve is an open-source file management system for building your own cloud storage. It works like Google Drive or Dropbox, except you run it on your own hardware and keep all the data.

The project supports local storage, S3-compatible backends, and OneDrive. You get drag-and-drop uploads, file previews in the browser, WebDAV access for mounting as a network drive, and Aria2 integration for downloading files directly to your server.

This guide covers deploying Cloudreve with Docker Compose and Dockge, using PostgreSQL and Redis for better performance than the default SQLite setup. I&apos;ll also show you how to expose it to the internet through Cloudflare Tunnels and configure remote downloads with Aria2.

## Cloudreve features

Cloudreve comes in two editions: Community (free and open source) and Pro (paid license). The Community edition covers most home server use cases.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Community Edition&quot;&gt;
    The free version includes:
    
    - **Multiple storage backends** - Local disk, S3-compatible services (Cloudflare R2, MinIO, Backblaze B2), OneDrive, SharePoint, and Chinese providers like Qiniu, Alibaba OSS, Tencent COS
    - **File sharing** - Generate share links with passwords and expiration dates
    - **WebDAV** - Mount your storage as a network drive on Windows, macOS, or Linux
    - **Browser previews** - View documents, images, videos, audio, ePub files, and code without downloading
    - **User management** - Create multiple users with different storage quotas and permissions
    - **Remote downloads** - Aria2 and qBittorrent integration for downloading from URLs, magnets, and torrents
    - **File compression** - Create and extract ZIP archives in the browser
    - **Thumbnails** - Automatic thumbnail generation for images and videos
    - **Media metadata** - Extract and search by EXIF data, video metadata, and custom tags
    - **OIDC authentication** - Single sign-on with Google, GitHub, or your own identity provider
    - **Customization** - Dark mode, custom themes, PWA support, multiple languages
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Pro Edition&quot;&gt;
    The Pro license adds features for larger deployments:
    
    - **Slave nodes** - Distribute storage and downloads across multiple servers
    - **Load balancing** - Spread traffic across storage backends
    - **Office document collaboration** - Real-time editing with WOPI integration (like Collabora or OnlyOffice)
    - **Custom payment providers** - Sell storage to users with your own payment gateway
    - **Priority support** - Direct access to the development team
    - **File encryption** - Encrypt files at rest on storage backends
    
    Pro licenses are purchased from [cloudreve.org](https://cloudreve.org). Pricing depends on deployment size.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

For a home server or small team, the Community edition has everything you need.

## Prerequisites

You&apos;ll need:

- A VPS or home server with Linux. I recommend [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for VPS hosting, or check out [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/) for local setups
- Docker and Dockge running on your server. See my [Dockge installation guide](https://www.bitdoze.com/dockge-install/) for setup instructions
- Cloudflare Tunnels configured if you want external access. The Dockge article covers this too

&lt;Button link=&quot;https://go.bitdoze.com/do&quot; text=&quot;DigitalOcean $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/vultr&quot; text=&quot;Vultr $100 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

&gt; Traefik works as an alternative to Cloudflare Tunnels. I wrote a separate guide: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/).

## Deploy Cloudreve with Docker Compose

This setup runs three containers: Cloudreve for the web interface and file handling, PostgreSQL for storing metadata and user data, and Redis for caching. PostgreSQL handles more users and larger file libraries better than SQLite.

### 1. Create the environment file

Keep credentials and configuration in a separate `.env` file. Dockge loads this automatically when you deploy the stack.

```bash
# .env file for Cloudreve

# PostgreSQL database credentials
POSTGRES_USER=cloudreve
POSTGRES_DB=cloudreve
POSTGRES_HOST_AUTH_METHOD=trust

# Cloudreve database connection
CR_DB_TYPE=postgres
CR_DB_HOST=postgresql
CR_DB_USER=cloudreve
CR_DB_NAME=cloudreve
CR_DB_PORT=5432

# Redis connection
CR_REDIS_SERVER=redis:6379
```

The `trust` authentication method works here because PostgreSQL only accepts connections from other containers in the same Docker network. Nothing outside can reach it directly.

### 2. Create the Docker Compose file

Here&apos;s the full stack with Cloudreve, PostgreSQL, and Redis. This configuration includes port 6888 for BitTorrent downloads (explained in the remote download section below):

```yaml
services:
  cloudreve:
    image: cloudreve/cloudreve:latest
    container_name: cloudreve-backend
    depends_on:
      - postgresql
      - redis
    restart: unless-stopped
    ports:
      - 5212:5212
      - 6888:6888
      - 6888:6888/udp
    environment:
      - CR_CONF_Database.Type=${CR_DB_TYPE}
      - CR_CONF_Database.Host=${CR_DB_HOST}
      - CR_CONF_Database.User=${CR_DB_USER}
      - CR_CONF_Database.Name=${CR_DB_NAME}
      - CR_CONF_Database.Port=${CR_DB_PORT}
      - CR_CONF_Redis.Server=${CR_REDIS_SERVER}
    volumes:
      - backend_data:/cloudreve/data

  postgresql:
    image: postgres:17
    container_name: cloudreve-db
    restart: unless-stopped
    environment:
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_HOST_AUTH_METHOD=${POSTGRES_HOST_AUTH_METHOD}
    volumes:
      - database_postgres:/var/lib/postgresql/data

  redis:
    image: redis:latest
    container_name: cloudreve-redis
    restart: unless-stopped
    volumes:
      - redis_data:/data

volumes:
  backend_data:
  database_postgres:
  redis_data:
```

&lt;Notice type=&quot;info&quot; title=&quot;About port 6888&quot;&gt;
Port 6888 (TCP and UDP) is used by Aria2 for BitTorrent peer connections. If you don&apos;t plan to use torrent downloads, you can remove these port mappings. The web interface only needs port 5212.
&lt;/Notice&gt;

### Understanding the Docker volumes

Docker volumes store data outside the container filesystem. When you restart or update a container, the volume keeps your files safe.

This stack uses three named volumes:

| Volume | Location inside container | What it stores |
|--------|--------------------------|----------------|
| `backend_data` | `/cloudreve/data` | Your uploaded files, avatars, thumbnails, and Cloudreve configuration |
| `database_postgres` | `/var/lib/postgresql/data` | PostgreSQL database files containing user accounts, file metadata, and share links |
| `redis_data` | `/data` | Redis cache data for session storage and performance |

Named volumes like `backend_data` are managed by Docker and stored in `/var/lib/docker/volumes/` on your host. You don&apos;t need to create folders manually.

**Using a custom storage location**

If you prefer storing files in a specific location on your host (for example, a separate drive for media), replace the named volume with a bind mount:

```yaml
volumes:
  - /mnt/storage/cloudreve:/cloudreve/data
```

This maps `/mnt/storage/cloudreve` on your host directly to `/cloudreve/data` inside the container. Create the folder and set permissions before starting the stack:

```bash
sudo mkdir -p /mnt/storage/cloudreve
sudo chown -R 1000:1000 /mnt/storage/cloudreve
```

**Adding external storage directories**

To use an existing directory like `/media/storage/downloads` with Cloudreve, you need to mount it into the container. Add it to the volumes section:

```yaml
services:
  cloudreve:
    image: cloudreve/cloudreve:latest
    container_name: cloudreve-backend
    volumes:
      - backend_data:/cloudreve/data
      - /media/storage/downloads:/cloudreve/downloads
```

This makes your `/media/storage/downloads` folder available inside the container at `/cloudreve/downloads`. You can add multiple directories:

```yaml
volumes:
  - backend_data:/cloudreve/data
  - /media/storage/downloads:/cloudreve/downloads
  - /media/storage/media:/cloudreve/media
  - /media/storage/backups:/cloudreve/backups
```

&lt;Notice type=&quot;warning&quot; title=&quot;Storage policies required&quot;&gt;
Mounting a directory doesn&apos;t automatically make files visible in Cloudreve. You need to create a storage policy pointing to that path. See the &quot;Configure storage policies&quot; section below for setup instructions.
&lt;/Notice&gt;

After adding volumes, restart the stack:

```bash
docker compose down &amp;&amp; docker compose up -d
```

### 3. Deploy with Dockge

Dockge gives you a web interface for managing Docker Compose stacks. Here&apos;s the deployment process:

1. Open Dockge in your browser (usually `http://your-server-ip:5001`)
2. Click the **+ Compose** button in the top right
3. Enter `cloudreve` as the stack name
4. Paste the Docker Compose content into the editor
5. Click the **Environment Variables** section below the editor
6. Add each variable from the `.env` file:
   - `POSTGRES_USER` = `cloudreve`
   - `POSTGRES_DB` = `cloudreve`
   - `POSTGRES_HOST_AUTH_METHOD` = `trust`
   - `CR_DB_TYPE` = `postgres`
   - `CR_DB_HOST` = `postgresql`
   - `CR_DB_USER` = `cloudreve`
   - `CR_DB_NAME` = `cloudreve`
   - `CR_DB_PORT` = `5432`
   - `CR_REDIS_SERVER` = `redis:6379`
7. Click **Save**
8. Click **Start** to deploy the stack

Dockge stores stack files in `/opt/stacks/cloudreve/` by default. You&apos;ll find `compose.yaml` there if you need to edit it later.

After clicking Start, Dockge shows real-time logs from all three containers. Wait until you see Cloudreve&apos;s startup message indicating it&apos;s ready to accept connections.

&lt;Tabs&gt;
  &lt;Tab name=&quot;Dockge UI&quot;&gt;
    The visual approach works well for most users:
    
    1. Navigate to your Dockge instance
    2. Create a new stack named `cloudreve`
    3. Paste the compose content
    4. Add environment variables in the UI
    5. Save and start
    
    Dockge handles the `.env` file creation automatically based on the variables you enter in the UI.
  &lt;/Tab&gt;
  &lt;Tab name=&quot;Command Line&quot;&gt;
    If you prefer working directly on the server:
    
    ```bash
    mkdir -p /opt/stacks/cloudreve
    cd /opt/stacks/cloudreve
    ```
    
    Create `compose.yaml` with the Docker Compose content above.
    
    Create `.env` with your environment variables.
    
    Then start the stack:
    
    ```bash
    docker compose up -d
    ```
    
    Check logs with:
    
    ```bash
    docker compose logs -f
    ```
  &lt;/Tab&gt;
&lt;/Tabs&gt;

### 4. Verify the containers are running

Check that all three containers started:

```bash
docker ps --format &quot;table {{.Names}}\t{{.Status}}\t{{.Ports}}&quot;
```

You should see `cloudreve-backend`, `cloudreve-db`, and `cloudreve-redis` all showing &quot;Up&quot; status.

## Configure Cloudflare Tunnels

Cloudflare Tunnels let you access Cloudreve from anywhere without opening ports on your router. The tunnel runs as a container on your server and creates an outbound connection to Cloudflare&apos;s network.

If you followed my Dockge guide, you already have a tunnel running. Add a new hostname for Cloudreve:

1. Log into the Cloudflare Zero Trust dashboard
2. Go to **Access &gt; Tunnels**
3. Click your tunnel name, then **Configure**
4. Under **Public Hostname**, click **Add a public hostname**
5. Fill in the details:
   - **Subdomain**: `cloudreve` (or pick something else)
   - **Domain**: Select your domain from the dropdown
   - **Service Type**: HTTP
   - **URL**: `cloudreve-backend:5212`

&lt;Picture src={imag1} alt=&quot;Cloudflare Tunnel configuration&quot; /&gt;

Using `cloudreve-backend:5212` works if your tunnel container shares a Docker network with Cloudreve. If they&apos;re on separate networks, use your server&apos;s local IP instead (like `192.168.1.50:5212`).


Save the hostname. Give it a minute to propagate, then try accessing `https://cloudreve.yourdomain.com`.

&gt; CloudPanel also works as a reverse proxy. See [Setup CloudPanel as Reverse Proxy with Docker and Dockge](https://www.bitdoze.com/cloudpanel-setup-dockge/) for that approach.

## Initial Cloudreve setup

### Create your admin account

Open Cloudreve in your browser. Use either your Cloudflare Tunnel URL (`https://cloudreve.yourdomain.com`) or the local address (`http://your-server-ip:5212`).

1. Click **Sign up** on the login page
2. Enter an email address and password
3. Click **Sign up** again to create the account
4. Log in with those credentials

The first account you create becomes the administrator. Cloudreve v4 dropped the old default admin credentials in favor of this signup flow.

### Configure site URLs

Tell Cloudreve about its public and local addresses:

1. Click your avatar in the top right
2. Select **Dashboard** to open the admin panel
3. Go to **Settings &gt; Basic**
4. Set the URLs:
   - **Primary Site URL**: Your local address, like `http://192.168.1.50:5212`
   - **Secondary Site URL**: Your public address, like `https://cloudreve.yourdomain.com`
5. Click **Save**

These URLs affect how Cloudreve generates share links and handles redirects.

### Configure storage policies

Storage policies control where files go and what limits apply. Each policy points to a specific storage location.

**Edit the default policy:**

1. In the Dashboard, go to **Storage Policies**
2. Click the default policy to edit it
3. Set maximum upload size, allowed file extensions, and storage quota
4. Save your changes

The default policy stores files in `/cloudreve/data` inside the container (mapped to `backend_data` volume or your bind mount).

**Create a policy for external directories:**

If you mounted an external directory like `/media/storage/downloads` into the container, create a new storage policy to use it:

1. Go to **Dashboard &gt; Storage Policies**
2. Click **New Storage Policy**
3. Select **Local** as the storage type
4. Configure the policy:
   - **Name**: Give it a descriptive name like &quot;Downloads Storage&quot;
   - **Storage Path**: Enter the container path, e.g., `/cloudreve/downloads` (this must match where you mounted the directory in your compose file)
   - **Max Size**: Set the maximum file size for uploads
   - **Allowed Extensions**: Leave empty for all types, or specify extensions
5. Save the policy

&lt;Notice type=&quot;info&quot; title=&quot;Path must match your mount&quot;&gt;
The storage path in Cloudreve must match the container path from your volume mount. If you mounted `-v /media/storage/downloads:/cloudreve/downloads`, use `/cloudreve/downloads` as the storage path.
&lt;/Notice&gt;

**Assign the policy to users:**

Storage policies are assigned through user groups:

1. Go to **Dashboard &gt; User Groups**
2. Edit the group you want to use the new storage
3. Under **Storage Policy**, select your new policy
4. Save the group

Users in that group will now upload to and see files from the external directory.

**About existing files:**

Cloudreve tracks files through its database. If you have existing files in `/media/storage/downloads`, they won&apos;t appear in the web interface automatically.

Use the built-in import feature to add existing files without copying them:

1. Go to **Dashboard &gt; Files &gt; Import**
2. Select the storage policy, source path, target user, and destination folder
3. Enable recursive import for subdirectories
4. Click Import

See the &quot;Using existing files on disk&quot; section below for full details.

### Add more users

To let other people use your Cloudreve:

1. Go to **Dashboard &gt; Users**
2. Click **New User**
3. Enter their email and set a password
4. Choose a user group (controls permissions)
5. Set their storage quota
6. Save the user

Each user gets their own file space and can create their own share links.

## How users and file storage work

Cloudreve doesn&apos;t store files in per-user folders on disk. Instead, it uses a database to track which files belong to which user.

### Where files are stored on disk

All user files go to the same storage location defined by the storage policy. Cloudreve renames uploaded files to random hashes:

```
/cloudreve/data/
├── upload/
│   ├── 1/
│   │   ├── a3f2b8c9d4e5.jpg
│   │   ├── 7b2e4f8a1c3d.pdf
│   │   └── ...
│   └── 2/
│       └── ...
├── thumb/
└── temp/
```

The numbered folders (`1/`, `2/`) correspond to storage policy IDs, not users. File names are hashed, so you can&apos;t browse user files directly on the filesystem.

### User file separation

Users only see their own files in the web interface. The database tracks ownership:

| What users see | What&apos;s on disk |
|----------------|----------------|
| `/Documents/report.pdf` | `/cloudreve/data/upload/1/7b2e4f8a1c3d.pdf` |
| `/Photos/vacation.jpg` | `/cloudreve/data/upload/1/a3f2b8c9d4e5.jpg` |

Two users can have files with the same name in the same virtual path. They&apos;re stored separately on disk with different hashes.

### Accessing files outside Cloudreve

You can&apos;t easily browse user files by navigating the storage folder. To access files:

- **WebDAV**: Mount the storage as a network drive. Each user authenticates and sees only their files.
- **Share links**: Create a share link in the web interface.
- **API**: Use Cloudreve&apos;s API to list and download files programmatically.
- **Direct download**: From the web interface, right-click a file and copy the download link.

### Using existing files on disk

Cloudreve tracks files through its database. Files placed directly in the storage folder won&apos;t appear in the interface until you import them.

**Import existing folders:**

Cloudreve has a built-in import feature that adds existing files to a user&apos;s library without copying them:

1. Go to **Dashboard &gt; Files &gt; Import**
2. Configure the import:
   - **Storage policy**: Select the policy where your files are located
   - **Source folder path**: Enter the path on disk, e.g., `/cloudreve/downloads/movies`
   - **Target user**: Search and select which user should own the files
   - **Destination folder path**: Where files appear in the user&apos;s file browser, e.g., `/Movies`
   - **Recursively import**: Enable to include subdirectories
   - **Extract media information**: Enable to pull metadata from videos and images
3. Click **Import**

&lt;Notice type=&quot;warning&quot; title=&quot;Import ownership&quot;&gt;
After import, Cloudreve manages the physical files. Don&apos;t modify or delete them outside Cloudreve. Importing the same file twice will skip duplicates. Files count against the user&apos;s storage quota even though no data is copied.
&lt;/Notice&gt;

This is the fastest way to add large existing collections. The files stay in place on disk, and Cloudreve creates database entries pointing to them.

## Remote downloads with Aria2

Cloudreve includes Aria2 in the official Docker image, so you can download files from URLs, magnet links, and torrents directly to your storage. The files appear in your Cloudreve file browser once the download completes.

### Why port 6888 matters

Aria2 uses port 6888 for BitTorrent peer connections. When downloading torrents, other peers need to reach your server on this port to share file pieces. Without it:

- Magnet links might not find enough peers
- Download speeds will be slower
- Some torrents won&apos;t start at all

The compose file maps both TCP and UDP on port 6888:

```yaml
ports:
  - 5212:5212      # Web interface
  - 6888:6888      # Aria2 BitTorrent TCP
  - 6888:6888/udp  # Aria2 BitTorrent UDP
```

&lt;Notice type=&quot;warning&quot; title=&quot;Firewall configuration&quot;&gt;
If you&apos;re running on a VPS, open port 6888 in your firewall. On Ubuntu with UFW:

```bash
sudo ufw allow 6888/tcp
sudo ufw allow 6888/udp
```

For home servers behind a router, forward port 6888 to your server&apos;s local IP.
&lt;/Notice&gt;

### Enable remote downloads

The built-in Aria2 runs automatically with the Cloudreve container. You just need to enable it in the admin settings:

1. Go to **Dashboard &gt; Nodes**
2. Click on the default node (or create one)
3. Check **Remote Download** in the Enabled Features section
4. Configure the downloader:
   - **Downloader Type**: Aria2
   - **RPC Server Address**: `http://localhost:6800`
   - **RPC Authorization Token**: Leave empty (the built-in Aria2 has no token by default)
   - **Temporary Download Directory**: Leave empty to use the default (`/cloudreve/data/temp`)
5. Save the node settings

### Enable for user groups

By default, remote downloads are disabled for users. Enable it for your user group:

1. Go to **Dashboard &gt; User Groups**
2. Edit the group you want to allow downloads for
3. Check **Remote Download** in the permissions section
4. Save the group

### Creating download tasks

Once enabled, users can create remote download tasks from the web interface:

1. Click the **+** button in the file browser
2. Select **Remote Download**
3. Paste a URL, magnet link, or upload a .torrent file
4. Choose the destination folder
5. Click **Create**

The download runs in the background. Progress shows in the remote download panel. When complete, files appear in your chosen folder.

### Aria2 configuration options

You can pass additional options to Aria2 through the node settings. In the **Downloader Task Parameters** field, add JSON:

```json
{
  &quot;max-download-limit&quot;: &quot;10M&quot;,
  &quot;max-concurrent-downloads&quot;: 3,
  &quot;bt-tracker&quot;: [
    &quot;udp://tracker.opentrackr.org:1337/announce&quot;,
    &quot;udp://tracker.openbittorrent.com:6969/announce&quot;
  ],
  &quot;seed-ratio&quot;: 1.0,
  &quot;seed-time&quot;: 60
}
```

This limits download speed to 10MB/s, allows 3 concurrent downloads, adds tracker servers for better peer discovery, and stops seeding after reaching a 1:1 ratio or 60 minutes.

&lt;Accordion label=&quot;Using qBittorrent instead of Aria2&quot; group=&quot;downloads&quot;&gt;
If you prefer qBittorrent for torrent downloads, run it as a separate container and point Cloudreve to its Web UI. qBittorrent handles torrents better than Aria2 for long-running seeds, but it can&apos;t download regular HTTP/FTP URLs.

Add qBittorrent to your compose file:

```yaml
  qbittorrent:
    image: linuxserver/qbittorrent:latest
    container_name: qbittorrent
    environment:
      - PUID=1000
      - PGID=1000
      - WEBUI_PORT=8080
    volumes:
      - qbit_config:/config
      - backend_data:/downloads
    ports:
      - 8080:8080
      - 6881:6881
      - 6881:6881/udp
    restart: unless-stopped
```

Then configure the node to use qBittorrent:
- **Downloader Type**: qBittorrent
- **Web UI Address**: `http://qbittorrent:8080`
- **Username/Password**: Set these in qBittorrent&apos;s settings

The shared `backend_data` volume lets qBittorrent download directly to Cloudreve&apos;s storage.
&lt;/Accordion&gt;

## Working with Cloudreve

The web interface handles most file operations:

- **Uploading** - Drag files onto the page or click the upload button. Large files upload in chunks and resume if interrupted.
- **Folders** - Create folders to organize files. Right-click for options.
- **Sharing** - Right-click a file or folder, select Share, and configure the link. Set a password or expiration if needed.
- **Previews** - Click files to preview them. Cloudreve handles images, videos, audio, PDFs, and code files.
- **Photo editing** - Right-click an image and open it with Photopea for quick edits without leaving the browser.

For desktop integration, enable WebDAV in your user settings and mount the drive on your computer. Windows, macOS, and Linux all support WebDAV natively.

## Troubleshooting

&lt;Accordion label=&quot;Containers fail to start&quot; group=&quot;faq&quot;&gt;
Pull up the logs:

```bash
docker logs cloudreve-backend
docker logs cloudreve-db
```

Common problems: PostgreSQL isn&apos;t ready when Cloudreve tries to connect (just restart the stack), environment variables are misspelled, or port 5212 is already in use by something else.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can&apos;t reach Cloudreve through Cloudflare Tunnel&quot; group=&quot;faq&quot;&gt;
First confirm it works locally:

```bash
curl http://localhost:5212
```

If that works but the tunnel doesn&apos;t:

- Check the tunnel container is running: `docker ps | grep cloudflared`
- Verify the hostname configuration in Cloudflare Zero Trust
- Make sure the URL in the hostname config matches your container name or IP
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Database connection errors&quot; group=&quot;faq&quot;&gt;
If logs show PostgreSQL connection failures:

1. Check that `cloudreve-db` container is running
2. Verify environment variables match between the `.env` file and compose.yaml
3. Restart the whole stack: `docker compose down &amp;&amp; docker compose up -d`
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Permission errors on bind mounts&quot; group=&quot;faq&quot;&gt;
If you&apos;re using a bind mount instead of named volumes and see permission errors:

```bash
# Check what user ID Cloudreve runs as
docker exec cloudreve-backend id

# Set ownership on your host folder
sudo chown -R 1000:1000 /mnt/storage/cloudreve
```

Adjust the user ID based on what the container actually uses.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Torrent downloads are slow or won&apos;t start&quot; group=&quot;faq&quot;&gt;
BitTorrent needs port 6888 reachable from the internet:

1. Check the port is mapped in your compose file
2. Open port 6888 in your server&apos;s firewall
3. If behind NAT, forward port 6888 to your server
4. Add more trackers in the Aria2 configuration to find peers

You can test if the port is open:

```bash
# From another machine
nc -zv your-server-ip 6888
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Remote download files not appearing&quot; group=&quot;faq&quot;&gt;
After a download completes, Cloudreve moves files from the temp directory to your storage. If files don&apos;t appear:

1. Check the remote download panel for errors
2. Verify Cloudreve has write permissions to the storage directory
3. Look at the logs: `docker logs cloudreve-backend | grep -i download`

The temp directory and storage directory must be accessible to the Cloudreve process.
&lt;/Accordion&gt;

## Wrapping up

Cloudreve gives you a file sharing system that runs on your own hardware. Combined with Cloudflare Tunnels, you can reach your files from anywhere without exposing your home network directly to the internet.

The PostgreSQL and Redis setup handles growth better than SQLite. Add Aria2 remote downloads to grab files from anywhere and have them waiting in your cloud storage. For a home server or small team, the free Community edition covers everything most people need.

Related guides you might find useful:

- [Deploy Filebrowser with Docker](https://www.bitdoze.com/deploy-filebrowser-docker/) - Lighter alternative if you just need file browsing
- [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/) - More applications for your home lab
- [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/) - Track CPU, memory, and disk usage</content:encoded><category>self-hosting</category><category>dockge</category><category>self-hosted</category></item><item><title>How to Style Countdown Timers in Carrd: Custom Designs</title><link>https://www.bitdoze.com/carrd-countdown-styling/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-countdown-styling/</guid><description>Learn how to create beautiful custom countdown timers for your Carrd website with different styles, animations, and responsive designs.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

[Carrd](https://go.bitdoze.com/carrd) has a built-in countdown timer, but it looks basic. If you&apos;re launching a product, promoting an event, or running a limited-time offer, you want something that actually creates urgency - not a timer that blends into the background.

Custom countdown timers can match your brand, animate to draw attention, and look professional on any device. Here are several styles you can drop into your Carrd site.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Add Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add WhatsApp Button to Carrd](https://www.bitdoze.com/carrd-whatsapp-button/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## When to Use Countdown Timers

Countdown timers work well for:

1. **Product launches** - Building anticipation before release
2. **Sales and promotions** - Creating urgency for limited-time offers
3. **Event registration** - Deadline reminders for signups
4. **Coming soon pages** - Showing visitors when to come back
5. **Webinars and live events** - Counting down to start time

The psychology is simple: a ticking clock creates urgency that static text can&apos;t match.

## How to Add Custom Countdowns to Carrd

### Step 1: Add an Embed Element

Click the `+` sign and add an Embed element:

- Type: Code
- Style: Inline / Above

(Use Inline/Above so the countdown displays where you place it, not hidden in the head)

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 2: Choose Your Style

Pick from the designs below and customize the target date.

---

## Option 1: Modern Card Style

Clean, professional boxes with labels underneath.

```html
&lt;style&gt;
  .countdown-modern {
    display: flex;
    justify-content: center;
    gap: 20px;
    flex-wrap: wrap;
    font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, sans-serif;
    padding: 20px 0;
  }

  .countdown-item {
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    border-radius: 16px;
    padding: 25px 20px;
    min-width: 100px;
    text-align: center;
    box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
    transition: transform 0.3s;
  }

  .countdown-item:hover {
    transform: translateY(-5px);
  }

  .countdown-value {
    font-size: 48px;
    font-weight: 700;
    color: white;
    line-height: 1;
    margin-bottom: 8px;
  }

  .countdown-label {
    font-size: 14px;
    color: rgba(255, 255, 255, 0.8);
    text-transform: uppercase;
    letter-spacing: 2px;
  }

  @media (max-width: 500px) {
    .countdown-item {
      min-width: 70px;
      padding: 18px 15px;
    }
    .countdown-value {
      font-size: 32px;
    }
    .countdown-label {
      font-size: 11px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;countdown-modern&quot; id=&quot;countdown1&quot;&gt;
  &lt;div class=&quot;countdown-item&quot;&gt;
    &lt;div class=&quot;countdown-value&quot; id=&quot;days1&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;countdown-label&quot;&gt;Days&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;countdown-item&quot;&gt;
    &lt;div class=&quot;countdown-value&quot; id=&quot;hours1&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;countdown-label&quot;&gt;Hours&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;countdown-item&quot;&gt;
    &lt;div class=&quot;countdown-value&quot; id=&quot;minutes1&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;countdown-label&quot;&gt;Minutes&lt;/div&gt;
  &lt;/div&gt;
  &lt;div class=&quot;countdown-item&quot;&gt;
    &lt;div class=&quot;countdown-value&quot; id=&quot;seconds1&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;countdown-label&quot;&gt;Seconds&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
var target1 = new Date(2026, 2, 1, 0, 0, 0).getTime();
function tick1() {
  var now = new Date().getTime();
  var dist = target1 - now;
  if (dist &lt; 0) {
    document.getElementById(&quot;countdown1&quot;).innerHTML = &quot;Event has started!&quot;;
    return;
  }
  var d = Math.floor(dist / 86400000);
  var h = Math.floor((dist % 86400000) / 3600000);
  var m = Math.floor((dist % 3600000) / 60000);
  var s = Math.floor((dist % 60000) / 1000);
  document.getElementById(&quot;days1&quot;).innerHTML = (d &lt; 10 ? &quot;0&quot; : &quot;&quot;) + d;
  document.getElementById(&quot;hours1&quot;).innerHTML = (h &lt; 10 ? &quot;0&quot; : &quot;&quot;) + h;
  document.getElementById(&quot;minutes1&quot;).innerHTML = (m &lt; 10 ? &quot;0&quot; : &quot;&quot;) + m;
  document.getElementById(&quot;seconds1&quot;).innerHTML = (s &lt; 10 ? &quot;0&quot; : &quot;&quot;) + s;
}
setTimeout(function() { tick1(); setInterval(tick1, 1000); }, 100);
&lt;/script&gt;
```

**To customize:** Change `new Date(2026, 2, 1, 0, 0, 0)` to your target date. Format: `new Date(year, month-1, day, hour, minute, second)`. Note: month is 0-indexed (January = 0, March = 2, etc.)

---

## Option 2: Minimal Dark Style

Sleek dark design with subtle separators.

```html
&lt;style&gt;
  .countdown-dark {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 10px;
    background: #1a1a2e;
    padding: 30px 40px;
    border-radius: 12px;
    font-family: &apos;Monaco&apos;, &apos;Consolas&apos;, monospace;
    max-width: 600px;
    margin: 20px auto;
  }

  .cd-block {
    text-align: center;
  }

  .cd-number {
    font-size: 56px;
    font-weight: 600;
    color: #eee;
    line-height: 1;
  }

  .cd-text {
    font-size: 12px;
    color: #888;
    text-transform: uppercase;
    margin-top: 10px;
    letter-spacing: 1px;
  }

  .cd-separator {
    font-size: 40px;
    color: #4a4a6a;
    padding: 0 5px;
    margin-top: -20px;
  }

  @media (max-width: 500px) {
    .countdown-dark {
      padding: 20px;
      gap: 8px;
    }
    .cd-number {
      font-size: 36px;
    }
    .cd-separator {
      font-size: 28px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;countdown-dark&quot; id=&quot;countdown2&quot;&gt;
  &lt;div class=&quot;cd-block&quot;&gt;
    &lt;div class=&quot;cd-number&quot; id=&quot;days2&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;cd-text&quot;&gt;Days&lt;/div&gt;
  &lt;/div&gt;
  &lt;span class=&quot;cd-separator&quot;&gt;:&lt;/span&gt;
  &lt;div class=&quot;cd-block&quot;&gt;
    &lt;div class=&quot;cd-number&quot; id=&quot;hours2&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;cd-text&quot;&gt;Hours&lt;/div&gt;
  &lt;/div&gt;
  &lt;span class=&quot;cd-separator&quot;&gt;:&lt;/span&gt;
  &lt;div class=&quot;cd-block&quot;&gt;
    &lt;div class=&quot;cd-number&quot; id=&quot;minutes2&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;cd-text&quot;&gt;Mins&lt;/div&gt;
  &lt;/div&gt;
  &lt;span class=&quot;cd-separator&quot;&gt;:&lt;/span&gt;
  &lt;div class=&quot;cd-block&quot;&gt;
    &lt;div class=&quot;cd-number&quot; id=&quot;seconds2&quot;&gt;00&lt;/div&gt;
    &lt;div class=&quot;cd-text&quot;&gt;Secs&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
(function() {
  var targetDate = new Date(2026, 2, 1, 0, 0, 0).getTime();

  function updateCountdown() {
    var now = new Date().getTime();
    var distance = targetDate - now;
    
    if (distance &lt; 0) {
      document.getElementById(&apos;countdown2&apos;).innerHTML = &apos;&lt;p style=&quot;color: #eee; font-size: 24px;&quot;&gt;Time is up!&lt;/p&gt;&apos;;
      return;
    }
    
    var days = Math.floor(distance / (1000 * 60 * 60 * 24));
    var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
    var seconds = Math.floor((distance % (1000 * 60)) / 1000);
    
    document.getElementById(&apos;days2&apos;).innerHTML = (days &lt; 10 ? &apos;0&apos; : &apos;&apos;) + days;
    document.getElementById(&apos;hours2&apos;).innerHTML = (hours &lt; 10 ? &apos;0&apos; : &apos;&apos;) + hours;
    document.getElementById(&apos;minutes2&apos;).innerHTML = (minutes &lt; 10 ? &apos;0&apos; : &apos;&apos;) + minutes;
    document.getElementById(&apos;seconds2&apos;).innerHTML = (seconds &lt; 10 ? &apos;0&apos; : &apos;&apos;) + seconds;
  }

  setTimeout(function() {
    updateCountdown();
    setInterval(updateCountdown, 1000);
  }, 100);
})();
&lt;/script&gt;
```

---

## Option 3: Circular Progress Style

Visual progress rings showing time remaining.

```html
&lt;style&gt;
  .countdown-circles {
    display: flex;
    justify-content: center;
    gap: 25px;
    flex-wrap: wrap;
    padding: 20px 0;
    font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, sans-serif;
  }

  .circle-item {
    position: relative;
    width: 100px;
    height: 100px;
  }

  .circle-item svg {
    transform: rotate(-90deg);
  }

  .circle-bg {
    fill: none;
    stroke: #e6e6e6;
    stroke-width: 8;
  }

  .circle-progress {
    fill: none;
    stroke: #10b981;
    stroke-width: 8;
    stroke-linecap: round;
    transition: stroke-dashoffset 0.5s;
  }

  .circle-content {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    text-align: center;
  }

  .circle-value {
    font-size: 28px;
    font-weight: 700;
    color: #1f2937;
  }

  .circle-label {
    font-size: 11px;
    color: #6b7280;
    text-transform: uppercase;
  }

  @media (max-width: 450px) {
    .circle-item {
      width: 75px;
      height: 75px;
    }
    .circle-value {
      font-size: 22px;
    }
    .circle-label {
      font-size: 9px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;countdown-circles&quot; id=&quot;countdown3&quot;&gt;
  &lt;div class=&quot;circle-item&quot;&gt;
    &lt;svg width=&quot;100&quot; height=&quot;100&quot; viewBox=&quot;0 0 100 100&quot;&gt;
      &lt;circle class=&quot;circle-bg&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot;/&gt;
      &lt;circle class=&quot;circle-progress&quot; id=&quot;dayCircle&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot; stroke-dasharray=&quot;264&quot; stroke-dashoffset=&quot;0&quot;/&gt;
    &lt;/svg&gt;
    &lt;div class=&quot;circle-content&quot;&gt;
      &lt;div class=&quot;circle-value&quot; id=&quot;days3&quot;&gt;00&lt;/div&gt;
      &lt;div class=&quot;circle-label&quot;&gt;Days&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;circle-item&quot;&gt;
    &lt;svg width=&quot;100&quot; height=&quot;100&quot; viewBox=&quot;0 0 100 100&quot;&gt;
      &lt;circle class=&quot;circle-bg&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot;/&gt;
      &lt;circle class=&quot;circle-progress&quot; id=&quot;hourCircle&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot; stroke-dasharray=&quot;264&quot; stroke-dashoffset=&quot;0&quot;/&gt;
    &lt;/svg&gt;
    &lt;div class=&quot;circle-content&quot;&gt;
      &lt;div class=&quot;circle-value&quot; id=&quot;hours3&quot;&gt;00&lt;/div&gt;
      &lt;div class=&quot;circle-label&quot;&gt;Hours&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;circle-item&quot;&gt;
    &lt;svg width=&quot;100&quot; height=&quot;100&quot; viewBox=&quot;0 0 100 100&quot;&gt;
      &lt;circle class=&quot;circle-bg&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot;/&gt;
      &lt;circle class=&quot;circle-progress&quot; id=&quot;minCircle&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot; stroke-dasharray=&quot;264&quot; stroke-dashoffset=&quot;0&quot;/&gt;
    &lt;/svg&gt;
    &lt;div class=&quot;circle-content&quot;&gt;
      &lt;div class=&quot;circle-value&quot; id=&quot;minutes3&quot;&gt;00&lt;/div&gt;
      &lt;div class=&quot;circle-label&quot;&gt;Mins&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;circle-item&quot;&gt;
    &lt;svg width=&quot;100&quot; height=&quot;100&quot; viewBox=&quot;0 0 100 100&quot;&gt;
      &lt;circle class=&quot;circle-bg&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot;/&gt;
      &lt;circle class=&quot;circle-progress&quot; id=&quot;secCircle&quot; cx=&quot;50&quot; cy=&quot;50&quot; r=&quot;42&quot; stroke-dasharray=&quot;264&quot; stroke-dashoffset=&quot;0&quot;/&gt;
    &lt;/svg&gt;
    &lt;div class=&quot;circle-content&quot;&gt;
      &lt;div class=&quot;circle-value&quot; id=&quot;seconds3&quot;&gt;00&lt;/div&gt;
      &lt;div class=&quot;circle-label&quot;&gt;Secs&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
(function() {
  var targetDate = new Date(2026, 2, 1, 0, 0, 0).getTime();
  var circumference = 264;

  function updateCountdown() {
    var now = new Date().getTime();
    var distance = targetDate - now;
    
    if (distance &lt; 0) {
      document.getElementById(&apos;countdown3&apos;).innerHTML = &apos;&lt;p style=&quot;font-size: 24px; color: #1f2937;&quot;&gt;Event Started!&lt;/p&gt;&apos;;
      return;
    }
    
    var days = Math.floor(distance / (1000 * 60 * 60 * 24));
    var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
    var seconds = Math.floor((distance % (1000 * 60)) / 1000);
    
    document.getElementById(&apos;days3&apos;).innerHTML = (days &lt; 10 ? &apos;0&apos; : &apos;&apos;) + days;
    document.getElementById(&apos;hours3&apos;).innerHTML = (hours &lt; 10 ? &apos;0&apos; : &apos;&apos;) + hours;
    document.getElementById(&apos;minutes3&apos;).innerHTML = (minutes &lt; 10 ? &apos;0&apos; : &apos;&apos;) + minutes;
    document.getElementById(&apos;seconds3&apos;).innerHTML = (seconds &lt; 10 ? &apos;0&apos; : &apos;&apos;) + seconds;
    
    document.getElementById(&apos;dayCircle&apos;).style.strokeDashoffset = circumference - (circumference * Math.min(days, 365) / 365);
    document.getElementById(&apos;hourCircle&apos;).style.strokeDashoffset = circumference - (circumference * hours / 24);
    document.getElementById(&apos;minCircle&apos;).style.strokeDashoffset = circumference - (circumference * minutes / 60);
    document.getElementById(&apos;secCircle&apos;).style.strokeDashoffset = circumference - (circumference * seconds / 60);
  }

  setTimeout(function() {
    updateCountdown();
    setInterval(updateCountdown, 1000);
  }, 100);
})();
&lt;/script&gt;
```

---

## Option 4: Flip Clock Animation

Classic flip-clock style with animated number changes.

```html
&lt;style&gt;
  .countdown-flip {
    display: flex;
    justify-content: center;
    gap: 30px;
    flex-wrap: wrap;
    padding: 20px 0;
    font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, sans-serif;
  }

  .flip-section {
    text-align: center;
  }

  .flip-card-container {
    display: flex;
    gap: 5px;
    margin-bottom: 10px;
  }

  .flip-card {
    position: relative;
    width: 50px;
    height: 70px;
    perspective: 400px;
  }

  .flip-card-inner {
    position: absolute;
    width: 100%;
    height: 100%;
    background: linear-gradient(180deg, #2d2d44 50%, #252538 50%);
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 40px;
    font-weight: 700;
    color: white;
    box-shadow: 0 4px 10px rgba(0,0,0,0.3);
  }

  .flip-card-inner::before {
    content: &apos;&apos;;
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    height: 1px;
    background: rgba(0,0,0,0.3);
  }

  .flip-label {
    font-size: 13px;
    color: #666;
    text-transform: uppercase;
    letter-spacing: 2px;
  }

  @keyframes flipTop {
    0% { transform: rotateX(0deg); }
    100% { transform: rotateX(-90deg); }
  }

  @keyframes flipBottom {
    0% { transform: rotateX(90deg); }
    100% { transform: rotateX(0deg); }
  }

  .flip-card.flip .flip-card-inner {
    animation: flipAnim 0.6s ease-in-out;
  }

  @keyframes flipAnim {
    0% { transform: rotateX(0deg); }
    50% { transform: rotateX(-10deg); }
    100% { transform: rotateX(0deg); }
  }

  @media (max-width: 500px) {
    .countdown-flip {
      gap: 15px;
    }
    .flip-card {
      width: 38px;
      height: 55px;
    }
    .flip-card-inner {
      font-size: 28px;
    }
    .flip-label {
      font-size: 10px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;countdown-flip&quot; id=&quot;countdown4&quot;&gt;
  &lt;div class=&quot;flip-section&quot;&gt;
    &lt;div class=&quot;flip-card-container&quot;&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;day1Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;day2Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flip-label&quot;&gt;Days&lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;flip-section&quot;&gt;
    &lt;div class=&quot;flip-card-container&quot;&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;hour1Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;hour2Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flip-label&quot;&gt;Hours&lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;flip-section&quot;&gt;
    &lt;div class=&quot;flip-card-container&quot;&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;min1Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;min2Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flip-label&quot;&gt;Minutes&lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;flip-section&quot;&gt;
    &lt;div class=&quot;flip-card-container&quot;&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;sec1Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
      &lt;div class=&quot;flip-card&quot; id=&quot;sec2Card&quot;&gt;&lt;div class=&quot;flip-card-inner&quot;&gt;0&lt;/div&gt;&lt;/div&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flip-label&quot;&gt;Seconds&lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
(function() {
  var targetDate = new Date(2026, 2, 1, 0, 0, 0).getTime();

  function updateFlipCard(cardId, value) {
    var card = document.getElementById(cardId);
    var inner = card.querySelector(&apos;.flip-card-inner&apos;);
    if (inner.innerHTML !== value) {
      card.classList.add(&apos;flip&apos;);
      inner.innerHTML = value;
      setTimeout(function() { card.classList.remove(&apos;flip&apos;); }, 600);
    }
  }

  function pad2(num) {
    return (num &lt; 10 ? &apos;0&apos; : &apos;&apos;) + num;
  }

  function updateCountdown() {
    var now = new Date().getTime();
    var distance = targetDate - now;
    
    if (distance &lt; 0) {
      document.getElementById(&apos;countdown4&apos;).innerHTML = &apos;&lt;p style=&quot;font-size: 24px;&quot;&gt;Launch Time!&lt;/p&gt;&apos;;
      return;
    }
    
    var days = Math.floor(distance / (1000 * 60 * 60 * 24));
    var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
    var seconds = Math.floor((distance % (1000 * 60)) / 1000);
    
    var dStr = pad2(days);
    var hStr = pad2(hours);
    var mStr = pad2(minutes);
    var sStr = pad2(seconds);
    
    updateFlipCard(&apos;day1Card&apos;, dStr[0]);
    updateFlipCard(&apos;day2Card&apos;, dStr[1]);
    updateFlipCard(&apos;hour1Card&apos;, hStr[0]);
    updateFlipCard(&apos;hour2Card&apos;, hStr[1]);
    updateFlipCard(&apos;min1Card&apos;, mStr[0]);
    updateFlipCard(&apos;min2Card&apos;, mStr[1]);
    updateFlipCard(&apos;sec1Card&apos;, sStr[0]);
    updateFlipCard(&apos;sec2Card&apos;, sStr[1]);
  }

  setTimeout(function() {
    updateCountdown();
    setInterval(updateCountdown, 1000);
  }, 100);
})();
&lt;/script&gt;
```

---

## Option 5: Simple Inline Text

For situations where you just want text, not boxes.

```html
&lt;style&gt;
  .countdown-text {
    font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, sans-serif;
    font-size: 24px;
    color: #374151;
    text-align: center;
    padding: 20px;
  }

  .countdown-text .highlight {
    color: #dc2626;
    font-weight: 700;
  }

  @media (max-width: 500px) {
    .countdown-text {
      font-size: 18px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;countdown-text&quot; id=&quot;countdown5&quot;&gt;
  Sale ends in &lt;span class=&quot;highlight&quot; id=&quot;textDays&quot;&gt;0&lt;/span&gt; days, 
  &lt;span class=&quot;highlight&quot; id=&quot;textHours&quot;&gt;0&lt;/span&gt; hours, 
  &lt;span class=&quot;highlight&quot; id=&quot;textMins&quot;&gt;0&lt;/span&gt; minutes
&lt;/div&gt;

&lt;script&gt;
(function() {
  var targetDate = new Date(2026, 2, 1, 0, 0, 0).getTime();

  function updateCountdown() {
    var now = new Date().getTime();
    var distance = targetDate - now;
    
    if (distance &lt; 0) {
      document.getElementById(&apos;countdown5&apos;).innerHTML = &apos;&lt;span class=&quot;highlight&quot;&gt;Sale has ended!&lt;/span&gt;&apos;;
      return;
    }
    
    var days = Math.floor(distance / (1000 * 60 * 60 * 24));
    var hours = Math.floor((distance % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
    var minutes = Math.floor((distance % (1000 * 60 * 60)) / (1000 * 60));
    
    document.getElementById(&apos;textDays&apos;).innerHTML = days;
    document.getElementById(&apos;textHours&apos;).innerHTML = hours;
    document.getElementById(&apos;textMins&apos;).innerHTML = minutes;
  }

  setTimeout(function() {
    updateCountdown();
    setInterval(updateCountdown, 60000);
  }, 100);
})();
&lt;/script&gt;
```

---

## Setting Your Target Date

All examples use this format:

```javascript
var targetDate = new Date(2026, 2, 1, 0, 0, 0).getTime();
```

**Format:** `new Date(year, month, day, hour, minute, second)`

**Important:** Month is 0-indexed, so January = 0, February = 1, March = 2, etc.

**Examples:**
- March 1, 2026 at midnight: `new Date(2026, 2, 1, 0, 0, 0)`
- December 25, 2026 at 9 AM: `new Date(2026, 11, 25, 9, 0, 0)`
- July 4, 2026 at 6 PM: `new Date(2026, 6, 4, 18, 0, 0)`

## Color Customization

Each style uses CSS variables or inline colors you can change:

**Gradient backgrounds:**
```css
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
/* Change the hex colors to match your brand */
```

**Solid colors:**
```css
background: #1a1a2e;  /* Dark background */
color: #10b981;       /* Accent color */
```

**Circle colors:**
```css
stroke: #10b981;  /* Progress color */
stroke: #e6e6e6;  /* Background circle */
```

## What Happens When Time Runs Out

Each countdown includes fallback text when the timer reaches zero. Customize it in the JavaScript:

```javascript
if (distance &lt; 0) {
  document.getElementById(&apos;countdown1&apos;).innerHTML = &apos;&lt;p&gt;Your custom message here!&lt;/p&gt;&apos;;
  return;
}
```

You could also redirect to another page:

```javascript
if (distance &lt; 0) {
  window.location.href = &apos;https://yoursite.com/launch&apos;;
  return;
}
```

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

A custom countdown timer does more than tell time - it creates anticipation and urgency. Pick the style that matches your brand, set your target date, and let the ticking clock do its work.

Combine this with a [popup modal](https://www.bitdoze.com/carrd-popup-modal/) for email capture or a [WhatsApp button](https://www.bitdoze.com/carrd-whatsapp-button/) for instant contact when visitors get excited about your launch.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Add a Dark Mode Toggle to Your Carrd Website</title><link>https://www.bitdoze.com/carrd-dark-mode-toggle/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-dark-mode-toggle/</guid><description>Learn how to add a light/dark theme switcher to your Carrd site that remembers user preferences and transitions smoothly.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

Dark mode went from niche preference to mainstream expectation. Many visitors browse at night with their phones on dark mode, and a bright white website hits them like a flashlight to the face.

Adding a dark mode toggle to your [Carrd](https://go.bitdoze.com/carrd) site gives visitors control over their experience. They can switch to whatever&apos;s easier on their eyes, and the site remembers their choice for next time.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Add Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/)
- [Add Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Why Add Dark Mode

A few reasons to offer theme switching:

1. **Reduced eye strain** - Dark mode is easier on the eyes in low-light conditions.

2. **Battery savings** - On OLED screens, dark mode uses less power.

3. **User preference** - Many people prefer dark interfaces. Giving them the option shows you care about their experience.

4. **Modern expectation** - Most major apps and websites now offer dark mode. It&apos;s becoming standard.

5. **Accessibility** - Some users have light sensitivity and genuinely need darker interfaces.

## How Dark Mode Works on Carrd

Carrd doesn&apos;t have built-in dark mode, so we&apos;ll add it with custom CSS and JavaScript. The code:

1. Creates a toggle button
2. Applies dark styles when activated
3. Saves the preference to localStorage
4. Respects the user&apos;s system preference by default

## How to Add Dark Mode to Carrd

### Step 1: Add an Embed Element

Click the `+` sign and add an Embed element:

- Type: Code
- Style: **Inline** (important - this displays the toggle button on your page)

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 2: Choose Your Toggle Style

I&apos;ve created different toggle styles. Pick the one that fits your design.

---

## Option 1: Floating Toggle Button

A small button that floats in the corner of the screen.

```html
&lt;style&gt;
  /* Light mode (default) colors */
  :root {
    --dm-bg-primary: #ffffff;
    --dm-bg-secondary: #f8fafc;
    --dm-text-primary: #1e293b;
    --dm-text-secondary: #64748b;
    --dm-border: #e2e8f0;
    --dm-accent: #3b82f6;
  }

  /* Dark mode colors */
  [data-theme=&quot;dark&quot;] {
    --dm-bg-primary: #0f172a;
    --dm-bg-secondary: #1e293b;
    --dm-text-primary: #f1f5f9;
    --dm-text-secondary: #94a3b8;
    --dm-border: #334155;
    --dm-accent: #60a5fa;
  }

  /* Apply colors to Carrd elements */
  [data-theme=&quot;dark&quot;] body,
  [data-theme=&quot;dark&quot;] #wrapper {
    background-color: var(--dm-bg-primary) !important;
  }

  [data-theme=&quot;dark&quot;] #main {
    background-color: var(--dm-bg-primary) !important;
  }

  [data-theme=&quot;dark&quot;] h1, 
  [data-theme=&quot;dark&quot;] h2, 
  [data-theme=&quot;dark&quot;] h3,
  [data-theme=&quot;dark&quot;] h4,
  [data-theme=&quot;dark&quot;] p,
  [data-theme=&quot;dark&quot;] span,
  [data-theme=&quot;dark&quot;] li,
  [data-theme=&quot;dark&quot;] a {
    color: var(--dm-text-primary) !important;
  }

  [data-theme=&quot;dark&quot;] .inner &gt; * {
    background-color: transparent !important;
  }

  /* Override specific Carrd sections if needed */
  [data-theme=&quot;dark&quot;] section,
  [data-theme=&quot;dark&quot;] .container {
    background-color: var(--dm-bg-primary) !important;
  }

  /* Style form inputs */
  [data-theme=&quot;dark&quot;] input,
  [data-theme=&quot;dark&quot;] textarea,
  [data-theme=&quot;dark&quot;] select {
    background-color: var(--dm-bg-secondary) !important;
    color: var(--dm-text-primary) !important;
    border-color: var(--dm-border) !important;
  }

  /* Smooth transition */
  body,
  #wrapper,
  #main,
  section,
  h1, h2, h3, h4, p, span, li, a,
  input, textarea {
    transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
  }

  /* Toggle button styles */
  .theme-toggle {
    position: fixed;
    bottom: 25px;
    left: 25px;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 24px;
    z-index: 9999;
    background: var(--dm-bg-secondary);
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
    transition: transform 0.2s, background-color 0.3s;
  }

  .theme-toggle:hover {
    transform: scale(1.1);
  }

  .theme-toggle .icon-sun,
  .theme-toggle .icon-moon {
    position: absolute;
    transition: opacity 0.3s, transform 0.3s;
  }

  .theme-toggle .icon-sun {
    opacity: 1;
    transform: rotate(0deg);
  }

  .theme-toggle .icon-moon {
    opacity: 0;
    transform: rotate(-90deg);
  }

  [data-theme=&quot;dark&quot;] .theme-toggle .icon-sun {
    opacity: 0;
    transform: rotate(90deg);
  }

  [data-theme=&quot;dark&quot;] .theme-toggle .icon-moon {
    opacity: 1;
    transform: rotate(0deg);
  }
&lt;/style&gt;

&lt;button class=&quot;theme-toggle&quot; id=&quot;themeToggle&quot; aria-label=&quot;Toggle dark mode&quot;&gt;
  &lt;span class=&quot;icon-sun&quot;&gt;☀️&lt;/span&gt;
  &lt;span class=&quot;icon-moon&quot;&gt;🌙&lt;/span&gt;
&lt;/button&gt;

&lt;script&gt;
var themeToggle = document.getElementById(&apos;themeToggle&apos;);

function getPreferredTheme() {
  var saved = localStorage.getItem(&apos;theme&apos;);
  if (saved) return saved;
  return window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;).matches ? &apos;dark&apos; : &apos;light&apos;;
}

function setTheme(theme) {
  document.documentElement.setAttribute(&apos;data-theme&apos;, theme);
  localStorage.setItem(&apos;theme&apos;, theme);
}

setTheme(getPreferredTheme());

themeToggle.onclick = function() {
  var current = document.documentElement.getAttribute(&apos;data-theme&apos;);
  setTheme(current === &apos;dark&apos; ? &apos;light&apos; : &apos;dark&apos;);
};
&lt;/script&gt;
```

---

## Option 2: Header Toggle Switch

A sleek toggle switch that can be placed in your header area.

```html
&lt;style&gt;
  /* Light mode colors */
  :root {
    --dm-bg: #ffffff;
    --dm-bg-alt: #f3f4f6;
    --dm-text: #111827;
    --dm-text-muted: #6b7280;
    --dm-border: #e5e7eb;
  }

  /* Dark mode colors */
  [data-theme=&quot;dark&quot;] {
    --dm-bg: #111827;
    --dm-bg-alt: #1f2937;
    --dm-text: #f9fafb;
    --dm-text-muted: #9ca3af;
    --dm-border: #374151;
  }

  /* Apply to page */
  [data-theme=&quot;dark&quot;] body,
  [data-theme=&quot;dark&quot;] #wrapper,
  [data-theme=&quot;dark&quot;] #main {
    background-color: var(--dm-bg) !important;
  }

  [data-theme=&quot;dark&quot;] h1, 
  [data-theme=&quot;dark&quot;] h2, 
  [data-theme=&quot;dark&quot;] h3,
  [data-theme=&quot;dark&quot;] h4,
  [data-theme=&quot;dark&quot;] p,
  [data-theme=&quot;dark&quot;] span,
  [data-theme=&quot;dark&quot;] li,
  [data-theme=&quot;dark&quot;] a:not(.button) {
    color: var(--dm-text) !important;
  }

  [data-theme=&quot;dark&quot;] section {
    background-color: var(--dm-bg) !important;
  }

  [data-theme=&quot;dark&quot;] input,
  [data-theme=&quot;dark&quot;] textarea {
    background-color: var(--dm-bg-alt) !important;
    color: var(--dm-text) !important;
    border-color: var(--dm-border) !important;
  }

  /* Transitions */
  *, *::before, *::after {
    transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
  }

  /* Toggle switch container */
  .switch-container {
    position: fixed;
    top: 20px;
    right: 20px;
    display: flex;
    align-items: center;
    gap: 10px;
    z-index: 9999;
    background: var(--dm-bg-alt);
    padding: 8px 15px;
    border-radius: 50px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
  }

  .switch-label {
    font-size: 18px;
  }

  .switch {
    position: relative;
    width: 56px;
    height: 28px;
  }

  .switch input {
    opacity: 0;
    width: 0;
    height: 0;
  }

  .switch-slider {
    position: absolute;
    cursor: pointer;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background-color: #e5e7eb;
    border-radius: 28px;
    transition: 0.3s;
  }

  .switch-slider:before {
    position: absolute;
    content: &quot;&quot;;
    height: 22px;
    width: 22px;
    left: 3px;
    bottom: 3px;
    background-color: white;
    border-radius: 50%;
    transition: 0.3s;
    box-shadow: 0 2px 5px rgba(0,0,0,0.2);
  }

  input:checked + .switch-slider {
    background-color: #3b82f6;
  }

  input:checked + .switch-slider:before {
    transform: translateX(28px);
  }
&lt;/style&gt;

&lt;div class=&quot;switch-container&quot;&gt;
  &lt;span class=&quot;switch-label&quot;&gt;☀️&lt;/span&gt;
  &lt;label class=&quot;switch&quot;&gt;
    &lt;input type=&quot;checkbox&quot; id=&quot;themeSwitch&quot;&gt;
    &lt;span class=&quot;switch-slider&quot;&gt;&lt;/span&gt;
  &lt;/label&gt;
  &lt;span class=&quot;switch-label&quot;&gt;🌙&lt;/span&gt;
&lt;/div&gt;

&lt;script&gt;
var themeSwitch = document.getElementById(&apos;themeSwitch&apos;);

function getTheme() {
  var saved = localStorage.getItem(&apos;theme&apos;);
  if (saved) return saved;
  return window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;).matches ? &apos;dark&apos; : &apos;light&apos;;
}

function setTheme(theme) {
  document.documentElement.setAttribute(&apos;data-theme&apos;, theme);
  localStorage.setItem(&apos;theme&apos;, theme);
  themeSwitch.checked = (theme === &apos;dark&apos;);
}

setTheme(getTheme());

themeSwitch.onchange = function() {
  setTheme(themeSwitch.checked ? &apos;dark&apos; : &apos;light&apos;);
};
&lt;/script&gt;
```

---

## Option 3: Minimal Icon Toggle

A simple icon that changes between sun and moon.

```html
&lt;style&gt;
  /* Theme colors */
  :root {
    --page-bg: #ffffff;
    --page-bg-alt: #f9fafb;
    --page-text: #18181b;
    --page-text-muted: #71717a;
  }

  [data-theme=&quot;dark&quot;] {
    --page-bg: #18181b;
    --page-bg-alt: #27272a;
    --page-text: #fafafa;
    --page-text-muted: #a1a1aa;
  }

  [data-theme=&quot;dark&quot;] body,
  [data-theme=&quot;dark&quot;] #wrapper,
  [data-theme=&quot;dark&quot;] #main,
  [data-theme=&quot;dark&quot;] section {
    background-color: var(--page-bg) !important;
  }

  [data-theme=&quot;dark&quot;] h1, 
  [data-theme=&quot;dark&quot;] h2, 
  [data-theme=&quot;dark&quot;] h3,
  [data-theme=&quot;dark&quot;] h4,
  [data-theme=&quot;dark&quot;] p,
  [data-theme=&quot;dark&quot;] span,
  [data-theme=&quot;dark&quot;] li,
  [data-theme=&quot;dark&quot;] a:not(.button) {
    color: var(--page-text) !important;
  }

  [data-theme=&quot;dark&quot;] input,
  [data-theme=&quot;dark&quot;] textarea {
    background-color: var(--page-bg-alt) !important;
    color: var(--page-text) !important;
  }

  * {
    transition: background-color 0.25s, color 0.25s;
  }

  /* Minimal toggle */
  .minimal-toggle {
    position: fixed;
    top: 20px;
    right: 20px;
    width: 44px;
    height: 44px;
    border: 2px solid var(--page-text);
    border-radius: 50%;
    background: transparent;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 20px;
    z-index: 9999;
    transition: all 0.3s;
  }

  .minimal-toggle:hover {
    background: var(--page-bg-alt);
    transform: rotate(20deg);
  }

  [data-theme=&quot;dark&quot;] .minimal-toggle {
    border-color: var(--page-text);
  }

  .minimal-toggle .sun-icon {
    display: block;
  }

  .minimal-toggle .moon-icon {
    display: none;
  }

  [data-theme=&quot;dark&quot;] .minimal-toggle .sun-icon {
    display: none;
  }

  [data-theme=&quot;dark&quot;] .minimal-toggle .moon-icon {
    display: block;
  }
&lt;/style&gt;

&lt;button class=&quot;minimal-toggle&quot; id=&quot;minimalToggle&quot; aria-label=&quot;Toggle theme&quot;&gt;
  &lt;span class=&quot;sun-icon&quot;&gt;☀️&lt;/span&gt;
  &lt;span class=&quot;moon-icon&quot;&gt;🌙&lt;/span&gt;
&lt;/button&gt;

&lt;script&gt;
var minimalToggle = document.getElementById(&apos;minimalToggle&apos;);

function getTheme() {
  var saved = localStorage.getItem(&apos;theme&apos;);
  if (saved) return saved;
  return window.matchMedia(&apos;(prefers-color-scheme: dark)&apos;).matches ? &apos;dark&apos; : &apos;light&apos;;
}

function applyTheme(theme) {
  document.documentElement.setAttribute(&apos;data-theme&apos;, theme);
  localStorage.setItem(&apos;theme&apos;, theme);
}

applyTheme(getTheme());

minimalToggle.onclick = function() {
  var current = document.documentElement.getAttribute(&apos;data-theme&apos;);
  applyTheme(current === &apos;dark&apos; ? &apos;light&apos; : &apos;dark&apos;);
};
&lt;/script&gt;
```

---

## Customizing Dark Mode Colors

The CSS variables at the top control your colors. Here&apos;s what to change:

### Light Mode Colors (in `:root`)
```css
:root {
  --dm-bg-primary: #ffffff;     /* Main background */
  --dm-bg-secondary: #f8fafc;   /* Secondary background (cards, inputs) */
  --dm-text-primary: #1e293b;   /* Main text color */
  --dm-text-secondary: #64748b; /* Muted text */
  --dm-border: #e2e8f0;         /* Border color */
  --dm-accent: #3b82f6;         /* Accent/link color */
}
```

### Dark Mode Colors (in `[data-theme=&quot;dark&quot;]`)
```css
[data-theme=&quot;dark&quot;] {
  --dm-bg-primary: #0f172a;     /* Dark background */
  --dm-bg-secondary: #1e293b;   /* Slightly lighter dark */
  --dm-text-primary: #f1f5f9;   /* Light text */
  --dm-text-secondary: #94a3b8; /* Muted light text */
  --dm-border: #334155;         /* Dark border */
  --dm-accent: #60a5fa;         /* Lighter accent for contrast */
}
```

## Handling Carrd-Specific Elements

Carrd generates specific class names that might need additional targeting. If some elements don&apos;t change color, add more specific selectors:

```css
/* Target specific Carrd elements */
[data-theme=&quot;dark&quot;] .inner,
[data-theme=&quot;dark&quot;] .content,
[data-theme=&quot;dark&quot;] [class*=&quot;style1&quot;],
[data-theme=&quot;dark&quot;] [class*=&quot;style2&quot;] {
  background-color: var(--dm-bg-primary) !important;
  color: var(--dm-text-primary) !important;
}

/* Target buttons */
[data-theme=&quot;dark&quot;] .button.primary {
  background-color: var(--dm-accent) !important;
}

/* Target icons */
[data-theme=&quot;dark&quot;] .icon {
  filter: brightness(0) invert(1);
}
```

## Handling Images

Images might look jarring against a dark background. You can soften them:

```css
[data-theme=&quot;dark&quot;] img {
  opacity: 0.9;
  filter: brightness(0.95);
}

/* Or add a subtle border */
[data-theme=&quot;dark&quot;] img {
  border: 1px solid var(--dm-border);
  border-radius: 8px;
}
```

## Positioning the Toggle

Change the position by modifying these CSS properties:

**Bottom left (default in Option 1):**
```css
bottom: 25px;
left: 25px;
```

**Top right:**
```css
top: 25px;
right: 25px;
```

**Bottom right:**
```css
bottom: 25px;
right: 25px;
```

## Tips for Better Dark Mode

**Test contrast** - Make sure text is readable against dark backgrounds. Use a contrast checker if unsure.

**Don&apos;t go pure black** - `#000000` is too harsh. Use dark grays like `#0f172a` or `#1e293b` instead.

**Lighten your accent color** - Bright colors that work on white backgrounds often need to be lightened for dark backgrounds.

**Check all elements** - Test every section of your Carrd site. Some elements might need additional CSS targeting.

**Consider images** - Logos and images designed for light backgrounds might need adjustments or alternative versions.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

Dark mode is no longer a nice-to-have feature. Visitors expect the option, especially on sites they might view at night. The toggle takes maybe 10 minutes to implement and significantly improves user experience for a large portion of your audience.

Start with Option 1 (the floating button) if you&apos;re unsure which style to choose. It works with any layout and stays out of the way.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Add a Popup Modal to Your Carrd Website</title><link>https://www.bitdoze.com/carrd-popup-modal/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-popup-modal/</guid><description>Learn how to add customizable popup modals to your Carrd site for email capture, announcements, or image lightboxes with complete code examples.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

Popups get a bad reputation, mostly because people abuse them. But a well-timed modal can capture emails, announce sales, or display important information without cluttering your main page.

[Carrd.co](https://go.bitdoze.com/carrd) doesn&apos;t have built-in popup functionality, but you can add it with custom code. I&apos;ll show you three different popup types you can use depending on your needs.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Add Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Why Add Popups to Your Carrd Site

Popups work well for specific situations:

1. **Email capture** - Grow your newsletter list without dedicating prime page space to signup forms.

2. **Announcements** - Let visitors know about sales, new products, or important updates.

3. **Image lightboxes** - Display larger versions of portfolio images or product photos.

4. **Exit intent** - Catch visitors before they leave with a final offer.

5. **Welcome messages** - Greet first-time visitors or share critical information.

The key is using them thoughtfully. Don&apos;t pop up immediately when someone lands on your page. Give them a few seconds to look around first.

## How to Add a Popup Modal to Carrd

### Step 1: Add an Embed Element

Go to the `+` sign in Carrd and add an Embed element anywhere on your page. Configure it like this:

- Type: Code
- Style: Hidden, Head

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 2: Choose Your Popup Type

I&apos;ve created three different popup styles. Pick the one that fits your needs.

---

## Option 1: Timed Email Capture Popup

This popup appears after a delay and includes an email signup form. Good for newsletter signups.

```html
&lt;style&gt;
  :root {
    --popup-bg: #ffffff;
    --popup-overlay: rgba(0, 0, 0, 0.6);
    --popup-accent: #4f46e5;
    --popup-text: #1f2937;
    --popup-secondary: #6b7280;
    --popup-radius: 16px;
    --popup-width: 450px;
    --popup-delay: 3000; /* milliseconds before popup shows */
  }

  .modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: var(--popup-overlay);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 9999;
    opacity: 0;
    transition: opacity 0.3s ease;
  }

  .modal-overlay.active {
    display: flex;
    opacity: 1;
  }

  .modal-content {
    background: var(--popup-bg);
    border-radius: var(--popup-radius);
    max-width: var(--popup-width);
    width: 90%;
    padding: 40px;
    position: relative;
    transform: scale(0.8);
    transition: transform 0.3s ease;
    box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
  }

  .modal-overlay.active .modal-content {
    transform: scale(1);
  }

  .modal-close {
    position: absolute;
    top: 15px;
    right: 15px;
    width: 32px;
    height: 32px;
    border: none;
    background: #f3f4f6;
    border-radius: 50%;
    cursor: pointer;
    font-size: 20px;
    color: var(--popup-secondary);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 0.2s;
  }

  .modal-close:hover {
    background: #e5e7eb;
  }

  .modal-title {
    font-size: 24px;
    font-weight: 700;
    color: var(--popup-text);
    margin: 0 0 10px 0;
    text-align: center;
  }

  .modal-description {
    font-size: 16px;
    color: var(--popup-secondary);
    margin: 0 0 25px 0;
    text-align: center;
    line-height: 1.5;
  }

  .modal-form {
    display: flex;
    flex-direction: column;
    gap: 15px;
  }

  .modal-input {
    padding: 14px 18px;
    border: 2px solid #e5e7eb;
    border-radius: 10px;
    font-size: 16px;
    transition: border-color 0.2s;
    outline: none;
  }

  .modal-input:focus {
    border-color: var(--popup-accent);
  }

  .modal-button {
    padding: 14px 24px;
    background: var(--popup-accent);
    color: white;
    border: none;
    border-radius: 10px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: background 0.2s, transform 0.1s;
  }

  .modal-button:hover {
    background: #4338ca;
    transform: translateY(-1px);
  }

  .modal-note {
    font-size: 13px;
    color: var(--popup-secondary);
    text-align: center;
    margin-top: 15px;
  }

  @media (max-width: 480px) {
    .modal-content {
      padding: 30px 20px;
    }
    .modal-title {
      font-size: 20px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;modal-overlay&quot; id=&quot;emailModal&quot;&gt;
  &lt;div class=&quot;modal-content&quot;&gt;
    &lt;button class=&quot;modal-close&quot; onclick=&quot;closeModal()&quot;&gt;×&lt;/button&gt;
    &lt;h2 class=&quot;modal-title&quot;&gt;Get Weekly Tips&lt;/h2&gt;
    &lt;p class=&quot;modal-description&quot;&gt;Join 5,000+ subscribers getting practical advice delivered to their inbox every week.&lt;/p&gt;
    &lt;form class=&quot;modal-form&quot; action=&quot;YOUR_FORM_ENDPOINT&quot; method=&quot;POST&quot;&gt;
      &lt;input type=&quot;email&quot; name=&quot;email&quot; class=&quot;modal-input&quot; placeholder=&quot;Enter your email&quot; required&gt;
      &lt;button type=&quot;submit&quot; class=&quot;modal-button&quot;&gt;Subscribe&lt;/button&gt;
    &lt;/form&gt;
    &lt;p class=&quot;modal-note&quot;&gt;No spam. Unsubscribe anytime.&lt;/p&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  function showModal() {
    document.getElementById(&apos;emailModal&apos;).classList.add(&apos;active&apos;);
    document.body.style.overflow = &apos;hidden&apos;;
  }

  function closeModal() {
    document.getElementById(&apos;emailModal&apos;).classList.remove(&apos;active&apos;);
    document.body.style.overflow = &apos;&apos;;
    sessionStorage.setItem(&apos;modalShown&apos;, &apos;true&apos;);
  }

  // Close on overlay click
  document.getElementById(&apos;emailModal&apos;).addEventListener(&apos;click&apos;, function(e) {
    if (e.target === this) closeModal();
  });

  // Close on Escape key
  document.addEventListener(&apos;keydown&apos;, function(e) {
    if (e.key === &apos;Escape&apos;) closeModal();
  });

  // Show popup after delay (only once per session)
  if (!sessionStorage.getItem(&apos;modalShown&apos;)) {
    setTimeout(showModal, 3000);
  }
&lt;/script&gt;
```

### Customization Options

**Change the delay:** Find `setTimeout(showModal, 3000)` and change `3000` to your preferred delay in milliseconds (3000 = 3 seconds).

**Change colors:** Modify the CSS variables at the top:
- `--popup-accent` controls the button color
- `--popup-bg` controls the background
- `--popup-text` controls the text color

**Connect to your email service:** Replace `YOUR_FORM_ENDPOINT` with your actual form endpoint from Mailchimp, ConvertKit, or whatever service you use. Most email platforms give you a form action URL.

---

## Option 2: Click-Triggered Popup

This popup only appears when visitors click a button. Better for product details, terms, or additional information.

```html
&lt;style&gt;
  :root {
    --click-popup-bg: #ffffff;
    --click-popup-overlay: rgba(0, 0, 0, 0.7);
    --click-popup-accent: #10b981;
    --click-popup-text: #111827;
    --click-popup-radius: 20px;
  }

  .click-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: var(--click-popup-overlay);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 9999;
    padding: 20px;
  }

  .click-modal-overlay.active {
    display: flex;
  }

  .click-modal-content {
    background: var(--click-popup-bg);
    border-radius: var(--click-popup-radius);
    max-width: 600px;
    width: 100%;
    max-height: 80vh;
    overflow-y: auto;
    position: relative;
    animation: slideUp 0.3s ease;
  }

  @keyframes slideUp {
    from {
      opacity: 0;
      transform: translateY(30px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  .click-modal-header {
    padding: 25px 30px;
    border-bottom: 1px solid #e5e7eb;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }

  .click-modal-title {
    font-size: 22px;
    font-weight: 700;
    color: var(--click-popup-text);
    margin: 0;
  }

  .click-modal-close {
    width: 36px;
    height: 36px;
    border: none;
    background: #f3f4f6;
    border-radius: 50%;
    cursor: pointer;
    font-size: 22px;
    color: #6b7280;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .click-modal-close:hover {
    background: #e5e7eb;
  }

  .click-modal-body {
    padding: 30px;
    color: #4b5563;
    line-height: 1.7;
  }

  .click-modal-body h3 {
    color: var(--click-popup-text);
    margin: 0 0 15px 0;
    font-size: 18px;
  }

  .click-modal-body p {
    margin: 0 0 20px 0;
  }

  .click-modal-body ul {
    margin: 0 0 20px 0;
    padding-left: 20px;
  }

  .click-modal-body li {
    margin-bottom: 10px;
  }

  .click-modal-footer {
    padding: 20px 30px;
    border-top: 1px solid #e5e7eb;
    text-align: right;
  }

  .click-modal-btn {
    padding: 12px 28px;
    background: var(--click-popup-accent);
    color: white;
    border: none;
    border-radius: 10px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
  }

  .click-modal-btn:hover {
    opacity: 0.9;
  }

  /* Trigger button style */
  .popup-trigger {
    padding: 14px 28px;
    background: var(--click-popup-accent);
    color: white;
    border: none;
    border-radius: 10px;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    transition: transform 0.2s;
  }

  .popup-trigger:hover {
    transform: translateY(-2px);
  }
&lt;/style&gt;

&lt;div class=&quot;click-modal-overlay&quot; id=&quot;clickModal&quot;&gt;
  &lt;div class=&quot;click-modal-content&quot;&gt;
    &lt;div class=&quot;click-modal-header&quot;&gt;
      &lt;h2 class=&quot;click-modal-title&quot;&gt;Service Details&lt;/h2&gt;
      &lt;button class=&quot;click-modal-close&quot; onclick=&quot;closeClickModal()&quot;&gt;×&lt;/button&gt;
    &lt;/div&gt;
    &lt;div class=&quot;click-modal-body&quot;&gt;
      &lt;h3&gt;What&apos;s Included&lt;/h3&gt;
      &lt;ul&gt;
        &lt;li&gt;Full website design and development&lt;/li&gt;
        &lt;li&gt;Mobile-responsive layout&lt;/li&gt;
        &lt;li&gt;SEO optimization&lt;/li&gt;
        &lt;li&gt;2 rounds of revisions&lt;/li&gt;
        &lt;li&gt;30 days of support after launch&lt;/li&gt;
      &lt;/ul&gt;
      &lt;p&gt;Turnaround time is typically 2-3 weeks depending on project complexity. We&apos;ll discuss your specific needs during our initial consultation.&lt;/p&gt;
    &lt;/div&gt;
    &lt;div class=&quot;click-modal-footer&quot;&gt;
      &lt;button class=&quot;click-modal-btn&quot; onclick=&quot;closeClickModal()&quot;&gt;Got It&lt;/button&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  function openClickModal() {
    document.getElementById(&apos;clickModal&apos;).classList.add(&apos;active&apos;);
    document.body.style.overflow = &apos;hidden&apos;;
  }

  function closeClickModal() {
    document.getElementById(&apos;clickModal&apos;).classList.remove(&apos;active&apos;);
    document.body.style.overflow = &apos;&apos;;
  }

  document.getElementById(&apos;clickModal&apos;).addEventListener(&apos;click&apos;, function(e) {
    if (e.target === this) closeClickModal();
  });

  document.addEventListener(&apos;keydown&apos;, function(e) {
    if (e.key === &apos;Escape&apos;) closeClickModal();
  });
&lt;/script&gt;
```

### Adding the Trigger Button

To trigger this popup, add a button element in Carrd with this onclick attribute, or add another embed with:

```html
&lt;button class=&quot;popup-trigger&quot; onclick=&quot;openClickModal()&quot;&gt;Learn More&lt;/button&gt;
```

Alternatively, you can use Carrd&apos;s built-in button and add this to its URL field:
```
javascript:openClickModal()
```

---

## Option 3: Exit Intent Popup

This popup appears when visitors move their mouse toward the browser&apos;s close button. Last chance to capture their attention.

```html
&lt;style&gt;
  :root {
    --exit-popup-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    --exit-popup-card: #ffffff;
    --exit-popup-text: #1f2937;
  }

  .exit-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background: rgba(0, 0, 0, 0.8);
    display: none;
    justify-content: center;
    align-items: center;
    z-index: 9999;
    padding: 20px;
  }

  .exit-modal-overlay.active {
    display: flex;
  }

  .exit-modal-content {
    background: var(--exit-popup-bg);
    border-radius: 24px;
    max-width: 500px;
    width: 100%;
    padding: 50px 40px;
    position: relative;
    text-align: center;
    animation: bounceIn 0.4s ease;
  }

  @keyframes bounceIn {
    0% {
      opacity: 0;
      transform: scale(0.5);
    }
    70% {
      transform: scale(1.05);
    }
    100% {
      opacity: 1;
      transform: scale(1);
    }
  }

  .exit-modal-close {
    position: absolute;
    top: 15px;
    right: 15px;
    width: 36px;
    height: 36px;
    border: none;
    background: rgba(255,255,255,0.2);
    border-radius: 50%;
    cursor: pointer;
    font-size: 20px;
    color: white;
  }

  .exit-modal-close:hover {
    background: rgba(255,255,255,0.3);
  }

  .exit-modal-emoji {
    font-size: 60px;
    margin-bottom: 20px;
  }

  .exit-modal-title {
    font-size: 28px;
    font-weight: 700;
    color: white;
    margin: 0 0 15px 0;
  }

  .exit-modal-description {
    font-size: 18px;
    color: rgba(255,255,255,0.9);
    margin: 0 0 30px 0;
    line-height: 1.6;
  }

  .exit-modal-cta {
    padding: 16px 40px;
    background: white;
    color: #667eea;
    border: none;
    border-radius: 12px;
    font-size: 18px;
    font-weight: 700;
    cursor: pointer;
    transition: transform 0.2s;
    margin-bottom: 15px;
  }

  .exit-modal-cta:hover {
    transform: scale(1.05);
  }

  .exit-modal-skip {
    background: none;
    border: none;
    color: rgba(255,255,255,0.7);
    font-size: 14px;
    cursor: pointer;
    text-decoration: underline;
  }

  .exit-modal-skip:hover {
    color: white;
  }

  @media (max-width: 480px) {
    .exit-modal-content {
      padding: 40px 25px;
    }
    .exit-modal-title {
      font-size: 24px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;exit-modal-overlay&quot; id=&quot;exitModal&quot;&gt;
  &lt;div class=&quot;exit-modal-content&quot;&gt;
    &lt;button class=&quot;exit-modal-close&quot; onclick=&quot;closeExitModal()&quot;&gt;×&lt;/button&gt;
    &lt;div class=&quot;exit-modal-emoji&quot;&gt;👋&lt;/div&gt;
    &lt;h2 class=&quot;exit-modal-title&quot;&gt;Wait! Before You Go...&lt;/h2&gt;
    &lt;p class=&quot;exit-modal-description&quot;&gt;Get 20% off your first order when you sign up for our newsletter.&lt;/p&gt;
    &lt;button class=&quot;exit-modal-cta&quot; onclick=&quot;window.location.href=&apos;#signup&apos;&quot;&gt;Claim My Discount&lt;/button&gt;
    &lt;br&gt;
    &lt;button class=&quot;exit-modal-skip&quot; onclick=&quot;closeExitModal()&quot;&gt;No thanks, I&apos;ll pay full price&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  let exitShown = false;

  function closeExitModal() {
    document.getElementById(&apos;exitModal&apos;).classList.remove(&apos;active&apos;);
    document.body.style.overflow = &apos;&apos;;
    sessionStorage.setItem(&apos;exitModalShown&apos;, &apos;true&apos;);
  }

  document.getElementById(&apos;exitModal&apos;).addEventListener(&apos;click&apos;, function(e) {
    if (e.target === this) closeExitModal();
  });

  // Exit intent detection (desktop only)
  document.addEventListener(&apos;mouseout&apos;, function(e) {
    if (!e.toElement &amp;&amp; !e.relatedTarget &amp;&amp; e.clientY &lt; 10) {
      if (!exitShown &amp;&amp; !sessionStorage.getItem(&apos;exitModalShown&apos;)) {
        document.getElementById(&apos;exitModal&apos;).classList.add(&apos;active&apos;);
        document.body.style.overflow = &apos;hidden&apos;;
        exitShown = true;
      }
    }
  });
&lt;/script&gt;
```

This popup only works on desktop since mobile devices don&apos;t have the same mouse behavior. On mobile, you might want to trigger it based on scroll position or time on page instead.

---

## Tips for Effective Popups

**Don&apos;t be annoying.** Show the popup once per session, not every page load. All the code examples above use `sessionStorage` to prevent repeat popups.

**Make closing easy.** The close button should be obvious. Let people click outside to dismiss. Support the Escape key.

**Provide value.** Nobody wants to sign up for &quot;updates.&quot; Offer something specific: a discount, a free guide, exclusive content.

**Test on mobile.** Some popup styles don&apos;t work well on small screens. Check your design on actual phones.

**Consider timing.** Immediate popups feel aggressive. Waiting 3-5 seconds or triggering on scroll often works better.

## Connecting to Email Services

Replace `YOUR_FORM_ENDPOINT` in the email capture popup with your actual form URL:

**Mailchimp:** Use your list&apos;s signup form action URL from Audience &gt; Signup forms

**ConvertKit:** Get the form action URL from Forms &gt; your form &gt; HTML

**Buttondown:** Use `https://buttondown.email/api/emails/embed-subscribe/YOUR_USERNAME`

Most services provide an HTML form that you can extract the action URL from.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

Popups aren&apos;t evil - they&apos;re just often used poorly. A well-designed modal that appears at the right time and offers real value can grow your email list without annoying visitors.

Pick the popup style that fits your needs, customize the colors and copy, and remember: less is more. One thoughtful popup beats three aggressive ones.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>Carrd.co Review: The Best Budget Landing Page Builder</title><link>https://www.bitdoze.com/carrd-review/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-review/</guid><description>Carrd.co is the perfect tool for creating a simple yet effective landing page. Read our comprehensive review to learn about its features, pricing, and pros and cons.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>I&apos;ve used plenty of website builders over the years, and most of them feel like overkill for what people actually need. You want a landing page, not a spaceship control panel.

Carrd.co gets this. It builds single-page websites without the bloat, without the monthly drain on your wallet, and without requiring a computer science degree to figure out. I&apos;ve been using it for several projects, and in this review I&apos;ll share what works, what doesn&apos;t, and whether it&apos;s right for you.

## What is Carrd.co?

[Carrd.co](https://try.carrd.co/bitdoze) builds responsive, single-page websites. That&apos;s it. No multi-page hierarchies, no complex CMS, no database nonsense. You get a page, you customize it, you publish it.

The platform works well for personal portfolios, business landing pages, and simple company sites. I&apos;ve seen people use it for everything from freelance portfolios to event countdown pages to simple product launches.

What I appreciate most is that Carrd doesn&apos;t pretend to be something it isn&apos;t. It won&apos;t build you a 50-page e-commerce store. But it will get a clean, professional-looking landing page online in about 15 minutes. For freelancers, side projects, and small businesses who just need something that works, that&apos;s often enough.

The platform also allows custom code for those who want more control. You can [add a sticky header](https://www.bitdoze.com/add-stickey-header-carrd/) for better navigation, implement [mobile responsive navigation](https://www.bitdoze.com/carrd-mobile-navbar/), or add a [sidebar menu](https://www.bitdoze.com/carrd-sidebar-menu/) for sites with multiple sections.

&gt; For complete guides, custom themes, and advanced plugins, visit my dedicated **[carrdme.com](https://carrdme.com/)** resource site.

## Carrd.co Video Review

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/nFnfaQI-nt0&quot;
  label=&quot;Carrd.co Review: The Best Budget Landing Page Builder&quot;
/&gt;

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Carrd.co Key Features

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;

Let me walk through what Carrd actually gives you.

### Intuitive Design Interface

The editor uses drag-and-drop. You move elements around, adjust layouts, and customize designs without writing code. The interface stays clean - no overwhelming sidebar with 47 options you&apos;ll never use.

You can add text, images, buttons, forms, videos, and various other elements. Nothing groundbreaking, but everything you&apos;d actually need for a landing page.

### Complete Element Library

![carrd elements](@images/25/07/carrdelements.png)

Here&apos;s what you can actually add to your pages:

&lt;ListCheck&gt;
- **Content Elements**: Text blocks, images, videos, and audio files. The image element handles optimization automatically.

- **Interactive Components**: Buttons, lists, links, and icons. Standard stuff for any landing page.

- **Media Features**: Galleries for portfolios, slideshows for presentations, and embedded video/audio.

- **Functional Tools**: Countdown timers for launches, contact forms for lead capture, and tables for organized data.

- **Advanced Elements**: Dividers, custom code embeds, containers for layout control, and interactive elements.
&lt;/ListCheck&gt;

Nothing revolutionary here, but the pieces fit together well. You can build a functional landing page without hitting unexpected limitations.

### Extensive Template Library

Over 100 templates cover personal bios, business portfolios, event pages, and product launches. They&apos;re mobile-optimized out of the box, which saves time. Pick one close to what you need and customize from there.

### Payment Integration

PayPal and Stripe integrations let you sell digital products or services directly from your landing page. Not a full e-commerce solution, but enough for simple transactions.

### Custom Domain Support

You can [connect your custom domain to Carrd](https://www.bitdoze.com/carrd-add-domain/) on paid plans. Having yourbrand.com instead of something.carrd.co makes a difference for credibility.

### Advanced Form Builder

Forms connect with ActiveCampaign, Mailchimp, ConvertKit, and other email platforms. The integrations work well for basic lead capture and newsletter signups.

### Enhanced User Experience Features

Several features help with longer pages: [accordion-style FAQ sections](https://www.bitdoze.com/add-accordion-carrd/) collapse information neatly, [pricing tables](https://www.bitdoze.com/carrd-add-pricing-table/) display service tiers clearly, [floating menus](https://www.bitdoze.com/carrd-floating-menu/) keep navigation accessible, and [back-to-top buttons](https://www.bitdoze.com/carrd-back-to-top-button/) help visitors navigate.

### SEO and Analytics

Basic SEO tools let you set titles, descriptions, and meta tags. Google Analytics integration tracks visitor behavior. You can also [add cookie notices](https://www.bitdoze.com/add-cookie-notice-carrd/) for GDPR compliance.

## Carrd Video Tutorials Playlist

I&apos;ve put together a playlist covering Carrd techniques from basic setup to advanced customizations:

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/videoseries?list=PL2JhdTMcNc2MMA1OPy0ZbEsmIj2UdGWxE&quot;
  label=&quot;Complete Carrd Tutorials Playlist&quot;
/&gt;

Watch these if you prefer learning by video rather than reading documentation.

## Carrd.co Pricing Structure

This is where Carrd really stands out. The pricing is annual, not monthly, and it&apos;s cheap.

### Free Plan

Basic functionality with a carrd.co subdomain. Good for testing or simple personal pages. You won&apos;t get custom domains or remove the Carrd branding, but you can build a functional site.

### Pro Lite ($9/year)

Custom domains and no Carrd branding. Nine dollars per year. That&apos;s less than most competitors charge per month.

### Pro Standard ($19/year)

More templates, embed functionality, and advanced customization. This is the plan most people end up on.

### Pro Plus ($49/year)

All features plus priority support and enhanced storage. Worth it if you&apos;re building multiple sites or need the extra capacity.

Compare this to Squarespace at $16/month or Wix at $17/month. Carrd&apos;s annual cost is less than one month on those platforms.

## Carrd.co Advantages and Limitations

Let&apos;s be honest about what works and what doesn&apos;t.

### What Works Well

&lt;ListCheck&gt;

- **The Price**: Annual plans cost less than one month on most competitors. Hard to beat $19/year for a professional landing page.

- **Quick to Learn**: Most people can build their first site within an hour. The interface doesn&apos;t hide important options behind menus within menus.

- **Good Templates**: The templates look modern and work on mobile. Start with one close to your vision and tweak from there.

- **Fast Loading**: Sites load quickly because they&apos;re simple. No bloated plugins or unnecessary scripts slowing things down.

- **Custom Code Support**: You can add HTML, CSS, and JavaScript through embed elements. This extends functionality beyond what the visual editor offers.

&lt;/ListCheck&gt;

### What Doesn&apos;t Work

&lt;ListCheck&gt;

- **Single Page Only**: No multi-page sites. If you need a blog, about page, and contact page as separate URLs, Carrd isn&apos;t the right tool. Consider [Astro](https://www.bitdoze.com/build-astro-blog-free/) for that.

- **Basic E-commerce**: PayPal and Stripe buttons work fine for simple sales. Inventory management, product variants, or shopping carts? Look elsewhere.

- **Limited SEO**: You get titles, descriptions, and meta tags. That&apos;s it. No advanced schema markup, no sitemap generation, no blog for content marketing.

- **Design Constraints**: The editor gives you flexibility within limits. Sometimes you&apos;ll want to do something and realize the platform doesn&apos;t support it.

&lt;/ListCheck&gt;

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Start with Carrd.co&quot; /&gt;

## Who Carrd Is For (And Who It Isn&apos;t)

I&apos;ve recommended Carrd to dozens of people. Some loved it, others needed something else entirely. Here&apos;s how to know which camp you&apos;re in.

**Carrd works well for:**

- Freelancers who need a simple portfolio or &quot;hire me&quot; page
- Side projects that need a landing page before the product exists
- Event organizers promoting a conference, wedding, or meetup
- Musicians, artists, or creators who want a link-in-bio style page
- Small businesses testing an idea before investing in a full website
- Anyone collecting emails for a newsletter or waitlist

**Carrd probably isn&apos;t right for:**

- Businesses that need multiple pages with different URLs
- Content creators who want to blog regularly
- E-commerce stores selling physical products
- Agencies managing sites for multiple clients
- Anyone who needs member logins or user accounts

The pattern is simple: if one page can tell your story and accomplish your goal, Carrd works. If you need complexity, structure, or regular content updates, look elsewhere.

## Real Examples: What People Actually Build

Abstract feature lists don&apos;t help much. Here&apos;s what I&apos;ve seen people create with Carrd:

**Personal branding pages** - Freelance designers linking their portfolio, social profiles, and contact form on one clean page. Takes maybe 30 minutes to build.

**Product launch pages** - Indie makers testing interest before building. Add a headline, some bullet points, maybe a mockup, and an email signup. See if anyone cares before writing code.

**Event countdowns** - Wedding sites, conference registrations, party invitations. The countdown timer element works well here. Add location, schedule, and RSVP form.

**&quot;Link in bio&quot; replacements** - Instead of Linktree, build something that matches your brand. Same functionality, more control over design.

**Service landing pages** - Consultants, coaches, and service providers explaining what they offer with a clear call to action. Pricing table, testimonials, contact form.

**Coming soon pages** - Domain parked with a professional placeholder while the real site gets built. Better than a blank page or generic &quot;under construction&quot; template.

None of these need multiple pages. None need a database. None need monthly maintenance. That&apos;s the sweet spot where Carrd makes sense.

## Carrd vs. Major Competitors

Here&apos;s how Carrd compares to the alternatives I&apos;ve actually used.

### Carrd vs. Webflow

Webflow builds complex, multi-page sites with sophisticated animations. It&apos;s powerful but has a steep learning curve and costs $14-39/month. If you need a simple landing page, Webflow is overkill. If you&apos;re building a complex marketing site, Webflow makes more sense.

### Carrd vs. Wix

Wix has more features but feels cluttered. The drag-and-drop editor is flexible, but you&apos;ll spend time figuring out where things are hidden. Pricing starts around $17/month. For a single landing page, Carrd is simpler and cheaper.

### Carrd vs. WordPress

WordPress can do anything, but that flexibility comes with maintenance headaches. You need hosting, security updates, plugin management, and regular backups. Carrd handles all that for you. If you need a blog or complex site, WordPress wins. For a landing page, Carrd saves time and hassle.

### Carrd vs. Squarespace

Squarespace has gorgeous templates and works well for portfolios and small business sites. It starts at $16/month. The quality is high, but you&apos;re paying roughly 10x what Carrd costs annually. For simple landing pages, the extra features don&apos;t justify the price difference.

### Carrd vs. Framer

Framer targets designers who want precise control over animations and interactions. It&apos;s sophisticated but complex. Unless you&apos;re a designer building a portfolio or prototype, Carrd&apos;s simplicity is probably a better fit.

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Start with Carrd.co&quot; /&gt;


## Using AI to Build Your Carrd Site

Here&apos;s something that&apos;s changed how I approach Carrd projects: AI tools can help with content, copywriting, and even custom code.

If you&apos;re new to programming or want to add custom functionality to your Carrd site, AI coding assistants can generate the HTML, CSS, and JavaScript you need. I covered this extensively in my [AI programming guide for beginners](https://www.bitdoze.com/ai-programming-beginners-guide/). The same principles apply to Carrd&apos;s embed functionality.

For example, you can ask an AI assistant to generate:
- Custom CSS animations
- Interactive elements
- Form validation scripts
- Analytics tracking code
- Custom navigation menus

The embed element in Carrd accepts custom code, so anything an AI generates can be dropped right in. This extends what&apos;s possible without needing to learn programming from scratch.

If you&apos;re considering building a more complex site with multiple pages and a blog, check out my guide on [building a free blog with Astro](https://www.bitdoze.com/build-astro-blog-free/). It covers setting up a full blog that costs nothing to host.

## Tips for Getting Good Results

A few things I&apos;ve learned from building Carrd sites:

### Keep It Simple

Single-page sites work best when they focus on one goal. Don&apos;t try to cram your entire business onto one page. Pick one action you want visitors to take and design around that.

### Optimize Your Images

Large images slow down your site. Compress them before uploading. There are free tools like TinyPNG that reduce file size without visible quality loss.

### Test on Mobile

More than half your visitors will probably be on phones. Check how your site looks on mobile before publishing. The responsive preview in Carrd helps, but also test on an actual phone.

### Use Custom Code When Needed

If the visual editor doesn&apos;t do what you want, check if custom code can solve it. My tutorials cover several customizations like [floating menus](https://www.bitdoze.com/carrd-floating-menu/) and [sidebar navigation](https://www.bitdoze.com/carrd-sidebar-menu/).

## Conclusion

Carrd does one thing well: simple, fast landing pages at a price that makes sense. It won&apos;t replace WordPress for blogs or Shopify for e-commerce, but it&apos;s not trying to.

For freelancers, side projects, event pages, or anyone who needs a professional web presence without the overhead, Carrd is worth considering. The learning curve is short, the pricing is honest, and the results look professional.

Try the free tier first. If it works for your needs, the $19/year Pro plan is one of the best values in web tools.

## All My Carrd Tutorials

I&apos;ve written several guides on extending Carrd&apos;s functionality:

&lt;ListCheck&gt;

- [Add a Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/) - Email capture popups and announcements
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/) - Smooth navigation between sections
- [Add a Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/) - Rotating customer reviews
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/) - Light/dark theme switcher
- [Add a WhatsApp Button to Carrd](https://www.bitdoze.com/carrd-whatsapp-button/) - Instant chat contact button
- [Style Countdown Timers in Carrd](https://www.bitdoze.com/carrd-countdown-styling/) - Custom countdown designs
- [Add a Sticky Header to Carrd](https://www.bitdoze.com/add-stickey-header-carrd/) - Keep navigation visible while scrolling
- [Add a Mobile Responsive Navbar](https://www.bitdoze.com/carrd-mobile-navbar/) - Better mobile navigation experience
- [Add a Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/) - Slide-in navigation for content-heavy sites
- [Add a Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/) - Hamburger menu that stays accessible
- [Add Accordion FAQs to Carrd](https://www.bitdoze.com/add-accordion-carrd/) - Collapsible FAQ sections
- [Add a Pricing Table to Carrd](https://www.bitdoze.com/carrd-add-pricing-table/) - Display service tiers clearly
- [Add a Back-to-Top Button](https://www.bitdoze.com/carrd-back-to-top-button/) - Help visitors navigate long pages
- [Add a Cookie Notice to Carrd](https://www.bitdoze.com/add-cookie-notice-carrd/) - GDPR compliance
- [Connect a Custom Domain to Carrd](https://www.bitdoze.com/carrd-add-domain/) - Use your own domain name
- [Build a One-Page Website on a Budget](https://www.bitdoze.com/build-one-page-website-budget/) - Compare Carrd with alternatives

&lt;/ListCheck&gt;

## Frequently Asked Questions

### Is Carrd suitable for blogging?

No. The single-page format doesn&apos;t work for regular content publishing. You can display a few posts in a blog-style layout, but for actual blogging, use something like [Astro](https://www.bitdoze.com/build-astro-blog-free/) or WordPress.

### Can Carrd handle portfolio websites?

Yes, portfolios work well. The gallery elements and templates are designed for showcasing creative work. Many freelancers and artists use Carrd for exactly this purpose.

### Does Carrd offer customer support?

Documentation and email support. Pro Plus subscribers get priority responses. Honestly, the platform is simple enough that most people don&apos;t need much support.

### How reliable is Carrd&apos;s hosting?

I haven&apos;t experienced downtime issues. Sites load fast because they&apos;re simple. Carrd handles security and maintenance on their end.

### Can I migrate away from Carrd later?

There&apos;s no export button, but single-page sites are easy to recreate elsewhere. Your content is your content - just rebuild the layout on another platform if needed.

&lt;Button link=&quot;https://try.carrd.co/bitdoze&quot; text=&quot;Get Started with Carrd.co&quot; /&gt;

&lt;Button link=&quot;https://carrdme.com/&quot; text=&quot;Carrd Plugins and Themes&quot; /&gt;</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Add Smooth Scroll and Anchor Links to Carrd</title><link>https://www.bitdoze.com/carrd-smooth-scroll/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-smooth-scroll/</guid><description>Learn how to implement smooth scrolling navigation in your Carrd website with anchor links that glide to sections instead of jumping.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

When visitors click navigation links on a one-page site, they expect a smooth experience. The default browser behavior is an instant jump - one moment you&apos;re at the top, the next you&apos;re halfway down the page. It works, but it feels jarring.

Smooth scrolling changes that. Click a link and the page glides to the target section, giving visitors a sense of where they are in relation to other content. It&apos;s a small detail that makes your [Carrd](https://go.bitdoze.com/carrd) site feel more polished.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Testimonial Slider to Carrd](https://www.bitdoze.com/carrd-testimonial-slider/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Why Smooth Scrolling Matters

A few reasons to add smooth scrolling:

1. **Better user orientation** - Visitors see the page moving, so they understand the site&apos;s structure better.

2. **Professional feel** - Small interactions like this separate amateur sites from polished ones.

3. **Reduced disorientation** - Instant jumps can confuse visitors, especially on longer pages.

4. **Works with any navigation** - Whether you use a [sticky header](https://www.bitdoze.com/add-stickey-header-carrd/), [floating menu](https://www.bitdoze.com/carrd-floating-menu/), or [sidebar navigation](https://www.bitdoze.com/carrd-sidebar-menu/), smooth scrolling improves the experience.

## How to Add Smooth Scrolling to Carrd

### Step 1: Set Up Your Section IDs in Carrd

First, you need to give each section an ID that links can target. In Carrd:

1. Click on a container or section you want to link to
2. Go to Settings (gear icon)
3. Find the &quot;ID&quot; field
4. Enter a simple name like `about`, `services`, `contact`

Do this for each section you want in your navigation.

### Step 2: Add the Embed Element

Go to the `+` sign and add an Embed element anywhere on your page:

- Type: Code
- Style: Hidden, Head

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 3: Add the Smooth Scroll Code

Here&apos;s the complete code with multiple options:

---

## Option 1: Simple Smooth Scroll (CSS Only)

The easiest approach uses pure CSS. It works in all modern browsers and with Carrd&apos;s built-in navigation.

```html
&lt;style&gt;
  html {
    scroll-behavior: smooth !important;
  }
&lt;/style&gt;
```

That&apos;s it. This enables smooth scrolling when you click Carrd&apos;s navigation links. The browser handles everything automatically.

**Limitation:** You can&apos;t control scroll speed or add offset for sticky headers with this method.

---

## Option 2: Custom Scroll Speed (No Sticky Header)

If you want to control how fast the page scrolls but don&apos;t have a sticky header, use this. It works with Carrd&apos;s navigation system by listening to URL hash changes.

```html
&lt;script&gt;
(function() {
  const scrollDuration = 800; // Change this: 600 = fast, 1000 = slow
  
  function smoothScroll(target) {
    const startPosition = window.pageYOffset;
    const targetPosition = target.offsetTop;
    const distance = targetPosition - startPosition;
    let startTime = null;
    
    function easeInOutQuad(t) {
      return t &lt; 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
    }
    
    function animate(currentTime) {
      if (!startTime) startTime = currentTime;
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / scrollDuration, 1);
      
      window.scrollTo(0, startPosition + distance * easeInOutQuad(progress));
      
      if (elapsed &lt; scrollDuration) {
        requestAnimationFrame(animate);
      }
    }
    
    requestAnimationFrame(animate);
  }
  
  // Carrd uses hashchange for navigation
  window.addEventListener(&apos;hashchange&apos;, function() {
    setTimeout(function() {
      const hash = location.hash;
      if (!hash) return;
      
      // Carrd uses ID format: &quot;sectionname-section&quot; (e.g., #about becomes #about-section)
      const target = document.querySelector(hash + &apos;-section&apos;) || 
                     document.querySelector(hash);
      
      if (target) {
        smoothScroll(target);
      }
    }, 10);
  });
})();
&lt;/script&gt;
```

**How it works:** Carrd intercepts link clicks and changes the URL hash. This script listens for those hash changes and performs smooth scrolling.

**Adjust speed:** Change `scrollDuration = 800` to your preference (600 = faster, 1200 = slower).

---

## Option 3: Smooth Scroll with Sticky Header Offset

If you have a [sticky header](https://www.bitdoze.com/add-stickey-header-carrd/), this prevents content from scrolling behind it.

```html
&lt;script&gt;
(function() {
  const headerOffset = 80; // Change this to your header&apos;s height in pixels
  const scrollDuration = 800;
  
  function smoothScroll(target) {
    const startPosition = window.pageYOffset;
    const targetPosition = target.offsetTop - headerOffset;
    const distance = targetPosition - startPosition;
    let startTime = null;
    
    function easeInOutQuad(t) {
      return t &lt; 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
    }
    
    function animate(currentTime) {
      if (!startTime) startTime = currentTime;
      const elapsed = currentTime - startTime;
      const progress = Math.min(elapsed / scrollDuration, 1);
      
      window.scrollTo(0, startPosition + distance * easeInOutQuad(progress));
      
      if (elapsed &lt; scrollDuration) {
        requestAnimationFrame(animate);
      }
    }
    
    requestAnimationFrame(animate);
  }
  
  window.addEventListener(&apos;hashchange&apos;, function() {
    setTimeout(function() {
      const hash = location.hash;
      if (!hash) return;
      
      const target = document.querySelector(hash + &apos;-section&apos;) || 
                     document.querySelector(hash);
      
      if (target) {
        smoothScroll(target);
      }
    }, 10);
  });
})();
&lt;/script&gt;
```

**Configuration:**
- `headerOffset = 80` - Set this to your sticky header&apos;s height in pixels
- `scrollDuration = 800` - Adjust scroll speed (600 = faster, 1000 = slower)

---

## Option 4: Scroll Progress Bar

Add a colored bar at the top showing scroll progress. Combine with Option 1 for smooth scrolling.

```html
&lt;style&gt;
  html {
    scroll-behavior: smooth !important;
  }
  
  .scroll-progress {
    position: fixed;
    top: 0;
    left: 0;
    width: 0%;
    height: 3px;
    background: #4f46e5;
    z-index: 99999;
    transition: width 0.1s;
  }
&lt;/style&gt;

&lt;div class=&quot;scroll-progress&quot; id=&quot;scrollProgress&quot;&gt;&lt;/div&gt;

&lt;script&gt;
(function() {
  const progressBar = document.getElementById(&apos;scrollProgress&apos;);
  
  function updateProgress() {
    const scrollTop = window.pageYOffset;
    const docHeight = document.documentElement.scrollHeight - window.innerHeight;
    
    if (docHeight &gt; 0) {
      progressBar.style.width = (scrollTop / docHeight) * 100 + &apos;%&apos;;
    }
  }
  
  window.addEventListener(&apos;scroll&apos;, updateProgress);
  updateProgress();
})();
&lt;/script&gt;
```

**Change color:** Modify `background: #4f46e5` to your brand color.

---

## Option 5: Smooth Scroll for Back-to-Top Button

If you have a [back-to-top button](https://www.bitdoze.com/carrd-back-to-top-button/), here&apos;s code specifically for that:

```html
&lt;style&gt;
  .back-to-top {
    position: fixed;
    bottom: 30px;
    right: 30px;
    width: 50px;
    height: 50px;
    background: #4f46e5;
    color: white;
    border: none;
    border-radius: 50%;
    cursor: pointer;
    font-size: 24px;
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    visibility: hidden;
    transition: all 0.3s ease;
    z-index: 1000;
    box-shadow: 0 4px 15px rgba(0,0,0,0.2);
  }

  .back-to-top.visible {
    opacity: 1;
    visibility: visible;
  }

  .back-to-top:hover {
    background: #4338ca;
    transform: translateY(-3px);
  }
&lt;/style&gt;

&lt;button class=&quot;back-to-top&quot; id=&quot;backToTop&quot; aria-label=&quot;Back to top&quot;&gt;↑&lt;/button&gt;

&lt;script&gt;
  const backToTopButton = document.getElementById(&apos;backToTop&apos;);
  
  // Show/hide button based on scroll position
  window.addEventListener(&apos;scroll&apos;, function() {
    if (window.pageYOffset &gt; 300) {
      backToTopButton.classList.add(&apos;visible&apos;);
    } else {
      backToTopButton.classList.remove(&apos;visible&apos;);
    }
  });
  
  // Smooth scroll to top
  backToTopButton.addEventListener(&apos;click&apos;, function() {
    window.scrollTo({
      top: 0,
      behavior: &apos;smooth&apos;
    });
  });
&lt;/script&gt;
```

The button appears after scrolling down 300 pixels and smoothly scrolls back to the top when clicked.

---

## Setting Up Navigation Links in Carrd

To make your navigation work with smooth scrolling:

1. **Create your navigation** using Carrd&apos;s built-in buttons or link elements

2. **For each link**, set the URL to the section ID with a `#` prefix:
   - Home: `#home`
   - About: `#about`
   - Services: `#services`
   - Contact: `#contact`

3. **Make sure sections have matching IDs** in their Settings panel

The smooth scroll code automatically handles any link that starts with `#`.

## Troubleshooting

**Scroll stops short or goes too far?** Adjust the `headerOffset` value to match your actual header height.

**Links don&apos;t work?** Check that your section IDs match exactly (case-sensitive). An ID of `About` won&apos;t match a link to `#about`.

**Scrolling feels too slow or fast?** Change the `scrollDuration` value. 600-1000ms usually feels natural.

**Active link highlighting not working?** Make sure your navigation links use the `a` tag with `href=&quot;#sectionid&quot;` format.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

Smooth scrolling is one of those details that visitors notice subconsciously. It makes your site feel more professional without being flashy. Start with the simple CSS-only option, and add the JavaScript version if you need header offset support or extra features.

Combine this with a [floating menu](https://www.bitdoze.com/carrd-floating-menu/) or [sidebar navigation](https://www.bitdoze.com/carrd-sidebar-menu/) for a complete navigation experience.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Add a Testimonial Slider to Your Carrd Website</title><link>https://www.bitdoze.com/carrd-testimonial-slider/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-testimonial-slider/</guid><description>Learn how to create an auto-rotating testimonial carousel for your Carrd site with navigation dots, arrows, and smooth transitions.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

Social proof sells. When potential customers see others vouching for your work, they&apos;re more likely to trust you. But cramming multiple testimonials onto a single-page [Carrd](https://go.bitdoze.com/carrd) site takes up valuable space.

A testimonial slider fixes that. It displays one quote at a time, rotating through your best reviews without overwhelming your layout. Visitors can browse at their own pace or let it autoplay.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Accordion FAQs to Carrd](https://www.bitdoze.com/add-accordion-carrd/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Why Use a Testimonial Slider

A few reasons this works better than static testimonials:

1. **Saves space** - Show 5-10 testimonials in the space of one.

2. **Keeps content fresh** - Visitors who stay on the page see different quotes without scrolling.

3. **Draws attention** - Movement catches the eye. A rotating slider pulls focus to your social proof.

4. **Looks professional** - It&apos;s a common pattern on well-designed sites.

5. **Works on mobile** - Swipe-friendly design for touch devices.

## How to Add a Testimonial Slider to Carrd

### Step 1: Add an Embed Element

Click the `+` sign and add an Embed element:

- Type: Code
- Style: **Inline** (important - this displays the slider on your page)

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 2: Choose Your Slider Style

I&apos;ve created three different designs. Pick the one that fits your site.

---

## Option 1: Clean Minimal Slider

A simple, elegant design that works with any color scheme.

```html
&lt;style&gt;
  :root {
    --testimonial-bg: #ffffff;
    --testimonial-text: #1f2937;
    --testimonial-secondary: #6b7280;
    --testimonial-accent: #4f46e5;
    --testimonial-quote: #e5e7eb;
    --testimonial-radius: 16px;
    --testimonial-max-width: 700px;
  }

  .testimonial-slider {
    max-width: var(--testimonial-max-width);
    margin: 40px auto;
    position: relative;
    font-family: inherit;
  }

  .testimonial-container {
    overflow: hidden;
    border-radius: var(--testimonial-radius);
    background: var(--testimonial-bg);
    box-shadow: 0 10px 40px rgba(0,0,0,0.1);
  }

  .testimonial-track {
    display: flex;
    transition: transform 0.5s ease;
  }

  .testimonial-slide {
    min-width: 100%;
    padding: 50px 40px;
    box-sizing: border-box;
    text-align: center;
  }

  .testimonial-quote-mark {
    font-size: 80px;
    color: var(--testimonial-quote);
    line-height: 1;
    margin-bottom: -20px;
    font-family: Georgia, serif;
  }

  .testimonial-text {
    font-size: 20px;
    line-height: 1.7;
    color: var(--testimonial-text);
    margin: 0 0 30px 0;
    font-style: italic;
  }

  .testimonial-author {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 15px;
  }

  .testimonial-avatar {
    width: 60px;
    height: 60px;
    border-radius: 50%;
    object-fit: cover;
    background: var(--testimonial-quote);
  }

  .testimonial-info {
    text-align: left;
  }

  .testimonial-name {
    font-size: 18px;
    font-weight: 600;
    color: var(--testimonial-text);
    margin: 0;
  }

  .testimonial-role {
    font-size: 14px;
    color: var(--testimonial-secondary);
    margin: 4px 0 0 0;
  }

  .testimonial-dots {
    display: flex;
    justify-content: center;
    gap: 10px;
    margin-top: 25px;
  }

  .testimonial-dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background: var(--testimonial-quote);
    border: none;
    cursor: pointer;
    transition: all 0.3s;
    padding: 0;
  }

  .testimonial-dot.active {
    background: var(--testimonial-accent);
    transform: scale(1.2);
  }

  .testimonial-dot:hover {
    background: var(--testimonial-accent);
    opacity: 0.7;
  }

  @media (max-width: 600px) {
    .testimonial-slide {
      padding: 35px 25px;
    }
    .testimonial-text {
      font-size: 17px;
    }
    .testimonial-quote-mark {
      font-size: 60px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;testimonial-slider&quot;&gt;
  &lt;div class=&quot;testimonial-container&quot;&gt;
    &lt;div class=&quot;testimonial-track&quot; id=&quot;testimonialTrack&quot;&gt;
      
      &lt;div class=&quot;testimonial-slide&quot;&gt;
        &lt;div class=&quot;testimonial-quote-mark&quot;&gt;&quot;&lt;/div&gt;
        &lt;p class=&quot;testimonial-text&quot;&gt;Working with them was a game-changer for our business. They delivered exactly what we needed, on time and within budget. Highly recommend!&lt;/p&gt;
        &lt;div class=&quot;testimonial-author&quot;&gt;
          &lt;img src=&quot;https://i.pravatar.cc/120?img=1&quot; alt=&quot;Sarah Johnson&quot; class=&quot;testimonial-avatar&quot;&gt;
          &lt;div class=&quot;testimonial-info&quot;&gt;
            &lt;p class=&quot;testimonial-name&quot;&gt;Sarah Johnson&lt;/p&gt;
            &lt;p class=&quot;testimonial-role&quot;&gt;CEO, TechStart Inc.&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;testimonial-slide&quot;&gt;
        &lt;div class=&quot;testimonial-quote-mark&quot;&gt;&quot;&lt;/div&gt;
        &lt;p class=&quot;testimonial-text&quot;&gt;The attention to detail was impressive. They took the time to understand our needs and created something that exceeded expectations.&lt;/p&gt;
        &lt;div class=&quot;testimonial-author&quot;&gt;
          &lt;img src=&quot;https://i.pravatar.cc/120?img=3&quot; alt=&quot;Michael Chen&quot; class=&quot;testimonial-avatar&quot;&gt;
          &lt;div class=&quot;testimonial-info&quot;&gt;
            &lt;p class=&quot;testimonial-name&quot;&gt;Michael Chen&lt;/p&gt;
            &lt;p class=&quot;testimonial-role&quot;&gt;Marketing Director, GrowthCo&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;testimonial-slide&quot;&gt;
        &lt;div class=&quot;testimonial-quote-mark&quot;&gt;&quot;&lt;/div&gt;
        &lt;p class=&quot;testimonial-text&quot;&gt;Fast, professional, and great communication throughout the project. This is how freelancing should work. Will definitely hire again.&lt;/p&gt;
        &lt;div class=&quot;testimonial-author&quot;&gt;
          &lt;img src=&quot;https://i.pravatar.cc/120?img=5&quot; alt=&quot;Emily Rodriguez&quot; class=&quot;testimonial-avatar&quot;&gt;
          &lt;div class=&quot;testimonial-info&quot;&gt;
            &lt;p class=&quot;testimonial-name&quot;&gt;Emily Rodriguez&lt;/p&gt;
            &lt;p class=&quot;testimonial-role&quot;&gt;Founder, DesignLab&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;testimonial-dots&quot;&gt;
    &lt;button class=&quot;testimonial-dot active&quot; onclick=&quot;goToSlide(0)&quot;&gt;&lt;/button&gt;
    &lt;button class=&quot;testimonial-dot&quot; onclick=&quot;goToSlide(1)&quot;&gt;&lt;/button&gt;
    &lt;button class=&quot;testimonial-dot&quot; onclick=&quot;goToSlide(2)&quot;&gt;&lt;/button&gt;
  &lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
var testimonialTrack = document.getElementById(&apos;testimonialTrack&apos;);
var testimonialDots = document.querySelectorAll(&apos;.testimonial-dot&apos;);
var testimonialSlides = document.querySelectorAll(&apos;.testimonial-slide&apos;);
var currentSlide = 0;
var slideInterval;

function goToSlide(index) {
  currentSlide = index;
  testimonialTrack.style.transform = &apos;translateX(-&apos; + (index * 100) + &apos;%)&apos;;
  for (var i = 0; i &lt; testimonialDots.length; i++) {
    testimonialDots[i].classList.remove(&apos;active&apos;);
  }
  testimonialDots[index].classList.add(&apos;active&apos;);
}

function nextSlide() {
  currentSlide = (currentSlide + 1) % testimonialSlides.length;
  goToSlide(currentSlide);
}

slideInterval = setInterval(nextSlide, 5000);

testimonialTrack.onmouseenter = function() { clearInterval(slideInterval); };
testimonialTrack.onmouseleave = function() { slideInterval = setInterval(nextSlide, 5000); };

var touchStartX = 0;
testimonialTrack.ontouchstart = function(e) {
  touchStartX = e.changedTouches[0].screenX;
  clearInterval(slideInterval);
};
testimonialTrack.ontouchend = function(e) {
  var diff = touchStartX - e.changedTouches[0].screenX;
  if (diff &gt; 50) { currentSlide = (currentSlide + 1) % testimonialSlides.length; goToSlide(currentSlide); }
  else if (diff &lt; -50) { currentSlide = (currentSlide - 1 + testimonialSlides.length) % testimonialSlides.length; goToSlide(currentSlide); }
  slideInterval = setInterval(nextSlide, 5000);
};
&lt;/script&gt;
```

---

## Option 2: Card Slider with Arrows

A more interactive design with navigation arrows for manual control.

```html
&lt;style&gt;
  :root {
    --card-bg: #f8fafc;
    --card-text: #0f172a;
    --card-secondary: #64748b;
    --card-accent: #0ea5e9;
    --card-border: #e2e8f0;
    --card-shadow: rgba(0, 0, 0, 0.08);
  }

  .card-slider {
    max-width: 800px;
    margin: 40px auto;
    position: relative;
    padding: 0 50px;
  }

  .card-slider-container {
    overflow: hidden;
  }

  .card-slider-track {
    display: flex;
    transition: transform 0.4s ease;
  }

  .card-slide {
    min-width: 100%;
    padding: 20px;
    box-sizing: border-box;
  }

  .card-content {
    background: white;
    border-radius: 20px;
    padding: 40px;
    border: 1px solid var(--card-border);
    box-shadow: 0 4px 20px var(--card-shadow);
  }

  .card-stars {
    color: #fbbf24;
    font-size: 20px;
    margin-bottom: 20px;
  }

  .card-text {
    font-size: 18px;
    line-height: 1.8;
    color: var(--card-text);
    margin: 0 0 25px 0;
  }

  .card-divider {
    height: 1px;
    background: var(--card-border);
    margin: 25px 0;
  }

  .card-author {
    display: flex;
    align-items: center;
    gap: 15px;
  }

  .card-avatar {
    width: 55px;
    height: 55px;
    border-radius: 50%;
    object-fit: cover;
  }

  .card-name {
    font-size: 17px;
    font-weight: 600;
    color: var(--card-text);
    margin: 0;
  }

  .card-role {
    font-size: 14px;
    color: var(--card-secondary);
    margin: 3px 0 0 0;
  }

  .card-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 45px;
    height: 45px;
    background: white;
    border: 2px solid var(--card-border);
    border-radius: 50%;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 20px;
    color: var(--card-text);
    transition: all 0.2s;
    z-index: 10;
  }

  .card-arrow:hover {
    background: var(--card-accent);
    border-color: var(--card-accent);
    color: white;
  }

  .card-arrow-left {
    left: 0;
  }

  .card-arrow-right {
    right: 0;
  }

  .card-counter {
    text-align: center;
    margin-top: 20px;
    font-size: 14px;
    color: var(--card-secondary);
  }

  @media (max-width: 600px) {
    .card-slider {
      padding: 0 15px;
    }
    .card-arrow {
      width: 36px;
      height: 36px;
      font-size: 16px;
    }
    .card-arrow-left {
      left: -5px;
    }
    .card-arrow-right {
      right: -5px;
    }
    .card-content {
      padding: 30px 25px;
    }
    .card-text {
      font-size: 16px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;card-slider&quot;&gt;
  &lt;button class=&quot;card-arrow card-arrow-left&quot; id=&quot;cardPrev&quot;&gt;←&lt;/button&gt;
  &lt;button class=&quot;card-arrow card-arrow-right&quot; id=&quot;cardNext&quot;&gt;→&lt;/button&gt;
  
  &lt;div class=&quot;card-slider-container&quot;&gt;
    &lt;div class=&quot;card-slider-track&quot; id=&quot;cardTrack&quot;&gt;
      
      &lt;div class=&quot;card-slide&quot;&gt;
        &lt;div class=&quot;card-content&quot;&gt;
          &lt;div class=&quot;card-stars&quot;&gt;★★★★★&lt;/div&gt;
          &lt;p class=&quot;card-text&quot;&gt;&quot;Absolutely fantastic experience from start to finish. The communication was excellent, the work was top-notch, and everything was delivered ahead of schedule. I couldn&apos;t be happier with the results.&quot;&lt;/p&gt;
          &lt;div class=&quot;card-divider&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;card-author&quot;&gt;
            &lt;img src=&quot;https://i.pravatar.cc/120?img=11&quot; alt=&quot;David Park&quot; class=&quot;card-avatar&quot;&gt;
            &lt;div&gt;
              &lt;p class=&quot;card-name&quot;&gt;David Park&lt;/p&gt;
              &lt;p class=&quot;card-role&quot;&gt;Product Manager, Innovate Labs&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;card-slide&quot;&gt;
        &lt;div class=&quot;card-content&quot;&gt;
          &lt;div class=&quot;card-stars&quot;&gt;★★★★★&lt;/div&gt;
          &lt;p class=&quot;card-text&quot;&gt;&quot;They transformed our outdated website into something modern and user-friendly. Our conversion rate increased by 40% within the first month. Worth every penny!&quot;&lt;/p&gt;
          &lt;div class=&quot;card-divider&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;card-author&quot;&gt;
            &lt;img src=&quot;https://i.pravatar.cc/120?img=9&quot; alt=&quot;Lisa Thompson&quot; class=&quot;card-avatar&quot;&gt;
            &lt;div&gt;
              &lt;p class=&quot;card-name&quot;&gt;Lisa Thompson&lt;/p&gt;
              &lt;p class=&quot;card-role&quot;&gt;Owner, Bloom Boutique&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      &lt;div class=&quot;card-slide&quot;&gt;
        &lt;div class=&quot;card-content&quot;&gt;
          &lt;div class=&quot;card-stars&quot;&gt;★★★★★&lt;/div&gt;
          &lt;p class=&quot;card-text&quot;&gt;&quot;Professional, creative, and incredibly easy to work with. They took our vague ideas and turned them into exactly what we needed. Already planning our next project together.&quot;&lt;/p&gt;
          &lt;div class=&quot;card-divider&quot;&gt;&lt;/div&gt;
          &lt;div class=&quot;card-author&quot;&gt;
            &lt;img src=&quot;https://i.pravatar.cc/120?img=7&quot; alt=&quot;James Wilson&quot; class=&quot;card-avatar&quot;&gt;
            &lt;div&gt;
              &lt;p class=&quot;card-name&quot;&gt;James Wilson&lt;/p&gt;
              &lt;p class=&quot;card-role&quot;&gt;Founder, Wilson Consulting&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

    &lt;/div&gt;
  &lt;/div&gt;
  
  &lt;div class=&quot;card-counter&quot; id=&quot;cardCounter&quot;&gt;1 / 3&lt;/div&gt;
&lt;/div&gt;

&lt;script&gt;
  const cardTrack = document.getElementById(&apos;cardTrack&apos;);
  const cardPrev = document.getElementById(&apos;cardPrev&apos;);
  const cardNext = document.getElementById(&apos;cardNext&apos;);
  const cardCounter = document.getElementById(&apos;cardCounter&apos;);
  const cardSlides = cardTrack.querySelectorAll(&apos;.card-slide&apos;);
  let cardIndex = 0;

  function updateCardSlider() {
    cardTrack.style.transform = `translateX(-${cardIndex * 100}%)`;
    cardCounter.textContent = `${cardIndex + 1} / ${cardSlides.length}`;
  }

  cardPrev.addEventListener(&apos;click&apos;, () =&gt; {
    cardIndex = (cardIndex - 1 + cardSlides.length) % cardSlides.length;
    updateCardSlider();
  });

  cardNext.addEventListener(&apos;click&apos;, () =&gt; {
    cardIndex = (cardIndex + 1) % cardSlides.length;
    updateCardSlider();
  });

  // Auto-rotate every 6 seconds
  setInterval(() =&gt; {
    cardIndex = (cardIndex + 1) % cardSlides.length;
    updateCardSlider();
  }, 6000);
&lt;/script&gt;
```

---

## Option 3: Multi-Testimonial Grid (No Slider)

If you prefer showing all testimonials at once on desktop but scrolling on mobile:

```html
&lt;style&gt;
  :root {
    --grid-bg: #fefce8;
    --grid-card-bg: #ffffff;
    --grid-text: #1c1917;
    --grid-secondary: #78716c;
    --grid-accent: #f59e0b;
  }

  .testimonial-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 25px;
    max-width: 1000px;
    margin: 40px auto;
    padding: 0 20px;
  }

  .grid-card {
    background: var(--grid-card-bg);
    border-radius: 16px;
    padding: 30px;
    box-shadow: 0 4px 15px rgba(0,0,0,0.05);
    transition: transform 0.2s, box-shadow 0.2s;
  }

  .grid-card:hover {
    transform: translateY(-5px);
    box-shadow: 0 8px 25px rgba(0,0,0,0.1);
  }

  .grid-rating {
    display: flex;
    gap: 3px;
    margin-bottom: 15px;
  }

  .grid-star {
    width: 20px;
    height: 20px;
    fill: var(--grid-accent);
  }

  .grid-text {
    font-size: 15px;
    line-height: 1.7;
    color: var(--grid-text);
    margin: 0 0 20px 0;
  }

  .grid-author {
    display: flex;
    align-items: center;
    gap: 12px;
    padding-top: 15px;
    border-top: 1px solid #f3f4f6;
  }

  .grid-avatar {
    width: 45px;
    height: 45px;
    border-radius: 50%;
    object-fit: cover;
  }

  .grid-name {
    font-size: 15px;
    font-weight: 600;
    color: var(--grid-text);
    margin: 0;
  }

  .grid-role {
    font-size: 13px;
    color: var(--grid-secondary);
    margin: 2px 0 0 0;
  }

  @media (max-width: 600px) {
    .testimonial-grid {
      grid-template-columns: 1fr;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;testimonial-grid&quot;&gt;
  
  &lt;div class=&quot;grid-card&quot;&gt;
    &lt;div class=&quot;grid-rating&quot;&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
    &lt;/div&gt;
    &lt;p class=&quot;grid-text&quot;&gt;&quot;Quick turnaround and excellent quality. Exactly what I was looking for. Will definitely work together again!&quot;&lt;/p&gt;
    &lt;div class=&quot;grid-author&quot;&gt;
      &lt;img src=&quot;https://i.pravatar.cc/120?img=12&quot; alt=&quot;Anna Lee&quot; class=&quot;grid-avatar&quot;&gt;
      &lt;div&gt;
        &lt;p class=&quot;grid-name&quot;&gt;Anna Lee&lt;/p&gt;
        &lt;p class=&quot;grid-role&quot;&gt;Freelance Designer&lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;grid-card&quot;&gt;
    &lt;div class=&quot;grid-rating&quot;&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
    &lt;/div&gt;
    &lt;p class=&quot;grid-text&quot;&gt;&quot;They went above and beyond to make sure everything was perfect. Communication was smooth the entire time.&quot;&lt;/p&gt;
    &lt;div class=&quot;grid-author&quot;&gt;
      &lt;img src=&quot;https://i.pravatar.cc/120?img=15&quot; alt=&quot;Tom Harris&quot; class=&quot;grid-avatar&quot;&gt;
      &lt;div&gt;
        &lt;p class=&quot;grid-name&quot;&gt;Tom Harris&lt;/p&gt;
        &lt;p class=&quot;grid-role&quot;&gt;Startup Founder&lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

  &lt;div class=&quot;grid-card&quot;&gt;
    &lt;div class=&quot;grid-rating&quot;&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
      &lt;svg class=&quot;grid-star&quot; viewBox=&quot;0 0 20 20&quot;&gt;&lt;path d=&quot;M10 15l-5.878 3.09 1.123-6.545L.489 6.91l6.572-.955L10 0l2.939 5.955 6.572.955-4.756 4.635 1.123 6.545z&quot;/&gt;&lt;/svg&gt;
    &lt;/div&gt;
    &lt;p class=&quot;grid-text&quot;&gt;&quot;Responsive and creative. They captured our brand voice perfectly. Our customers love the new design!&quot;&lt;/p&gt;
    &lt;div class=&quot;grid-author&quot;&gt;
      &lt;img src=&quot;https://i.pravatar.cc/120?img=20&quot; alt=&quot;Rachel Green&quot; class=&quot;grid-avatar&quot;&gt;
      &lt;div&gt;
        &lt;p class=&quot;grid-name&quot;&gt;Rachel Green&lt;/p&gt;
        &lt;p class=&quot;grid-role&quot;&gt;Marketing Manager&lt;/p&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;

&lt;/div&gt;
```

---

## Customizing Your Testimonials

### Adding Your Own Testimonials

Replace the placeholder content in each slide:

1. **Text**: Change the paragraph content to your actual testimonial
2. **Name**: Update the name to your client&apos;s name
3. **Role**: Change the role/company
4. **Avatar**: Replace the image URL with your client&apos;s photo (or use placeholder services like pravatar.cc)

### Adding More Slides

Copy an entire `&lt;div class=&quot;testimonial-slide&quot;&gt;...&lt;/div&gt;` block and paste it after the last slide. The JavaScript automatically adjusts for any number of slides.

### Changing Autoplay Speed

Find the `setInterval` line and change the number (in milliseconds):
- `5000` = 5 seconds
- `7000` = 7 seconds
- `3000` = 3 seconds

### Disabling Autoplay

Remove or comment out the `setInterval` code block if you want manual-only navigation.

## Tips for Better Testimonials

**Use real photos** - Stock photos look fake. Ask clients for headshots or use their LinkedIn photos with permission.

**Keep quotes short** - Long testimonials lose attention. Edit down to the most powerful 2-3 sentences.

**Include specifics** - &quot;Great work!&quot; is forgettable. &quot;Increased our conversion rate by 40%&quot; is memorable.

**Diversify your testimonials** - Show different types of clients and different benefits they received.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

A testimonial slider adds social proof without eating up your entire page. Pick the style that matches your design, customize the colors and content, and let your happy clients do the selling for you.

Combine this with a [pricing table](https://www.bitdoze.com/carrd-add-pricing-table/) and [contact form](https://www.bitdoze.com/add-accordion-carrd/) for a complete service page.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Add a WhatsApp Chat Button to Your Carrd Website</title><link>https://www.bitdoze.com/carrd-whatsapp-button/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-whatsapp-button/</guid><description>Learn how to add a floating WhatsApp button to your Carrd site so visitors can message you instantly with pre-filled text.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import imag1 from &quot;../../assets/images/24/02/carrd-back-to-top-embed.png&quot;;

Contact forms are fine, but some visitors want answers now. A WhatsApp button lets them reach you instantly without leaving your [Carrd](https://go.bitdoze.com/carrd) site or filling out forms.

This works especially well for service businesses, local shops, freelancers, and anyone who can handle direct messages. If you&apos;re already using WhatsApp for business communication, putting a button on your site just makes sense.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Popup Modal to Carrd](https://www.bitdoze.com/carrd-popup-modal/)
- [Add Smooth Scroll to Carrd](https://www.bitdoze.com/carrd-smooth-scroll/)
- [Add Dark Mode Toggle to Carrd](https://www.bitdoze.com/carrd-dark-mode-toggle/)
- [Add Floating Menu to Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Sidebar Menu to Carrd](https://www.bitdoze.com/carrd-sidebar-menu/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## Why Add a WhatsApp Button

A few reasons this works:

1. **Instant connection** - Visitors can message you right away instead of waiting for email replies.

2. **Higher conversion** - People are more likely to reach out when it&apos;s this easy.

3. **Mobile-friendly** - WhatsApp is already on their phone. One tap and they&apos;re chatting.

4. **Pre-filled messages** - You can set a default message so visitors don&apos;t have to think about what to write.

5. **Global reach** - WhatsApp has over 2 billion users. In many countries, it&apos;s the primary communication method.

## How to Add a WhatsApp Button to Carrd

### Step 1: Get Your WhatsApp Link

WhatsApp has a direct chat link format:

```
https://wa.me/YOURNUMBER
```

Replace `YOURNUMBER` with your phone number in international format without any + or spaces. For example:
- US number: `12125551234`
- UK number: `447911123456`

To add a pre-filled message:
```
https://wa.me/YOURNUMBER?text=Hi,%20I%20found%20you%20on%20your%20website
```

Spaces need to be `%20` in URLs.

### Step 2: Add an Embed Element

Click the `+` sign and add an Embed element:

- Type: Code
- Style: Hidden, Head

&lt;Picture src={imag1} alt=&quot;Carrd embed element&quot; /&gt;

### Step 3: Choose Your Button Style

Pick from the options below.

---

## Option 1: Simple Floating Button

A clean WhatsApp button that floats in the corner.

```html
&lt;style&gt;
  .whatsapp-float {
    position: fixed;
    bottom: 25px;
    right: 25px;
    width: 60px;
    height: 60px;
    background-color: #25D366;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4);
    z-index: 9999;
    transition: all 0.3s ease;
    text-decoration: none;
  }

  .whatsapp-float:hover {
    transform: scale(1.1);
    box-shadow: 0 6px 20px rgba(37, 211, 102, 0.5);
  }

  .whatsapp-float svg {
    width: 32px;
    height: 32px;
    fill: white;
  }

  /* Pulse animation */
  .whatsapp-float::before {
    content: &apos;&apos;;
    position: absolute;
    width: 100%;
    height: 100%;
    background-color: #25D366;
    border-radius: 50%;
    z-index: -1;
    animation: pulse 2s infinite;
  }

  @keyframes pulse {
    0% {
      transform: scale(1);
      opacity: 0.7;
    }
    70% {
      transform: scale(1.3);
      opacity: 0;
    }
    100% {
      transform: scale(1);
      opacity: 0;
    }
  }

  /* Hide on desktop if you only want mobile */
  /* 
  @media (min-width: 769px) {
    .whatsapp-float {
      display: none;
    }
  }
  */
&lt;/style&gt;

&lt;a href=&quot;https://wa.me/YOURNUMBER?text=Hi,%20I%20found%20you%20on%20your%20website&quot; class=&quot;whatsapp-float&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; aria-label=&quot;Chat on WhatsApp&quot;&gt;
  &lt;svg viewBox=&quot;0 0 32 32&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
    &lt;path d=&quot;M16.004 0h-.008C7.174 0 0 7.176 0 16c0 3.5 1.128 6.744 3.046 9.378L1.054 32l6.826-2.192A15.915 15.915 0 0016.004 32C24.826 32 32 24.822 32 16S24.826 0 16.004 0zm9.35 22.614c-.396 1.116-1.956 2.042-3.216 2.312-.864.184-1.99.33-5.786-1.244-4.858-2.014-7.984-6.952-8.226-7.274-.232-.322-1.958-2.606-1.958-4.972s1.238-3.528 1.678-4.012c.396-.436 1.036-.636 1.65-.636.198 0 .376.01.536.018.44.02.66.046.95.734.364.864 1.25 3.05 1.36 3.272.11.222.184.482.036.772-.138.3-.208.486-.416.748-.208.262-.438.584-.626.784-.208.24-.424.5-.182.98.242.48 1.076 1.774 2.312 2.874 1.588 1.414 2.926 1.852 3.342 2.058.416.206.658.172.9-.104.252-.286 1.072-1.248 1.358-1.676.276-.428.562-.356.95-.214.39.144 2.476 1.168 2.9 1.38.424.214.706.322.81.498.104.176.104 1.022-.292 2.138z&quot;/&gt;
  &lt;/svg&gt;
&lt;/a&gt;
```

**Important:** Replace `YOURNUMBER` with your actual phone number (e.g., `12125551234`).

---

## Option 2: Button with Text

A button that shows &quot;Chat with us&quot; text on hover.

```html
&lt;style&gt;
  .wa-button {
    position: fixed;
    bottom: 25px;
    right: 25px;
    display: flex;
    align-items: center;
    gap: 12px;
    background: #25D366;
    padding: 15px 20px;
    border-radius: 50px;
    text-decoration: none;
    box-shadow: 0 4px 15px rgba(0,0,0,0.2);
    z-index: 9999;
    transition: all 0.3s ease;
  }

  .wa-button:hover {
    transform: translateY(-3px);
    box-shadow: 0 6px 20px rgba(0,0,0,0.25);
  }

  .wa-button svg {
    width: 28px;
    height: 28px;
    fill: white;
    flex-shrink: 0;
  }

  .wa-button-text {
    color: white;
    font-size: 16px;
    font-weight: 600;
    white-space: nowrap;
  }

  /* Collapse to icon only on mobile */
  @media (max-width: 600px) {
    .wa-button {
      padding: 15px;
      border-radius: 50%;
    }
    .wa-button-text {
      display: none;
    }
  }
&lt;/style&gt;

&lt;a href=&quot;https://wa.me/YOURNUMBER?text=Hi,%20I&apos;m%20interested%20in%20your%20services&quot; class=&quot;wa-button&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;
  &lt;svg viewBox=&quot;0 0 32 32&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
    &lt;path d=&quot;M16.004 0h-.008C7.174 0 0 7.176 0 16c0 3.5 1.128 6.744 3.046 9.378L1.054 32l6.826-2.192A15.915 15.915 0 0016.004 32C24.826 32 32 24.822 32 16S24.826 0 16.004 0zm9.35 22.614c-.396 1.116-1.956 2.042-3.216 2.312-.864.184-1.99.33-5.786-1.244-4.858-2.014-7.984-6.952-8.226-7.274-.232-.322-1.958-2.606-1.958-4.972s1.238-3.528 1.678-4.012c.396-.436 1.036-.636 1.65-.636.198 0 .376.01.536.018.44.02.66.046.95.734.364.864 1.25 3.05 1.36 3.272.11.222.184.482.036.772-.138.3-.208.486-.416.748-.208.262-.438.584-.626.784-.208.24-.424.5-.182.98.242.48 1.076 1.774 2.312 2.874 1.588 1.414 2.926 1.852 3.342 2.058.416.206.658.172.9-.104.252-.286 1.072-1.248 1.358-1.676.276-.428.562-.356.95-.214.39.144 2.476 1.168 2.9 1.38.424.214.706.322.81.498.104.176.104 1.022-.292 2.138z&quot;/&gt;
  &lt;/svg&gt;
  &lt;span class=&quot;wa-button-text&quot;&gt;Chat with us&lt;/span&gt;
&lt;/a&gt;
```

---

## Option 3: Expandable Chat Widget

A button that expands to show a message preview before opening WhatsApp.

```html
&lt;style&gt;
  .wa-widget {
    position: fixed;
    bottom: 25px;
    right: 25px;
    z-index: 9999;
    font-family: -apple-system, BlinkMacSystemFont, &apos;Segoe UI&apos;, Roboto, sans-serif;
  }

  .wa-widget-button {
    width: 60px;
    height: 60px;
    background: #25D366;
    border-radius: 50%;
    border: none;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 15px rgba(37, 211, 102, 0.4);
    transition: all 0.3s;
  }

  .wa-widget-button:hover {
    transform: scale(1.05);
  }

  .wa-widget-button svg {
    width: 32px;
    height: 32px;
    fill: white;
  }

  .wa-widget-popup {
    position: absolute;
    bottom: 75px;
    right: 0;
    width: 320px;
    background: white;
    border-radius: 16px;
    box-shadow: 0 10px 40px rgba(0,0,0,0.15);
    overflow: hidden;
    transform: scale(0);
    transform-origin: bottom right;
    transition: transform 0.3s ease;
  }

  .wa-widget-popup.active {
    transform: scale(1);
  }

  .wa-widget-header {
    background: #075E54;
    color: white;
    padding: 20px;
  }

  .wa-widget-header h4 {
    margin: 0 0 5px 0;
    font-size: 18px;
  }

  .wa-widget-header p {
    margin: 0;
    font-size: 13px;
    opacity: 0.9;
  }

  .wa-widget-body {
    padding: 20px;
    background: #ECE5DD;
  }

  .wa-widget-message {
    background: white;
    padding: 12px 15px;
    border-radius: 8px;
    border-top-left-radius: 0;
    box-shadow: 0 1px 2px rgba(0,0,0,0.1);
    position: relative;
    margin-bottom: 15px;
  }

  .wa-widget-message::before {
    content: &apos;&apos;;
    position: absolute;
    top: 0;
    left: -8px;
    border: 8px solid transparent;
    border-right-color: white;
    border-top-color: white;
  }

  .wa-widget-message p {
    margin: 0;
    font-size: 14px;
    color: #333;
    line-height: 1.5;
  }

  .wa-widget-time {
    font-size: 11px;
    color: #999;
    text-align: right;
    margin-top: 5px;
  }

  .wa-widget-cta {
    display: block;
    width: 100%;
    padding: 15px;
    background: #25D366;
    color: white;
    border: none;
    font-size: 16px;
    font-weight: 600;
    cursor: pointer;
    text-decoration: none;
    text-align: center;
    transition: background 0.2s;
  }

  .wa-widget-cta:hover {
    background: #20bd5a;
  }

  @media (max-width: 400px) {
    .wa-widget-popup {
      width: 280px;
      right: -10px;
    }
  }
&lt;/style&gt;

&lt;div class=&quot;wa-widget&quot;&gt;
  &lt;div class=&quot;wa-widget-popup&quot; id=&quot;waPopup&quot;&gt;
    &lt;div class=&quot;wa-widget-header&quot;&gt;
      &lt;h4&gt;Need help?&lt;/h4&gt;
      &lt;p&gt;Typically replies within minutes&lt;/p&gt;
    &lt;/div&gt;
    &lt;div class=&quot;wa-widget-body&quot;&gt;
      &lt;div class=&quot;wa-widget-message&quot;&gt;
        &lt;p&gt;Hi there! 👋 How can I help you today?&lt;/p&gt;
        &lt;div class=&quot;wa-widget-time&quot;&gt;Just now&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;a href=&quot;https://wa.me/YOURNUMBER?text=Hi,%20I%20have%20a%20question&quot; class=&quot;wa-widget-cta&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;
      Start Chat
    &lt;/a&gt;
  &lt;/div&gt;
  
  &lt;button class=&quot;wa-widget-button&quot; id=&quot;waButton&quot; aria-label=&quot;Open WhatsApp chat&quot;&gt;
    &lt;svg viewBox=&quot;0 0 32 32&quot; xmlns=&quot;http://www.w3.org/2000/svg&quot;&gt;
      &lt;path d=&quot;M16.004 0h-.008C7.174 0 0 7.176 0 16c0 3.5 1.128 6.744 3.046 9.378L1.054 32l6.826-2.192A15.915 15.915 0 0016.004 32C24.826 32 32 24.822 32 16S24.826 0 16.004 0zm9.35 22.614c-.396 1.116-1.956 2.042-3.216 2.312-.864.184-1.99.33-5.786-1.244-4.858-2.014-7.984-6.952-8.226-7.274-.232-.322-1.958-2.606-1.958-4.972s1.238-3.528 1.678-4.012c.396-.436 1.036-.636 1.65-.636.198 0 .376.01.536.018.44.02.66.046.95.734.364.864 1.25 3.05 1.36 3.272.11.222.184.482.036.772-.138.3-.208.486-.416.748-.208.262-.438.584-.626.784-.208.24-.424.5-.182.98.242.48 1.076 1.774 2.312 2.874 1.588 1.414 2.926 1.852 3.342 2.058.416.206.658.172.9-.104.252-.286 1.072-1.248 1.358-1.676.276-.428.562-.356.95-.214.39.144 2.476 1.168 2.9 1.38.424.214.706.322.81.498.104.176.104 1.022-.292 2.138z&quot;/&gt;
    &lt;/svg&gt;
  &lt;/button&gt;
&lt;/div&gt;

&lt;script&gt;
  const waButton = document.getElementById(&apos;waButton&apos;);
  const waPopup = document.getElementById(&apos;waPopup&apos;);
  
  waButton.addEventListener(&apos;click&apos;, () =&gt; {
    waPopup.classList.toggle(&apos;active&apos;);
  });
  
  // Close popup when clicking outside
  document.addEventListener(&apos;click&apos;, (e) =&gt; {
    if (!e.target.closest(&apos;.wa-widget&apos;)) {
      waPopup.classList.remove(&apos;active&apos;);
    }
  });
&lt;/script&gt;
```

---

## Option 4: Multi-Chat Button (WhatsApp + Others)

If you want to offer multiple contact options:

```html
&lt;style&gt;
  .chat-buttons {
    position: fixed;
    bottom: 25px;
    right: 25px;
    display: flex;
    flex-direction: column;
    gap: 12px;
    z-index: 9999;
  }

  .chat-btn {
    width: 55px;
    height: 55px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    text-decoration: none;
    box-shadow: 0 4px 12px rgba(0,0,0,0.2);
    transition: all 0.3s;
    transform: scale(0);
    animation: popIn 0.3s ease forwards;
  }

  .chat-btn:nth-child(1) { animation-delay: 0s; }
  .chat-btn:nth-child(2) { animation-delay: 0.1s; }
  .chat-btn:nth-child(3) { animation-delay: 0.2s; }

  @keyframes popIn {
    to {
      transform: scale(1);
    }
  }

  .chat-btn:hover {
    transform: scale(1.1) !important;
  }

  .chat-btn svg {
    width: 28px;
    height: 28px;
    fill: white;
  }

  .chat-btn.whatsapp {
    background: #25D366;
  }

  .chat-btn.telegram {
    background: #0088cc;
  }

  .chat-btn.email {
    background: #ea4335;
  }

  /* Tooltip */
  .chat-btn::before {
    content: attr(data-tooltip);
    position: absolute;
    right: 65px;
    background: #333;
    color: white;
    padding: 8px 12px;
    border-radius: 6px;
    font-size: 13px;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s;
  }

  .chat-btn:hover::before {
    opacity: 1;
  }
&lt;/style&gt;

&lt;div class=&quot;chat-buttons&quot;&gt;
  &lt;a href=&quot;https://wa.me/YOURNUMBER?text=Hi&quot; class=&quot;chat-btn whatsapp&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-tooltip=&quot;WhatsApp&quot;&gt;
    &lt;svg viewBox=&quot;0 0 32 32&quot;&gt;&lt;path d=&quot;M16.004 0h-.008C7.174 0 0 7.176 0 16c0 3.5 1.128 6.744 3.046 9.378L1.054 32l6.826-2.192A15.915 15.915 0 0016.004 32C24.826 32 32 24.822 32 16S24.826 0 16.004 0zm9.35 22.614c-.396 1.116-1.956 2.042-3.216 2.312-.864.184-1.99.33-5.786-1.244-4.858-2.014-7.984-6.952-8.226-7.274-.232-.322-1.958-2.606-1.958-4.972s1.238-3.528 1.678-4.012c.396-.436 1.036-.636 1.65-.636.198 0 .376.01.536.018.44.02.66.046.95.734.364.864 1.25 3.05 1.36 3.272.11.222.184.482.036.772-.138.3-.208.486-.416.748-.208.262-.438.584-.626.784-.208.24-.424.5-.182.98.242.48 1.076 1.774 2.312 2.874 1.588 1.414 2.926 1.852 3.342 2.058.416.206.658.172.9-.104.252-.286 1.072-1.248 1.358-1.676.276-.428.562-.356.95-.214.39.144 2.476 1.168 2.9 1.38.424.214.706.322.81.498.104.176.104 1.022-.292 2.138z&quot;/&gt;&lt;/svg&gt;
  &lt;/a&gt;
  
  &lt;a href=&quot;https://t.me/YOURUSERNAME&quot; class=&quot;chat-btn telegram&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-tooltip=&quot;Telegram&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.562 8.161c-.18 1.897-.962 6.502-1.359 8.627-.168.9-.5 1.201-.82 1.23-.697.064-1.226-.461-1.901-.903-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.015-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.139-5.062 3.345-.479.329-.913.489-1.302.481-.428-.009-1.252-.242-1.865-.442-.751-.244-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.831-2.529 6.998-3.015 3.333-1.386 4.025-1.627 4.477-1.635.099-.002.321.023.465.141.121.1.154.234.169.334.016.1.035.323.02.498z&quot;/&gt;&lt;/svg&gt;
  &lt;/a&gt;
  
  &lt;a href=&quot;mailto:your@email.com&quot; class=&quot;chat-btn email&quot; data-tooltip=&quot;Email&quot;&gt;
    &lt;svg viewBox=&quot;0 0 24 24&quot;&gt;&lt;path d=&quot;M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z&quot;/&gt;&lt;/svg&gt;
  &lt;/a&gt;
&lt;/div&gt;
```

---

## Customization Options

### Change Position

Move the button to a different corner:

```css
/* Bottom left */
bottom: 25px;
left: 25px;
right: auto;

/* Top right */
top: 25px;
right: 25px;
bottom: auto;
```

### Change Size

Adjust the button dimensions:

```css
.whatsapp-float {
  width: 50px;  /* Smaller */
  height: 50px;
}

/* Or larger */
.whatsapp-float {
  width: 70px;
  height: 70px;
}
```

### Disable Pulse Animation

Remove the `::before` pseudo-element or set `animation: none`:

```css
.whatsapp-float::before {
  display: none;
}
```

### Custom Pre-filled Message

Customize what shows up when visitors open the chat:

```
https://wa.me/12125551234?text=Hi!%20I&apos;m%20interested%20in%20your%20web%20design%20services.%20My%20name%20is%20___
```

Use `%20` for spaces and `%0A` for line breaks.

## Mobile vs Desktop

The button works on both, but the experience differs:
- **Mobile**: Opens the WhatsApp app directly
- **Desktop**: Opens WhatsApp Web in a new tab

If you want to show the button only on mobile:

```css
@media (min-width: 769px) {
  .whatsapp-float {
    display: none;
  }
}
```

## Business Considerations

**Response time matters** - If you add a chat button, be prepared to respond quickly. Slow replies make a bad impression.

**Set availability hours** - Consider mentioning your response hours in the pre-filled message or popup widget.

**Use WhatsApp Business** - The free WhatsApp Business app offers auto-replies, quick responses, and business profiles.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Try Carrd.co&quot; /&gt;

## Conclusion

A WhatsApp button removes friction between visitors and getting in touch. Pick the style that fits your site, add your phone number, and you&apos;re set. Just make sure you&apos;re ready to handle the messages that come in.

If you want more contact options, combine this with a [contact form](https://www.bitdoze.com/carrd-review/) or [popup modal](https://www.bitdoze.com/carrd-popup-modal/).</content:encoded><category>web-development</category><category>carrd</category></item><item><title>NextDNS Review: Cloud DNS Protection That Actually Works</title><link>https://www.bitdoze.com/nextdns-review/</link><guid isPermaLink="true">https://www.bitdoze.com/nextdns-review/</guid><description>An honest look at NextDNS after months of use. Is it worth the subscription? How does it handle ads, malware, and privacy? Here&apos;s what I found.</description><pubDate>Wed, 21 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

I&apos;ve been running [NextDNS](https://go.bitdoze.com/nextdns) for several months now across all my devices. No ads on my phone apps, no tracking scripts loading in the background, and my ISP can&apos;t see what I&apos;m browsing. Here&apos;s my take on whether it&apos;s worth your time.

&lt;Button text=&quot;Try NextDNS Free&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## What NextDNS Does

NextDNS sits between your devices and the internet. Every time you visit a website, your device asks &quot;where is example.com?&quot; and NextDNS answers. The difference from your ISP&apos;s default DNS is that NextDNS encrypts these queries and checks them against blocklists before responding.

The result: ads don&apos;t load, tracking scripts get blocked, and malware domains return empty responses. Your ISP sees encrypted traffic to NextDNS servers but can&apos;t tell which websites you&apos;re visiting.

&lt;Notice type=&quot;info&quot; title=&quot;Want the full technical breakdown?&quot;&gt;

I wrote a detailed guide covering both NextDNS and self-hosted alternatives with AdGuard Home. It explains DNS encryption protocols, setup options, and when to choose each approach.

&lt;Button text=&quot;Read the Complete DNS Protection Guide&quot; link=&quot;/block-ads-malware-dns-protection/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;book-open&quot; /&gt;

&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Chrome is killing classic ad blockers&quot;&gt;

Manifest V3 disables uBlock Origin and other classic extensions in Chrome 150. NextDNS keeps ads blocked on every device without a browser extension. I put together a guide on exactly how to set that up.

&lt;Button text=&quot;Block Ads After Manifest V3 with NextDNS&quot; link=&quot;/block-ads-manifest-v3-nextdns/&quot; variant=&quot;outline&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

&lt;/Notice&gt;

## NextDNS Features

Here&apos;s what you get with NextDNS and what each feature actually does:

### Security Features

| Feature | What It Does |
|---------|--------------|
| **Threat Intelligence Feeds** | Blocks domains flagged by security researchers as hosting malware, phishing, or command-and-control servers |
| **Google Safe Browsing** | Taps into Google&apos;s database of dangerous sites, updated constantly |
| **Cryptojacking Protection** | Stops websites from using your CPU to mine cryptocurrency in the background |
| **DNS Rebinding Protection** | Prevents attackers from using DNS to access your local network devices |
| **IDN Homograph Protection** | Blocks fake domains that use lookalike characters (like using &quot;rn&quot; to fake &quot;m&quot;) |
| **Typosquatting Protection** | Catches common misspellings of popular domains that scammers register |
| **DGA Protection** | Blocks randomly generated domains that malware uses to phone home |
| **NRD (Newly Registered Domains)** | Optionally blocks domains registered in the last 30 days, which are often used for attacks |

### Privacy Features

| Feature | What It Does |
|---------|--------------|
| **Blocklists** | Choose from dozens of community-maintained lists that block ads, trackers, and malware domains |
| **Native Tracking Protection** | Blocks telemetry from Apple, Windows, Samsung, Xiaomi, Huawei, Amazon, and Roku devices |
| **Affiliate &amp; Tracking Links** | Blocks tracking redirects and affiliate link services |
| **Disguised Trackers** | Catches trackers that use CNAME cloaking to hide as first-party domains |

### Parental Controls

| Feature | What It Does |
|---------|--------------|
| **Website Categories** | Block entire categories: porn, gambling, dating, piracy, social media, etc. |
| **Recreation Time** | Set schedules when blocked categories become accessible |
| **Safe Search** | Forces safe search on Google, Bing, DuckDuckGo, and YouTube |
| **YouTube Restricted Mode** | Enables YouTube&apos;s built-in content filter |
| **Block Bypass Methods** | Prevents kids from using VPNs, proxies, or other DNS services to bypass your rules |

### Denylist and Allowlist

You can manually block or allow specific domains. The allowlist overrides blocklists when legitimate services get caught. The denylist lets you block domains that aren&apos;t on any list.

### Analytics Dashboard

The dashboard shows:
- Total queries and percentage blocked
- Top blocked domains
- Top allowed domains
- Queries by device (if you name them)
- Queries over time
- GAFAM (Google, Amazon, Facebook, Apple, Microsoft) traffic breakdown

### Logs

Query logs show every DNS request with timestamps, device info, and whether it was blocked or allowed. You control retention: keep them for an hour, a day, a week, or disable logging entirely.

## How to Set Up NextDNS

### Step 1: Create Your Account

1. Go to [NextDNS](https://go.bitdoze.com/nextdns) and click **Try it now**
2. Sign up with your email
3. You&apos;ll get a unique Configuration ID (something like `abc123`)

This ID is your profile. You can create multiple profiles for different use cases.

### Step 2: Configure Your Security Settings

In the **Security** tab, enable the protections you want:

```
Recommended settings:
- Threat Intelligence Feeds: ON
- Google Safe Browsing: ON  
- Cryptojacking Protection: ON
- DNS Rebinding Protection: ON
- IDN Homograph Attacks Protection: ON
- Typosquatting Protection: ON
```

NRD (Newly Registered Domains) blocking is aggressive. It can break legitimate new services, so I leave it off unless I&apos;m setting up a network for non-technical users.

### Step 3: Add Your Blocklists

In the **Privacy** tab, click **Add a blocklist** and choose from the list. My recommendations:

- **OISD** - Comprehensive list that blocks most ads and trackers without breaking sites
- **AdGuard DNS filter** - Well-maintained, good balance
- **Steven Black&apos;s Unified Hosts** - Another solid option with multiple variants

You don&apos;t need all of them. Two or three lists with good overlap is better than ten lists that slow down resolution.

Under **Native Tracking Protection**, enable blocking for the device types you own. If you have Apple devices, enable Apple. Windows PCs, enable Windows. And so on.

### Step 4: Set Up Parental Controls (Optional)

Skip this if you don&apos;t have kids on the network. Otherwise, the **Parental Control** tab lets you:

1. Block categories (porn, gambling, social media, etc.)
2. Set recreation times when blocks lift
3. Force safe search on search engines
4. Block bypass methods so VPNs and proxies don&apos;t work

### Step 5: Connect Your Devices

NextDNS gives you several connection methods. Pick based on what you&apos;re protecting:

**For Your Entire Network (Router)**

Change your router&apos;s DNS settings to NextDNS. Find the DNS or WAN settings in your router admin panel and enter:

```
DNS-over-HTTPS: https://dns.nextdns.io/YOUR_CONFIG_ID
```

Or use the linked IP addresses from your NextDNS dashboard if your router doesn&apos;t support DoH.

**For Individual Devices**

Download the NextDNS app:
- **iOS/Android**: Install from App Store or Play Store, enter your Configuration ID
- **Windows/Mac**: Download from nextdns.io, runs as a system service
- **Linux**: Install via their shell script or package manager

**For Browsers Only**

Firefox and Chrome support DNS-over-HTTPS natively:
- Firefox: Settings &gt; Privacy &amp; Security &gt; DNS over HTTPS &gt; Custom &gt; `https://dns.nextdns.io/YOUR_CONFIG_ID`
- Chrome: Settings &gt; Privacy and security &gt; Security &gt; Use secure DNS &gt; Custom &gt; same URL

### Step 6: Verify It&apos;s Working

1. Visit [test.nextdns.io](https://test.nextdns.io)
2. You should see &quot;All good! You are using NextDNS&quot;
3. Check your dashboard - queries should start appearing in the logs

If the test fails, double-check your DNS settings. On some networks, your ISP forces their DNS, and you&apos;ll need DoH or the native app to bypass that.

### Step 7: Fine-Tune Your Settings

After a few days of use, check your logs:
- If legitimate sites break, add them to your allowlist
- If annoying domains slip through, add them to your denylist
- Adjust blocklists if you&apos;re seeing too many false positives

The **Settings** tab has additional options:
- **Logs**: Set retention period or disable entirely
- **Block Page**: Show a page when domains are blocked (I disable this)
- **Anonymized EDNS Client Subnet**: Hides your IP from upstream resolvers
- **Cache Boost**: Improves response times

## What I Like About NextDNS

### Setup Takes Five Minutes

Create an account, get a configuration ID, and point your devices at NextDNS servers. That&apos;s it. No server to manage, no Docker containers to maintain, no firewall rules to configure.

For router-level protection, you just change your DNS settings once and every device on your network gets coverage automatically. Smart TVs, gaming consoles, IoT devices, phones, laptops. Everything.

### The Blocking Works Well

I added OISD, AdGuard DNS filter, and Steven Black&apos;s list to my configuration. YouTube still shows some ads (those are harder to block at DNS level since they come from the same domains as videos), but everything else is clean:

- In-app ads on mobile games: gone
- Banner ads on websites: gone
- Tracking scripts from Facebook, Google Analytics, etc.: blocked
- Those annoying cookie consent popups on some sites: reduced

The dashboard shows what&apos;s being blocked in real time. Watching my smart TV phone home to analytics servers only to get blocked is oddly satisfying.

### Privacy Settings That Make Sense

You can configure NextDNS to keep zero logs. No retention of query data, no IP address storage, nothing. Or you can keep logs for debugging (helpful when something breaks) and delete them after a set period.

The anonymized EDNS option hides your IP from upstream DNS resolvers. Combined with encrypted DNS protocols, this means neither your ISP nor the destination servers know exactly what you&apos;re doing.

### Multiple Configurations

You can create separate profiles for different use cases. I have one for my main network with aggressive blocking, another for my parents&apos; house with safer defaults, and a third for testing when I need to bypass filters temporarily.

## What Could Be Better

### The Free Tier Limit

300,000 queries per month sounds like a lot until you realize how chatty modern devices are. A household with a few phones, a smart TV, and some IoT devices can burn through that in two weeks.

When you hit the limit, NextDNS stops filtering and just passes queries through. You still have DNS service, but without the blocking. The $1.99/month pro plan removes this limit entirely.

### YouTube Ads Still Get Through

DNS-level blocking can&apos;t touch YouTube ads because they&apos;re served from the same domains as the actual video content. Blocking those domains would break YouTube entirely. You&apos;ll still need a browser extension like uBlock Origin for YouTube specifically.

### Some Sites Break

Occasionally a legitimate service gets caught by blocklists. Affiliate links, certain CDNs, or obscure tracking domains that websites actually need to function. The allowlist feature handles this, but you need to notice the problem first and figure out which domain to unblock.

## Pricing

| Plan | Queries/Month | Price |
|------|---------------|-------|
| Free | 300,000 | $0 |
| Pro | Unlimited | $1.99/month |
| Business | Unlimited | Custom |

The free tier works for testing or light personal use. Most households need Pro. At under $2/month, it&apos;s cheaper than most VPNs and arguably more useful for daily browsing.

## My Configuration

Here&apos;s what I&apos;m running:

**Security tab:**
- Threat Intelligence Feeds: enabled
- Google Safe Browsing: enabled
- Cryptojacking Protection: enabled
- DNS Rebinding Protection: enabled

**Privacy tab (blocklists):**
- OISD (comprehensive coverage)
- AdGuard DNS filter
- Steven Black&apos;s Unified Hosts

**Settings:**
- Logs: 1 hour retention (for debugging)
- Anonymized EDNS: enabled
- Cache Boost: enabled

This catches most ads and trackers without breaking too many websites. I check the logs occasionally and allowlist domains when something legitimate gets blocked.

## Who Should Use NextDNS

&lt;ListCheck&gt;

- People who want ad blocking without managing servers
- Families who need protection across all devices
- Mobile users who want filtering outside their home network
- Anyone frustrated with ISP tracking
- Users who prefer paying a small fee over running infrastructure

&lt;/ListCheck&gt;

## Who Should Look Elsewhere

If you want complete control over your DNS infrastructure, self-hosting AdGuard Home makes more sense. It runs on a VPS, Raspberry Pi, or home server and gives you unlimited queries without subscriptions.

&lt;Button text=&quot;See How NextDNS Compares to AdGuard Home&quot; link=&quot;/block-ads-malware-dns-protection/&quot; variant=&quot;outline&quot; color=&quot;green&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;

The tradeoff is maintenance. You handle updates, monitor uptime, and troubleshoot when things break. NextDNS handles all that for you.

## Final Thoughts

NextDNS does what it promises. Encrypted DNS queries, network-wide ad blocking, malware protection, and a clean dashboard to monitor everything. Setup is straightforward, the apps work well, and the $1.99/month pro tier removes the only real limitation of the free plan.

I keep it running on all my devices and recommend it to anyone who asks about network-level ad blocking. The minor annoyances (YouTube ads still showing, occasional false positives) are outweighed by the convenience of not maintaining my own DNS server.

If you want protection without the infrastructure headache, NextDNS is worth trying.

&lt;Button text=&quot;Get Started with NextDNS&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

---

**Related Articles:**
- [Manifest V3 Broke Your Ad Blocker? Block Ads Everywhere with NextDNS](/block-ads-manifest-v3-nextdns/)
- [How to Block Ads, Malware &amp; Stop ISP Tracking with NextDNS and AdGuard Home](/block-ads-malware-dns-protection/)
- [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/)
- [How to Self-Host SearXNG - Privacy-Focused Metasearch Engine](https://www.bitdoze.com/searxng-self-host-privacy-search/)</content:encoded><category>tools</category><category>privacy</category><category>security</category></item><item><title>How to Block Ads, Malware &amp; Stop ISP Tracking with NextDNS and AdGuard Home</title><link>https://www.bitdoze.com/block-ads-malware-dns-protection/</link><guid isPermaLink="true">https://www.bitdoze.com/block-ads-malware-dns-protection/</guid><description>Learn how to protect your entire network from ads, malware, and ISP tracking using encrypted DNS solutions. Complete guide for NextDNS (cloud) and self-hosted AdGuard Home with Docker and Dockge integration.</description><pubDate>Mon, 12 Jan 2026 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;

Every time you visit a website, your device sends a DNS query that reveals what you&apos;re browsing. Your ISP logs these queries, advertisers track you across sites, and malicious domains can slip through. Protecting your network at the DNS level makes sense.

In this guide, I&apos;ll show you how to protect your network from:
- Ads that slow down your browsing
- Malware and phishing domains
- ISP tracking of your online activity

I&apos;ll cover two approaches: NextDNS (a cloud-based service) and AdGuard Home (a self-hosted solution you run yourself). Either way, you&apos;ll get encrypted DNS that keeps your browsing private.

&gt; If you&apos;re interested in other privacy-focused self-hosted solutions, check out [How to Self-Host SearXNG — Privacy-Focused Metasearch Engine](https://www.bitdoze.com/searxng-self-host-privacy-search/) for a private search engine you can run yourself.

## What is DNS and Why It Matters for Your Privacy

### Understanding DNS

DNS (Domain Name System) translates domain names into IP addresses. When you type `google.com`, DNS finds the corresponding IP address like `142.250.80.46` that computers understand.

The problem: traditional DNS isn&apos;t encrypted. This means:

| Privacy Risk | What Happens |
|-------------|--------------|
| **ISP Monitoring** | Your internet provider sees every website you visit |
| **Data Collection** | DNS queries can be logged, sold, or shared with third parties |
| **Man-in-the-Middle Attacks** | Attackers can intercept and modify DNS responses |
| **No Ad Blocking** | Standard DNS servers resolve all domains, including ad servers |

### How DNS-Level Protection Works

DNS-level protection handles these issues in two ways:

1. **Encrypted DNS Protocols**:
   - DNS-over-HTTPS (DoH): Encrypts DNS queries using HTTPS on port 443
   - DNS-over-TLS (DoT): Encrypts DNS queries using TLS on port 853
   - DNS-over-QUIC (DoQ): A newer protocol with better performance

2. **DNS Filtering**: Instead of resolving requests to known ad servers, trackers, or malware domains, a filtering DNS server returns a null response. This blocks the content before it reaches your device.

&lt;Notice type=&quot;info&quot; title=&quot;Why DNS-Level Blocking is Superior&quot;&gt;

Browser-based ad blockers only work in one application. DNS-level blocking protects your entire network, including smart TVs, IoT devices, gaming consoles, and mobile apps that don&apos;t support traditional ad blockers.

&lt;/Notice&gt;

## Option 1: NextDNS (Cloud-Based Solution)

[NextDNS](https://go.bitdoze.com/nextdns) is a cloud-based DNS service with encrypted DNS and filtering capabilities. It works well if you want protection without managing servers.

&lt;Button text=&quot;Try NextDNS Free&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

### What is NextDNS?

NextDNS is a DNS resolver that sits between your devices and the internet. When your device makes a DNS query, NextDNS:

1. Receives the encrypted request
2. Checks it against your blocklists
3. Blocks ads, trackers, and malware domains
4. Returns the result (or blocks it) via encrypted connection

Your ISP only sees encrypted traffic to NextDNS servers, so they can&apos;t see which websites you&apos;re visiting.

### Key Features of NextDNS

&lt;ListCheck&gt;

- **Ad Blocking**: Blocks ads across devices and apps using blocklists
- **Malware &amp; Phishing Protection**: Threat intelligence blocks dangerous domains
- **Tracker Blocking**: Stops cross-site tracking from data collectors
- **Parental Controls**: Blocks adult content, gambling, social media, and more
- **Analytics Dashboard**: Shows what&apos;s blocked and which devices are querying
- **Encrypted DNS**: Supports DoH, DoT, and DoQ protocols
- **No Logging Option**: Configure zero-logs for privacy
- **Cross-Platform**: Works on devices, routers, and operating systems

&lt;/ListCheck&gt;

### NextDNS Pricing

| Plan | Queries/Month | Price | Best For |
|------|---------------|-------|----------|
| **Free** | 300,000 | $0 | Individual users, testing |
| **Pro** | Unlimited | $1.99/month | Families, power users |
| **Business** | Unlimited | Custom | Organizations |

&gt; 300,000 queries sounds like a lot, but a household with multiple devices can exceed this. Check your usage in the dashboard.

### Setting Up NextDNS

#### Step 1: Create Your NextDNS Account

1. Visit [NextDNS](https://go.bitdoze.com/nextdns) and click **Try it now**
2. Create a free account with your email
3. You&apos;ll receive a unique **Configuration ID** (looks like `abc123`)

#### Step 2: Configure Your Blocklists

Navigate to the **Security** tab and enable:

- **Threat Intelligence Feeds**: Blocks known malware domains
- **Google Safe Browsing**: Leverages Google&apos;s threat database
- **Cryptojacking Protection**: Blocks cryptocurrency mining scripts
- **DNS Rebinding Protection**: Prevents DNS rebinding attacks
- **IDN Homograph Attacks Protection**: Blocks look-alike domains

Navigate to the **Privacy** tab and enable:

- **Blocklists**: Add popular lists like:
  - OISD (comprehensive)
  - AdGuard DNS filter
  - Steven Black&apos;s Unified Hosts
- **Native Tracking Protection**: Block OS-level tracking (Apple, Windows, etc.)

#### Step 3: Configure Your Devices

**Method 1: Per-Device Configuration**

NextDNS provides apps for all major platforms:

- **Windows/Mac**: Download the official NextDNS app
- **iOS**: Download from App Store or use the DNS profile
- **Android**: Download from Play Store or configure Private DNS

**Method 2: Router Configuration (Recommended)**

For network-wide protection, configure NextDNS on your router:

1. Access your router&apos;s admin panel (usually `192.168.1.1`)
2. Find DNS settings (often under WAN or Internet settings)
3. Replace existing DNS servers with NextDNS addresses:

```sh
# NextDNS DNS-over-HTTPS endpoint (replace abc123 with your ID)
https://dns.nextdns.io/abc123

# Or use the dedicated IPv4 addresses from your dashboard
```

4. For DNS-over-TLS (if your router supports it):

```sh
# DoT hostname
abc123.dns.nextdns.io
```

#### Step 4: Verify Your Setup

1. Visit [test.nextdns.io](https://test.nextdns.io/)
2. It should show &quot;Congratulations! You are using NextDNS&quot;
3. Check the **Logs** tab in your dashboard to see queries

### NextDNS Privacy Settings

For maximum privacy, configure these settings in the **Settings** tab:

| Setting | Recommended Value | Purpose |
|---------|------------------|---------|
| **Logs** | Disabled or 1 hour | Minimize data retention |
| **Block Page** | Disabled | Don&apos;t reveal what&apos;s blocked |
| **Anonymized EDNS** | Enabled | Hide your IP from upstream |
| **Cache Boost** | Enabled | Faster responses |

### NextDNS Pros and Cons

**Pros:**
- No server to manage
- Works immediately
- Good mobile app support
- Regular blocklist updates
- Generous free tier

**Cons:**
- You trust a third party
- Free tier has query limits
- Less customization than self-hosted
- Depends on NextDNS infrastructure

## Option 2: Self-Hosted AdGuard Home

If you want control over your DNS infrastructure, AdGuard Home is an open-source ad and tracker blocker you can run on your own hardware.

&lt;Button text=&quot;Try Hetzner Cloud for Self-Hosting&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;outline&quot; color=&quot;green&quot; size=&quot;md&quot; external={true} icon=&quot;server&quot; /&gt;

&gt; If you&apos;re wondering whether self-hosting is right for you, read [Why You Need a Home Server in 2026](https://www.bitdoze.com/why-need-home-server/) for an overview of the benefits.

### What is AdGuard Home?

[AdGuard Home](https://github.com/AdguardTeam/AdGuardHome) is a free, open-source DNS server with ad blocking, tracker blocking, and parental controls. It runs on your server (VPS, home server, or Raspberry Pi) and acts as your network&apos;s DNS resolver.

### Key Features of AdGuard Home

&lt;ListCheck&gt;

- **Network-Wide Blocking**: Protects devices on your network automatically
- **Custom Filtering Rules**: Create your own rules or import blocklists
- **Encrypted DNS Server**: Serves DoH, DoT, and DoQ to your clients
- **DHCP Server**: Optionally replaces your router&apos;s DHCP for control
- **Query Logs**: Detailed analytics of DNS queries
- **Per-Client Settings**: Different rules for different devices
- **Parental Controls**: Safe search and adult content blocking
- **Dashboard**: Web UI for configuration and monitoring

&lt;/ListCheck&gt;

### Prerequisites

Before installing AdGuard Home, you&apos;ll need:

&lt;ListCheck&gt;

- **A Server**: This can be:
  - A VPS from providers like [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) (see our [Hetzner Cloud Review](https://www.bitdoze.com/hetzner-cloud-review/))
  - A home server or [Mini PC](https://www.bitdoze.com/best-mini-pc-home-server/)
  - A Raspberry Pi
- **Docker Installed**: Follow our guide to install Docker if needed
- **Basic Terminal Knowledge**: Ability to run commands via SSH
- **A Domain (Optional)**: For accessing the dashboard remotely with HTTPS

&lt;/ListCheck&gt;

&gt; For a comprehensive list of applications you can run alongside AdGuard Home, check out [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/).

### Setup Option 1: Docker Compose (Standalone)

This method is ideal for servers where you want direct access to AdGuard Home without a reverse proxy.

#### Step 1: Create Project Directory

Connect to your server via SSH and create a directory for AdGuard Home:

```bash
mkdir -p ~/adguard-home
cd ~/adguard-home
```

#### Step 2: Create Docker Compose Configuration

Create a `docker-compose.yml` file:

```bash
nano docker-compose.yml
```

Add the following configuration:

```yaml
services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    ports:
      # DNS ports - required for DNS resolution
      - &quot;53:53/tcp&quot;
      - &quot;53:53/udp&quot;
      # Admin panel
      - &quot;3000:3000/tcp&quot;
      # DNS-over-HTTPS (optional)
      - &quot;443:443/tcp&quot;
      - &quot;443:443/udp&quot;
      # DNS-over-TLS (optional)
      - &quot;853:853/tcp&quot;
      # DNS-over-QUIC (optional)
      - &quot;853:853/udp&quot;
      - &quot;8853:8853/udp&quot;
      # DHCP server (optional, only if replacing router DHCP)
      # - &quot;67:67/udp&quot;
      # - &quot;68:68/udp&quot;
    volumes:
      - ./work:/opt/adguardhome/work
      - ./conf:/opt/adguardhome/conf
    cap_add:
      - NET_ADMIN
```

&lt;Notice type=&quot;warning&quot; title=&quot;Port 53 Conflicts&quot;&gt;

Many Linux systems run `systemd-resolved` which uses port 53. If you get a port conflict error, you&apos;ll need to disable it:

```bash
sudo systemctl stop systemd-resolved
sudo systemctl disable systemd-resolved
sudo rm /etc/resolv.conf
echo &quot;nameserver 8.8.8.8&quot; | sudo tee /etc/resolv.conf
```

&lt;/Notice&gt;

#### Step 3: Launch AdGuard Home

Start the container:

```bash
docker compose up -d
```

Check that it&apos;s running:

```bash
docker compose ps
```

You should see the `adguardhome` container with status `Up`.

#### Step 4: Complete Initial Setup

1. Open your browser and navigate to `http://YOUR_SERVER_IP:3000`
2. Follow the setup wizard:
   - Set the **Admin Web Interface** to listen on all interfaces, port 3000
   - Set the **DNS Server** to listen on all interfaces, port 53
   - Create your **admin username and password**
3. Click **Next** to complete the setup

After setup, the admin panel will be available at `http://YOUR_SERVER_IP:3000` (or port 80 if you configured it that way).

### Setup Option 2: Dockge Deployment

[Dockge](https://www.bitdoze.com/dockge-install/) provides a beautiful web interface for managing Docker Compose stacks. This method is perfect if you&apos;re already using Dockge or want an easier way to manage your containers.

&gt; If you haven&apos;t installed Dockge yet, follow our guide: [Dockge Install - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/)

#### Step 1: Access Dockge Dashboard

1. Open your Dockge web interface (typically `http://YOUR_SERVER_IP:5001`)
2. Click the **+ Compose** button in the top right

#### Step 2: Create New Stack

1. Give your stack a name: `adguardhome`
2. In the compose editor, paste the following:

```yaml
services:
  adguardhome:
    image: adguard/adguardhome:latest
    container_name: adguardhome
    restart: unless-stopped
    ports:
      - &quot;53:53/tcp&quot;
      - &quot;53:53/udp&quot;
      - &quot;3000:3000/tcp&quot;
      - &quot;443:443/tcp&quot;
      - &quot;443:443/udp&quot;
      - &quot;853:853/tcp&quot;
      - &quot;853:853/udp&quot;
      - &quot;8853:8853/udp&quot;
    volumes:
      - ./work:/opt/adguardhome/work
      - ./conf:/opt/adguardhome/conf
    cap_add:
      - NET_ADMIN
```

#### Step 3: Deploy the Stack

1. Click the **Deploy** button
2. Dockge will pull the image and start the container
3. You can monitor the logs in real-time in the Dockge interface

#### Step 4: Complete Setup

Navigate to `http://YOUR_SERVER_IP:3000` and complete the initial setup wizard as described in the standalone method above.

The advantage of using Dockge is that you can easily:
- View logs in real-time
- Stop, start, and restart the container
- Edit the compose file and redeploy
- Monitor resource usage

### Configuring AdGuard Home

Once AdGuard Home is running, configure it for optimal protection.

#### Adding Blocklists

1. Go to **Filters** → **DNS blocklists**
2. Click **Add blocklist** → **Choose from list**
3. Recommended blocklists to enable:

| Blocklist | Purpose |
|-----------|---------|
| **AdGuard DNS filter** | General ad blocking |
| **AdAway Default Blocklist** | Mobile ad blocking |
| **OISD Blocklist** | Comprehensive blocking |
| **Steven Black&apos;s List** | Unified hosts with extensions |
| **Phishing Army** | Phishing protection |
| **Malware Domain List** | Malware protection |

4. Click **Apply** after adding lists

#### Configuring Upstream DNS (Privacy)

To prevent your queries from being visible to your ISP, configure encrypted upstream DNS:

1. Go to **Settings** → **DNS settings**
2. In **Upstream DNS servers**, add encrypted resolvers:

```text
# Cloudflare DoH
https://cloudflare-dns.com/dns-query

# Quad9 DoH (with malware blocking)
https://dns.quad9.net/dns-query

# Google DoH
https://dns.google/dns-query
```

3. Enable **Parallel requests** for faster resolution
4. Under **Bootstrap DNS servers**, add:

```text
9.9.9.9
1.1.1.1
8.8.8.8
```

5. Click **Apply**

&lt;Notice type=&quot;info&quot; title=&quot;Why Encrypted Upstream DNS Matters&quot;&gt;

Even though AdGuard Home is running on your network, it still needs to query upstream DNS servers. By using DNS-over-HTTPS (DoH), these queries are encrypted—your ISP cannot see which domains you&apos;re resolving.

&lt;/Notice&gt;

#### Enabling AdGuard Home&apos;s Own Encrypted DNS Server

To protect devices outside your home network, you can enable DoH/DoT on AdGuard Home itself:

1. Go to **Settings** → **Encryption settings**
2. Enable encryption
3. Enter your domain name (requires valid SSL certificate)
4. Configure certificate paths or use Let&apos;s Encrypt

This allows you to use your own AdGuard Home instance as an encrypted DNS server from anywhere in the world.

#### Additional Security Settings

Navigate to **Settings** → **General settings** and enable:

- **Use AdGuard browsing security web service**: Blocks malware and phishing
- **Use AdGuard parental control web service**: Optional, for family protection
- **Safe search**: Forces safe search on popular search engines

### Connecting Devices to AdGuard Home

#### Option 1: Router Configuration (Recommended)

Configure your router to use AdGuard Home as the DNS server:

1. Access your router&apos;s admin panel
2. Find DNS settings (usually under DHCP or LAN settings)
3. Set the primary DNS to your AdGuard Home server&apos;s IP address
4. Set secondary DNS to the same IP (or leave blank)
5. Save and reboot the router

Now all devices on your network automatically use AdGuard Home.

#### Option 2: Per-Device Configuration

For individual devices, change DNS settings to point to your AdGuard Home server:

**Windows:**
1. Open Network &amp; Internet settings
2. Click on your network → Properties
3. Under DNS server assignment, click Edit
4. Set to Manual and enter your AdGuard Home IP

**macOS:**
1. System Preferences → Network
2. Select your connection → Advanced → DNS
3. Add your AdGuard Home IP address

**iOS:**
1. Settings → Wi-Fi → tap your network
2. Scroll down to DNS → Configure DNS → Manual
3. Add your AdGuard Home IP

**Android:**
1. Settings → Network &amp; Internet → Private DNS
2. Select &quot;Private DNS provider hostname&quot;
3. Enter your AdGuard Home DoT hostname (requires encryption setup)

### AdGuard Home Pros and Cons

**Pros:**
- Control over your data
- No query limits
- No subscription fees
- Customizable
- Can serve as encrypted DNS server
- Local processing for faster responses
- Open source

**Cons:**
- Requires server maintenance
- Setup is more complex
- You handle updates
- Needs reliable hardware/hosting
- You handle security

&gt; For additional server security, consider implementing [CrowdSec to Secure Your VPS](https://www.bitdoze.com/crowdsec-secure-server/) alongside AdGuard Home.

## NextDNS vs AdGuard Home: Comparison

| Feature | NextDNS | AdGuard Home |
|---------|---------|--------------|
| **Setup Difficulty** | Easy (5 min) | Moderate (30 min) |
| **Cost** | Free tier / $1.99/mo | Free (server costs apply) |
| **Query Limits** | 300k free / unlimited paid | Unlimited |
| **Data Location** | NextDNS servers | Your server |
| **Maintenance** | None (managed service) | You manage updates |
| **Customization** | Good | Excellent |
| **Offline Access** | No (requires internet) | Yes (for local network) |
| **Mobile Apps** | Official apps available | Third-party clients |
| **Privacy** | Trust NextDNS | Complete control |
| **Best For** | Beginners, mobile users | Privacy enthusiasts, homelabs |

### Which Should You Choose?

**Choose NextDNS if:**
- You want quick setup
- You don&apos;t want to manage infrastructure
- You need protection on mobile devices outside home
- You&apos;re okay with a managed service

**Choose AdGuard Home if:**
- You want control over your DNS
- You have a home server or VPS
- You don&apos;t want third-party involvement
- You enjoy self-hosting

**Or use both.** Many users run AdGuard Home at home and use NextDNS as the upstream encrypted DNS.

&lt;Button text=&quot;Get Started with NextDNS&quot; link=&quot;https://go.bitdoze.com/nextdns&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## Best Practices for DNS Security

&lt;ListCheck&gt;

- **Always use encrypted DNS**: Use DoH, DoT, or DoQ instead of unencrypted DNS
- **Regularly update blocklists**: Set AdGuard Home to auto-update lists daily
- **Monitor query logs**: Check for unusual activity or blocked threats
- **Backup your configuration**: Export AdGuard Home settings regularly
- **Use strong admin passwords**: Protect your DNS dashboard
- **Keep software updated**: Update AdGuard Home and Docker regularly
- **Consider redundancy**: Run a secondary DNS server for reliability
- **Test your setup**: Use [dnsleaktest.com](https://dnsleaktest.com) to verify

&lt;/ListCheck&gt;

&gt; If you&apos;re running Docker containers, make sure to also read [How to Fix Docker Bypassing Firewall](https://www.bitdoze.com/docker-bypasses-firewall/) to ensure your security configurations aren&apos;t being circumvented.

## Conclusion

Protecting your network at the DNS level blocks ads, prevents malware infections, and stops ISP tracking. Both NextDNS and AdGuard Home work well:

- **NextDNS** offers a cloud-based approach for beginners and mobile users
- **AdGuard Home** gives you control and privacy if you prefer self-hosting

You&apos;ll notice:
- **Faster browsing**: No more loading ads and trackers
- **Better security**: Malware and phishing domains get blocked
- **More privacy**: Your ISP can&apos;t see your DNS queries
- **Network-wide protection**: Every device benefits, including smart TVs and IoT devices

Whether you choose NextDNS or AdGuard Home, you&apos;re making your internet experience more private and secure.

&lt;Button text=&quot;Try Hetzner Cloud for Self-Hosting&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

---

**Related Articles:**
- [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/)
- [Dockge Install - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/)
- [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)
- [How to Self-Host SearXNG — Privacy-Focused Metasearch Engine](https://www.bitdoze.com/searxng-self-host-privacy-search/)
- [How To Secure a VPS Server with CrowdSec](https://www.bitdoze.com/crowdsec-secure-server/)</content:encoded><category>tools</category><category>self-hosted</category><category>docker</category><category>privacy</category></item><item><title>Tugtainer: Self-Hosted Docker Container Auto-Updater</title><link>https://www.bitdoze.com/tugtainer-docker-autoupdate/</link><guid isPermaLink="true">https://www.bitdoze.com/tugtainer-docker-autoupdate/</guid><description>Automate Docker container updates with Tugtainer - a self-hosted solution featuring Web UI, multi-host support, and notifications. Compare with Watchtower &amp; Ouroboros. Deploy easily with Dokploy.</description><pubDate>Mon, 05 Jan 2026 00:00:00 GMT</pubDate><content:encoded>Updating Docker containers by hand gets tedious fast when you&apos;re managing multiple servers. Tugtainer is a self-hosted tool that automates this while letting you decide what updates and when. If you&apos;re new to Docker, start with our guide on [essential Docker commands](https://www.bitdoze.com/docker-commands/).

## What is Tugtainer?

Tugtainer checks for and updates your Docker containers automatically. Unlike similar tools, it has a web interface where you can manage updates, see what&apos;s happening, and set up notifications. You can also manage containers across multiple servers by running agents on each one.

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Production Use&quot;&gt;
The application is distributed &quot;as is&quot; and the developers do not recommend it for critical production environments without testing. Always ensure you have backups of important data before enabling auto-updates.
&lt;/Notice&gt;

## Tugtainer vs alternatives

Several tools can automatically update your Docker containers. Here&apos;s how Tugtainer compares to Watchtower and Ouroboros:

| Feature | Tugtainer | Watchtower | Ouroboros |
|---------|-----------|------------|-----------|
| **Web UI** | Yes (with authentication) | No | No |
| **Multi-Host Support** | Yes (via agents) | No | No |
| **Notifications** | Yes (Apprise - 80+ services) | Yes | Yes |
| **Auto-Update Groups** | Yes (dependency-aware) | No | No |
| **Configuration** | GUI | Environment variables | Environment variables |
| **Private Registries** | Yes | Yes | Yes |
| **Granular Control** | Yes (per-container) | Yes (per-container) | Yes (per-container) |
| **Dependency Handling** | Yes (automatic) | No | No |
| **Self-Update** | Manual only | Yes | Yes |
| **Pruning** | Yes (auto/manual) | Yes | Yes |

&lt;Notice type=&quot;info&quot; title=&quot;Key terms&quot;&gt;
- **Dependency-Aware**: Detects which containers depend on others (like databases) and updates them in the right order. It stops dependent services first, then updates the dependencies.
- **Socket-Proxy**: A security container that controls access to the Docker socket. Tugtainer can&apos;t update itself or the proxy from within.
&lt;/Notice&gt;

**When to pick Tugtainer:**
- You want a web interface instead of configuring environment variables
- You&apos;re managing containers across multiple servers
- You need automatic dependency handling

**When to pick Watchtower:**
- You have a simple single-host setup and want something lightweight

**When to pick Ouroboros:**
- You prefer minimal configuration via environment variables

## Main features

Tugtainer includes these features:

&lt;ListCheck&gt;
- **Web UI**: A clean interface with login to manage all your containers
- **Multi-Host Support**: Manage containers on different servers using the Tugtainer Agent
- **Granular Control**: Set each container to &quot;check only&quot; or &quot;auto-update&quot; mode
- **Notifications**: Send alerts to Discord, Telegram, Slack, email, and other services via Apprise
- **Private Registries**: Pull images from private Docker registries
- **Dependency Handling**: Updates containers in the right order (stops dependent services first)
- **Pruning**: Clean up old images automatically or manually
&lt;/ListCheck&gt;

## How to deploy Tugtainer

You can deploy Tugtainer with Dokploy for an easy setup, or use Docker Compose.

### Option 1: Deploy with Dokploy

If you&apos;re using Dokploy to manage your server, deploying Tugtainer is simple. Dokploy handles SSL certificates and reverse proxy configuration automatically. If you don&apos;t have Dokploy installed yet, follow our [Dokploy installation guide](https://www.bitdoze.com/dokploy-install/).

1.  **Create a Service**: In your Dokploy project, click &quot;Add Service&quot; and select &quot;Compose&quot;
2.  **Name It**: Give it a name like `tugtainer`
3.  **Add Configuration**: Paste this configuration into the Compose editor:

```yaml
services:
  tugtainer:
    image: quenary/tugtainer:latest
    container_name: tugtainer
    restart: unless-stopped
    environment:
      - PORT=80
    volumes:
      - tugtainer_data:/tugtainer
      - /var/run/docker.sock:/var/run/docker.sock:ro

volumes:
  tugtainer_data:
```

4.  **Configure Domain**: In the &quot;Domains&quot; tab, add your domain (like `updates.yourdomain.com`) and map it to port **80**
5.  **Deploy**: Click &quot;Deploy&quot;. Dokploy will start the container and issue an SSL certificate

&lt;Notice type=&quot;info&quot; title=&quot;Dokploy integration&quot;&gt;
Using Dokploy puts Tugtainer behind HTTPS, which is recommended for accessing the web UI.
&lt;/Notice&gt;

### Option 2: Deploy with Docker Compose

You can also deploy Tugtainer manually with Docker Compose. This configuration sets up the main instance and gives it access to the Docker socket.

```yaml
services:
  tugtainer:
    image: quenary/tugtainer:latest
    container_name: tugtainer
    restart: unless-stopped
    ports:
      - 9412:80
    volumes:
      - tugtainer_data:/tugtainer
      - /var/run/docker.sock:/var/run/docker.sock:ro

volumes:
  tugtainer_data:
```

&lt;Notice type=&quot;info&quot; title=&quot;Self-Update Limitation&quot;&gt;
Tugtainer cannot update itself or the socket-proxy from within the app. You should exclude these from auto-updates to prevent errors. Update them manually or via another tool like Portainer.
&lt;/Notice&gt;

### Managing remote hosts

You don&apos;t need a full Tugtainer instance on each server. Deploy the **Tugtainer Agent** on remote servers instead:

```yaml
services:
  tugtainer-agent:
    image: quenary/tugtainer-agent:latest
    container_name: tugtainer-agent
    restart: unless-stopped
    ports:
      - 9413:8001
    environment:
      - AGENT_SECRET=CHANGE_ME!
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
```

You can also deploy the agent via **Dokploy** using the same compose file. If you remove the `ports` section, you can expose the agent through a domain instead.

After deploying the agent, go to the Tugtainer web UI, navigate to **Menu -&gt; Hosts**, and add the new host using its IP and the `AGENT_SECRET` you set.

## Tugtainer web UI

Once you deploy Tugtainer and access the web interface, you&apos;ll see a dashboard that shows all your containers and their update status.

![Tugtainer main dashboard view showing all containers and update status](../../assets/images/26/01/tugtainer-image-view.webp)

The dashboard shows all your containers, their current versions, and whether updates are available. You can quickly see which containers are set to auto-update and which are protected.

![Tugtainer containers list view showing detailed container information](../../assets/images/26/01/tugtainer-all-containers.webp)

From this view, you can:
- View detailed information about each container
- Toggle between &quot;check only&quot; and &quot;auto-update&quot; modes
- See update history and logs
- Manually trigger updates for specific containers or groups
- Configure custom labels and dependencies

![Tugtainer single container detail view showing configuration options](../../assets/images/26/01/tugtainer-one-container-view.webp)

The individual container view lets you configure:
- Update schedule
- Notification preferences
- Dependency management
- Custom labels for advanced control

### Initial login

When you first visit the Tugtainer web UI, you&apos;ll set up an administrator account.

1.  **Create Password**: Set a secure password during the initial setup
2.  **Login**: Use the password you created to access the dashboard
3.  **Change Password**: You can change your password later in the settings

Tugtainer doesn&apos;t generate a random password like some Docker tools - you set it yourself during the setup wizard.

## Check and update process

Tugtainer organizes containers into **groups** for safe updating. A group includes linked containers, like those in the same Docker Compose project or manually linked via labels.

When an update runs (by schedule or manually):
1.  **Image Pull**: Tugtainer checks for and pulls new images
2.  **Stop Order**: If an update is available and enabled, containers in the group stop starting from the most dependent ones
3.  **Update &amp; Start**: Containers are recreated with the new image and started in reverse order (dependencies first)

### Custom labels

You can control Tugtainer&apos;s behavior with Docker labels on your containers:

-   `dev.quenary.tugtainer.protected=true`: Prevents Tugtainer from stopping or updating a container. Use this for the agent itself.
-   `dev.quenary.tugtainer.depends_on=&quot;db,redis&quot;`: Manually declare dependencies for containers not in the same compose file.

## Monitoring your containers

When running auto-update systems, monitor your server resources to avoid issues. You can use [server monitoring dashboards](https://www.bitdoze.com/sever-monitoring/) to track CPU, memory, and disk usage.

## Notifications

Tugtainer uses **Apprise** for notifications, so you can send alerts to Discord, Telegram, Slack, email, and many other services. You can customize notification messages using Jinja2 templates.

Notifications report on:
-   **Available**: New image found
-   **Updated**: Container successfully updated
-   **Rolled Back**: Update failed, reverted to old image
-   **Failed**: Update failed

## Frequently asked questions

&lt;Accordion label=&quot;How does Tugtainer handle container dependencies?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Tugtainer automatically detects dependencies between containers in the same Docker Compose project. During updates, it stops containers starting from the most dependent ones (leaves first) and starts them in reverse order (roots first). This ensures databases and other dependencies are running before dependent services restart. For containers not in the same compose file, manually declare dependencies with the `dev.quenary.tugtainer.depends_on` label.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I exclude specific containers from auto-updates?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Yes. You can exclude containers in several ways:
- Use the `dev.quenary.tugtainer.protected=true` label to prevent Tugtainer from stopping or updating a container
- Configure individual containers in the web UI for &quot;check only&quot; mode
- Manually manage certain containers while letting Tugtainer handle the rest
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if an update fails?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Tugtainer can roll back failed updates:
- The container reverts to the previous working image
- You receive a notification about the rollback via your configured Apprise services
- The old image remains available so you can restore it manually
- This prevents downtime from failed updates
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is Tugtainer safe for production environments?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
The developers don&apos;t recommend using Tugtainer in critical production environments without testing. Always:
- Test updates in a staging environment first
- Maintain regular backups of your data
- Monitor your applications after auto-updates
- Consider using &quot;check only&quot; mode for critical services initially
- Have a rollback plan before enabling auto-updates
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I manage Docker disk space with Tugtainer?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
Tugtainer includes automatic or manual pruning of old images. For a complete cleanup, follow our guide on [how to clean all Docker images, containers, and volumes](https://www.bitdoze.com/cleanup-all-docker-things/).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can Tugtainer update itself?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
No, Tugtainer can&apos;t update itself or the socket-proxy. Exclude Tugtainer from auto-updates to prevent errors. Update it manually via Docker Compose or another tool like Portainer. The same applies to the Tugtainer Agent - protect it with the `dev.quenary.tugtainer.protected=true` label.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Tugtainer compare to manual Docker Compose updates?&quot; group=&quot;faq&quot; expanded=&quot;false&quot;&gt;
You can update containers manually with `docker compose pull` and `docker compose up -d` (as shown in our [Docker Compose update guide](https://www.bitdoze.com/updating-container-docker-compose/)), but Tugtainer automates this:
- Checks for updates on a schedule
- Handles dependencies intelligently
- Sends notifications about available updates
- Provides a centralized web UI for managing multiple containers
- Supports multiple hosts from one interface
&lt;/Accordion&gt;

## Authentication and security

By default, Tugtainer uses password authentication stored in an encrypted file. Starting with v1.6.0, you can configure an **OpenID Connect (OIDC)** provider to handle login with your existing identity management system.

### Setting up OIDC authentication

To enable OIDC authentication, you need:

1. **OIDC Provider**: Configure your provider (Auth0, Keycloak, Azure AD, Google, etc.) with:
   - Client ID
   - Client Secret
   - Issuer URL
   - Redirect URL (your Tugtainer domain + `/auth/callback`)

2. **Environment Variables**: Add these to your Tugtainer container:

```yaml
environment:
  - OIDC_ENABLED=true
  - OIDC_CLIENT_ID=your_client_id
  - OIDC_CLIENT_SECRET=your_client_secret
  - OIDC_ISSUER=https://your-provider.com
  - OIDC_REDIRECT_URI=https://tugtainer.yourdomain.com/auth/callback
  - OIDC_SCOPES=openid email profile
```

3. **Restart Container**: Apply the changes by recreating the container

Users can then log in with their OIDC credentials instead of the default password system.

## Related articles

- [Best Self-Hosted Panels: A Comparison](https://www.bitdoze.com/best-self-hosted-panels/) - Compare Tugtainer with other self-hosted management solutions
- [Top 50+ Docker Commands You MUST Know](https://www.bitdoze.com/docker-commands/) - Essential Docker commands for container management
- [Dokploy Install - Self-Host Your SaaS](https://www.bitdoze.com/dokploy-install/) - Complete Dokploy installation guide
- [How To Update Containers With Docker Compose](https://www.bitdoze.com/updating-container-docker-compose/) - Manual container update guide
- [How To Clean All Docker Things](https://www.bitdoze.com/cleanup-all-docker-things/) - Complete Docker cleanup guide
- [Monitor Server Resources](https://www.bitdoze.com/sever-monitoring/) - Server and Docker resource monitoring

&lt;Button text=&quot;View Tugtainer on GitHub&quot; link=&quot;https://github.com/quenary/tugtainer&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>devops</category></item><item><title>Termix Self-Hosted SSH Manager and Server Hub</title><link>https://www.bitdoze.com/termix-self-host/</link><guid isPermaLink="true">https://www.bitdoze.com/termix-self-host/</guid><description>Self-host Termix to manage SSH, tunnels, files, stats, and Docker from one dashboard. Deploy easily with Dokploy or Docker Compose.</description><pubDate>Fri, 19 Dec 2025 00:00:00 GMT</pubDate><content:encoded>Self-hosting gives you freedom, but it also means you are responsible for access, security, and day-to-day operations. **Termix** is built for people who want that control without juggling five separate tools. It is open-source, forever free, and runs entirely on your infrastructure.

## What is Termix?

Termix is a self-hosted platform that brings SSH access, file management, monitoring, and Docker control into one web interface. Instead of hopping between a terminal app, SFTP, and dashboards, you get a single place to manage your servers.

&lt;YouTubeEmbed url=&quot;https://www.youtube.com/watch?v=FtVMs7ba4QQ&quot; label=&quot;Termix Self-Hosted SSH Manager Overview&quot; /&gt;

## Termix core features

&lt;ListCheck&gt;
- SSH terminal with tabs and up to four split panels
- SSH tunnels with auto reconnect and health checks
- Jump host (bastion) support for private networks
- Session recording and playback
- Remote file editor with syntax highlighting
- Process manager (top-like view)
- Host manager with tags and folders
- Real-time CPU, memory, and disk stats
- User access with admin controls, OIDC, and 2FA
- Modern UI built with React, Tailwind, and Shadcn
- Languages: English and Chinese support included
- Platform support: web app today, desktop app in progress, mobile planned
&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;All data stays on your server&quot;&gt;
Termix is self-hosted with no external accounts or subscriptions. You control updates, access, and data retention.
&lt;/Notice&gt;

## App overview

Here is a quick tour of the main areas you will use daily, plus the actions to reach them.

### Terminal workspace

![Termix terminal](../../assets/images/25/12/termix-terminal.webp)

The browser terminal supports multiple tabs and split panels. Click the `&lt;|&gt;` icon on a tab (next to the close button) to open a split screen and the SSH tool sidebar for that session.

### Tools, snippets, and command history

![Termix tools](../../assets/images/25/12/termix-tools.webp)

Click the hammer icon in the top right to open the tools sidebar. This is where you find SSH tools, command history, snippets, and split screen helpers.

### Add and manage hosts

![Termix add host](../../assets/images/25/12/termixa-add-host.webp)

Open **Host Manager** from the top left to add SSH hosts, set tags, and enable features like terminal, file manager, server stats, and tunnels. You can also configure a **Jump Host** here to access servers behind a private network. To deploy credentials, add a key in **Add Credential**, then return to **Credential Viewer** and click the green arrow on the credential to follow the deployment steps.

### Server details and tunnels

![Termix server details](../../assets/images/25/12/termix-server-details.webp)

After connecting to a host from the left sidebar, open **Server Details** to view CPU, memory, disk, process list, and tunnel status. Server stats and tunnels show up only if they are enabled for that host in Host Manager.

### File manager and editor

![Termix file manager](../../assets/images/25/12/termix-file-manager.webp)

The file manager lets you browse and edit files on Linux hosts directly in the UI. It supports file operations like upload, download, compress, and media playback. Enable **File Manager** on the host, then open **File Manager** from the left sidebar after connecting.

### Admin settings and identity

Admin users can open **Admin Settings** by clicking their username in the bottom left. This is where you configure:

- OIDC (must be created after a local account exists)
- Export/Import in the **Database** tab
- SSL certificate generation (see the SSL settings)

### SSH tunnel access

If a host has **Terminal** enabled, click the `&gt;_` icon in the left sidebar to open a terminal session. If the host has tunnels configured, view them in **Server Details**.

### Command palette

Double tap **Left Shift** to open the command palette for quick navigation.


## Why Termix is useful in real life

The web interface is focused on daily ops: open a terminal in the browser, edit a config file, check CPU usage, and restart a container without leaving the dashboard. For small teams or solo operators, it removes friction without giving up ownership.

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

### How Termix compares to a tool stack

| Task | Typical stack | Termix |
| --- | --- | --- |
| SSH access | Terminal app | Built-in web terminal |
| File edits | SFTP + editor | Built-in file manager |
| Server stats | Separate monitor | Built-in stats |
| Tunnels | CLI or GUI tool | Built-in tunnels |
| Docker control | Portainer or CLI | Built-in Docker view |

## Accessing Private Networks

If you host Termix on a public VPS but need to manage your home lab, you have two solid options:

1.  **Jump Host**: Configure a Jump Host in the **Host Manager** to tunnel through a bastion server.
2.  **Mesh VPN**: Install tools like **Tailscale** or **Netbird** on your Termix server and home nodes. This puts them on the same private network, allowing direct connection without opening firewall ports.

## Option 1: Deploy with Dokploy

If you already use Dokploy, this is the fastest way to get Termix online with automatic HTTPS. Dokploy acts as a self-hosted PaaS that simplifies deployment, management, and monitoring of your applications. If you do not have Dokploy yet, start here: [Dokploy install guide](https://www.bitdoze.com/dokploy-install/).

### Step 1: Create the service

1. Open your Dokploy project
2. Click **Add Service** and choose **Compose**
3. Name it `termix`

### Step 2: Paste the compose file

```yaml
services:
  termix:
    image: ghcr.io/lukegus/termix:latest
    networks:
      - dokploy-network
    restart: unless-stopped
    environment:
      - PORT=8080
    volumes:
      - termix-data:/app/data

networks:
  dokploy-network:
    external: true

volumes:
  termix-data:
```

### Step 3: Domain and port

Create a domain in Dokploy and map it to port **8080**. After deploy, open `https://your-domain.com` and complete the first-time setup.

**Notes about the compose file**

- `termix-data` stores users, SSH hosts, and settings. Keep it on local disk for best performance.
- Termix listens on port `8080` by default and Dokploy handles HTTPS for you.
- You can change the internal port by updating `PORT` and your domain mapping.

&lt;Notice type=&quot;info&quot; title=&quot;Private by design&quot;&gt;
Termix does not require external accounts or cloud services. Everything stays on your server.
&lt;/Notice&gt;

## Option 2: Docker Compose (standalone)

If you are new to Docker, start with our self-hosting guides to get Docker installed first.

```yaml
services:
  termix:
    image: ghcr.io/lukegus/termix:latest
    container_name: termix
    restart: unless-stopped
    ports:
      - &quot;8080:8080&quot;
    volumes:
      - termix-data:/app/data
    environment:
      PORT: &quot;8080&quot;

volumes:
  termix-data:
    driver: local
```

Start it:

```bash
docker compose up -d
```

Open `http://your-server-ip:8080` and finish the initial setup.

&lt;Notice type=&quot;warning&quot; title=&quot;Back up your data volume&quot;&gt;
All Termix settings and credentials live in `/app/data`. Keep the `termix-data` volume backed up like any other critical service.
&lt;/Notice&gt;

## Post-deploy checklist

&lt;ListCheck&gt;
- Create your admin account and enable 2FA
- Add SSH hosts with tags and folders
- Verify you can open a terminal and edit files
- Configure tunnels you need for internal services
- Confirm Docker visibility and container actions
&lt;/ListCheck&gt;

## Security and access model

Termix focuses on access control. You can run local users or connect OIDC for centralized identity, then enforce 2FA for privileged accounts. It also supports encrypted storage for its database, which reduces risk if the data directory is ever copied.

## FAQ

&lt;Accordion label=&quot;Does Termix replace SSH keys?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. Termix uses standard SSH authentication under the hood. You still manage keys or credentials the same way you do today.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Where does Termix store its data?&quot; group=&quot;faq&quot;&gt;
All data is stored under `/app/data` inside the container. With Docker Compose, that maps to the `termix-data` volume.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I expose Termix with HTTPS?&quot; group=&quot;faq&quot;&gt;
Yes. Put it behind a reverse proxy like Nginx or Traefik, or use your platform&apos;s managed HTTPS.
&lt;/Accordion&gt;

## Final thoughts

Termix is a strong choice if you want a self-hosted alternative to tools like Termius, without giving up control of your infrastructure. It is actively developed and already covers the core tasks most server admins deal with every day.

&lt;Button text=&quot;View Termix on GitHub&quot; link=&quot;https://github.com/Termix-SSH/Termix&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;</content:encoded><category>linux</category><category>self-hosted</category><category>ssh</category><category>docker</category></item><item><title>Turbocharge Your Mac Terminal: The Ultimate Starship and Ghostty Setup Guide</title><link>https://www.bitdoze.com/starship-ghostty-terminal/</link><guid isPermaLink="true">https://www.bitdoze.com/starship-ghostty-terminal/</guid><description>Transform your Mac terminal with Ghostty + Starship: install, pick a preset, apply a clean prompt config, and add productivity tools like zoxide and eza.</description><pubDate>Thu, 18 Dec 2025 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

This guide walks through setting up **Ghostty** (a GPU-accelerated terminal) and **Starship** (a cross-shell prompt) on your Mac. I&apos;ll start with a Ghostty config, apply a Starship preset (Tokyo Night or Catppuccin Powerline), then add practical tools like **zoxide** and **eza** (the modern replacement for `ls`). I&apos;ll also show you how to edit your `~/.zshrc` without accidentally duplicating init lines.

If you want a Ghostty-first overview covering features, the inspector, and multiplexer vs tmux, check out **[Ghostty Terminal: A Complete Setup Guide for Modern Mac Development](/ghostty-terminal/)**.

If you are going beyond prompt tweaks and want a terminal layout built around AI coding agents, browser automation, and notifications, I also wrote a dedicated guide to **[cmux Terminal](/cmux-terminal/)**.

## TL;DR: Quick setup (copy/paste)

Run these, then follow the sections below for the &quot;why&quot; and optional upgrades:

```bash
brew install --cask ghostty
brew install starship zoxide eza
brew install font-meslo-lg-nerd-font
```

Add Starship and zoxide *once* to your `~/.zshrc`:

```bash
open -e ~/.zshrc
```

Add:

```bash
eval &quot;$(starship init zsh)&quot;
eval &quot;$(zoxide init zsh)&quot;
```

Pick a Starship preset:

```bash
mkdir -p ~/.config
starship preset tokyo-night -o ~/.config/starship.toml
# or:
# starship preset catppuccin-powerline -o ~/.config/starship.toml
```

Reload:

```bash
source ~/.zshrc
```

Now continue for the recommended Ghostty config, safer `.zshrc` editing notes, and Starship tweaks.

## Why Ghostty and Starship?

**Ghostty** is a terminal emulator written in Zig. It uses GPU acceleration, works natively on macOS, and has a straightforward config system. You can learn more at [ghostty.org](https://ghostty.org/).

**Starship** works with Zsh (the default macOS shell since Catalina) and other shells. It provides a prompt that shows Git status, directory paths, programming language versions, and other context. You can explore presets and configuration at [starship.rs](https://starship.rs/).

If you later decide you want Ghostty-style rendering plus vertical workspaces, agent notifications, and a built-in browser, have a look at [cmux](/cmux-terminal/).

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/W77CPoZxLWI&quot;
  label=&quot;Dominate Mac Terminal: Starship and Ghostty Setup Guide&quot;
/&gt;


## Step 1: Install Ghostty on Your Mac

Install Ghostty using Homebrew:

```bash
brew install --cask ghostty
```

This installs Ghostty into your Applications folder. If you don&apos;t have Homebrew, set it up first:

```bash
/bin/bash -c &quot;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)&quot;
```

Launch Ghostty from Applications or type `ghostty` in your current terminal. GPU acceleration makes it noticeably faster than alternatives. For other installation methods, visit [ghostty.org/download](https://ghostty.org/download).

## Step 2: Install a Nerd Font for Enhanced Visuals

Starship needs a font with extensive glyph support. **Meslo Nerd Font** works well and has clear icons.

Install it via Homebrew:

```bash
brew install font-meslo-lg-nerd-font
```

Check the font&apos;s installation in **Font Book** by searching for &quot;Meslo.&quot; For other options, see [nerdfonts.com](https://www.nerdfonts.com/).

## Step 3: Configure Ghostty for Optimal Appearance

Create the config directory first (Ghostty won&apos;t read a file that doesn&apos;t exist):

```bash
mkdir -p ~/.config/ghostty
```

Create or edit the config file:

```bash
touch ~/.config/ghostty/config
open -e ~/.config/ghostty/config
```

Add this configuration:

```
font-family = MesloLGS Nerd Font Mono
font-size = 16
background-opacity = 0.85
theme = Argonaut
```

Quick checks:

- List fonts: `ghostty +list-fonts`
- List themes: `ghostty +list-themes`

This configuration sets `MesloLGS Nerd Font Mono` as the font for clear icon rendering, uses 16pt size for readability, applies 85% background opacity, and selects the Argonaut theme. List available themes with `ghostty +list-themes` or fonts with `ghostty +list-fonts`.

Save the file and restart Ghostty to apply changes.

## Step 4: Install Starship for a Dynamic Prompt

Starship adds contextual information to your prompt. Install it with Homebrew:

```bash
brew install starship
```

Integrate Starship with Zsh by adding it to your `~/.zshrc`.

Instead of blindly appending with `echo ... &gt;&gt;` (which can create duplicates), open your config and add the init line once:

```bash
open -e ~/.zshrc
```

Add:

```bash
eval &quot;$(starship init zsh)&quot;
```

Then reload:

```bash
source ~/.zshrc
```

Verify:

```bash
starship --version
```

For other shells, see [Starship&apos;s installation guide](https://starship.rs/guide/#installation).

## Step 5: Apply Starship Presets for Instant Style

Starship presets provide ready-to-use configurations. Two popular options are **Tokyo Night** and **Catppuccin Powerline**. Browse all presets at [starship.rs/presets](https://starship.rs/presets/).

### Option 1: Tokyo Night Preset

The Tokyo Night preset is a dark theme with purples and blues.

Apply it:

```bash
mkdir -p ~/.config
starship preset tokyo-night -o ~/.config/starship.toml
```

This creates `starship.toml` in `~/.config/`. Inspect it:

```bash
open -e ~/.config/starship.toml
```

The preset includes modules for Git status, current directory, and programming language versions. Restart your terminal or run `source ~/.zshrc` to see the result.

### Option 2: Catppuccin Powerline Preset

The Catppuccin Powerline preset has a softer, pastel color scheme with a powerline-style prompt.

Apply it:

```bash
mkdir -p ~/.config
starship preset catppuccin-powerline -o ~/.config/starship.toml
```

This overwrites the existing `starship.toml`. Reload your shell with `source ~/.zshrc` to apply it.

## Step 6: Tweak Your Starship Config (recommended, minimal changes)

Presets are a good start, but a couple of changes usually make the prompt more practical: shorter paths, optional timestamps, and command duration for slow commands.

Open the config file:

```bash
open -e ~/.config/starship.toml
```

Paste these additions at the end. They work with both Tokyo Night and Catppuccin.

### Recommended baseline tweaks

```toml
# Keep the prompt responsive if anything external is slow
command_timeout = 1000

[directory]
truncation_length = 3
truncate_to_repo = true

[cmd_duration]
min_time = 500
format = &quot;took [$duration]($style) &quot;
style = &quot;yellow bold&quot;
```

Reload:

```bash
source ~/.zshrc
```

### Optional: show time (useful in SSH sessions)

```toml
[time]
disabled = false
format = &quot;[$time]($style) &quot;
time_format = &quot;%R&quot;
```

### Optional: show battery (laptops)

```toml
[battery]
disabled = false
```

### Optional: Git status styling

If you want clearer Git signals:

```toml
[git_status]
style = &quot;bold purple&quot;
```

For a comprehensive list of modules and options, see [Starship&apos;s configuration documentation](https://starship.rs/config/).

If you prefer a more information-dense prompt (username/host, Node, etc.), add modules one at a time to keep the prompt fast and readable.

For example, enable Node.js when doing Node work:

```toml
[nodejs]
disabled = false
```

If your prompt feels slow, disable modules you don&apos;t need and keep `command_timeout` set.

## Step 7: Install zoxide for Smarter Navigation

**zoxide** learns your most frequently used directories and lets you jump to them with `z` instead of `cd`.

Install it:

```bash
brew install zoxide
```

Add it to your `~/.zshrc`:

```bash
echo &apos;eval &quot;$(zoxide init zsh)&quot;&apos; &gt;&gt; ~/.zshrc
source ~/.zshrc
```

Use `z` instead of `cd`. For example, `z proj` jumps to your `~/Projects` directory if you use it often. Zoxide prioritizes your most-visited directories over time. Learn more at [zoxide&apos;s documentation](https://github.com/ajeetdsouza/zoxide).

## Step 8: Install eza for Prettier Directory Listings

Note: **eza** is the modern replacement for `ls` (what people mean when they reference &quot;exa&quot; in older tutorials).

Replace the default `ls` command with **eza**, which provides colorized, icon-rich directory listings.

Install it:

```bash
brew install eza
```

Add aliases to your `~/.zshrc` (add these once, don&apos;t repeatedly append them):

```bash
open -e ~/.zshrc
```

Add:

```bash
alias ls=&quot;eza --icons&quot;
alias ll=&quot;eza -l --icons&quot;
alias la=&quot;eza -la --icons&quot;
```

Reload:

```bash
source ~/.zshrc
```

These aliases:
- `ls`: Lists files with icons for file types
- `ll`: Shows a detailed list with icons
- `la`: Includes hidden files in the detailed list

Explore eza&apos;s features at [eza&apos;s website](https://eza.rocks/).

## Step 9: Create Custom Commands with Aliases

Streamline your workflow with custom aliases in your `~/.zshrc`.

Add these to `~/.zshrc`:

```bash
# Quick edit configuration files
alias editstarship=&quot;vim ~/.config/starship.toml&quot;
alias editghost=&quot;vim ~/.config/ghostty/config&quot;

# Git shortcuts
alias gs=&quot;git status&quot;
alias ga=&quot;git add&quot;
alias gc=&quot;git commit -m&quot;
alias gp=&quot;git push&quot;

# Quick directory navigation
alias dev=&quot;cd ~/Development&quot;
alias docs=&quot;cd ~/Documents&quot;
```

Apply:

```bash
source ~/.zshrc
```

These aliases:
- Let you quickly edit Starship and Ghostty configs (`editstarship`, `editghost`)
- Simplify Git commands (`gs`, `ga`, `gc`, `gp`)
- Provide fast navigation to common directories (`dev`, `docs`)

## Step 10: Bonus Zsh Plugins for Enhanced Productivity

Add **zsh-autosuggestions** and **zsh-syntax-highlighting** for better command input and error detection.

Install:

```bash
brew install zsh-autosuggestions zsh-syntax-highlighting
```

Add to your `~/.zshrc` (place **syntax-highlighting near the end** of the file):

```bash
open -e ~/.zshrc
```

Add:

```bash
source $(brew --prefix)/share/zsh-autosuggestions/zsh-autosuggestions.zsh
source $(brew --prefix)/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
```

Reload:

```bash
source ~/.zshrc
```

- **zsh-autosuggestions**: Suggests commands based on your history (gray text). Accept with the right arrow key.
- **zsh-syntax-highlighting**: Highlights valid commands in green and invalid ones in red, catching errors before execution.

For detailed setup guides, see [Enable Command Autocomplete in Zsh](https://www.bitdoze.com/enable-command-autocomplete-in-zsh/) and [Enable Syntax Highlighting in Zsh](https://www.bitdoze.com/enable-syntax-highlighting-zsh/).

## Troubleshooting: Ghostty Gotchas

**Error: `&apos;xterm-ghostty&apos;: unknown terminal type`**

Some tools don&apos;t recognize Ghostty&apos;s terminal type. Fix it for the current session:

```bash
export TERM=xterm-256color
```

For a permanent solution, set this only for SSH so you don&apos;t change behavior locally.

Add to your `~/.zshrc`:

```bash
open -e ~/.zshrc
```

Add:

```bash
# Some servers don&apos;t know about Ghostty&apos;s TERM entry
if [[ -n &quot;$SSH_CONNECTION&quot; ]]; then
  export TERM=xterm-256color
fi
```

Reload:

```bash
source ~/.zshrc
```

## FAQ

&lt;Accordion label=&quot;Starship vs Powerlevel10k: which should you use?&quot; group=&quot;faq&quot;&gt;
Starship works across multiple shells and machines with minimal setup. Powerlevel10k is great if you&apos;re using only Zsh and want deep Zsh-native customization.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do you still need tmux if Ghostty has multiplexing?&quot; group=&quot;faq&quot;&gt;
For local workflows, Ghostty&apos;s built-in multiplexing works well. Tmux is still useful if you need to detach/attach remote sessions, run long jobs on servers, or need a mature ecosystem and portability.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;My prompt icons look broken—what&apos;s wrong?&quot; group=&quot;faq&quot;&gt;
Check the font. Make sure Ghostty is set to a Nerd Font (like MesloLGS Nerd Font Mono) and restart the terminal.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why do some remote servers show &apos;unknown terminal type&apos;?&quot; group=&quot;faq&quot;&gt;
Some environments don&apos;t ship Ghostty&apos;s terminfo entry. Setting `TERM=xterm-256color` for SSH sessions fixes this.
&lt;/Accordion&gt;

## Conclusion

Your Mac terminal now has Ghostty and Starship, a Starship preset you like, minimal tweaks, and practical tools like zoxide, eza, and sensible aliases.

Next steps:
- Explore Ghostty capabilities (themes, inspector, shaders)
- Keep your Starship config lean by adding modules only when you need them
- For a Ghostty overview and feature tour, read: **[Ghostty Terminal: A Complete Setup Guide for Modern Mac Development](/ghostty-terminal/)**

For more ideas, see [toolhunt.net&apos;s Mac apps section](https://toolhunt.net/mac/) or Starship&apos;s [preset gallery](https://starship.rs/presets/).

If you&apos;re interested in using Starship with Fish Shell instead of Zsh, I have a dedicated guide: [How to set up Starship prompt with Fish Shell](/fish-shell-starship-prompt/). Also see [Fish Shell vs Zsh](/fish-shell-vs-zsh/) if you&apos;re considering switching shells.</content:encoded><category>linux</category><category>starship</category><category>ghostty</category></item><item><title>Docker Compose Secrets: What Works, What Doesn&apos;t, and Secure Alternatives</title><link>https://www.bitdoze.com/docker-compose-secrets/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-compose-secrets/</guid><description>Learn what Docker Compose secrets actually do today, when Swarm secrets are required, how to wire secret files safely, and which alternatives to use in production.</description><pubDate>Fri, 12 Dec 2025 00:00:00 GMT</pubDate><content:encoded>Docker Compose makes it easy to define and run multi-container apps. Once you need database passwords, API keys, or private keys, though, things get tricky: **how do you inject secrets without accidentally leaking them into Git, image layers, logs, or environment variables**?

Here&apos;s what matters up front:

- **&quot;Docker secrets&quot; with encryption are a Swarm feature.** Plain Docker Compose doesn&apos;t encrypt secrets at rest—it just mounts files. Real encryption and service-scoped access only happen in **Docker Swarm**.
- **Docker Compose can still mount secret data as files**, but the security comes down to your host filesystem, backups, and how you manage them.

I&apos;ll cover two approaches:
1) Real Docker secrets in Swarm and how to reference them from Compose, and  
2) Compose secret files for local and self-hosted setups, plus production alternatives.

## Understanding &quot;Secrets&quot; in Docker: Compose vs Swarm

### What are Docker Secrets (Swarm secrets)?

**Docker secrets** are a Swarm feature for managing sensitive data securely: passwords, API keys, TLS keys, and so on.

When you run **Swarm services**, secrets are:
- **Encrypted at rest** in the Swarm Raft log
- **Encrypted in transit** between Swarm nodes
- **Exposed to containers as files** (typically under `/run/secrets/&lt;name&gt;`)
- **Scoped to services** that explicitly request them

This is what the &quot;real&quot; Docker secrets security model looks like.

### What are &quot;secrets&quot; in Docker Compose (non-Swarm)?

In plain Docker Compose (without Swarm), a `secrets:` entry is a convenient way to **mount a file** into the container (similar to a bind mount, but with standardized path and permissions). It does **not** automatically give you Swarm&apos;s encrypted secret store.

So:
- Use **Swarm secrets** when you need strong security guarantees inside Docker itself.
- Use **Compose secret files** for local dev and small self-hosted setups, and protect them like any sensitive file.

### Benefits (and what they depend on)

**If you use Swarm secrets:**
1. **Strong security properties**: encrypted at rest and in transit.
2. **Service-scoped access**: only services that declare the secret can read it.
3. **Runtime injection**: not baked into image layers.
4. **Rotation workflow**: create a new secret, update service, remove old secret.

**If you use Compose secret files (non-Swarm):**
1. **Keeps secrets out of images** (still injected at runtime as a file).
2. **Cleaner app config**: apps read `/run/secrets/...` rather than hardcoded values.
3. **Version-control friendly**: the Compose file references secret *files*, and you keep those secret files out of Git.

Just remember: **Compose secret files are only as secure as your host and your operational practices**.


## Option A (Production-grade): Docker Swarm Secrets

Docker secrets require Swarm. If you&apos;re not running Swarm, skip to **Option B**.

### 1) Initialize Swarm (one-time)

```sh
docker swarm init
```

### 2) Create a secret

Avoid `echo &quot;secret&quot; &gt; file` when possible (shell history and tooling can leak). Prefer reading from stdin:

```sh
printf &apos;%s&apos; &apos;mysupersecretpassword&apos; | docker secret create my_db_password -
```

Or from a file:

```sh
docker secret create my_db_password db_password.txt
chmod 400 db_password.txt
```

List secrets:

```sh
docker secret ls
```

**Common commands**

| Command                       | Description                        |
|------------------------------|------------------------------------|
| `docker secret create`       | Creates a new secret               |
| `docker secret ls`           | Lists all secrets                  |
| `docker secret inspect`      | Shows secret metadata (not value)  |
| `docker secret rm &lt;secret&gt;`  | Removes a secret                   |

Docker won&apos;t let you read the secret value back via CLI. That&apos;s by design.

## Using Swarm secrets from a Compose file

If you deploy your stack to Swarm (for example via `docker stack deploy`), you can reference an **external** Swarm secret:

```yaml
version: &quot;3.8&quot;

services:
  myapp:
    image: myapp:latest
    secrets:
      - my_db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/my_db_password

secrets:
  my_db_password:
    external: true
```

### Accessing secrets in containers

Secrets are mounted as files under `/run/secrets/&lt;secret_name&gt;`:

```python
with open(&apos;/run/secrets/my_db_password&apos;, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
    db_password = f.read().strip()
```

This &quot;read from file&quot; pattern is recommended across languages because it avoids leaking secrets through process listings and environment dumps.

## Option B (Compose-friendly): Secret files mounted into containers

If you&apos;re running plain Docker Compose (no Swarm), you can still use the `secrets:` key to mount sensitive files into containers.

This works well for:
- local development
- single-host self-hosting
- hobby or prototype deployments

### Step 1: Create a local secrets directory (and ignore it in Git)

Create files like:

- `./secrets/db_password.txt`
- `./secrets/api_key.txt`

Make sure they&apos;re not committed to version control (add `secrets/` to `.gitignore`).

### Step 2: Define secrets in `docker-compose.yml`

```yaml
services:
  database:
    image: mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt
```

### Step 3: Access secrets in containers

```sh
cat /run/secrets/db_password
```

Remember: this approach is backed by your host filesystem. Protect the `./secrets/` directory with strict permissions and secure backups.

For a full reverse-proxy example, see: https://www.bitdoze.com/traefik-wildcard-certificate/

### External vs file-based secrets (what &quot;external&quot; really means)

You&apos;ll see two patterns:

- **External secrets (`external: true`)**: this means &quot;the secret already exists in the platform&quot;.
  - This is most relevant for **Swarm secrets**.
- **File-based secrets (`file: ...`)**: this means &quot;mount this local file into the container as `/run/secrets/&lt;name&gt;`&quot;.
  - This is what most people use in plain Docker Compose.

#### External Secrets

External secrets are created and managed outside of the Docker Compose file, typically using the Docker CLI or a secret management system.

Key characteristics:
1. Created independently of the Docker Compose file
2. Referenced in the Compose file using `external: true`
3. Provide separation of concerns and better security
4. Can be shared across multiple services and stacks


#### Important note: Compose &quot;secrets from environment variables&quot;

In Docker Compose, secrets come from **files**. Some older guides suggest you can define secret values directly inside the Compose file or from environment variables—don&apos;t do that.

If you need to use environment variables, prefer:
- `.env` for non-sensitive configuration
- a proper secret manager for sensitive values (Vault or a cloud provider)
- or generate the secret file during deployment and mount it (never commit it)

For more on env usage, see: https://www.bitdoze.com/docker-env-vars/



#### Key Differences

1. **Management:** External secrets are managed outside the Compose file, internal secrets are defined within it.
2. **Security:** External secrets offer better security since they&apos;re not visible in the Compose file.
3. **Reusability:** External secrets can be shared across multiple services and stacks.
4. **Deployment:** Internal secrets are easier to deploy in dev but may require extra steps in production.
5. **Versioning:** External secrets can be versioned independently of your application.

#### When to Use Each Type

- Use external secrets for:
  - Production environments
  - Sensitive data that needs to be shared across multiple services
  - When you need to manage secrets independently of your application code

- Use internal secrets for:
  - Development and testing
  - Quick prototyping
  - When the secret is specific to a single service


## Best Practices (modern and practical)

When working with secrets, the core goal stays the same: **avoid shipping secrets in images, Git, or logs**, and **minimize the blast radius** if something leaks.

### Naming conventions

- Choose descriptive names: `prod_db_password` instead of `secret1`.
- Use consistent prefixes: `prod_` for production secrets, `dev_` for development.
- Avoid sensitive information in names themselves.

Example:
```yaml
secrets:
  prod_api_key:
    external: true
  dev_db_password:
    external: true
```

### Version control considerations

- Never commit secret values to Git.
- Add your secrets directory to `.gitignore` (for example `secrets/`).
- Consider keeping a `secrets.example/` directory with placeholder files, or document required filenames in a README.
- Treat `.env` files as secrets if they contain passwords or tokens, and don&apos;t commit them.

Example `.gitignore` entry:
```
*.env
secrets/
```

### Rotating secrets

- Rotate secrets regularly to limit exposure.
- In Swarm, rotation typically looks like:
  1. Create a new secret (with a versioned name).
  2. Update the service to add the new secret and switch the app to it.
  3. Remove the old secret after rollout.

Also rotate credentials at the source (database user password, cloud key, etc.), not only inside Docker.

Example:
```bash
docker secret create db_password_v2 new_password.txt
docker service update --secret-rm db_password_v1 --secret-add db_password_v2 myservice
docker secret rm db_password_v1
```

### Additional best practices

- **Least privilege**: only mount the secrets a service needs.
- **Prefer file-based consumption**: use `*_FILE` patterns instead of putting secrets in environment variables.
- **Don&apos;t print secrets**: avoid logging config objects that include secret paths or values.
- **Lock down your host**: if using Compose secret files, secure filesystem permissions and backups.
- **Use a secret manager**: for production, consider Vault or a cloud secret manager.

## Example Use Cases

Here are common scenarios where Docker secrets boost security:

### Database credentials

Securely manage database passwords without hardcoding them in your application or Docker files.

```yaml
version: &apos;3.8&apos;
services:
  db:
    image: postgres
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

  app:
    image: myapp
    secrets:
      - db_password
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

secrets:
  db_password:
    external: true
```

### API keys

Safely use API keys in your services without exposing them in your code or configuration files.

```yaml
version: &apos;3.8&apos;
services:
  api_service:
    image: api_service
    secrets:
      - api_key
    environment:
      API_KEY_FILE: /run/secrets/api_key

secrets:
  api_key:
    external: true
```

### SSL certificates

Manage SSL certificates securely for services that require HTTPS.

```yaml
version: &apos;3.8&apos;
services:
  web:
    image: nginx
    secrets:
      - site_certificate
      - site_key
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

secrets:
  site_certificate:
    file: ./certs/site.crt
  site_key:
    file: ./certs/site.key
```

### JWT signing keys

Securely manage keys used for signing JSON Web Tokens (JWTs) in authentication services.

```yaml
version: &apos;3.8&apos;
services:
  auth_service:
    image: auth_service
    secrets:
      - jwt_private_key
      - jwt_public_key
    environment:
      JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key
      JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key

secrets:
  jwt_private_key:
    external: true
  jwt_public_key:
    external: true
```


## Limitations and Alternatives (updated)

### Swarm mode requirement (for real Docker secrets)

True Docker secrets require **Swarm**. If you are using plain Docker Compose, you don&apos;t get Swarm&apos;s encrypted secret store—only file mounts.

This means:
- If you want Docker-managed secret encryption and scoping, you need Swarm (or another orchestrator).
- For Kubernetes, use Kubernetes Secrets (ideally with encryption-at-rest and an external secret manager integration).

### Other options for managing sensitive data

Given these limitations, here are better alternatives depending on your environment:

1. **Secret files mounted via Compose**
   - Pros: simple, keeps secrets out of image layers, works everywhere
   - Cons: security depends on host filesystem, backups, and ops discipline

2. **Environment variables (use with caution)**
   - Pros: simple and widely supported
   - Cons: easy to leak via process inspection, crash dumps, logs, or &quot;show config&quot; tooling

   Example (avoid for highly sensitive secrets if you can):
   ```yaml
   services:
     app:
       image: myapp
       environment:
         DB_PASSWORD: mysecretpassword
   ```

3. **Cloud secret managers**
   - AWS Secrets Manager, SSM Parameter Store, Google Secret Manager, Azure Key Vault
   - Pros: strong security posture, IAM-based access, rotation
   - Cons: platform coupling and potential cost

4. **HashiCorp Vault**
   - Pros: strong, platform-agnostic, dynamic secrets, auditability
   - Cons: more operational complexity

   Example integration:
   ```yaml
   services:
     app:
       image: myapp
       environment:
         - VAULT_ADDR=http://vault:8200
       entrypoint: [&quot;vault-agent&quot;, &quot;-config=/vault-agent-config.hcl&quot;]
   ```

5. **Kubernetes Secrets (plus external secret operators)**
   - Pros: first-class in k8s ecosystems; integrates well with secret stores
   - Cons: requires k8s; base k8s secrets are not encrypted unless configured

6. **Docker Config**
   - Similar to Docker secrets but for non-sensitive configuration data
   - Can be used alongside secrets for comprehensive configuration management

When choosing an alternative, consider your specific security requirements, existing infrastructure, team expertise, and scalability needs.

## Conclusion

Docker Compose can help you **wire secrets into containers as files**, but **the strong &quot;Docker secrets&quot; security properties come from Swarm**.

Use this decision rule:
- If you want Docker-managed encryption and service scoping: use **Swarm secrets**.
- If you&apos;re on plain Compose: use **secret files**, lock them down, and consider a **real secret manager** for production deployments.

Either way, prefer applications reading secrets from `/run/secrets/...` and keep secret values out of images, Git, and logs.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>Deleting Lines with Sed: Advanced Techniques</title><link>https://www.bitdoze.com/sed-delete-lines/</link><guid isPermaLink="true">https://www.bitdoze.com/sed-delete-lines/</guid><description>Master sed command for deleting lines - remove specific lines, patterns, and ranges with practical examples and best practices, including GNU vs BSD sed notes.</description><pubDate>Wed, 10 Dec 2025 00:00:00 GMT</pubDate><content:encoded>Need to remove specific lines from text files quickly? `sed` makes line deletion simple. Whether you&apos;re cleaning log files, removing comments, or filtering data, sed deletion techniques will help you process text efficiently.

&lt;Notice type=&quot;info&quot; title=&quot;GNU sed vs BSD sed (macOS)&quot;&gt;
Most Linux distributions ship with **GNU sed**, while macOS uses **BSD sed**. The biggest practical difference in this article is in-place editing:
- GNU sed: `sed -i.bak &apos;...&apos; file`
- BSD sed (macOS): `sed -i &apos;.bak&apos; &apos;...&apos; file` (backup extension is required)
If you want a portable approach, write to a temp file and move it into place (examples below).
&lt;/Notice&gt;

## What is sed and Why Use It for Line Deletion?

**sed** (Stream Editor) is a command-line utility for filtering and transforming text in Unix-like systems. It works well for line deletion because it processes text efficiently without loading entire files into memory.

### Key Advantages for Line Deletion

**Non-interactive processing** - Make changes without opening text editors
- Works well for automation and shell scripts
- Handles large files and batch operations
- Integrates with other Unix tools via pipes

**Flexible targeting** - Delete lines with precision
- Line numbers: `sed &apos;5d&apos;` deletes line 5
- Line ranges: `sed &apos;10,20d&apos;` deletes lines 10-20
- Pattern matching: `sed &apos;/error/d&apos;` deletes lines containing &quot;error&quot;
- Regular expressions: Advanced pattern matching for complex criteria

**Performance**:
- Speed: Processes files rapidly, even large datasets
- Memory efficiency: Streams data line by line
- Scriptable: Automates repetitive deletion tasks

### Why Choose sed for Line Deletion?

- Target exact lines, patterns, or ranges
- Handle massive files without performance issues
- Combine with other commands for complex workflows
- Available on virtually all Unix systems

Master sed&apos;s complete toolkit for text manipulation:
- [Delete lines](https://www.bitdoze.com/sed-delete-lines/) using `d` command (this guide)
- [Insert or append text](https://www.bitdoze.com/sed-insert-append-text/) with `i` and `a` commands
- [Transform text case](https://www.bitdoze.com/sed-change-case/) for standardization
- [Search and replace text](https://www.bitdoze.com/sed-search-replace/) with pattern matching

## Basic sed Syntax for Line Deletion

Understanding sed&apos;s syntax matters for effective line deletion. The basic structure combines addresses (which lines to target) with the delete command (`d`).

### Core Components

**Basic syntax:**
```shell
sed &apos;ADDRESS d&apos; filename
```

- **ADDRESS**: Specifies which lines to delete
- **d**: The delete command
- **filename**: Target file (or stdin if omitted)

### Addressing Methods

**1. Line numbers:**
```shell
sed &apos;5d&apos; file.txt          # Delete line 5
sed &apos;1d&apos; file.txt          # Delete first line
sed &apos;$d&apos; file.txt          # Delete last line
```

**2. Line ranges:**
```shell
sed &apos;2,5d&apos; file.txt        # Delete lines 2-5
sed &apos;10,$d&apos; file.txt       # Delete from line 10 to end
sed &apos;1,3d&apos; file.txt        # Delete first 3 lines
```

**3. Pattern matching:**
```shell
sed &apos;/error/d&apos; file.txt    # Delete lines containing &quot;error&quot;
sed &apos;/^#/d&apos; file.txt       # Delete lines starting with #
sed &apos;/^$/d&apos; file.txt       # Delete empty lines
```

**4. Regular expressions:**
```shell
sed &apos;/^[0-9]/d&apos; file.txt   # Delete lines starting with digits
sed &apos;/\.log$/d&apos; file.txt   # Delete lines ending with .log
```

### Key Options

| Option | Function | Example |
|--------|----------|---------|
| `-i` | Edit files in-place | GNU: `sed -i &apos;1d&apos; file.txt` / BSD: `sed -i &apos;&apos; &apos;1d&apos; file.txt` |
| `-i.bak` | Edit in-place with backup | GNU: `sed -i.bak &apos;1d&apos; file.txt` / BSD: `sed -i &apos;.bak&apos; &apos;1d&apos; file.txt` |
| `-n` | Suppress default output | Used with `p` command |

### Execution Flow

sed operates in a simple cycle:
1. **Read** a line into pattern space
2. **Apply** commands (like delete)
3. **Output** remaining lines (unless deleted)
4. **Repeat** for next line

**Important**: sed processes each line independently, making it good for stream processing and large files.

## Deleting Specific Lines

sed makes targeting and deleting specific lines straightforward with its flexible addressing system.

### Delete by Line Number

**Single line deletion:**
```shell
sed &apos;2d&apos; filename          # Delete line 2
sed &apos;1d&apos; filename          # Delete first line
sed &apos;$d&apos; filename          # Delete last line
```

**Multiple specific lines:**
```shell
sed &apos;1d;3d;5d&apos; filename    # Delete lines 1, 3, and 5
sed -e &apos;2d&apos; -e &apos;5d&apos; filename # Alternative syntax
```

### Delete Line Ranges

**Continuous ranges:**
```shell
sed &apos;10,20d&apos; filename      # Delete lines 10-20
sed &apos;1,5d&apos; filename        # Delete first 5 lines
sed &apos;10,$d&apos; filename       # Delete from line 10 to end
```

**Practical examples:**
```shell
sed &apos;1d&apos; config.txt        # Remove header line
sed &apos;$d&apos; data.txt          # Remove footer/last line
sed &apos;2,4d&apos; log.txt         # Remove lines 2-4
```

### Delete by Pattern Matching

**Simple patterns:**
```shell
sed &apos;/error/d&apos; logfile.txt    # Delete lines containing &quot;error&quot;
sed &apos;/^#/d&apos; config.txt        # Delete comment lines
sed &apos;/^$/d&apos; file.txt          # Delete empty lines
```

**Case sensitivity:**
```shell
sed &apos;/Error/d&apos; file.txt       # Case-sensitive (matches &quot;Error&quot;)
sed &apos;/[Ee]rror/d&apos; file.txt    # Matches &quot;Error&quot; or &quot;error&quot;
```

### Advanced Pattern Examples

**Lines starting with specific characters:**
```shell
sed &apos;/^[0-9]/d&apos; file.txt      # Delete lines starting with digits
sed &apos;/^[A-Z]/d&apos; file.txt      # Delete lines starting with uppercase
```

**Lines ending with patterns:**
```shell
sed &apos;/\.log$/d&apos; file.txt      # Delete lines ending with &quot;.log&quot;
sed &apos;/;$/d&apos; code.txt          # Delete lines ending with semicolon
```

**Complex patterns:**
```shell
sed &apos;/^[[:space:]]*$/d&apos; file.txt  # Delete blank lines (including whitespace)
sed &apos;/^[[:space:]]*#/d&apos; file.txt  # Delete comment lines with leading spaces
```

### Safety and Testing

**Preview changes first:**
```shell
sed &apos;2d&apos; file.txt             # Shows output without modifying file
sed &apos;2d&apos; file.txt &gt; new.txt   # Save to new file
```

**In-place editing with backup:**
```shell
# GNU sed (Linux)
sed -i.bak &apos;2d&apos; file.txt      # Creates file.txt.bak

# BSD sed (macOS)
sed -i &apos;.bak&apos; &apos;2d&apos; file.txt   # Creates file.txt.bak
```

**Test with line numbers:**
```shell
nl file.txt | sed &apos;2d&apos;        # Show line numbers to verify targeting
```

### Practical Use Cases

- Log cleaning: `sed &apos;/DEBUG/d&apos; app.log`
- Config files: `sed &apos;/^#/d&apos; nginx.conf`
- Data processing: `sed &apos;1d&apos; data.csv` (remove CSV header)
- Code cleanup: `sed &apos;/^\/\//d&apos; script.js` (remove JS comments)

**Pro tip**: Always test your sed commands on sample data before applying to important files.

## Pattern-Based Line Deletion with Regular Expressions

sed&apos;s pattern matching with regular expressions provides powerful tools for deleting lines based on content rather than position.

### Basic Pattern Matching

**Simple string patterns:**
```sh
sed &apos;/error/d&apos; filename       # Delete lines containing &quot;error&quot;
sed &apos;/warning/d&apos; logfile.txt  # Delete lines with &quot;warning&quot;
sed &apos;/DEBUG/d&apos; app.log        # Delete debug messages
```

**Word boundaries for exact matches:**
```sh
# Note: \b word boundaries are not portable across sed implementations.
# Portable alternatives:

# 1) Use extended regex with explicit &quot;word boundaries&quot; built from non-word characters.
# Keep in mind this defines &quot;word&quot; as [A-Za-z0-9_].
sed -E &apos;/(^|[^[:alnum:]_])error([^[:alnum:]_]|$)/d&apos; filename
sed -E &apos;/(^|[^[:alnum:]_])test([^[:alnum:]_]|$)/d&apos; file.txt

# 2) If you only care about whitespace boundaries:
sed -E &apos;/(^|[[:space:]])error([[:space:]]|$)/d&apos; filename
```

### Regular Expression Patterns

**Character classes:**
```sh
sed &apos;/[0-9]/d&apos; filename       # Delete lines containing any digit
sed &apos;/[A-Z]/d&apos; filename       # Delete lines with uppercase letters
sed &apos;/[aeiou]/d&apos; filename     # Delete lines containing vowels
```

**Anchors (position matching):**
```sh
sed &apos;/^error/d&apos; filename      # Delete lines starting with &quot;error&quot;
sed &apos;/error$/d&apos; filename      # Delete lines ending with &quot;error&quot;
sed &apos;/^[0-9]/d&apos; filename      # Delete lines starting with digits
```

**Quantifiers:**
```sh
sed &apos;/[0-9]\{3\}/d&apos; filename     # Delete lines with 3+ consecutive digits
sed &apos;/^.\{80,\}/d&apos; filename      # Delete lines longer than 80 chars
sed &apos;/error.*critical/d&apos; file    # Delete lines with &quot;error&quot; followed by &quot;critical&quot;
```

### Advanced Pattern Examples

**Empty and whitespace lines:**
```sh
sed &apos;/^$/d&apos; filename              # Delete empty lines
sed &apos;/^[[:space:]]*$/d&apos; filename  # Delete blank lines (including whitespace)
# Note: \s is not portable in sed regex. Prefer POSIX character classes:
sed &apos;/^[[:space:]]*$/d&apos; filename   # Whitespace-only lines (portable)
```

**Comment patterns:**
```sh
sed &apos;/^#/d&apos; config.txt            # Delete lines starting with #
sed &apos;/^[[:space:]]*#/d&apos; file.txt  # Delete comments with leading whitespace
sed &apos;/^\/\//d&apos; script.js          # Delete JavaScript comments
sed &apos;/^\/\*/d&apos; style.css          # Delete CSS comment starts
```

**Complex patterns:**
```sh
sed &apos;/^[0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}/d&apos; file.txt  # Delete date lines (YYYY-MM-DD)
sed &apos;/^[a-zA-Z0-9._%+-]\+@[a-zA-Z0-9.-]\+\.[a-zA-Z]\{2,\}/d&apos; file.txt  # Delete email lines
```

### Negation and Inverse Matching

**Keep only matching lines (delete non-matches):**
```sh
sed &apos;/success/!d&apos; filename    # Keep only lines with &quot;success&quot;
sed &apos;/^#/!d&apos; config.txt       # Keep only comment lines
sed &apos;/error/!d&apos; log.txt       # Keep only error lines
```

### Extended Regular Expressions

**Using sed -E for enhanced patterns:**
```sh
sed -E &apos;/^[0-9]{3}-[0-9]{3}-[0-9]{4}$/d&apos; file.txt    # Delete phone numbers
sed -E &apos;/^(http|https):/d&apos; urls.txt                   # Delete HTTP URLs
sed -E &apos;/^[A-Z]{2,}/d&apos; file.txt                       # Delete lines starting with 2+ caps
```

### Practical Examples

**Log file cleanup:**
```sh
sed &apos;/INFO/d&apos; app.log         # Remove info messages
sed &apos;/^\[.*DEBUG.*\]/d&apos; log   # Remove debug entries
sed &apos;/^$/d&apos; access.log        # Remove empty lines
```

**Configuration files:**
```sh
sed &apos;/^#/d&apos; nginx.conf        # Remove comments
sed &apos;/^[[:space:]]*$/d&apos; config.ini  # Remove blank lines
```

**Data processing:**
```sh
sed &apos;/^test/d&apos; data.txt       # Remove test entries
sed &apos;/,$/d&apos; csv.txt           # Remove lines ending with comma
```

### Safety and Testing

**Preview patterns before deletion:**
```sh
grep &apos;pattern&apos; filename       # See what will be deleted
sed -n &apos;/pattern/p&apos; filename  # Print matching lines
```

**Test with line numbers:**
```sh
nl filename | sed &apos;/pattern/d&apos;  # Show line numbers for context
```

**Common mistakes to avoid:**
- Forgetting to escape special characters: `/./d` vs `/\./d`
- Case sensitivity: Use `[Ee]rror` for both cases
- Overly broad patterns: Use word boundaries `\b` when needed

**Pro tip**: Regular expressions are powerful but can be tricky. Test your patterns thoroughly before applying to important files.

## Advanced Line Deletion Techniques

For complex text processing tasks, sed offers features that go beyond basic pattern matching and line ranges.

### Range-Based Pattern Deletion

**Delete between pattern markers:**
```sh
sed &apos;/START/,/END/d&apos; file.txt         # Delete from START to END markers
sed &apos;/BEGIN/,/FINISH/d&apos; config.txt    # Delete configuration blocks
sed &apos;/&lt;!--/,/--&gt;/d&apos; html.txt          # Delete HTML comments
```

**Delete from pattern to line number:**
```sh
sed &apos;/ERROR/,10d&apos; file.txt            # Delete from first ERROR to line 10
sed &apos;5,/STOP/d&apos; file.txt              # Delete from line 5 to first STOP
```

**Delete from pattern to end of file:**
```sh
sed &apos;/FOOTER/,$d&apos; file.txt            # Delete from FOOTER to end
sed &apos;/^---/,$d&apos; document.txt          # Delete from separator to end
```

### Multi-Line Pattern Deletion

**Delete paragraph blocks:**
```sh
sed &apos;/^$/,/^$/d&apos; file.txt             # Delete empty line blocks
sed &apos;/^[[:space:]]*$/,/^[[:space:]]*$/d&apos; file.txt  # Include whitespace-only lines
```

**Delete function definitions (example in code):**
```sh
sed &apos;/^function/,/^}/d&apos; script.js     # Delete JavaScript functions
sed &apos;/^def /,/^$/d&apos; script.py         # Delete Python function definitions
```

### Conditional Deletion

**Delete lines matching multiple conditions:**
```sh
sed &apos;/error.*critical/d&apos; log.txt     # Delete lines with both &quot;error&quot; and &quot;critical&quot;
sed &apos;/^[0-9].*error/d&apos; file.txt      # Delete lines starting with digit and containing &quot;error&quot;
```

**Delete except specific patterns:**
```sh
sed &apos;/INFO\|WARN\|ERROR/!d&apos; log.txt  # Keep only log levels, delete everything else
sed &apos;/^[A-Za-z]/!d&apos; file.txt         # Keep only lines starting with letters
```

### Using Address Ranges with Steps

**Delete every nth line:**
```sh
sed &apos;1~2d&apos; file.txt                   # Delete every odd line (1st, 3rd, 5th...)
sed &apos;2~3d&apos; file.txt                   # Delete every 3rd line starting from line 2
sed &apos;0~5d&apos; file.txt                   # Delete every 5th line
```

### Working with Hold and Pattern Space

**Delete duplicate consecutive lines:**
```sh
sed &apos;$!N; /^\(.*\)\n\1$/d&apos; file.txt   # Remove consecutive duplicate lines
```

**Delete lines based on next line content:**
```sh
sed &apos;$!N; /error\n/d&apos; file.txt        # Delete lines followed by lines containing &quot;error&quot;
```

### Complex Multi-Command Operations

**Combine multiple deletion criteria:**
```sh
sed -e &apos;/^#/d&apos; -e &apos;/^$/d&apos; -e &apos;/DEBUG/d&apos; file.txt    # Remove comments, empty lines, and debug
```

**Script-based complex deletion:**
```sh
sed -f delete_script.sed file.txt
```

Where `delete_script.sed` contains:
```
/^#/d
/^$/d
/DEBUG/d
/^[[:space:]]*$/d
```

### Practical Advanced Examples

**Clean log files:**
```sh
# Remove debug, empty lines, and timestamp lines
sed -e &apos;/DEBUG/d&apos; -e &apos;/^$/d&apos; -e &apos;/^\[.*\]$/d&apos; app.log
```

**Process configuration files:**
```sh
# Remove comments and empty lines, keep only active config
sed -e &apos;/^[[:space:]]*#/d&apos; -e &apos;/^[[:space:]]*$/d&apos; nginx.conf
```

**Code cleanup:**
```sh
# Remove empty lines, single-line comments, and console.log statements
sed -e &apos;/^$/d&apos; -e &apos;/^[[:space:]]*\/\//d&apos; -e &apos;/console\.log/d&apos; script.js
```

### Advanced Safety Practices

**Test complex commands step by step:**
```sh
# Step 1: Test first condition
sed &apos;/^#/d&apos; file.txt | head -20

# Step 2: Add second condition
sed -e &apos;/^#/d&apos; -e &apos;/^$/d&apos; file.txt | head -20

# Step 3: Add final conditions
sed -e &apos;/^#/d&apos; -e &apos;/^$/d&apos; -e &apos;/DEBUG/d&apos; file.txt | head -20
```

**Use intermediate files for complex operations:**
```sh
sed &apos;/START/,/END/d&apos; file.txt &gt; temp1.txt
sed &apos;/ERROR/d&apos; temp1.txt &gt; temp2.txt
sed &apos;/^$/d&apos; temp2.txt &gt; final.txt
```

**Backup and restore capabilities:**
```sh
cp original.txt original.txt.backup
sed -i.$(date +%Y%m%d) &apos;complex_deletion_commands&apos; original.txt
```

### Performance Considerations

- **Large files**: Use specific patterns rather than broad matches
- **Multiple files**: Combine operations where possible
- **Memory usage**: Complex hold space operations can consume memory
- **Speed**: Simple line number ranges are faster than complex regex patterns

**Pro tip**: For extremely complex deletion logic, consider combining sed with other tools like awk or writing a custom script for better maintainability.

## Conclusion

sed&apos;s line deletion capabilities give you efficient tools for text processing. You can:

- Delete specific lines by number, range, or pattern
- Use regular expressions for precise pattern matching
- Handle complex scenarios with advanced techniques
- Process files safely with proper testing and backups

### Key Takeaways

1. Start simple with line numbers and basic patterns
2. Test first and preview changes before applying them permanently
3. Use backups with `-i.bak` for in-place editing safety
4. Combine different addressing methods for complex tasks
5. Practice regularly to build proficiency

### Best Practices

- Test commands without `-i` flag first
- Create backups before in-place editing
- Use specific patterns to avoid unintended deletions
- Verify results with sample files
- Document complex commands for future reference

### When to Use Alternatives

While sed works well for line deletion, consider these alternatives:
- **grep -v**: Simple pattern exclusion
- **awk**: Complex field-based processing
- **text editors**: Interactive deletion with visual feedback

### Master sed&apos;s Complete Toolkit

Expand your sed expertise with related techniques:
- [Insert and append text](https://www.bitdoze.com/sed-insert-append-text/) - Add content precisely
- [Transform text case](https://www.bitdoze.com/sed-change-case/) - Standardize capitalization
- [Search and replace](https://www.bitdoze.com/sed-search-replace/) - Pattern-based substitution


### FAQ: Do hold space and multi-line pattern space matter for deletion?

Yes. `sed` has two main buffers:

- **pattern space**: holds the current line (or multiple lines if you use `N`)
- **hold space**: a secondary buffer you can copy to/from (`h`, `H`, `g`, `G`, `x`)

For deletion tasks, multi-line techniques help when you need decisions based on adjacent lines or blocks (for example, delete a line only if the next line matches a pattern).

### Why learn advanced techniques for text processing?

They help you handle real-world inputs: logs, config files, semi-structured text, and messy data. Even if you don&apos;t use advanced `sed` daily, understanding ranges, negation, and multi-line tricks makes it easier to write safe automation.</content:encoded><category>linux</category><category>sed</category></item><item><title>Create a Video Intro Editor with Python on Mac with Silero VAD</title><link>https://www.bitdoze.com/python-video-intro-editor/</link><guid isPermaLink="true">https://www.bitdoze.com/python-video-intro-editor/</guid><description>Learn how to build an AI-powered video intro generator using Python, MoviePy, and Silero VAD that automatically detects key speaking moments and creates 3D-styled intros.</description><pubDate>Tue, 09 Dec 2025 00:00:00 GMT</pubDate><content:encoded>Video intros take time to create manually. This guide shows you how to build a Python script that analyzes your footage, detects when people are speaking using Voice Activity Detection (VAD), and generates a 3D-styled intro montage.

The script pulls speaking moments from your existing footage and turns them into fast-paced clips with perspective effects.

&lt;Notice type=&quot;success&quot; title=&quot;What You&apos;ll Learn&quot;&gt;
&lt;ListCheck&gt;

- Using Silero VAD for speech detection
- Working with MoviePy for video composition and effects
- Creating 3D perspective effects with FFmpeg
- Running Python scripts with uv

&lt;/ListCheck&gt;
&lt;/Notice&gt;

## Prerequisites

---

Before we dive into the script, you&apos;ll need to have the following installed on your Mac:

### FFmpeg

FFmpeg is essential for video processing. Install it using Homebrew:

```bash
brew install ffmpeg
```

### uv Package Manager

We&apos;ll use `uv` to run our script with all dependencies automatically managed. If you don&apos;t have uv installed:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

&lt;Notice type=&quot;info&quot; title=&quot;New to uv?&quot;&gt;

If you&apos;re new to uv, check out our comprehensive guide [Getting Started with uv: Setting Up Your Python Project](https://www.bitdoze.com/uv-get-start/) to understand how it simplifies Python project management.

&lt;/Notice&gt;

## How the Video Intro Editor Works

---

The script works through these steps:

1. Extracts audio from your video using FFmpeg
2. Uses Silero VAD to identify when someone is speaking
3. Selects the best speaking moments for the intro
4. Applies perspective warping and blur effects
5. Combines blurred background with 3D-warped foreground clips
6. Renders the intro with crossfade transitions

## The Complete Script

---

Here&apos;s the full Python script that creates video intros. Save this as `intro_generator.py`:

```python
# /// script
# requires-python = &quot;&gt;=3.10&quot;
# dependencies = [
#     &quot;openai-whisper&quot;,
#     &quot;torch&quot;,
#     &quot;torchaudio&lt;2.6&quot;,
#     &quot;soundfile&quot;,
#     &quot;numpy&lt;2.0.0&quot;,
#     &quot;moviepy==1.0.3&quot;,
#     &quot;packaging&quot;,
#     &quot;Pillow&lt;10.0.0&quot;,
# ]
# ///

import os
import sys
import warnings

# 1. Suppress warnings BEFORE importing moviepy
# This hides the &quot;invalid escape sequence&quot; text
warnings.filterwarnings(&quot;ignore&quot;)

import random
import subprocess
from pathlib import Path

import torch
import whisper
from moviepy.editor import (
    CompositeVideoClip,
    VideoFileClip,
    concatenate_videoclips,
    vfx,
)

# --- Configuration ---
MIN_SILENCE = 0.5
MIN_SPEECH = 0.25
PADDING = 0.1

# Intro Style Settings
INTRO_CLIP_COUNT = 6  # Number of fast cuts
INTRO_SPEED = 3.0  # Speed multiplier (3x fast)
CLIP_DURATION = 1.5  # Duration of each clip in seconds
OUTPUT_FILENAME = &quot;intro_only.mp4&quot;


def check_ffmpeg():
    try:
        subprocess.run([&quot;ffmpeg&quot;, &quot;-version&quot;], capture_output=True, check=True)
    except:
        print(&quot;❌ Error: FFmpeg not found. Run: brew install ffmpeg&quot;)
        sys.exit(1)


def extract_audio(video_path, audio_path):
    subprocess.run(
        [
            &quot;ffmpeg&quot;,
            &quot;-y&quot;,
            &quot;-i&quot;,
            video_path,
            &quot;-vn&quot;,
            &quot;-acodec&quot;,
            &quot;pcm_s16le&quot;,
            &quot;-ar&quot;,
            &quot;16000&quot;,
            &quot;-ac&quot;,
            &quot;1&quot;,
            &quot;-loglevel&quot;,
            &quot;error&quot;,
            audio_path,
        ],
        check=True,
    )


def get_good_segments(audio_path):
    &quot;&quot;&quot;Finds segments where people are actually speaking (Key Moments).&quot;&quot;&quot;
    print(&quot;🧠 Scanning video for key moments...&quot;)

    # Load Silero VAD
    # trust_repo=True fixes the &quot;cache&quot; warning/error
    model, utils = torch.hub.load(
        repo_or_dir=&quot;snakers4/silero-vad&quot;, model=&quot;silero_vad&quot;, trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )

    segments = []
    for v in vad_stamps:
        segments.append((v[&quot;start&quot;] / 16000, v[&quot;end&quot;] / 16000))

    return segments


def apply_blur_background(input_path, output_path):
    subprocess.run(
        [
            &quot;ffmpeg&quot;,
            &quot;-y&quot;,
            &quot;-i&quot;,
            input_path,
            &quot;-vf&quot;,
            &quot;boxblur=40:5,eq=brightness=-0.4&quot;,
            &quot;-c:v&quot;,
            &quot;libx264&quot;,
            &quot;-preset&quot;,
            &quot;ultrafast&quot;,
            &quot;-an&quot;,
            &quot;-loglevel&quot;,
            &quot;error&quot;,
            output_path,
        ],
        check=True,
    )


def apply_3d_warp(input_path, output_path, direction=&quot;left&quot;):
    if direction == &quot;left&quot;:
        vf = &quot;perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination&quot;
    else:
        vf = &quot;perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination&quot;

    vf += &quot;,pad=w=iw+100:h=ih+100:x=50:y=50:color=black@0&quot;

    subprocess.run(
        [
            &quot;ffmpeg&quot;,
            &quot;-y&quot;,
            &quot;-i&quot;,
            input_path,
            &quot;-vf&quot;,
            vf,
            &quot;-c:v&quot;,
            &quot;libx264&quot;,
            &quot;-preset&quot;,
            &quot;ultrafast&quot;,
            &quot;-an&quot;,
            &quot;-loglevel&quot;,
            &quot;error&quot;,
            output_path,
        ],
        check=True,
    )


def generate_intro(video_path, segments):
    print(&quot;✨ Rendering 3D Intro (No Text)...&quot;)

    long_segments = [s for s in segments if (s[1] - s[0]) &gt; 2.0]

    if len(long_segments) &lt; INTRO_CLIP_COUNT:
        print(f&quot;⚠️ Not enough footage found. Need {INTRO_CLIP_COUNT} distinct moments.&quot;)
        if not long_segments:
            return
        picks = random.choices(long_segments, k=INTRO_CLIP_COUNT)
    else:
        picks = sorted(random.sample(long_segments, INTRO_CLIP_COUNT))

    intro_clips = []
    temp_files = []

    for i, (start, end) in enumerate(picks):
        raw_dur = CLIP_DURATION * INTRO_SPEED
        mid = start + (end - start) / 2 - (raw_dur / 2)

        raw_clip = f&quot;temp_raw_{i}.mp4&quot;
        bg_clip = f&quot;temp_bg_{i}.mp4&quot;
        fg_clip = f&quot;temp_fg_{i}.mp4&quot;

        # 1. Extract
        subprocess.run(
            [
                &quot;ffmpeg&quot;,
                &quot;-y&quot;,
                &quot;-ss&quot;,
                str(mid),
                &quot;-t&quot;,
                str(raw_dur),
                &quot;-i&quot;,
                video_path,
                &quot;-c:v&quot;,
                &quot;libx264&quot;,
                &quot;-an&quot;,
                &quot;-loglevel&quot;,
                &quot;error&quot;,
                raw_clip,
            ],
            check=True,
        )

        # 2. Process
        apply_blur_background(raw_clip, bg_clip)

        direction = &quot;left&quot; if i % 2 == 0 else &quot;right&quot;
        apply_3d_warp(raw_clip, fg_clip, direction)

        # 3. Composite
        try:
            bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
            fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

            if direction == &quot;left&quot;:
                fg = fg.set_position(
                    lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), &quot;center&quot;)
                )
            else:
                fg = fg.set_position(
                    lambda t: (int(50 - 50 * (t / CLIP_DURATION)), &quot;center&quot;)
                )

            comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)
            if i &gt; 0:
                comp = comp.crossfadein(0.2)

            intro_clips.append(comp)
            temp_files.extend([raw_clip, bg_clip, fg_clip])

        except Exception as e:
            print(f&quot;   ⚠️ Error processing clip {i}: {e}&quot;)

    if not intro_clips:
        print(&quot;❌ Failed to generate intro clips.&quot;)
        return

    # Concatenate
    full_montage = concatenate_videoclips(intro_clips, method=&quot;compose&quot;)

    print(&quot;   💾 Saving video file...&quot;)
    full_montage.write_videofile(
        &quot;temp_visual_intro.mp4&quot;, fps=24, codec=&quot;libx264&quot;, logger=None
    )

    # Add silent audio
    print(&quot;   🔊 Adding silent audio track...&quot;)
    subprocess.run(
        [
            &quot;ffmpeg&quot;,
            &quot;-y&quot;,
            &quot;-i&quot;,
            &quot;temp_visual_intro.mp4&quot;,
            &quot;-f&quot;,
            &quot;lavfi&quot;,
            &quot;-i&quot;,
            &quot;anullsrc=channel_layout=mono:sample_rate=44100&quot;,
            &quot;-c:v&quot;,
            &quot;copy&quot;,
            &quot;-c:a&quot;,
            &quot;aac&quot;,
            &quot;-shortest&quot;,
            &quot;-loglevel&quot;,
            &quot;error&quot;,
            OUTPUT_FILENAME,
        ],
        check=True,
    )

    # Cleanup
    for f in temp_files:
        if os.path.exists(f):
            os.remove(f)
    if os.path.exists(&quot;temp_visual_intro.mp4&quot;):
        os.remove(&quot;temp_visual_intro.mp4&quot;)

    print(f&quot;✅ Success! Intro saved as: {OUTPUT_FILENAME}&quot;)


def main():
    if len(sys.argv) &lt; 2:
        print(&quot;Usage: uv run intro_generator.py &lt;video.mp4&gt;&quot;)
        sys.exit(1)

    input_video = sys.argv[1]
    check_ffmpeg()

    temp_wav = &quot;temp_analysis.wav&quot;

    try:
        extract_audio(input_video, temp_wav)

        good_parts = get_good_segments(temp_wav)

        if not good_parts:
            print(&quot;⚠️ No speech detected. Picking random segments...&quot;)
            duration = float(
                subprocess.check_output(
                    [
                        &quot;ffprobe&quot;,
                        &quot;-v&quot;,
                        &quot;error&quot;,
                        &quot;-show_entries&quot;,
                        &quot;format=duration&quot;,
                        &quot;-of&quot;,
                        &quot;default=noprint_wrappers=1:nokey=1&quot;,
                        input_video,
                    ]
                )
            )
            good_parts = [(t, t + 5) for t in range(0, int(duration), 10)]

        generate_intro(input_video, good_parts)

    finally:
        if os.path.exists(temp_wav):
            os.remove(temp_wav)


if __name__ == &quot;__main__&quot;:
    main()
```

## Understanding the Script

---

The script uses PEP 723 inline metadata to tell `uv` which dependencies to install:

```python
# /// script
# requires-python = &quot;&gt;=3.10&quot;
# dependencies = [
#     &quot;openai-whisper&quot;,
#     &quot;torch&quot;,
#     &quot;torchaudio&lt;2.6&quot;,
#     &quot;soundfile&quot;,
#     &quot;numpy&lt;2.0.0&quot;,
#     &quot;moviepy==1.0.3&quot;,
#     &quot;packaging&quot;,
#     &quot;Pillow&lt;10.0.0&quot;,
# ]
# ///
```

This format lets `uv` install the required packages automatically. See our guide on [Running Test Scripts with uv](https://www.bitdoze.com/uv-run-scripts-guide/) for more on this pattern.

### Configuration Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `MIN_SILENCE` | 0.5 | Minimum silence duration in seconds |
| `MIN_SPEECH` | 0.25 | Minimum speech duration in seconds |
| `INTRO_CLIP_COUNT` | 6 | Number of clips in the intro |
| `INTRO_SPEED` | 3.0 | Speed multiplier for clips |
| `CLIP_DURATION` | 1.5 | Duration of each clip in seconds |
| `OUTPUT_FILENAME` | &quot;intro_only.mp4&quot; | Output file name |

### Voice Activity Detection (VAD)

The `get_good_segments()` function detects speech using Silero VAD:

```python
def get_good_segments(audio_path):
    &quot;&quot;&quot;Finds segments where people are actually speaking (Key Moments).&quot;&quot;&quot;
    model, utils = torch.hub.load(
        repo_or_dir=&quot;snakers4/silero-vad&quot;, model=&quot;silero_vad&quot;, trust_repo=True
    )
    (get_speech_timestamps, _, read_audio, _, _) = utils

    wav = read_audio(audio_path)
    vad_stamps = get_speech_timestamps(
        wav,
        model,
        threshold=0.5,
        min_speech_duration_ms=int(MIN_SPEECH * 1000),
        min_silence_duration_ms=int(MIN_SILENCE * 1000),
    )
    
    return [(v[&quot;start&quot;] / 16000, v[&quot;end&quot;] / 16000) for v in vad_stamps]
```

Silero VAD runs locally and doesn&apos;t need an API key or internet connection after the initial download.

### 3D Perspective Effects

The script creates a 3D look using FFmpeg&apos;s perspective filter:

```python
def apply_3d_warp(input_path, output_path, direction=&quot;left&quot;):
    if direction == &quot;left&quot;:
        vf = &quot;perspective=x0=0:y0=0:x1=W:y1=H/5:x2=0:y2=H:x3=W:y3=4*H/5:sense=destination&quot;
    else:
        vf = &quot;perspective=x0=0:y0=H/5:x1=W:y1=0:x2=0:y2=4*H/5:x3=W:y3=H:sense=destination&quot;
```

This alternates between left and right perspective warps.

### Video Compositing

The script layers a blurred background with a 3D-warped foreground:

```python
bg = VideoFileClip(bg_clip).fx(vfx.speedx, INTRO_SPEED)
fg = VideoFileClip(fg_clip).fx(vfx.speedx, INTRO_SPEED)

if direction == &quot;left&quot;:
    fg = fg.set_position(
        lambda t: (int(-50 + 50 * (t / CLIP_DURATION)), &quot;center&quot;)
    )

comp = CompositeVideoClip([bg, fg]).set_duration(CLIP_DURATION)
```

## Running the Script

---

With `uv` installed, run:

```bash
uv run intro_generator.py your_video.mp4
```

The first run takes longer as `uv` downloads dependencies. Subsequent runs use cached packages.

### Expected Output

```
🧠 Scanning video for key moments...
✨ Rendering 3D Intro (No Text)...
   💾 Saving video file...
   🔊 Adding silent audio track...
✅ Success! Intro saved as: intro_only.mp4
```

## Customizing the Output

---

Edit the script to change the intro style.

### Change Number of Clips

Edit `INTRO_CLIP_COUNT`:

```python
INTRO_CLIP_COUNT = 8  # More clips
```

### Adjust Speed

Modify `INTRO_SPEED`:

```python
INTRO_SPEED = 2.0  # Slower
INTRO_SPEED = 4.0  # Faster
```

### Change Clip Duration

Adjust how long each clip appears:

```python
CLIP_DURATION = 2.0  # Longer clips
CLIP_DURATION = 1.0  # Shorter clips
```

### Modify Blur Intensity

Edit the `apply_blur_background()` function:

```python
# Stronger blur
&quot;-vf&quot;, &quot;boxblur=60:10,eq=brightness=-0.5&quot;

# Lighter blur
&quot;-vf&quot;, &quot;boxblur=20:3,eq=brightness=-0.2&quot;
```

## Dependencies Explained

---

| Package | Purpose |
|---------|---------|
| **openai-whisper** | Speech recognition (used for loading audio utilities) |
| **torch** | PyTorch for running the VAD model |
| **torchaudio** | Audio processing with PyTorch |
| **soundfile** | Reading audio files |
| **numpy** | Numerical operations |
| **moviepy** | Video editing and compositing |
| **Pillow** | Image processing (required by MoviePy) |
| **packaging** | Version handling utilities |

&lt;Notice type=&quot;warning&quot; title=&quot;Version Constraints&quot;&gt;

The version constraints ensure compatibility between packages. `torchaudio&lt;2.6` and `numpy&lt;2.0.0` prevent breaking changes.

&lt;/Notice&gt;

## Troubleshooting

---

&lt;Accordion label=&quot;FFmpeg not found error&quot; group=&quot;faq&quot;&gt;

Install FFmpeg using Homebrew:

```bash
brew install ffmpeg
```

Verify the installation:

```bash
ffmpeg -version
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Not enough footage found warning&quot; group=&quot;faq&quot;&gt;

This happens when the video doesn&apos;t have enough speaking segments longer than 2 seconds. The script reuses segments in this case. Try:

- Using a longer source video
- Reducing `INTRO_CLIP_COUNT`
- Lowering the minimum segment duration in the code

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Memory errors with large videos&quot; group=&quot;faq&quot;&gt;

For large videos, try:

- Processing shorter clips
- Reducing the source video resolution
- Closing other applications

&lt;/Accordion&gt;

&lt;Accordion label=&quot;First run is slow&quot; group=&quot;faq&quot;&gt;

The first run downloads the Silero VAD model and Python dependencies. Subsequent runs are faster because `uv` and PyTorch cache these files.

&lt;/Accordion&gt;

## Conclusion

---

This script combines Silero VAD for speech detection, FFmpeg for video processing, and MoviePy for compositing. The `uv` inline dependency management handles package installation automatically.

You can adjust the number of clips, speed, duration, and visual effects to match your style. The tool saves time on YouTube intros, social media content, or presentation openers.

For more Python scripting with `uv`, see:

- [Getting Started with uv: Setting Up Your Python Project](https://www.bitdoze.com/uv-get-start/)
- [Running Test Scripts with uv: No Dependencies Management Required](https://www.bitdoze.com/uv-run-scripts-guide/)</content:encoded><category>ai</category><category>python</category><category>uv</category></item><item><title>Monitor Your Server Like a Pro: Beszel &amp; Uptime Kuma Setup on Dokploy</title><link>https://www.bitdoze.com/beszel-uptime-kuma/</link><guid isPermaLink="true">https://www.bitdoze.com/beszel-uptime-kuma/</guid><description>Complete guide to setting up Beszel for resource monitoring (CPU, memory, disk, Docker) and Uptime Kuma for uptime checks on Dokploy with SMTP alerts.</description><pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Image } from &quot;astro:assets&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;

import beszel1 from &quot;../../assets/images/24/11/beszel1.jpeg&quot;;
import beszel2 from &quot;../../assets/images/24/11/beszel2.jpeg&quot;;

Monitoring your self-hosted infrastructure matters. Whether you&apos;re running a simple VPS or a cluster of servers, knowing when a service goes down or when a disk is full matters. You need visibility into what&apos;s happening inside your servers (resources) and confirmation that services are accessible from the outside (uptime).

Server monitoring tracks server performance metrics. This helps maintain system reliability and prevent issues before they impact services. Server monitoring includes:

- **Resource utilization**: CPU, memory, disk, network
- **Application performance**: Response times, error rates
- **Service availability**: Uptime checks, health endpoints
- **Container health**: Docker container stats and performance

In this guide, we&apos;ll set up monitoring using [Dokploy](https://www.bitdoze.com/dokploy-install/). We&apos;ll deploy **Beszel** for internal resource monitoring (CPU, memory, disk, Docker stats) and **Uptime Kuma** for external uptime checks (HTTP/DNS).

&lt;Notice type=&quot;info&quot; title=&quot;More Monitoring Options&quot;&gt;
For a broader look at monitoring options like Netdata or Prometheus + Grafana, check out our comprehensive guide on [Server Monitoring Tools](https://www.bitdoze.com/sever-monitoring/).
&lt;/Notice&gt;

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/T7izljcBFeE&quot;
  label=&quot;Monitor Your Server Like a Pro: Beszel &amp; Uptime Kuma Setup on Dokploy&quot;
/&gt;

## Understanding Key Monitoring Metrics

Before setting up monitoring tools, understand what metrics matter:

### Essential Server Metrics

| Metric Category | What to Monitor | Why It Matters |
|-----------------|-----------------|----------------|
| **CPU** | Load average (1, 5, 15 min), per-core usage, system/user time | Identifies processing bottlenecks |
| **Memory** | Available RAM, swap usage, buffer/cache utilization | Prevents out-of-memory crashes |
| **Disk** | Space usage, inode utilization, I/O wait times | Avoids disk-full failures |
| **Network** | Bandwidth, packet loss, latency, connection states | Detects network issues early |

### Monitoring Priorities by Environment

Different environments have different monitoring focus:

| Metric Type | Traditional Server | Container Environment | Cloud Infrastructure |
|-------------|-------------------|----------------------|---------------------|
| CPU | Overall usage | Per container usage | Instance utilization |
| Memory | Physical/Swap | Container limits | Instance limits |
| Storage | Partition usage | Volume usage | Block storage |
| Network | Interface stats | Container networks | VPC metrics |

## Why Beszel and Uptime Kuma?

Here&apos;s what each tool does:

| Tool | Purpose | What It Monitors |
|------|---------|------------------|
| **Beszel** | Internal resource monitoring | CPU, memory, disk, network, Docker containers |
| **Uptime Kuma** | External availability checks | HTTP/HTTPS endpoints, DNS, TCP ports, SSL certificates |

### Beszel - Lightweight Monitoring Hub

[Beszel](https://github.com/henrygd/beszel) is a lightweight monitoring solution. Unlike Prometheus + Grafana, Beszel uses a single binary agent and provides:

&lt;ListCheck&gt;
- Real-time server metrics monitoring
- Historical data with graphs
- Docker container statistics
- Custom notification channels (email, webhooks)
- Public key authentication for secure agent communication
- Multi-server support from a single hub
- Very low resource footprint
&lt;/ListCheck&gt;

Here&apos;s what the Beszel interface looks like in action:

&lt;Image src={beszel1} alt=&quot;Beszel Main Interface showing server metrics dashboard&quot; /&gt;

Beszel provides detailed historical graphs for all your metrics:

&lt;Image src={beszel2} alt=&quot;Beszel Graphs showing CPU, memory, and network usage over time&quot; /&gt;

**Beszel Feature Overview:**

| Feature | Description | Benefit |
|---------|-------------|---------|
| Lightweight | Minimal resource usage (~10-20MB RAM per agent) | Ideal for small servers |
| Docker Integration | Native container monitoring | Easy container tracking |
| Public Key Auth | Secure agent communication via SSH keys | Enhanced security |
| Multi-server Support | Monitor multiple servers from one hub | Centralized monitoring |
| Historical Data | Stores metrics with graphs over time | Trend analysis |

### Uptime Kuma - Uptime Monitoring

[Uptime Kuma](https://github.com/louislam/uptime-kuma) is a self-hosted uptime monitoring solution. It checks if your websites and services are accessible from the outside world and sends notifications if they aren&apos;t.

&lt;ListCheck&gt;
- HTTP/HTTPS endpoint monitoring
- DNS, TCP, Ping, and Docker container checks
- SSL certificate expiration monitoring
- Beautiful status pages you can share
- 90+ notification integrations
- Maintenance windows
- Multi-language support
&lt;/ListCheck&gt;

### Why Use Both Together?

**Internal health ≠ external availability**:

- Your server could show 10% CPU usage, but your website might be down due to a misconfigured nginx
- A container might be &quot;running&quot; but returning 502 errors
- Your disk might be 95% full, but Uptime Kuma won&apos;t know until the app crashes

Beszel tells you what&apos;s happening inside. Uptime Kuma tells you what users experience.

### How This Stack Compares to Other Solutions

| Feature | Beszel + Uptime Kuma | Prometheus/Grafana | Netdata |
|---------|---------------------|-------------------|---------|
| **Scalability** | Small-Medium | Enterprise-grade | Medium |
| **Setup Complexity** | Low | High | Low |
| **Customization** | Basic-Moderate | Extensive | Moderate |
| **Resource Usage** | Very Low (~200MB total) | Moderate-High | Low |
| **Learning Curve** | Gentle | Steep | Moderate |
| **Uptime Monitoring** | Built-in (Kuma) | Requires additional setup | Limited |
| **Status Pages** | Yes (Kuma) | Manual setup needed | No |

### The Importance of Proactive Notifications

A well-configured alerting system should:

1. **Provide Early Warning**
   - Detect potential issues before they become critical
   - Monitor trend changes that might indicate future problems
   - Alert on unusual patterns or anomalies

2. **Enable Quick Response**
   - Deliver notifications through multiple channels (email, SMS, Slack)
   - Include relevant diagnostic information
   - Provide clear action items

3. **Prevent Alert Fatigue**
   - Use intelligent thresholds
   - Implement alert correlation
   - Configure proper alert priorities

## Prerequisites

Before starting:

&lt;ListCheck&gt;
- **A VPS or Server**: Minimum 2GB RAM recommended (1GB works for small deployments)
- **Dokploy Installed**: Follow our [Dokploy Installation Guide](https://www.bitdoze.com/dokploy-install/) if you haven&apos;t already
- **A Domain Name**: For accessing your monitoring dashboards (e.g., `monitor.yourdomain.com`, `status.yourdomain.com`)
- **Email Service (Optional)**: For alert notifications (we&apos;ll use Brevo/Sendinblue which offers 300 free emails/day)
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

## Part 1: Deploying Beszel (Resource Monitoring Hub)

Beszel has two components:
1. **The Hub**: The main dashboard where you view all metrics
2. **The Agent**: Installed on every server you want to monitor

### Step 1: Create the Beszel Service in Dokploy

1. Go to your Dokploy dashboard and navigate to your project
2. Click **Create Service** → **Template**
3. Search for **Beszel** and select it
4. Choose the server you want to deploy the Hub on
5. Select the latest version tag (e.g., `0.9.1` or `latest`)

&lt;Notice type=&quot;info&quot; title=&quot;Deployment Tip&quot;&gt;
For redundancy, consider deploying the Hub on a separate node from your main production apps. If your production server goes down, you&apos;ll still receive alerts.
&lt;/Notice&gt;

### Step 2: Configure the Domain

1. In Dokploy, go to the **Domains** tab for your new Beszel service
2. Add your domain (e.g., `monitor.yourdomain.com`)
3. Set the port to **8090** (Beszel&apos;s default UI port)
4. Enable **HTTPS** (Let&apos;s Encrypt)
5. Click **Create**

If using Cloudflare:
- Create an **A Record** pointing `monitor` to your server IP
- Set SSL/TLS mode to **Full** (not Flexible)

### Step 3: Set Essential Environment Variables

To ensure email alerts contain the correct links (instead of `localhost`), you must set the application URL.

1. Go to the **Environment** tab in Dokploy
2. Add the following variable:

```sh
APP_URL=https://monitor.yourdomain.com
```

3. Click **Save** and **Redeploy**

### Step 4: Initial Beszel Setup

1. Visit your new URL (`https://monitor.yourdomain.com`)
2. Create your admin account with a strong password
3. You&apos;ll land on the main dashboard (empty for now)

**Best Practices for Beszel Deployment:**
- Use secure networking between server and agents
- Implement proper backup for Beszel data
- Regular updates of both server and agent containers
- Monitor agent connectivity from the Hub

## Part 2: Installing the Beszel Agent

Now that the Hub is running, we need to install the Agent on every server we want to monitor.

### Step 1: Get the Agent Configuration from the Hub

1. In the Beszel Hub, click **Add System**
2. Name your system (e.g., `main-dokploy-node` or `production-1`)
3. Copy the Docker Compose snippet provided - it contains your unique public **KEY** for authentication

The snippet will look something like this:

```yaml
services:
  beszel-agent:
    image: henrygd/beszel-agent
    container_name: beszel-agent
    restart: unless-stopped
    network_mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      PORT: 45876
      KEY: &apos;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHiPxG...&apos;
```

### Step 2: Deploy the Agent via Dokploy

1. Go back to Dokploy on the server you want to monitor
2. Create a new **Compose** service (not a template)
3. Name it `beszel-agent`
4. Paste the Docker Compose configuration you copied from the Hub
5. Click **Deploy**

&lt;Notice type=&quot;warning&quot; title=&quot;Important&quot;&gt;
Ensure the port mapping matches what the Hub expects. The default is **45876**. If you change it, update it in both the agent and the Hub.
&lt;/Notice&gt;

### Step 3: Verify the Connection

Once deployed:
- The icon in your Beszel Hub should turn **green**
- You&apos;ll start seeing real-time CPU, Memory, Disk, and Docker container stats
- Historical graphs will begin populating

**What Metrics You&apos;ll See:**

| Metric | Description | Alert Threshold Suggestion |
|--------|-------------|---------------------------|
| CPU Load | 1, 5, 15 minute load averages | &gt; 90% for 5 minutes |
| Memory | Used, available, cached, swap | &gt; 85% used |
| Disk | Usage per mount point, I/O stats | &gt; 85% capacity |
| Network | Bandwidth in/out, packet stats | Unusual spikes |
| Docker | Per-container CPU, memory, network | Container-specific |

### Monitoring Additional Servers

Repeat the agent installation process for each server you want to monitor:

1. In the Hub, click **Add System** for each new server
2. Give it a unique name
3. Copy the Docker Compose snippet (each will have a unique KEY)
4. Deploy the agent on that server

## Part 3: Setting Up Uptime Kuma

Beszel watches internal resources, Uptime Kuma watches external availability.

### Step 1: Deploy via Dokploy Template

1. In Dokploy, create a new service from **Template**
2. Search for **Uptime Kuma** and select it
3. Check the image tag - change to the latest stable version if needed (e.g., `1` or specific version like `1.23.15`)
4. Click **Deploy**

### Step 2: Configure the Domain

1. Go to the **Domains** tab
2. Set up a domain (e.g., `status.yourdomain.com`)
3. The internal port should be **3001**
4. Enable **HTTPS**
5. Click **Create**

&lt;Notice type=&quot;info&quot; title=&quot;Volume Persistence&quot;&gt;
Dokploy handles the volume creation automatically to persist your monitoring data across container restarts.
&lt;/Notice&gt;

### Step 3: Initial Uptime Kuma Setup

1. Visit your new URL (`https://status.yourdomain.com`)
2. Create your admin account
3. You&apos;ll see an empty dashboard ready for monitors

### Step 4: Add Your First Monitor

1. Click **Add New Monitor**
2. Choose monitor type: **HTTP(s)** for websites
3. Enter the URL you want to monitor
4. Set the heartbeat interval (e.g., 60 seconds)
5. Click **Save**

**Recommended monitors to set up:**

| Monitor Type | Use Case | Example |
|--------------|----------|---------|
| HTTP(s) | Website availability | `https://yourdomain.com` |
| HTTP(s) - Keyword | Content verification | Check for &quot;Welcome&quot; text |
| TCP Port | Database connectivity | PostgreSQL on port 5432 |
| DNS | DNS resolution | Check A record for domain |
| Docker Container | Container health | Monitor specific container |

## Part 4: Configuring SMTP Alerts with Brevo

Monitoring doesn&apos;t help if you don&apos;t get notified when things break. Here&apos;s how to set up email alerts using [Brevo](https://www.brevo.com/) (formerly Sendinblue), which offers 300 free emails per day.

### Step 1: Get SMTP Details from Brevo

1. Create a free Brevo account if you don&apos;t have one
2. Go to **Settings** → **SMTP &amp; API**
3. Generate a new **SMTP Key**
4. Note down your credentials:

| Setting | Value |
|---------|-------|
| Server | `smtp-relay.brevo.com` |
| Port | `587` |
| Login | Your Brevo email |
| Password | The SMTP Key you generated |

&lt;Notice type=&quot;warning&quot; title=&quot;Sender Authentication&quot;&gt;
Ensure your sender address (e.g., `alerts@yourdomain.com`) is authenticated in Brevo. Go to **Settings** → **Senders &amp; IP** → **Senders** and add/verify your domain.
&lt;/Notice&gt;

### Step 2: Configure Beszel Alerts

1. In Beszel, go to **Settings** → **SMTP** (or General settings depending on version)
2. Enter the SMTP details:
   - **Host**: `smtp-relay.brevo.com`
   - **Port**: `587`
   - **Username**: Your Brevo email
   - **Password**: Your SMTP Key
   - **Sender Address**: `alerts@yourdomain.com`
3. Save the settings

**Set up alert thresholds:**

1. Go to **Systems** → click your server → **Alerts**
2. Enable alerts for specific thresholds:

| Metric | Recommended Threshold |
|--------|----------------------|
| CPU Usage | &gt; 90% for 5 minutes |
| Memory Usage | &gt; 85% |
| Disk Usage | &gt; 85% |
| System Status | Down |

### Step 3: Configure Uptime Kuma Alerts

1. In Uptime Kuma, go to **Settings** → **Notifications**
2. Click **Setup Notification**
3. Choose **Email (SMTP)**
4. Fill in the same Brevo details:
   - **Hostname**: `smtp-relay.brevo.com`
   - **Port**: `587`
   - **Security**: STARTTLS
   - **Username**: Your Brevo email
   - **Password**: Your SMTP Key
   - **From Email**: `alerts@yourdomain.com`
   - **To Email**: Your notification email
5. Click **Test** to verify it works
6. Save the notification

**Attach notifications to monitors:**

When creating or editing a monitor, scroll down to **Notifications** and select your email notification to receive alerts for that specific monitor.

## Part 5: Testing Your Monitoring Stack

Verify everything works before assuming you&apos;re covered.

### Test Beszel Alerts

1. SSH into your monitored server
2. Stop the Beszel Agent container:
   ```sh
   docker stop beszel-agent
   ```
3. Wait 1-2 minutes
4. You should receive an email saying &quot;System is Down&quot;
5. Restart the agent:
   ```sh
   docker start beszel-agent
   ```
6. You should receive a &quot;System is Up&quot; email

### Test Uptime Kuma Alerts

1. Stop one of your monitored web applications:
   ```sh
   docker stop your-web-app
   ```
2. Uptime Kuma should detect the error (502/Timeout) within your heartbeat interval
3. You should receive a notification email
4. Restart your app and verify you get an &quot;Up&quot; notification

## Advanced Configuration

### Creating Status Pages with Uptime Kuma

Uptime Kuma can generate public status pages to share with your users:

1. Go to **Status Pages** in the menu
2. Click **New Status Page**
3. Give it a name and slug (e.g., `status`)
4. Add monitors to display
5. Customize the look and feel
6. Share the public URL with users

### Monitoring Docker Containers Directly

Uptime Kuma can monitor Docker containers without going through HTTP:

1. Create a new monitor with type **Docker Container**
2. Enter the container name or ID
3. The monitor checks if the container is running (not just the port)

&lt;Notice type=&quot;info&quot; title=&quot;Docker Socket Access&quot;&gt;
For Docker container monitoring in Uptime Kuma, you need to mount the Docker socket. The Dokploy template should handle this, but verify the volume mount exists:
```yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock
```
&lt;/Notice&gt;

### Monitoring Multiple Servers

For a multi-server setup:

1. Deploy **one Beszel Hub** (central dashboard)
2. Deploy **Beszel Agents on each server** (using unique keys)
3. Deploy **one Uptime Kuma instance** (can monitor all external endpoints)
4. Configure alerts to go to the same email/Slack/Discord channel

## Best Practices

&lt;ListCheck&gt;
- **Set meaningful alert thresholds**: Avoid alert fatigue by not alerting on every 80% CPU spike
- **Use multiple notification channels**: Email + Slack/Discord for critical alerts
- **Monitor your monitoring**: Set up an external service (like UptimeRobot free tier) to monitor your Uptime Kuma instance
- **Regular testing**: Test your alerts monthly to ensure they still work
- **Document your setup**: Keep notes on what each monitor checks and why
- **Backup configurations**: Export your Uptime Kuma configuration periodically
&lt;/ListCheck&gt;

### Recommended Alert Thresholds

Based on real-world experience, here are suggested thresholds for common metrics:

| Metric | Warning Level | Critical Level | Notes |
|--------|---------------|----------------|-------|
| CPU Usage | 70% for 5 min | 90% for 5 min | Sustained high CPU indicates bottleneck |
| Memory Usage | 75% | 85% | Leave headroom for spikes |
| Disk Usage | 80% | 90% | Plan expansion before hitting critical |
| Disk I/O Wait | 20% | 40% | High I/O wait slows everything |
| Network Errors | Any increase | Sustained errors | Usually indicates hardware issues |
| Container Restarts | 2 in 10 min | 5 in 10 min | Crash loops need investigation |

### When to Scale Up Your Monitoring

Consider moving to more robust solutions when:

1. **Small deployments (1-5 servers)**: Beszel + Uptime Kuma is perfect
2. **Medium deployments (5-20 servers)**: Consider adding Netdata for deeper insights
3. **Large deployments (20+ servers)**: Evaluate Prometheus/Grafana for enterprise features

## Conclusion

By combining **Beszel** for internal resource metrics and **Uptime Kuma** for external uptime checks, you have a self-hosted monitoring stack running on Dokploy. This setup is:

- **Lightweight**: Minimal resource usage compared to Prometheus/Grafana
- **Cost-effective**: Free to self-host
- **Comprehensive**: Covers internal health and external availability
- **Alert-ready**: You&apos;ll be first to know when something breaks

Your monitoring stack provides visibility into:
- Server resources (CPU, memory, disk, network)
- Docker container health and statistics
- Website and API availability
- SSL certificate expiration
- And more

### Choosing the Right Tool for Your Needs

| Your Situation | Recommended Approach |
|----------------|---------------------|
| **Beginner with 1-2 servers** | Start with this Beszel + Uptime Kuma stack |
| **Need log monitoring too** | Add Dozzle for Docker logs |
| **Want cloud-managed option** | Consider Netdata Cloud (free for 5 nodes) |
| **Enterprise with complex needs** | Prometheus + Grafana stack |

**Summary of Best Practices:**
- Start with basic monitoring and expand as needed
- Implement proper alerting with meaningful thresholds
- Regular backup of monitoring data
- Keep monitoring tools updated
- Document monitoring setup and procedures

For more advanced monitoring scenarios or to explore other tools like Netdata or Prometheus, check out our [Server Monitoring Guide](https://www.bitdoze.com/sever-monitoring/).

&lt;Button link=&quot;https://github.com/henrygd/beszel&quot; text=&quot;Beszel on GitHub&quot; /&gt;
&lt;Button link=&quot;https://github.com/louislam/uptime-kuma&quot; text=&quot;Uptime Kuma on GitHub&quot; /&gt;

## Frequently Asked Questions

&lt;Accordion label=&quot;What&apos;s the difference between Beszel and Uptime Kuma?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
**Beszel** monitors internal server resources - CPU usage, memory consumption, disk space, network bandwidth, and Docker container stats. It tells you what&apos;s happening inside your server.

**Uptime Kuma** monitors external availability - whether your websites are accessible, APIs are responding, and services are reachable from outside. It tells you what users experience.

You need both because a server can have healthy internal metrics but still serve errors to users (misconfig, full queue, etc.), and vice versa.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much resources do Beszel and Uptime Kuma use?&quot; group=&quot;faq&quot;&gt;
Both tools are lightweight:

- **Beszel Hub**: ~50-100MB RAM
- **Beszel Agent**: ~10-20MB RAM per monitored server
- **Uptime Kuma**: ~100-200MB RAM depending on number of monitors

Combined, expect under 500MB RAM for a complete monitoring stack, which is less than Prometheus + Grafana.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use other notification services besides email?&quot; group=&quot;faq&quot;&gt;
Both tools support multiple notification channels:

**Beszel** supports:
- Email (SMTP)
- Webhooks (for Slack, Discord, etc.)
- Custom integrations

**Uptime Kuma** has 90+ integrations including:
- Slack, Discord, Microsoft Teams
- Telegram, Pushover, Gotify
- PagerDuty, Opsgenie
- SMS services
- Custom webhooks
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I monitor servers that aren&apos;t running Dokploy?&quot; group=&quot;faq&quot;&gt;
The Beszel Agent is a Docker container (or binary) that runs anywhere:

1. Install it via Docker on any Linux server
2. Run the binary directly without Docker
3. Deploy it on Kubernetes

As long as the agent can reach the Hub over the network (port 45876 by default), it works.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a way to create public status pages?&quot; group=&quot;faq&quot;&gt;
Uptime Kuma has built-in status page functionality:

1. Go to **Status Pages** in the menu
2. Create a new status page with your monitors
3. Customize the appearance
4. Share the public URL with your users

You can have multiple status pages for different audiences (internal team vs. public users).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I backup my monitoring data?&quot; group=&quot;faq&quot;&gt;
For **Dokploy deployments**, data is stored in Docker volumes:

- **Beszel**: Data in `/beszel_data` inside the container
- **Uptime Kuma**: Data in the Kuma data volume

To backup:
1. Use Dokploy&apos;s backup features if available
2. Or manually backup the Docker volumes:
```sh
docker run --rm -v uptime-kuma_data:/data -v $(pwd):/backup alpine tar cvf /backup/kuma-backup.tar /data
```

For Beszel, export system configurations from the UI when possible.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What are the recommended alert thresholds?&quot; group=&quot;faq&quot;&gt;
Based on production experience, here are suggested thresholds:

- **CPU**: Alert at 90% sustained for 5+ minutes
- **Memory**: Alert at 85% usage
- **Disk**: Warning at 80%, critical at 90%
- **System Status**: Immediate alert when down

These can be adjusted based on your workload patterns. Some applications have natural CPU spikes that shouldn&apos;t trigger alerts.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I monitor additional disk partitions with Beszel?&quot; group=&quot;faq&quot;&gt;
Yes! Beszel supports monitoring additional disks by mounting folders in the `/extra-filesystems` directory. In your agent&apos;s Docker Compose, add:

```yaml
volumes:
  - /var/run/docker.sock:/var/run/docker.sock:ro
  - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
```

This allows you to monitor external drives, NAS mounts, or additional partitions beyond the root filesystem.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does Beszel compare to Netdata or Prometheus?&quot; group=&quot;faq&quot;&gt;
Each tool has its strengths:

- **Beszel**: Lightest weight (~20MB RAM), perfect for small deployments, simple setup
- **Netdata**: More metrics out of the box, ML anomaly detection, cloud option for 5 free nodes
- **Prometheus/Grafana**: Most customizable, enterprise-grade, but steeper learning curve and higher resource usage

For most self-hosted scenarios with 1-10 servers, Beszel + Uptime Kuma provides the best balance of features and resource efficiency.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>monitoring</category></item><item><title>Introduction to MCP (Model Context Protocol) for Beginners</title><link>https://www.bitdoze.com/mcp-introduction-beginners/</link><guid isPermaLink="true">https://www.bitdoze.com/mcp-introduction-beginners/</guid><description>Learn what MCP (Model Context Protocol) is, how it works, and why it matters. Beginner&apos;s guide with setup instructions and Docker MCP integration.</description><pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;

If you&apos;ve been using AI coding assistants, you&apos;ve probably heard MCP mentioned. It&apos;s worth understanding - once I got MCP working, my AI assistant became genuinely useful for things it couldn&apos;t do before, like searching the web or querying my databases.

This guide covers what MCP actually is, how to set it up, and why Docker&apos;s MCP Catalog makes the whole process much less painful than it used to be.

&lt;Notice type=&quot;info&quot; title=&quot;What You&apos;ll Learn&quot;&gt;
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;What MCP (Model Context Protocol) is and how it works&lt;/li&gt;
&lt;li&gt;Why MCP is essential for modern AI development&lt;/li&gt;
&lt;li&gt;How to set up your first MCP servers&lt;/li&gt;
&lt;li&gt;Understanding Docker MCP Catalog and Toolkit&lt;/li&gt;
&lt;li&gt;Dynamic MCP management for efficient AI workflows&lt;/li&gt;
&lt;li&gt;Best practices for beginners&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## What is MCP (Model Context Protocol)?

MCP (Model Context Protocol) is an open protocol from Anthropic that standardizes how AI models connect to external tools and data. Without it, AI models are stuck with their training data - they can&apos;t search the web, access your database, or call APIs.

Before MCP existed, connecting an AI to external tools meant building custom integrations for each tool and each AI model. MCP fixes this by defining a standard protocol. Build one MCP server, and it works with Claude, Cursor, VS Code, or any other MCP-compatible client.

### The USB analogy

MCP is like USB for AI tools:

- **Before USB:** Every device needed its own weird connector
- **After USB:** One port works with everything

Same idea here. Developers build MCP servers once, and they work with any AI client that supports the protocol.

### How MCP Works

The MCP architecture consists of three main components:

| Component | Description | Examples |
|-----------|-------------|----------|
| **MCP Hosts** | AI applications that want to use external tools | Claude Desktop, Cursor, VS Code, Windsurf |
| **MCP Clients** | Protocol handlers within the host application | Built into Claude, Cursor, etc. |
| **MCP Servers** | Services that provide tools and data access | BrightData MCP, GitHub MCP, Database MCP |

When you ask Claude to &quot;search for the latest news about Docker,&quot; here&apos;s what happens:

1. Claude (the host) recognizes it needs web search capability
2. It connects to a web search MCP server through the MCP client
3. The MCP server executes the search and returns results
4. Claude processes the results and provides you with an answer

## Why bother with MCP?

MCP solves real problems that make AI assistants frustrating to use:

### 1. Real-time data access

AI models only know what was in their training data. Ask about something that happened last week and they&apos;re useless. MCP lets them:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Search the web for up-to-date information&lt;/li&gt;
&lt;li&gt;Access live databases and APIs&lt;/li&gt;
&lt;li&gt;Retrieve current stock prices, weather, or news&lt;/li&gt;
&lt;li&gt;Interact with your local files and projects&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### 2. Build once, use everywhere

Write an MCP server for GitHub access, and it works with Claude, Cursor, VS Code, Windsurf - any client that speaks MCP. No more maintaining separate integrations for each platform.

### 3. Actually useful capabilities

With MCP servers, your AI can:

- Execute code in sandboxed environments
- Query databases directly
- Interact with version control systems
- Automate browser tasks
- Access specialized APIs (Amazon, LinkedIn, GitHub, etc.)

### 4. Control over what AI can access

MCP gives you a structured way to grant permissions. The AI only gets access to tools you explicitly enable, credentials stay on your machine, and you can revoke access anytime.

## Getting Started with MCP

If you&apos;re new to [AI programming](/ai-programming-beginners-guide/), MCP might seem complicated. It&apos;s not that bad once you set up your first server.

### What you need

Before setting up MCP servers:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;An AI assistant that supports MCP (Claude Desktop, Cursor, VS Code with extensions)&lt;/li&gt;
&lt;li&gt;Node.js installed on your system (for most MCP servers)&lt;/li&gt;
&lt;li&gt;Basic familiarity with JSON configuration files&lt;/li&gt;
&lt;li&gt;Docker Desktop (recommended for the easiest setup)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### MCP configuration basics

MCP servers are configured through JSON files. Here&apos;s what one looks like:

```json
{
  &quot;mcpServers&quot;: {
    &quot;server-name&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@package/mcp-server&quot;],
      &quot;env&quot;: {
        &quot;API_KEY&quot;: &quot;your-api-key&quot;
      }
    }
  }
}
```

The key parts:

- **command**: How to start the server (`npx` or `node` usually)
- **args**: What to pass to the command
- **env**: API keys and other secrets

### Your first MCP server: Context7

Context7 is a good one to start with - it gives your AI access to current framework documentation instead of whatever was in its training data.

&lt;Accordion label=&quot;Setting up Context7 MCP&quot; group=&quot;setup&quot; expanded=&quot;true&quot;&gt;

For Claude Desktop, add this to your `claude_desktop_config.json`:

```json
{
  &quot;mcpServers&quot;: {
    &quot;context7&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@upstash/context7-mcp&quot;]
    }
  }
}
```

Now when you ask about the latest React or Astro features, Claude actually knows what it&apos;s talking about.

&lt;/Accordion&gt;

## MCP servers worth installing

Here are the ones I&apos;d start with:

### 1. Web search and scraping

For real-time web data, [BrightData MCP](/brightdata-mcp-guide/) works well. You get 5,000 free requests monthly, access to search engines, and structured data from 40+ platforms. It handles bot detection automatically.

### 2. Documentation

Context7 gives your AI current framework docs. Useful when you&apos;re working with fast-moving frameworks where the AI&apos;s training data is already outdated.

### 3. Browser automation

Playwright MCP lets your AI control a browser - navigate pages, fill forms, click buttons, take screenshots. Good for testing or scraping dynamic sites.

### 4. Databases

Database MCP servers let your AI query PostgreSQL, MySQL, or SQLite directly. Useful for generating reports or exploring data through conversation.

### 5. Agent memory

[Hindsight](/hindsight-docker-deploy/) is an MCP server that gives your AI persistent long-term memory. It stores facts, builds mental models, and learns from conversations over time. Works with Claude, Cursor, OpenCode, and any other MCP client. If you&apos;re tired of your AI forgetting everything between sessions, this is the one to set up.

## The problem with lots of MCP servers

MCP works great when you have 2-3 servers. But power users have ended up with hundreds of servers and thousands of tools. That creates problems:

### Context window bloat

Every MCP server adds tool definitions to your AI&apos;s context window. If you have 1,000 tools but only need 2 for a conversation, you&apos;re wasting tokens loading stuff you&apos;ll never use.

### Trust issues

Who made this MCP server? Can you trust it with your API keys? There&apos;s no real verification process for community servers.

### Configuration headaches

Managing auth, updates, and configs for dozens of servers gets old fast. Something always breaks.

&lt;Notice type=&quot;warning&quot; title=&quot;Token math&quot;&gt;
With many MCP servers, a huge chunk of your context window goes to tool definitions alone. Less room for your actual conversation.
&lt;/Notice&gt;

## Docker MCP Catalog and Toolkit

Docker built a solution: the MCP Catalog and Toolkit. It fixes most of the problems above.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/98M_6njOnus&quot;
  label=&quot;Docker MCP Catalog and Toolkit Introduction&quot;
/&gt;

### The Catalog

A curated registry of verified MCP servers on Docker Hub. Pre-containerized, ready to use. Stripe, Elastic, Neo4j, New Relic - the popular ones are there. One-click setup.

### The Toolkit

A management layer between your AI clients and MCP servers:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Centralized management** - One place to manage all your MCP servers&lt;/li&gt;
&lt;li&gt;**Easy authentication** - Authenticate once, use everywhere&lt;/li&gt;
&lt;li&gt;**Client integration** - Connect Claude, VS Code, Cursor, and more with one click&lt;/li&gt;
&lt;li&gt;**Security** - All servers run in isolated Docker containers&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Setting it up

1. Update Docker Desktop to version 4.48+
2. Enable MCP Toolkit in settings (Beta Features)
3. Browse the Catalog, add what you need
4. Connect your AI clients

Your AI client talks to Docker, Docker manages the servers. You don&apos;t deal with individual server configs anymore.

## Dynamic MCP loading

This is the clever part. Instead of loading every tool definition at startup, Docker&apos;s MCP Gateway lets AI agents discover and load tools only when needed.

### How it works

The Gateway gives your AI these meta-tools:

| Tool | Purpose |
|------|---------|
| `mcp_find` | Search for MCP servers by name or description |
| `mcp_add` | Add an MCP server to the current session |
| `mcp_remove` | Remove an MCP server from the session |

So your AI starts with just these three tools. When it needs GitHub access, it searches for and loads the GitHub server. Context window stays clean.

### Code Mode

The Gateway also lets AI agents write JavaScript that calls MCP tools directly:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Token efficiency** - The AI writes a custom tool once and reuses it&lt;/li&gt;
&lt;li&gt;**Chaining tools** - Combine multiple MCP tools into one workflow&lt;/li&gt;
&lt;li&gt;**Sandboxed execution** - Code runs safely in Docker containers&lt;/li&gt;
&lt;li&gt;**State persistence** - Data can be stored between tool calls&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Example: GitHub to Notion

Say you want to search GitHub repos and save results to Notion. With Code Mode:

1. AI writes JavaScript that calls both GitHub and Notion APIs
2. Code runs in a sandboxed container
3. AI gets a summary back, full results go to Notion
4. No huge JSON payloads eating your context window

Way more efficient than the AI processing raw data from each tool separately.

## Docker Hub MCP Server

Docker also released an MCP server for Docker Hub itself. Search for images, manage repos, all through natural language.

### Setting it up

1. Open **MCP Toolkit** in Docker Desktop
2. Go to the **Catalog** tab
3. Search for &quot;Docker Hub&quot;
4. Click the plus icon to add it
5. Enter your Docker Hub username and personal access token

Then you can ask things like &quot;find the latest Node.js Alpine image&quot; or &quot;what&apos;s the size of the official Python image&quot; and get real answers.

## Tips for getting started

**Start with 2-3 servers.** Context7 for docs, one web search server, maybe a database server. Add more as you actually need them.

**Use Docker&apos;s Toolkit if you can.** It handles updates, credentials, and client configuration. Less stuff to break.

**Know what you&apos;re installing.** Before adding an MCP server, understand what tools it provides and what data it can access.

**Watch your token usage.** If conversations feel limited, you probably have too many tools loaded. Use dynamic loading when available.

## Use cases

**Developers:** Current framework docs, direct database queries, Git automation, browser-based testing.

**Content creators:** Real-time research, product data extraction, competitor monitoring. If you&apos;re building [AI affiliate websites](/ai-affiliate-websites-amazon/), MCP servers like BrightData help you get actual product data.

**Researchers:** Academic database access, structured data collection, report generation from multiple sources.

## MCP with different AI tools

**GitHub Copilot:** Has its own integrations, but you can add MCP through VS Code extensions. See the [Copilot guide](/github-copilot-complete-guide/).

**Cursor and Windsurf:** Built-in MCP support. Configure in settings, access through chat.

**Claude Code:** Configure MCP in the config file. [Amp Code](/amp-code-free-ai-coding-agent/) and similar tools work the same way.

**Open source LLMs:** Many [open source models](/best-open-source-llms-claude-alternative/) work with MCP through compatible clients.

## Security

MCP servers can access real systems with real credentials. A few things to keep in mind:

**Use Docker.** Containers isolate MCP servers from your system. If something goes wrong, cleanup is easy. See [using Docker with AI CLI tools](/docker-podman-ai-cli-tools-safe-environment/).

**Don&apos;t commit API keys.** Use environment variables, don&apos;t hardcode credentials in config files that might end up in Git.

**Know what&apos;s running.** Periodically check which servers are active and what they can access. Remove servers you&apos;re not using.

## Free options

You don&apos;t need to pay to try MCP:

- **Context7** - Free docs access
- **BrightData MCP** - 5,000 free requests/month
- **Playwright MCP** - Free browser automation
- **SQLite MCP** - Free local database access
- **Docker Desktop** - Free for personal use

You can also [use Claude and GPT for free](/use-claude-sonnet-4-5-gpt-5-free/) through various platforms.

## FAQ

&lt;Accordion label=&quot;Do I need to code to use MCP?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
No. You copy a JSON config once, then interact through natural language. The AI handles the technical bits.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is MCP only for developers?&quot; group=&quot;faq&quot;&gt;
No. Anyone who wants to extend AI capabilities can use it - content creators, researchers, data analysts. If you can benefit from web scraping, database access, or automation, MCP helps.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How is MCP different from ChatGPT plugins?&quot; group=&quot;faq&quot;&gt;
ChatGPT plugins only worked with OpenAI. MCP is an open protocol - build an MCP server once, it works with Claude, Cursor, VS Code, Windsurf, and anything else that supports the protocol.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I build my own MCP server?&quot; group=&quot;faq&quot;&gt;
Yes. Anthropic provides SDKs for building MCP servers. You need programming knowledge, but it&apos;s not that complicated.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need Docker?&quot; group=&quot;faq&quot;&gt;
No, but it makes things easier. Docker handles isolation, dependencies, and updates. Without it you manage all that yourself.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How many servers can I run?&quot; group=&quot;faq&quot;&gt;
No hard limit, but more servers means more tool definitions eating your context window. Use dynamic loading if available.
&lt;/Accordion&gt;

## Wrapping up

MCP makes AI assistants actually useful for real work by connecting them to external tools and data. The protocol itself is straightforward - the complexity comes from managing many servers, which is why Docker&apos;s Toolkit is worth using.

Start with 2-3 servers, use Docker if you can, and add more as you actually need them. Don&apos;t install everything at once.

&lt;Button text=&quot;Get Started with Docker MCP&quot; link=&quot;https://hub.docker.com/mcp&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; /&gt;

## Related Resources

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;[Mastra tools vs MCP](/mastra-tools-vs-mcp/) - When to use native agent tools vs MCP servers (RAM, approvals, feature flags)&lt;/li&gt;
&lt;li&gt;[Build an AI agent with Mastra](/build-ai-agent-mastra/) - TypeScript agent with createTool, memory, and Studio&lt;/li&gt;
&lt;li&gt;[AI Programming Beginners Guide](/ai-programming-beginners-guide/) - Complete guide to programming with AI assistance&lt;/li&gt;
&lt;li&gt;[BrightData MCP Complete Guide](/brightdata-mcp-guide/) - In-depth look at web scraping with MCP&lt;/li&gt;
&lt;li&gt;[Docker/Podman AI CLI Safe Environment](/docker-podman-ai-cli-tools-safe-environment/) - Setting up secure AI development&lt;/li&gt;
&lt;li&gt;[GitHub Copilot Complete Guide](/github-copilot-complete-guide/) - Master AI-assisted coding&lt;/li&gt;
&lt;li&gt;[Best Open Source LLMs](/best-open-source-llms-claude-alternative/) - Alternatives to commercial AI models&lt;/li&gt;
&lt;li&gt;[Top AI GitHub repos](/top-ai-github-repos/) - MCP directory, agents, frameworks, gateways&lt;/li&gt;
&lt;li&gt;[Use Claude and GPT for Free](/use-claude-sonnet-4-5-gpt-5-free/) - Access premium AI models without cost&lt;/li&gt;
&lt;li&gt;[Amp Code Free AI Coding Agent](/amp-code-free-ai-coding-agent/) - Free alternative for AI coding&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;</content:encoded><category>ai</category><category>mcp</category><category>docker</category></item><item><title>How to Self-Host RustFS with Dokploy or Docker Compose</title><link>https://www.bitdoze.com/rustfs-self-host/</link><guid isPermaLink="true">https://www.bitdoze.com/rustfs-self-host/</guid><description>Complete guide to self-hosting RustFS, a high-performance S3-compatible object storage system written in Rust. Deploy with Dokploy or Docker Compose as a MinIO alternative.</description><pubDate>Fri, 05 Dec 2025 00:00:00 GMT</pubDate><content:encoded>import { Image } from &quot;astro:assets&quot;;
import Button from &quot;@components/widgets/Button.astro&quot;;
import Notice from &quot;@components/widgets/Notice.astro&quot;;
import ListCheck from &quot;@components/widgets/ListCheck.astro&quot;;
import Accordion from &quot;@components/widgets/Accordion.astro&quot;;
import YouTubeEmbed from &quot;@components/widgets/YouTubeEmbed.astro&quot;;

If you&apos;ve been using MinIO for S3-compatible object storage, you may have noticed recent changes to its licensing and feature availability. The MinIO &quot;Community Edition&quot; has entered maintenance mode, with key features like the web console and replication now gated behind the commercial &quot;AIStor&quot; product.

Enter **RustFS** — a fast, 100% S3-compatible object storage system written in Rust that&apos;s designed as a MinIO replacement. In this guide, I&apos;ll show you how to self-host RustFS on your infrastructure using either Dokploy (easiest method) or Docker Compose.

## What is RustFS?

[RustFS](https://github.com/rustfs/rustfs) is a high-performance, distributed object storage system built in Rust. It was created to address the licensing and feature-gating issues users faced with MinIO while delivering better performance.

### Key Features of RustFS

&lt;ListCheck&gt;
- **Fast Performance**: 2.3x faster than MinIO for 4KB object payloads
- **100% S3 Compatible**: Works with any S3-compatible application, SDK, or tool
- **Built-in Web Console**: Management interface for buckets, users, and objects
- **Apache 2.0 License**: Permissive licensing with no AGPL restrictions
- **Memory Safe**: Built with Rust for memory safety — no GC pauses or memory leaks
- **Single Binary Deployment**: Simple deployment with minimal dependencies
- **Versioning Support**: Object versioning capabilities
- **Event Notifications**: Webhook support for object events
- **Kubernetes Ready**: Helm charts available for deployments
&lt;/ListCheck&gt;

### RustFS vs MinIO vs Other Object Storage

| Feature | RustFS | MinIO | SeaweedFS | Garage |
|---------|--------|-------|-----------|--------|
| **Primary Goal** | Speed &amp; MinIO drop-in replacement | Enterprise object storage | Scalability &amp; billions of files | Reliability &amp; self-hosting |
| **Web Console** | ✅ Built-in Console | ⚠️ Limited in Community Edition | ✅ Filer UI | ❌ External tools only |
| **License** | Apache 2.0 (Permissive) | AGPL v3 (Restrictive) | Apache 2.0 | AGPLv3 |
| **Language** | Rust (Memory safe, no GC) | Go (GC pauses) | Go | Rust |
| **Performance** | Extremely High (2.3x MinIO) | High | High | Moderate |
| **Maturity** | Alpha/Beta (Newer) | Very Stable | Very Stable | Stable |

&lt;Notice type=&quot;warning&quot; title=&quot;Maturity Consideration&quot;&gt;
RustFS is newer than SeaweedFS or MinIO. While it has a web console, it hasn&apos;t been tested as many years. For critical production data, run tests or consider a hybrid approach during the evaluation period.
&lt;/Notice&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/pNY7NlPuFyM&quot;
  label=&quot;Stop Using Minio? Meet RustFS: The Faster S3 Alternative&quot;
/&gt;

### Why Choose RustFS?

**The &quot;MinIO Feel&quot;**: RustFS replicates MinIO&apos;s interface, including a web console for managing buckets and users. If you&apos;re migrating from MinIO, you&apos;ll feel at home.

**Superior Performance**: RustFS is designed for speed. Rust avoids garbage collection pauses that Go (MinIO&apos;s language) suffers from, making it good for small file operations.

**Apache 2.0 License**: This helps businesses. Unlike MinIO (AGPL), you can use RustFS in commercial products without legal issues or source code disclosure requirements.

**Active Development**: The project has 13.8k+ GitHub stars with active development.

## Prerequisites

Before you begin, make sure you have:

&lt;ListCheck&gt;
- **A VPS or Server**: Minimum 2GB RAM and 2 CPU cores (4GB+ recommended for production)
- **A Domain Name**: For accessing your RustFS console (e.g., `storage.yourdomain.com`)
- **Docker Installed**: Docker 20.10+ and Docker Compose
- **Storage Space**: Adequate disk space for your object storage needs
- **Basic Command Line Knowledge**: For running deployment commands
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Hosting Recommendations&quot;&gt;
For production use, we recommend a VPS with at least 4GB RAM and SSD/NVMe storage. Avoid network file systems (NFS) for the data directory. Providers like Hetzner, DigitalOcean, or AWS work well.
&lt;/Notice&gt;

## Understanding RustFS Ports

RustFS uses two ports by default:

- **Port 9000**: S3 API endpoint — this is where your applications connect to interact with object storage
- **Port 9001**: Web Console — the management interface for creating buckets, managing users, and viewing objects

## Option 1: Deploy with Dokploy (Easiest Method)

Dokploy is an open-source Platform as a Service that simplifies deploying Docker applications. If you haven&apos;t set up Dokploy yet, check out our [Dokploy Installation Guide](https://www.bitdoze.com/dokploy-install/).

### Step 1: Install Dokploy

If not already installed:

```sh
curl -sSL https://dokploy.com/install.sh | sh
```

Access Dokploy at `http://your-vps-ip:3000` and complete the setup.

### Step 2: Create a New Project

1. Log in to Dokploy dashboard
2. Click **&quot;Create Project&quot;** and name it (e.g., &quot;RustFS&quot;)
3. Inside the project, click **&quot;Add Service&quot;** → **&quot;Compose&quot;**
4. Select **&quot;Docker Compose&quot;** type (not Stack)
5. Name it &quot;rustfs-stack&quot;

### Step 3: Add Docker Compose Configuration

Go to the **General** tab and paste the following Docker Compose configuration:

```yaml
services:
  rustfs:
    image: rustfs/rustfs:latest
    networks:
      - dokploy-network
    volumes:
      - rustfs-data:/data
      - rustfs-logs:/app/logs
    environment:
      - RUSTFS_VOLUMES=/data
      - RUSTFS_ADDRESS=0.0.0.0:9000
      - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
      - RUSTFS_CONSOLE_ENABLE=true
      - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
      - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY}
      - RUSTFS_CORS_ALLOWED_ORIGINS=*
      - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
    healthcheck:
      test: [&quot;CMD&quot;, &quot;sh&quot;, &quot;-c&quot;, &quot;curl -f http://localhost:9000/health &amp;&amp; curl -f http://localhost:9001/rustfs/console/health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: &quot;2.0&quot;
          memory: 4GB

networks:
  dokploy-network:
    external: true

volumes:
  rustfs-data:
  rustfs-logs:
```

&lt;Notice type=&quot;info&quot; title=&quot;Volume Explanation&quot;&gt;
- `rustfs-data:/data` - Persists all object storage data (buckets and objects)
- `rustfs-logs:/app/logs` - Persists RustFS logs for debugging and monitoring

Using named volumes ensures data persists across Dokploy deployments.
&lt;/Notice&gt;

### Step 4: Configure the Domains

This Docker Compose configuration doesn&apos;t include Traefik labels or exposed ports. Instead, configure domains through **Dokploy&apos;s Domain tab**:

Be sure that they are pointing to the server.

1. After deploying, go to the **Domains** tab for your compose service
2. Click **Add Domain** and configure for the S3 API:
   - **Domain**: `s3.yourdomain.com`
   - **Container**: Select the `rustfs` service
   - **Port**: `9000`
   - Enable **HTTPS** for automatic SSL
3. Add another domain for the Console:
   - **Domain**: `storage.yourdomain.com`
   - **Container**: Select the `rustfs` service
   - **Port**: `9001`
   - Enable **HTTPS** for automatic SSL

This approach is cleaner than inline Traefik labels and allows easy domain management through the Dokploy UI.


&lt;Notice type=&quot;warning&quot; title=&quot;Important Notes&quot;&gt;
- The `dokploy-network` is required for Traefik routing
- Don&apos;t set `container_name` as it causes issues with Dokploy features
- Generate a strong secret key for production use
&lt;/Notice&gt;

### Step 5: Configure Environment Variables

Go to the **Environment** tab and add these variables:

```sh
# RustFS Access Key (username)
RUSTFS_ACCESS_KEY=rustfsadmin

# RustFS Secret Key (password - generate a strong one!)
RUSTFS_SECRET_KEY=your-super-secure-secret-key-here

# Your domain (optional, for virtual-hosted style URLs)
SERVER_DOMAIN=s3.yourdomain.com
```

Generate a secure secret key:

```sh
openssl rand -base64 32
```

### Step 6: Configure DNS

Before deploying, set up your DNS A records:

1. `s3.yourdomain.com` → Your VPS IP (for S3 API)
2. `storage.yourdomain.com` → Your VPS IP (for Web Console)

### Step 7: Deploy and Access

1. Click **&quot;Deploy&quot;** and wait for the service to start
2. Monitor the logs in the **Deployments** or **Logs** tab
3. Go to the **Domains** tab and add domains for each port (see notes above)
4. Wait about 30 seconds for Traefik to generate SSL certificates

Once deployed, access the web console at `https://storage.yourdomain.com` with your configured credentials.


![RustFS UI](../../assets/images/25/12/rustfs-ui.webp)



## Option 2: Deploy with Docker Compose Only

For more manual control or if you prefer not to use Dokploy, here&apos;s how to deploy with Docker Compose directly.

### Step 1: Prepare Your Server

Update system and install Docker:

```sh
# Update packages
sudo apt update &amp;&amp; sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Install Docker Compose plugin
sudo apt install docker-compose-plugin -y
```

### Step 2: Create Project Directory and Set Permissions

RustFS container runs as a non-root user `rustfs` with UID `10001`. You need to set the correct ownership for mounted directories:

```sh
mkdir -p ~/rustfs
cd ~/rustfs

# Create data and logs directories
mkdir -p data logs

# Change the owner to match the container user (UID 10001)
sudo chown -R 10001:10001 data logs
```

&lt;Notice type=&quot;info&quot; title=&quot;Logs Directory&quot;&gt;
RustFS stores logs in `/app/logs` inside the container. We mount `./logs` to `/app/logs` for persistence.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Permission Required&quot;&gt;
If you skip the `chown` step, RustFS will encounter &quot;permission denied&quot; errors when trying to write data. The container runs as UID 10001 for security purposes.
&lt;/Notice&gt;

### Step 3: Create Docker Compose File

```sh
nano docker-compose.yml
```

Paste the following configuration:

```yaml
services:
  rustfs:
    image: rustfs/rustfs:latest
    container_name: rustfs
    networks:
      - rustfs-network
    volumes:
      - ./data:/data
      - ./logs:/app/logs
    environment:
      - RUSTFS_VOLUMES=/data
      - RUSTFS_ADDRESS=0.0.0.0:9000
      - RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001
      - RUSTFS_CONSOLE_ENABLE=true
      - RUSTFS_ACCESS_KEY=${RUSTFS_ACCESS_KEY:-rustfsadmin}
      - RUSTFS_SECRET_KEY=${RUSTFS_SECRET_KEY}
      - RUSTFS_CORS_ALLOWED_ORIGINS=*
      - RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS=*
    ports:
      - &quot;9000:9000&quot;
      - &quot;9001:9001&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;sh&quot;, &quot;-c&quot;, &quot;curl -f http://localhost:9000/health &amp;&amp; curl -f http://localhost:9001/rustfs/console/health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: &quot;2.0&quot;
          memory: 4GB

networks:
  rustfs-network:
    name: rustfs-network
```

### Step 4: Create Environment File

```sh
nano .env
```

Add your configuration:

```sh
# RustFS Access Key (username)
RUSTFS_ACCESS_KEY=rustfsadmin

# RustFS Secret Key (password - CHANGE THIS!)
RUSTFS_SECRET_KEY=your-super-secure-secret-key-minimum-8-chars
```

&lt;Notice type=&quot;warning&quot; title=&quot;Security Warning&quot;&gt;
Never use the default credentials in production! Generate a strong secret key using `openssl rand -base64 32`.
&lt;/Notice&gt;

### Step 5: Start RustFS

```sh
# Start services
docker compose up -d

# View logs
docker compose logs -f

# Check status
docker compose ps
```

You should see output indicating RustFS is running and the console is available.

### Step 6: Set Up Reverse Proxy with Nginx

For production with custom domains and SSL:

```sh
sudo apt install nginx certbot python3-certbot-nginx -y
```

Create Nginx configuration:

```sh
sudo nano /etc/nginx/sites-available/rustfs
```

Paste:

```nginx
# RustFS S3 API
server {
    listen 80;
    server_name s3.yourdomain.com;

    # Allow large file uploads
    client_max_body_size 0;

    location / {
        proxy_pass http://localhost:9000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;

        # Timeouts for large uploads
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
        proxy_read_timeout 300;
    }
}

# RustFS Console
server {
    listen 80;
    server_name storage.yourdomain.com;

    location / {
        proxy_pass http://localhost:9001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}
```

Enable the site and get SSL certificates:

```sh
sudo ln -s /etc/nginx/sites-available/rustfs /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

# Get SSL certificates
sudo certbot --nginx -d s3.yourdomain.com -d storage.yourdomain.com
```

## Option 3: Quick Start with Docker Run

For quick testing without Docker Compose:

```sh
# Create directories and set permissions
mkdir -p data logs
sudo chown -R 10001:10001 data logs

# Run RustFS
docker run -d \
  --name rustfs \
  -p 9000:9000 \
  -p 9001:9001 \
  -v $(pwd)/data:/data \
  -v $(pwd)/logs:/app/logs \
  -e RUSTFS_VOLUMES=/data \
  -e RUSTFS_ADDRESS=0.0.0.0:9000 \
  -e RUSTFS_CONSOLE_ADDRESS=0.0.0.0:9001 \
  -e RUSTFS_CONSOLE_ENABLE=true \
  -e RUSTFS_ACCESS_KEY=rustfsadmin \
  -e RUSTFS_SECRET_KEY=your-secret-key-here \
  rustfs/rustfs:latest
```

Access the console at `http://localhost:9001` with your credentials.

## Using RustFS

### Access the Web Console

Open your browser and navigate to:

- **Local**: `http://localhost:9001`
- **With Domain**: `https://storage.yourdomain.com`

Log in with your configured access key and secret key (default: `rustfsadmin` / `rustfsadmin`).

### Create a Bucket

1. In the console, click **&quot;Create Bucket&quot;**
2. Enter a bucket name (e.g., `my-first-bucket`)
3. Configure versioning and locking options as needed
4. Click **&quot;Create Bucket&quot;**

### Upload Objects

1. Click on your bucket name
2. Click **&quot;Upload&quot;** or drag and drop files
3. Your files are now stored in RustFS!

### Using S3 CLI (mc)

RustFS is fully compatible with MinIO Client (mc) and AWS CLI:

```sh
# Install MinIO Client
curl https://dl.min.io/client/mc/release/linux-amd64/mc -o mc
chmod +x mc
sudo mv mc /usr/local/bin/

# Configure alias
mc alias set rustfs http://localhost:9000 rustfsadmin your-secret-key

# Or with domain
mc alias set rustfs https://s3.yourdomain.com rustfsadmin your-secret-key

# Create bucket
mc mb rustfs/my-bucket

# Upload file
mc cp myfile.txt rustfs/my-bucket/

# List objects
mc ls rustfs/my-bucket

# Download file
mc cp rustfs/my-bucket/myfile.txt ./downloaded.txt
```

### Using AWS CLI

```sh
# Configure AWS CLI
aws configure
# Access Key ID: rustfsadmin
# Secret Access Key: your-secret-key
# Region: us-east-1 (or any)
# Output format: json

# Use with endpoint URL
aws --endpoint-url http://localhost:9000 s3 ls

# Create bucket
aws --endpoint-url http://localhost:9000 s3 mb s3://my-bucket

# Upload file
aws --endpoint-url http://localhost:9000 s3 cp myfile.txt s3://my-bucket/

# List objects
aws --endpoint-url http://localhost:9000 s3 ls s3://my-bucket/
```

## Integrating with Applications

### Using with Backup Tools

RustFS works with any S3-compatible backup tool. Here&apos;s an example with Restic:

```sh
# Set environment variables
export AWS_ACCESS_KEY_ID=rustfsadmin
export AWS_SECRET_ACCESS_KEY=your-secret-key
export RESTIC_REPOSITORY=s3:http://localhost:9000/backups

# Initialize repository
restic init

# Create backup
restic backup /path/to/data
```

### Using with Docker Registry

Configure Docker Registry to use RustFS as storage backend:

```yaml
services:
  registry:
    image: registry:2
    environment:
      REGISTRY_STORAGE: s3
      REGISTRY_STORAGE_S3_ACCESSKEY: rustfsadmin
      REGISTRY_STORAGE_S3_SECRETKEY: your-secret-key
      REGISTRY_STORAGE_S3_REGION: us-east-1
      REGISTRY_STORAGE_S3_BUCKET: docker-registry
      REGISTRY_STORAGE_S3_REGIONENDPOINT: http://rustfs:9000
```

### Using with Applications

Most applications that support S3 can use RustFS. Common environment variables:

```sh
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY=rustfsadmin
S3_SECRET_KEY=your-secret-key
S3_BUCKET=my-bucket
S3_REGION=us-east-1
```

## Configuration Options

### Environment Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `RUSTFS_VOLUMES` | Storage volume path(s) | — |
| `RUSTFS_ACCESS_KEY` | Access key (username) | `rustfsadmin` |
| `RUSTFS_SECRET_KEY` | Secret key (password) | `rustfsadmin` |
| `RUSTFS_ADDRESS` | S3 API bind address | `0.0.0.0:9000` |
| `RUSTFS_CONSOLE_ADDRESS` | Console bind address | `0.0.0.0:9001` |
| `RUSTFS_CONSOLE_ENABLE` | Enable web console | `false` |
| `RUSTFS_CORS_ALLOWED_ORIGINS` | CORS allowed origins for S3 API | — |
| `RUSTFS_CONSOLE_CORS_ALLOWED_ORIGINS` | CORS allowed origins for console | — |
| `RUSTFS_EXTERNAL_ADDRESS` | External address for redirects | — |
| `RUSTFS_TLS_PATH` | Path to TLS certificates | — |
| `RUSTFS_OBS_ENDPOINT` | OpenTelemetry endpoint | — |
| `RUSTFS_OBS_LOGGER_LEVEL` | Log level (info, debug, etc.) | `info` |

### Command Line Arguments

RustFS can also be configured via command line arguments, though environment variables are recommended for Docker deployments:

```sh
rustfs [OPTIONS] &lt;VOLUMES&gt;

Options:
  --address &lt;ADDRESS&gt;              S3 API bind address (default: :9000)
  --console-address &lt;ADDRESS&gt;      Console bind address (default: :9001)
  --console-enable                 Enable the web console
  --access-key &lt;KEY&gt;               Set access key
  --secret-key &lt;KEY&gt;               Set secret key
  --server-domains &lt;DOMAINS&gt;       Set server domains
```

### TLS Configuration

For native TLS without a reverse proxy:

```yaml
services:
  rustfs:
    image: rustfs/rustfs:latest
    volumes:
      - ./data:/data
      - ./certs:/certs
    environment:
      - RUSTFS_TLS_PATH=/certs
    command: [&quot;--address&quot;, &quot;:9000&quot;, &quot;/data&quot;]
```

Place your `public.crt` and `private.key` in the `./certs` directory.

## Maintenance and Backups

### Backup Strategy

For RustFS data, back up the entire data directory:

```sh
# Stop RustFS (optional, for consistent backup)
docker compose stop rustfs

# Backup data directory
tar -czvf rustfs-backup-$(date +%Y%m%d).tar.gz ./data

# Restart RustFS
docker compose start rustfs
```

For automated backups, create a cron job:

```sh
# Edit crontab
crontab -e

# Add daily backup at 2 AM
0 2 * * * cd /home/user/rustfs &amp;&amp; tar -czvf /backups/rustfs-$(date +\%Y\%m\%d).tar.gz ./data
```

### Updating RustFS

```sh
# Pull latest image
docker compose pull

# Recreate container with new image
docker compose up -d

# Clean up old images
docker image prune -f
```

&lt;Notice type=&quot;info&quot; title=&quot;Version Pinning&quot;&gt;
For production, consider pinning to a specific version:

```yaml
image: rustfs/rustfs:1.0.0.alpha.68
```

Check the [RustFS releases](https://github.com/rustfs/rustfs/releases) for the latest version.
&lt;/Notice&gt;

### Monitoring

RustFS supports observability through Prometheus metrics. The official docker-compose includes Grafana, Prometheus, and Jaeger:

```sh
# Clone RustFS repository
git clone https://github.com/rustfs/rustfs.git
cd rustfs

# Start with observability stack
docker compose --profile observability up -d
```

This starts:
- **Grafana** on port 3000
- **Prometheus** on port 9090
- **Jaeger** on port 16686

## Security Best Practices

&lt;ListCheck&gt;
- **Change Default Credentials**: Never use `rustfsadmin/rustfsadmin` in production
- **Use Strong Secret Keys**: Generate with `openssl rand -base64 32`
- **Enable TLS**: Use HTTPS for all connections (via reverse proxy or native TLS)
- **Restrict Network Access**: Use firewall rules to limit access to ports 9000/9001
- **Regular Backups**: Implement automated backup strategy
- **Monitor Logs**: Check logs regularly for suspicious activity
- **Keep Updated**: Regularly update to the latest RustFS version
- **Use Named Volumes**: For Docker deployments, use named volumes for data persistence
&lt;/ListCheck&gt;

## Migrating from MinIO

RustFS is designed as a drop-in replacement for MinIO. For most applications, you can:

1. Export your data from MinIO using `mc mirror`
2. Deploy RustFS with the same bucket structure
3. Import your data using `mc mirror`
4. Update your application&apos;s S3 endpoint URL

```sh
# Export from MinIO
mc mirror minio/my-bucket ./backup/

# Import to RustFS
mc mirror ./backup/ rustfs/my-bucket/
```

## Conclusion

RustFS provides a self-hosted alternative to MinIO with better performance, a permissive Apache 2.0 license, and a familiar user experience. While it&apos;s newer than MinIO or SeaweedFS, its development and community make it worth considering for your object storage needs.

### Key Takeaways

&lt;ListCheck&gt;
- **Performance**: 2.3x faster than MinIO for small object operations
- **License**: Apache 2.0 — no AGPL restrictions
- **Compatibility**: 100% S3 compatible with built-in web console
- **Easy Deployment**: Single container with minimal configuration
- **Active Development**: Growing with community support
&lt;/ListCheck&gt;

### Next Steps

&lt;ListCheck&gt;
- Explore the [RustFS Documentation](https://docs.rustfs.com/) for advanced configuration
- Set up automated backups for your data
- Configure monitoring with Prometheus and Grafana
- Join the [GitHub Discussions](https://github.com/rustfs/rustfs/discussions) community
- Consider contributing to the project
&lt;/ListCheck&gt;

## Frequently Asked Questions

&lt;Accordion label=&quot;Is RustFS production-ready?&quot; group=&quot;faq&quot;&gt;
RustFS is currently in alpha/beta stage. While it&apos;s feature-complete for basic object storage operations and performs excellently in benchmarks, it hasn&apos;t been battle-tested as long as MinIO or SeaweedFS. For critical production workloads, consider running extensive tests first or maintaining backups on a proven system during the evaluation period.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I migrate from MinIO to RustFS?&quot; group=&quot;faq&quot;&gt;
Yes! RustFS is designed as a drop-in replacement for MinIO. You can use `mc mirror` to copy data between MinIO and RustFS. Since both are 100% S3 compatible, your applications should work without code changes — just update the endpoint URL.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the difference between ports 9000 and 9001?&quot; group=&quot;faq&quot;&gt;
- **Port 9000**: The S3 API endpoint — this is where applications connect to upload/download objects
- **Port 9001**: The web console — a management interface for creating buckets, managing users, and browsing objects

For applications, you&apos;ll typically only expose port 9000. The console (9001) can be restricted to internal access.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why do I need to chown the data directory to UID 10001?&quot; group=&quot;faq&quot;&gt;
RustFS runs as a non-root user inside the container for security purposes. This user has UID 10001. If you mount a host directory, it needs to be owned by this UID so RustFS can write data. This is a security best practice that prevents the container from running as root.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How does RustFS compare to SeaweedFS?&quot; group=&quot;faq&quot;&gt;
Both are excellent S3-compatible storage systems:
- **RustFS**: Focuses on performance and being a MinIO drop-in replacement with a web console
- **SeaweedFS**: More mature, focuses on scalability with billions of small files

Choose RustFS if you want the MinIO experience with better performance. Choose SeaweedFS if you need proven stability and massive scale.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does RustFS support distributed/cluster mode?&quot; group=&quot;faq&quot;&gt;
Distributed mode is currently under testing in RustFS. For production cluster deployments, you may want to wait for the feature to stabilize or use the single-node mode with proper backups. Check the [RustFS GitHub](https://github.com/rustfs/rustfs) for the latest status on distributed mode.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use RustFS with Kubernetes?&quot; group=&quot;faq&quot;&gt;
Yes! RustFS provides official Helm charts for Kubernetes deployment. Check the `helm/` directory in the RustFS repository for installation instructions. The charts support various configurations including resource limits, persistence, and ingress.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I enable TLS/HTTPS?&quot; group=&quot;faq&quot;&gt;
You have two options:
1. **Reverse Proxy (Recommended)**: Use Nginx, Traefik, or Caddy in front of RustFS to handle TLS termination
2. **Native TLS**: Mount certificates to the container and set `RUSTFS_TLS_PATH=/certs`

For Dokploy deployments, HTTPS is automatically handled by Traefik when you configure domains.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category><category>storage</category></item><item><title>How to Self-Host Cognee with Dokploy or Docker Compose</title><link>https://www.bitdoze.com/cognee-self-host/</link><guid isPermaLink="true">https://www.bitdoze.com/cognee-self-host/</guid><description>Complete guide to self-hosting Cognee AI memory platform on your own infrastructure using Dokploy or Docker Compose with PostgreSQL, pgvector, and MCP integration.</description><pubDate>Thu, 27 Nov 2025 00:00:00 GMT</pubDate><content:encoded>If you&apos;re building AI applications that need persistent memory and knowledge graphs, [Cognee](https://github.com/topoteretes/cognee) is an open-source platform worth looking at. It turns your data into structured knowledge graphs with semantic search, and it works well for RAG applications, chatbots, and AI assistants. Self-hosting means you own your data and keep costs predictable.

In this guide, I&apos;ll show you how to self-host Cognee on your own infrastructure using Dokploy or Docker Compose. We&apos;ll set up a production-ready deployment with PostgreSQL and pgvector for both metadata and vector storage, using OpenAI for the embedding model, plus the MCP server for AI assistant integration.

## What is Cognee?

[Cognee](https://github.com/topoteretes/cognee) is an AI memory platform that organizes your data into knowledge graphs. Where vector databases stop at similarity search, Cognee maps relationships between your data points so AI applications can retrieve and reason across connected information.

### Key Features of Cognee

&lt;ListCheck&gt;
- **Knowledge Graph Construction**: Automatically extracts entities and relationships from your documents
- **Vector Embeddings**: Semantic search using configurable embedding providers (OpenAI, Gemini, Ollama, etc.)
- **Multi-Provider LLM Support**: Works with OpenAI, Anthropic, Google Gemini, Ollama, and more
- **MCP Integration**: Model Context Protocol support for AI coding assistants like Cursor, Claude, and VS Code
- **REST API**: Full-featured API for data ingestion, processing, and search
- **Flexible Storage**: Supports PostgreSQL, SQLite, Neo4j, and various vector stores
- **Code Intelligence**: Special pipelines for analyzing and understanding codebases
- **Dataset Management**: Organize data into separate datasets with permissions
- **Session Memory**: Maintain conversational context across interactions
&lt;/ListCheck&gt;

### Why Self-Host Cognee?

**Benefits of Self-Hosting**:
- Complete data privacy and ownership
- No usage limits or API costs (beyond LLM providers)
- Custom infrastructure and scaling options
- Integration with private networks and services
- Full control over model and embedding choices

**Use Cases**:
- Building AI assistants with long-term memory
- RAG (Retrieval Augmented Generation) applications
- Code analysis and documentation tools
- Knowledge management systems
- AI-powered search for internal documents

## Prerequisites

Before you begin, make sure you have:

&lt;ListCheck&gt;
- **A VPS or Server**: Minimum 4GB RAM and 2 CPU cores recommended
- **A Domain Name**: For accessing your Cognee API (e.g., `cognee.yourdomain.com`)
- **Docker Installed**: Docker and Docker Compose (Dokploy includes this)
- **OpenAI API Key**: For embeddings and LLM operations (or alternative provider)
- **Basic Command Line Knowledge**: For running deployment commands
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud Now&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Hosting Recommendations&quot;&gt;
For production use, we recommend a VPS with at least 4GB RAM. We use `pgvector/pgvector:pg17` which is PostgreSQL with the pgvector extension - this single database handles both relational data (metadata, users, datasets) AND vector embeddings for semantic search. This simplifies deployment significantly. Providers like Hetzner, DigitalOcean, or AWS work well.
&lt;/Notice&gt;

## Understanding the Storage Architecture

Before deploying, understand how Cognee stores data:

&lt;Notice type=&quot;info&quot; title=&quot;Three Storage Layers&quot;&gt;
Cognee uses three storage layers, and our Docker Compose handles all of them:

1. **Relational Database** (`DB_PROVIDER=postgres`): Stores metadata, user accounts, datasets, document information, and pipeline state
2. **Vector Database** (`VECTOR_DB_PROVIDER=pgvector`): Stores embeddings for semantic similarity search using the pgvector extension
3. **Graph Database** (`GRAPH_DATABASE_PROVIDER=kuzu`): Stores knowledge graph data (entities and relationships) in a file-based directory

We use `pgvector/pgvector:pg17` - PostgreSQL 17 with the pgvector extension - for both relational and vector storage. For the graph database, **Kuzu stores data inside the container&apos;s filesystem**, so we need a volume to persist it across container restarts.
&lt;/Notice&gt;

## Option 1: Deploy with Dokploy

Dokploy is an open-source Platform as a Service that simplifies deploying Docker applications. If you haven&apos;t set up Dokploy yet, check out our [Dokploy Installation Guide](https://www.bitdoze.com/dokploy-install/).

### Step 1: Install Dokploy

If not already installed:

```sh
curl -sSL https://dokploy.com/install.sh | sh
```

Access Dokploy at `http://your-vps-ip:3000` and complete the setup.

### Step 2: Create a New Project

1. Log in to Dokploy dashboard
2. Click **&quot;Create Project&quot;** and name it (e.g., &quot;Cognee&quot;)
3. Inside the project, click **&quot;Add Service&quot;** → **&quot;Compose&quot;**
4. Select **&quot;Docker Compose&quot;** type (not Stack)
5. Name it &quot;cognee-stack&quot;

### Step 3: Add Docker Compose Configuration

Go to the **General** tab and paste the following Docker Compose configuration:

```yaml
services:
  cognee:
    image: cognee/cognee:main
    networks:
      - dokploy-network
      - cognee-network
    volumes:
      - cognee-data:/app/.cognee_system
    environment:
      - HOST=0.0.0.0
      - ENVIRONMENT=production
      - LOG_LEVEL=INFO
      # Authentication (REQUIRED for public deployment)
      - REQUIRE_AUTHENTICATION=true
      # LLM Configuration
      - LLM_API_KEY=${LLM_API_KEY}
      - LLM_PROVIDER=${LLM_PROVIDER:-openai}
      - LLM_MODEL=${LLM_MODEL:-gpt-4o-mini}
      # Embedding Configuration (OpenAI)
      - EMBEDDING_PROVIDER=${EMBEDDING_PROVIDER:-openai}
      - EMBEDDING_MODEL=${EMBEDDING_MODEL:-openai/text-embedding-3-small}
      - EMBEDDING_DIMENSIONS=${EMBEDDING_DIMENSIONS:-1536}
      - EMBEDDING_API_KEY=${LLM_API_KEY}
      # Database Configuration (PostgreSQL for relational data)
      - DB_PROVIDER=postgres
      - DB_HOST=cognee-postgres
      - DB_PORT=5432
      - DB_NAME=cognee_db
      - DB_USERNAME=cognee
      - DB_PASSWORD=${DB_PASSWORD}
      # Vector Database (pgvector - uses SAME PostgreSQL instance)
      - VECTOR_DB_PROVIDER=pgvector
      # Graph Database (default Kuzu - file-based)
      - GRAPH_DATABASE_PROVIDER=${GRAPH_DATABASE_PROVIDER:-kuzu}
    depends_on:
      cognee-postgres:
        condition: service_healthy
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8000/health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    deploy:
      resources:
        limits:
          cpus: &quot;2.0&quot;
          memory: 4GB

  cognee-mcp:
    image: cognee/cognee-mcp:main
    networks:
      - dokploy-network
      - cognee-network
    environment:
      - TRANSPORT_MODE=http
      - API_URL=http://cognee:8000
      - LOG_LEVEL=INFO
    depends_on:
      cognee:
        condition: service_healthy
    restart: unless-stopped

  cognee-postgres:
    image: pgvector/pgvector:pg17
    networks:
      - cognee-network
    environment:
      - POSTGRES_USER=cognee
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=cognee_db
    volumes:
      - cognee-postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U cognee -d cognee_db&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

networks:
  cognee-network:
    name: cognee-network
  dokploy-network:
    external: true

volumes:
  cognee-data:
  cognee-postgres-data:
```

&lt;Notice type=&quot;info&quot; title=&quot;Volume Explanation&quot;&gt;
- `cognee-data:/app/.cognee_system` - Persists Kuzu graph database and system files
- `cognee-postgres-data:/var/lib/postgresql/data` - Persists PostgreSQL data (relational + vector)

Named volumes persist data across Dokploy deployments.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;UI Not Available in Docker&quot;&gt;
The Cognee frontend Docker image (`cognee-frontend`) is experimental and currently not well-supported. **For the Cognee UI**, you need to run `cognee-cli -ui` locally with a Python installation, which launches both frontend and backend. For Docker deployments, use the Swagger UI at `https://cognee.yourdomain.com/docs` for full API access.
&lt;/Notice&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Configuring Domains in Dokploy&quot;&gt;
This Docker Compose configuration doesn&apos;t include Traefik labels or exposed ports. Instead, configure domains through **Dokploy&apos;s Domain tab**:

1. After deploying, go to the **Domains** tab for your compose service
2. Click **Add Domain** and configure:
   - **Domain**: `cognee.yourdomain.com`
   - **Container**: Select the `cognee` service
   - **Port**: `8000`
   - Enable **HTTPS** for automatic SSL
3. Repeat for the MCP service:
   - **Domain**: `mcp.yourdomain.com`
   - **Container**: Select the `cognee-mcp` service
   - **Port**: `8000`

This approach is cleaner than inline Traefik labels and allows domain management through Dokploy UI.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Important Notes&quot;&gt;
- The `dokploy-network` is required for Traefik routing
- Don&apos;t set `container_name` as it causes issues with Dokploy features
- The same PostgreSQL instance (`cognee-postgres`) is used for BOTH relational data AND vector storage via pgvector
&lt;/Notice&gt;

### Step 4: Configure Environment Variables

Go to the **Environment** tab and add these variables:

```sh
# OpenAI API Key (required for LLM and embeddings)
LLM_API_KEY=sk-your-openai-api-key-here

# Database Password (generate a strong password)
DB_PASSWORD=your-secure-database-password-here

# Optional: LLM Configuration
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini

# Optional: Embedding Configuration
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=openai/text-embedding-3-small
EMBEDDING_DIMENSIONS=1536

# Optional: Graph Database (kuzu is default, can use neo4j or falkordb)
GRAPH_DATABASE_PROVIDER=kuzu
```

Generate a secure database password:
```sh
openssl rand -base64 32
```

### Step 5: Configure DNS

Before deploying, set up your DNS A records:

1. `cognee.yourdomain.com` → Your VPS IP
2. `mcp.yourdomain.com` → Your VPS IP

### Step 6: Deploy and Configure Domains

1. Click **&quot;Deploy&quot;** and wait for the services to start
2. Monitor the logs in the **Deployments** or **Logs** tab
3. Go to the **Domains** tab and add domains for each service (see notes above)
4. Wait about 30 seconds for Traefik to generate SSL certificates

Once deployed, verify:
```sh
# Check API health
curl https://cognee.yourdomain.com/health

# Check MCP health
curl https://mcp.yourdomain.com/health

# Access API documentation
open https://cognee.yourdomain.com/docs
```

## Option 2: Deploy with Docker Compose Only

For more manual control or to avoid Dokploy, here&apos;s how to deploy with Docker Compose directly.

### Step 1: Prepare Your Server

Update system and install Docker:

```sh
# Update packages
sudo apt update &amp;&amp; sudo apt upgrade -y

# Install Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh

# Install Docker Compose plugin
sudo apt install docker-compose-plugin -y
```

### Step 2: Create Project Directory

```sh
mkdir -p ~/cognee
cd ~/cognee
```

### Step 3: Create Docker Compose File

```sh
nano docker-compose.yml
```

Paste the following configuration:

```yaml
services:
  cognee:
    image: cognee/cognee:main
    container_name: cognee
    networks:
      - cognee-network
    volumes:
      - cognee-data:/app/.cognee_system
    environment:
      - HOST=0.0.0.0
      - ENVIRONMENT=production
      - LOG_LEVEL=INFO
      # Authentication (REQUIRED for public deployment)
      - REQUIRE_AUTHENTICATION=true
      # LLM Configuration
      - LLM_API_KEY=${LLM_API_KEY}
      - LLM_PROVIDER=${LLM_PROVIDER:-openai}
      - LLM_MODEL=${LLM_MODEL:-gpt-4o-mini}
      # Embedding Configuration (OpenAI)
      - EMBEDDING_PROVIDER=${EMBEDDING_PROVIDER:-openai}
      - EMBEDDING_MODEL=${EMBEDDING_MODEL:-openai/text-embedding-3-small}
      - EMBEDDING_DIMENSIONS=${EMBEDDING_DIMENSIONS:-1536}
      - EMBEDDING_API_KEY=${LLM_API_KEY}
      # Database Configuration (PostgreSQL for relational data)
      - DB_PROVIDER=postgres
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_NAME=cognee_db
      - DB_USERNAME=cognee
      - DB_PASSWORD=${DB_PASSWORD}
      # Vector Database (pgvector - uses SAME PostgreSQL instance)
      - VECTOR_DB_PROVIDER=pgvector
      # Graph Database
      - GRAPH_DATABASE_PROVIDER=${GRAPH_DATABASE_PROVIDER:-kuzu}
    ports:
      - &quot;8000:8000&quot;
    depends_on:
      postgres:
        condition: service_healthy
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8000/health&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
    restart: unless-stopped
    deploy:
      resources:
        limits:
          cpus: &quot;2.0&quot;
          memory: 4GB

  cognee-mcp:
    image: cognee/cognee-mcp:main
    container_name: cognee-mcp
    networks:
      - cognee-network
    environment:
      - TRANSPORT_MODE=http
      - API_URL=http://cognee:8000
      - LOG_LEVEL=INFO
    ports:
      - &quot;8001:8000&quot;
    depends_on:
      cognee:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: pgvector/pgvector:pg17
    container_name: cognee-postgres
    networks:
      - cognee-network
    environment:
      - POSTGRES_USER=cognee
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=cognee_db
    volumes:
      - cognee-postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: [&quot;CMD-SHELL&quot;, &quot;pg_isready -U cognee -d cognee_db&quot;]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

networks:
  cognee-network:
    name: cognee-network

volumes:
  cognee-postgres-data:
  cognee-data:
```

&lt;Notice type=&quot;info&quot; title=&quot;Volume Explanation&quot;&gt;
- `cognee-data:/app/.cognee_system` - Persists Kuzu graph database and Cognee system files
- `cognee-postgres-data:/var/lib/postgresql/data` - Persists PostgreSQL data (relational + vector via pgvector)

The `pgvector/pgvector:pg17` image is PostgreSQL 17 with the pgvector extension. When we set `DB_PROVIDER=postgres` and `VECTOR_DB_PROVIDER=pgvector`, Cognee uses the **same PostgreSQL database** for both relational and vector storage.
&lt;/Notice&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;UI Not Available in Docker&quot;&gt;
The Cognee frontend Docker image is experimental and currently not well-supported. **To access the Cognee UI**, you need to run `cognee-cli -ui` locally with a Python installation. For Docker deployments, use the Swagger UI at `http://localhost:8000/docs` for full API access.
&lt;/Notice&gt;

### Step 4: Create Environment File

```sh
nano .env
```

Add your configuration:

```sh
# OpenAI API Key (required)
LLM_API_KEY=sk-your-openai-api-key-here

# LLM Configuration
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini

# Embedding Configuration
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=openai/text-embedding-3-small
EMBEDDING_DIMENSIONS=1536

# Database Configuration
DB_PASSWORD=your-secure-database-password

# Graph Database Provider (kuzu, neo4j, or falkordb)
GRAPH_DATABASE_PROVIDER=kuzu
```

### Step 5: Start Cognee

```sh
# Start services
docker compose up -d

# View logs
docker compose logs -f

# Check status
docker compose ps
```

### Step 6: Set Up Reverse Proxy with Nginx

For production with custom domains and SSL:

```sh
sudo apt install nginx certbot python3-certbot-nginx -y
```

Create Nginx configuration:

```sh
sudo nano /etc/nginx/sites-available/cognee
```

Paste:

```nginx
# Cognee API
server {
    listen 80;
    server_name cognee.yourdomain.com;

    location / {
        proxy_pass http://localhost:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
    }
}

# MCP Server
server {
    listen 80;
    server_name mcp.yourdomain.com;

    location / {
        proxy_pass http://localhost:8001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &apos;upgrade&apos;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300;
        proxy_connect_timeout 300;
        proxy_send_timeout 300;
    }
}
```

Enable and get SSL:

```sh
# Enable site
sudo ln -s /etc/nginx/sites-available/cognee /etc/nginx/sites-enabled/

# Test configuration
sudo nginx -t

# Reload Nginx
sudo systemctl reload nginx

# Get SSL certificates
sudo certbot --nginx -d cognee.yourdomain.com -d mcp.yourdomain.com
```

## Option 3: MCP Server Only (Without Full API)

If you only need the MCP server for AI coding assistants like Cursor or Claude Code, you can run the MCP server standalone **without the full Cognee API stack**. This is a lightweight option perfect for personal development environments.

&lt;Notice type=&quot;info&quot; title=&quot;When to Use MCP-Only Mode&quot;&gt;
The standalone MCP server is ideal when:
- You only need AI assistant memory features (not the full REST API)
- You want a minimal, single-container deployment
- You&apos;re using it for personal development, not shared team knowledge graphs
- You want quick setup without managing PostgreSQL

Each MCP instance maintains its own separate data in this mode.
&lt;/Notice&gt;

### Quick Start with Docker

```sh
# Set your API key
export LLM_API_KEY=your_openai_api_key_here

# Create env file
echo &quot;LLM_API_KEY=$LLM_API_KEY&quot; &gt; .env

# Start MCP server
docker run -e TRANSPORT_MODE=http --env-file ./.env -p 8000:8000 --rm -it cognee/cognee-mcp:main
```

### Verify the Server

```sh
curl http://localhost:8000/health
```

### Connect to AI Clients

Once running, connect your AI coding assistant:

**Cursor IDE:**
```json
{
  &quot;mcpServers&quot;: {
    &quot;cognee&quot;: {
      &quot;url&quot;: &quot;http://localhost:8000/mcp&quot;
    }
  }
}
```

**Claude Code:**
```sh
claude mcp add --transport http cognee http://localhost:8000/mcp -s project
```

### Docker Compose for MCP-Only (with Persistence)

For persistent storage with the standalone MCP server:

```yaml
services:
  cognee-mcp:
    image: cognee/cognee-mcp:main
    container_name: cognee-mcp
    environment:
      - TRANSPORT_MODE=http
      - LLM_API_KEY=${LLM_API_KEY}
      - LLM_PROVIDER=${LLM_PROVIDER:-openai}
      - LLM_MODEL=${LLM_MODEL:-gpt-4o-mini}
      - LOG_LEVEL=INFO
    volumes:
      - cognee-mcp-data:/app/.cognee_system
    ports:
      - &quot;8000:8000&quot;
    restart: unless-stopped

volumes:
  cognee-mcp-data:
```

Create `.env` file:
```sh
LLM_API_KEY=sk-your-openai-api-key-here
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
```

Start with:
```sh
docker compose up -d
```

&lt;Notice type=&quot;warning&quot; title=&quot;Standalone vs API Mode&quot;&gt;
**Standalone Mode** (shown above): Each MCP instance has its own database. Data is not shared between instances.

**API Mode** (Options 1 &amp; 2): Multiple MCP clients connect to a shared Cognee backend with centralized PostgreSQL storage. Use this for team collaboration or when you need the full REST API.
&lt;/Notice&gt;

## Configuration Options

Cognee is highly configurable. Here are the key options you can customize:

### LLM Providers

Cognee supports multiple LLM providers. Update the environment variables accordingly:

**OpenAI (Default):**
```sh
LLM_PROVIDER=openai
LLM_MODEL=gpt-4o-mini
LLM_API_KEY=sk-your-key
```

**Anthropic Claude:**
```sh
LLM_PROVIDER=anthropic
LLM_MODEL=claude-3-5-sonnet-20241022
LLM_API_KEY=sk-ant-your-key
```

**Google Gemini:**
```sh
LLM_PROVIDER=gemini
LLM_MODEL=gemini/gemini-2.0-flash
LLM_API_KEY=AIza-your-key
```

**Ollama (Local):**
```sh
LLM_PROVIDER=ollama
LLM_MODEL=llama3.1:8b
LLM_ENDPOINT=http://host.docker.internal:11434/v1
LLM_API_KEY=ollama
```

### Embedding Providers

Configure embedding models for vector search:

**OpenAI (Default):**
```sh
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=openai/text-embedding-3-small
EMBEDDING_DIMENSIONS=1536
```

**OpenAI Large (Better Quality):**
```sh
EMBEDDING_PROVIDER=openai
EMBEDDING_MODEL=openai/text-embedding-3-large
EMBEDDING_DIMENSIONS=3072
```

**Google Gemini:**
```sh
EMBEDDING_PROVIDER=gemini
EMBEDDING_MODEL=gemini/text-embedding-004
EMBEDDING_DIMENSIONS=768
EMBEDDING_API_KEY=AIza-your-key
```

&lt;Notice type=&quot;warning&quot; title=&quot;Dimension Consistency&quot;&gt;
If you change embedding dimensions, you must reset your vector database. The dimensions must match between your embedding provider and vector store configuration. Since we use pgvector (same PostgreSQL), resetting means dropping and recreating the vector tables.
&lt;/Notice&gt;

### Graph Database Options

Cognee supports different graph databases for knowledge graph storage:

**Kuzu (Default - File-based):**
```sh
GRAPH_DATABASE_PROVIDER=kuzu
```

**Neo4j (For production/multi-agent):**
```sh
GRAPH_DATABASE_PROVIDER=neo4j
GRAPH_DATABASE_URL=bolt://neo4j:7687
GRAPH_DATABASE_USERNAME=neo4j
GRAPH_DATABASE_PASSWORD=your-password
```

To add Neo4j to your Docker Compose:

```yaml
  neo4j:
    image: neo4j:latest
    container_name: cognee-neo4j
    networks:
      - cognee-network
    ports:
      - &quot;7474:7474&quot;
      - &quot;7687:7687&quot;
    environment:
      - NEO4J_AUTH=neo4j/your-password
      - NEO4J_PLUGINS=[&quot;apoc&quot;, &quot;graph-data-science&quot;]
    volumes:
      - neo4j-data:/data
```

## Using the Cognee API

Once deployed, you can interact with Cognee via its REST API. With authentication enabled, you need to register and login first.

&lt;Notice type=&quot;info&quot; title=&quot;Accessing Cognee&quot;&gt;
Cognee provides several ways to interact with it:

1. **Swagger UI** - Interactive API documentation at `/docs` (e.g., `https://cognee.yourdomain.com/docs`) - **Recommended for Docker deployments**
2. **REST API** - All operations via HTTP endpoints
3. **MCP Integration** - Through AI coding assistants like Cursor or Claude
4. **CLI with Web UI** - Run `cognee-cli -ui` locally to launch a full web interface (requires local Python installation)

**Note**: The Cognee Web UI is currently only available through the CLI (`cognee-cli -ui`), not via Docker. For Docker deployments, use the Swagger UI at `/docs` for complete API access.
&lt;/Notice&gt;

### Check Health

```sh
curl https://cognee.yourdomain.com/health
```

### Register a User

```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/auth/register&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{&quot;email&quot;: &quot;user@example.com&quot;, &quot;password&quot;: &quot;your-strong-password&quot;}&apos;
```

### Login and Get Token

```sh
TOKEN=$(curl -s -X POST &quot;https://cognee.yourdomain.com/api/v1/auth/login&quot; \
  -H &quot;Content-Type: application/x-www-form-urlencoded&quot; \
  -d &quot;username=user@example.com&amp;password=your-strong-password&quot; | jq -r .access_token)

echo $TOKEN
```

### Create a Dataset

```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/datasets&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; \
  -d &apos;{&quot;name&quot;: &quot;my_documents&quot;}&apos;
```

### Add Data

```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/add&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; \
  -F &quot;data=@/path/to/document.pdf&quot; \
  -F &quot;datasetName=my_documents&quot;
```

### Build Knowledge Graph (Cognify)

```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/cognify&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; \
  -d &apos;{&quot;datasets&quot;: [&quot;my_documents&quot;]}&apos;
```

### Search

```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/search&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot; \
  -d &apos;{&quot;query&quot;: &quot;What are the main topics?&quot;, &quot;datasets&quot;: [&quot;my_documents&quot;], &quot;top_k&quot;: 10}&apos;
```

### View API Documentation (Swagger UI)

The **Swagger UI** is your main interface for exploring and testing the API:
```
https://cognee.yourdomain.com/docs
```

This interactive documentation lets you:
- Browse all available endpoints
- Test API calls directly in the browser
- View request/response schemas
- Authenticate and manage your session

## Using MCP with Self-Hosted Cognee

Cognee&apos;s Model Context Protocol (MCP) integration allows AI coding assistants like Cursor, Claude Code, and VS Code extensions to use Cognee as persistent memory.

### What is MCP?

MCP (Model Context Protocol) is a standard for connecting AI assistants to external tools and data sources. Cognee&apos;s MCP server provides 11 tools including:

- **add**: Store documents and data in memory
- **cognify**: Transform data into knowledge graphs
- **search**: Semantic search across your knowledge
- **codify**: Analyze and index code repositories
- **save_interaction**: Store conversation context
- **get_developer_rules**: Retrieve coding patterns and rules
- **list_datasets**: View all stored datasets
- **prune**: Clear all memory for a fresh start

### MCP is Already Included!

If you followed the Docker Compose configurations above, the MCP server is already running as part of your deployment:

- **Dokploy**: Available at `https://mcp.yourdomain.com`
- **Docker Compose**: Available at `http://localhost:8001` (or your configured domain)

The MCP server connects to the Cognee backend internally via the Docker network (`http://cognee:8000`).

### Connecting Cursor IDE

1. Open Cursor Settings → Tools &amp; MCP
2. Click **+ Add MCP Server**
3. Add this configuration to `mcp.json`:

For local development:
```json
{
  &quot;mcpServers&quot;: {
    &quot;cognee&quot;: {
      &quot;url&quot;: &quot;http://localhost:8001/mcp&quot;
    }
  }
}
```

For your public MCP server:
```json
{
  &quot;mcpServers&quot;: {
    &quot;cognee&quot;: {
      &quot;url&quot;: &quot;https://mcp.yourdomain.com/mcp&quot;
    }
  }
}
```

4. Refresh the MCP connection in Cursor
5. Use Agent mode to access Cognee tools

### Connecting Claude Code

```sh
# For local development
claude mcp add --transport http cognee http://localhost:8001/mcp -s project

# For remote server
claude mcp add --transport http cognee https://mcp.yourdomain.com/mcp -s project
```

### Using MCP Tools

Once connected, you can ask your AI assistant to:

- &quot;Add this file to Cognee memory&quot;
- &quot;Search Cognee for authentication patterns&quot;
- &quot;Codify this repository to build a knowledge graph&quot;
- &quot;Save our conversation as developer rules&quot;
- &quot;List all my Cognee datasets&quot;

The AI will automatically use the appropriate Cognee MCP tools.

&lt;Notice type=&quot;info&quot; title=&quot;MCP Authentication&quot;&gt;
The MCP server connects to your Cognee backend internally. If you need to authenticate MCP requests to the Cognee API, you can add `API_TOKEN` environment variable to the MCP service configuration.
&lt;/Notice&gt;

## Maintenance and Backups

### Regular Backups

**For Dokploy deployments**, configure automated backups through Dokploy&apos;s interface or follow our [Dokploy Backups Guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/).

**For Docker Compose with PostgreSQL:**

```sh
# Manual backup (includes both relational data AND vector embeddings)
docker compose exec postgres pg_dump -U cognee cognee_db &gt; backup-$(date +%Y%m%d).sql

# Restore backup
cat backup-20241127.sql | docker compose exec -T postgres psql -U cognee cognee_db
```

**Automated backup script** (`backup.sh`):

```bash
#!/bin/bash
BACKUP_DIR=&quot;/backups/cognee&quot;
DATE=$(date +%Y%m%d-%H%M)
mkdir -p $BACKUP_DIR

# Backup PostgreSQL (contains both relational and vector data)
docker compose exec -T postgres pg_dump -U cognee cognee_db | gzip &gt; $BACKUP_DIR/cognee-$DATE.sql.gz

# Keep only last 7 days
find $BACKUP_DIR -name &quot;cognee-*.sql.gz&quot; -mtime +7 -delete
```

Add to crontab:
```sh
chmod +x backup.sh
crontab -e
# Add: 0 2 * * * /path/to/backup.sh
```

### Updating Cognee

**With Dokploy:**
1. Go to your service
2. Click **&quot;Redeploy&quot;**
3. Dokploy pulls the latest image

**With Docker Compose:**
```sh
cd ~/cognee
docker compose pull
docker compose up -d
```

&lt;Notice type=&quot;info&quot; title=&quot;Version Pinning&quot;&gt;
For production stability, consider pinning to a specific version tag instead of `:main`:

```yaml
image: cognee/cognee:v0.1.0
image: cognee/cognee-mcp:v0.1.0
```

Check the [GitHub releases](https://github.com/topoteretes/cognee/releases) for versions.
&lt;/Notice&gt;

## Security Best Practices

&lt;ListCheck&gt;
- **Authentication Enabled**: We set `REQUIRE_AUTHENTICATION=true` - never disable this for public deployments
- **Use HTTPS**: Always use SSL/TLS in production (Traefik/Certbot handles this)
- **Strong Passwords**: Use complex passwords for database and user accounts
- **Environment Variables**: Never commit `.env` files to version control
- **Firewall**: Only expose necessary ports (80, 443)
- **Regular Updates**: Keep Cognee and Docker images updated
- **Backup Encryption**: Encrypt database backups at rest
- **Network Isolation**: Use Docker networks to isolate services
- **Monitor Logs**: Set up log monitoring for security events
- **Rate Limiting**: Consider adding rate limits via Nginx or Traefik
&lt;/ListCheck&gt;

## Conclusion

With this setup, you own your data and pick the infrastructure that fits your needs. PostgreSQL with pgvector keeps things simple by handling both relational data and vector embeddings in one database, and OpenAI covers the embedding side.

What you get:
- **Single PostgreSQL instance** with pgvector handles both metadata AND vector storage
- **MCP server included** so AI assistants can connect out of the box
- **Authentication enabled** for secure public deployment
- **Production-ready** with health checks, resource limits, and proper networking

Dokploy or raw Docker Compose — either way, you can have Cognee running in minutes. The MCP server is the real bonus here: your coding assistants keep persistent memory across sessions without extra setup.

### Next Steps

&lt;ListCheck&gt;
- Explore the [Cognee documentation](https://docs.cognee.ai/) for advanced features
- Set up automated backups with our [Dokploy Backups Guide](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/)
- Try the MCP integration with Cursor or Claude Code
- Experiment with different LLM and embedding providers
- Build custom pipelines for your specific use cases
- Compare with [Hindsight](/cognee-vs-hindsight/) to see which agent memory system fits your use case
&lt;/ListCheck&gt;

&lt;Button link=&quot;https://github.com/topoteretes/cognee&quot; text=&quot;View Cognee on GitHub&quot; /&gt;

Have questions about self-hosting Cognee? Drop a comment below!

## Frequently Asked Questions

&lt;Accordion label=&quot;What&apos;s the difference between Cognee and a regular vector database?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Vector databases like Pinecone or Weaviate store embeddings for semantic search. Cognee does that too (via pgvector in our setup), but it also builds knowledge graphs that map relationships between entities. The result is retrieval that understands context and connections on top of similarity scores.

Cognee adds:
- Entity extraction and relationship mapping
- Graph-based reasoning
- Multi-hop queries across related data
- Automatic summarization and chunking
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Why use pgvector instead of a dedicated vector database?&quot; group=&quot;faq&quot;&gt;
Using `pgvector/pgvector:pg17` gives you PostgreSQL with the pgvector extension, which serves both purposes:

**Advantages:**
- Single database to manage, backup, and maintain
- ACID transactions across both relational and vector data
- Lower resource usage than running separate databases
- Simpler deployment and networking

**When to consider alternatives:**
- Very large vector datasets (billions of vectors)
- Need for specialized vector search features
- Already have Qdrant/Weaviate/Pinecone infrastructure

For most self-hosted deployments, pgvector works well and keeps operations simple.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need OpenAI, or can I use other providers?&quot; group=&quot;faq&quot;&gt;
OpenAI is the default and easiest option, but Cognee supports multiple providers:

**LLM Providers:**
- OpenAI (GPT-4, GPT-4o-mini)
- Anthropic (Claude)
- Google Gemini
- Ollama (local models)
- Any OpenAI-compatible endpoint

**Embedding Providers:**
- OpenAI (text-embedding-3-small/large)
- Google Gemini
- Ollama
- Fastembed (local, CPU-friendly)

You can mix providers - for example, use a local Ollama LLM with OpenAI embeddings.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much does self-hosting cost?&quot; group=&quot;faq&quot;&gt;
**Monthly costs** (example):
- VPS with 4GB RAM (Hetzner): $8/month
- Domain: $1/month
- OpenAI API usage: Variable ($5-50/month depending on usage)

**Total**: $15-60/month depending on usage

The main variable cost is LLM/embedding API usage. Using local models with Ollama can reduce this to nearly zero.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Cognee without the MCP integration?&quot; group=&quot;faq&quot;&gt;
Absolutely! The MCP server is optional. You can remove the `cognee-mcp` service from the Docker Compose and use Cognee purely as a REST API for:
- Building RAG applications
- Creating AI assistants with memory
- Document analysis and search
- Knowledge management systems

The MCP integration is specifically useful for AI coding assistants like Cursor and Claude Code.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the best graph database for production?&quot; group=&quot;faq&quot;&gt;
**Kuzu (default)** works well for single-server deployments and is the easiest to set up (file-based, no additional services).

**Neo4j** is recommended for:
- Multi-agent deployments (concurrent access)
- Large-scale knowledge graphs
- When you need Neo4j&apos;s visualization tools
- Enterprise features and support

**FalkorDB** is a good middle ground offering both graph and vector capabilities.

Start with Kuzu and migrate to Neo4j if you need more scalability.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I reset the database and start fresh?&quot; group=&quot;faq&quot;&gt;
To completely reset Cognee:

```sh
# Stop services
docker compose down

# Remove volumes (WARNING: deletes all data including vectors)
docker volume rm cognee_postgres-data

# Start fresh
docker compose up -d
```

For a soft reset (keep user data but clear knowledge graphs), use the Cognee API:
```sh
curl -X POST &quot;https://cognee.yourdomain.com/api/v1/prune&quot; \
  -H &quot;Authorization: Bearer $TOKEN&quot;
```
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I migrate from another vector database to Cognee?&quot; group=&quot;faq&quot;&gt;
Cognee doesn&apos;t directly import from other vector databases, but you can:

1. Export your documents from the source system
2. Re-ingest them into Cognee using the `/add` endpoint
3. Run `cognify` to build the knowledge graph

The knowledge graph structure Cognee creates is different from raw vector embeddings, so re-processing is typically the best approach anyway.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I monitor Cognee in production?&quot; group=&quot;faq&quot;&gt;
Cognee provides several monitoring options:

1. **Health endpoint**: `GET /health` for basic liveness checks
2. **Detailed health**: `GET /health/detailed` for component status
3. **Logs**: Container logs show processing status and errors
4. **Dataset status**: `GET /api/v1/datasets/{id}/status` for processing state

For production monitoring:
- Set up uptime monitoring (UptimeRobot, Pingdom)
- Configure log aggregation (Loki, ELK)
- Monitor PostgreSQL metrics
- Track API response times
&lt;/Accordion&gt;</content:encoded><category>ai</category><category>self-hosted</category><category>docker</category></item><item><title>Convex Self-Hosted vs Cloud Free Tier: Performance Benchmarks</title><link>https://www.bitdoze.com/convex-self-hosted-benchmark/</link><guid isPermaLink="true">https://www.bitdoze.com/convex-self-hosted-benchmark/</guid><description>Real-world performance benchmarks comparing Convex self-hosted deployment vs cloud free tier. Test results with oha load testing tool showing response times, throughput, and CDN impact.</description><pubDate>Tue, 25 Nov 2025 00:00:00 GMT</pubDate><content:encoded>import { Image } from &quot;astro:assets&quot;;
import benchmarkGraph from &quot;../../assets/images/25/11/convex-benchmark-graph.svg&quot;;

When I started looking at [Convex](https://go.bitdoze.com/convex) for a real-time backend, I wanted actual performance numbers, not just marketing claims. So I ran load tests on both self-hosted and cloud deployments to see how they stack up in real conditions.

## Test Setup and Methodology

I used [oha](https://www.bitdoze.com/oha-website-load-testing/) to hit different Convex setups with traffic. It&apos;s a Rust-based HTTP load testing tool that shows how each configuration handles concurrent requests.

### Test Parameters

All tests were run with the following parameters:

| Parameter | Value | Description |
|-----------|-------|-------------|
| Total Requests | 1,000 | Number of HTTP requests to send |
| Concurrent Connections | 500 | Simulated users accessing simultaneously |
| Rate Limit | 50 req/sec | Controlled request rate for consistency |

The command used:
```bash
oha -n 1000 -c 500 -q 50 https://target-url/
```

### What oha Measures

oha reports:

- Success rate: Percentage of requests that returned HTTP 200
- Average response time: Mean time from request to response
- Response time distribution: Percentile breakdown showing consistency
- Throughput: Requests handled per second
- DNS+dialup time: Connection establishment overhead

These metrics show both raw performance and reliability under load.

## Test Configurations

I tested four different configurations:

1. **Self-Hosted (No CDN)**: Convex backend running on VPS, accessed directly
2. **Self-Hosted (Full CDN)**: CloudFlare CDN active for both website and Convex endpoints
3. **Self-Hosted (Site CDN Only)**: CloudFlare CDN for website, direct access to Convex
4. **Cloud Convex Free Tier**: Standard cloud-hosted Convex with CloudFlare on frontend

&lt;Notice type=&quot;info&quot; title=&quot;Self-Hosting Guide&quot;&gt;
If you want to deploy your own Convex instance, check out my comprehensive guide on [How to Self-Host Convex with Dokploy or Docker Compose](https://www.bitdoze.com/convex-self-host/).
&lt;/Notice&gt;

## Benchmark Results

### Visual Comparison

&lt;Image src={benchmarkGraph} alt=&quot;Convex Performance Benchmark Graph&quot; /&gt;

### Summary Table

| Configuration | Avg Response | Fastest | Slowest | Req/sec | Success Rate |
|--------------|-------------|---------|---------|---------|--------------|
| Self-Hosted (Full CDN) | **0.16s** | 0.08s | 0.37s | 49.65 | 100% |
| Self-Hosted (Site CDN) | 0.63s | 0.17s | 2.57s | 47.74 | 100% |
| Self-Hosted (No CDN) | 0.98s | 0.28s | 2.88s | 47.22 | 100% |
| Cloud Convex | 1.88s | 0.21s | 4.53s | 44.42 | 100% |

## Detailed Results

### Self-Hosted with No CDN

Direct access to self-hosted Convex, no CDN.

```sh
oha -n 1000 -c 500 -q 50 https://sh-convex.bitbuddies.me/
Summary:
  Success rate: 100.00%
  Total:        21.1762 secs
  Slowest:      2.8789 secs
  Fastest:      0.2811 secs
  Average:      0.9796 secs
  Requests/sec: 47.2227

  Total data:   125.04 MiB
  Size/request: 128.05 KiB
  Size/sec:     5.90 MiB

Response time histogram:
  0.281 [1]   |
  0.541 [65]  |■■■■
  0.801 [196] |■■■■■■■■■■■■
  1.060 [507] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.320 [114] |■■■■■■■
  1.580 [43]  |■■
  1.840 [39]  |■■
  2.100 [17]  |■
  2.359 [7]   |
  2.619 [3]   |
  2.879 [8]   |

Response time distribution:
  10.00% in 0.5999 secs
  25.00% in 0.7839 secs
  50.00% in 0.9395 secs
  75.00% in 1.0484 secs
  90.00% in 1.3932 secs
  95.00% in 1.6885 secs
  99.00% in 2.5827 secs
  99.90% in 2.8789 secs
  99.99% in 2.8789 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.1573 secs, 0.1193 secs, 0.2184 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0010 secs

Status code distribution:
  [200] 1000 responses
```

The setup averaged around 1 second per request, with 507 out of 1000 requests finishing between 0.8 and 1.0 seconds. DNS+dialup took about 0.16 seconds on average, which is noticeable overhead. This gives us a baseline for the raw server performance.

### Self-Hosted with Full CloudFlare CDN

CloudFlare CDN active for both the website and Convex API endpoints.

```sh
oha -n 1000 -c 500 -q 50 https://sh-convex.bitbuddies.me/
Summary:
  Success rate: 100.00%
  Total:        20.1391 secs
  Slowest:      0.3701 secs
  Fastest:      0.0803 secs
  Average:      0.1592 secs
  Requests/sec: 49.6546

  Total data:   14.49 MiB
  Size/request: 14.83 KiB
  Size/sec:     736.54 KiB

Response time histogram:
  0.080 [1]   |
  0.109 [97]  |■■■■■■■■■
  0.138 [219] |■■■■■■■■■■■■■■■■■■■■■
  0.167 [324] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.196 [194] |■■■■■■■■■■■■■■■■■■■
  0.225 [96]  |■■■■■■■■■
  0.254 [44]  |■■■■
  0.283 [15]  |■
  0.312 [6]   |
  0.341 [3]   |
  0.370 [1]   |

Response time distribution:
  10.00% in 0.1096 secs
  25.00% in 0.1302 secs
  50.00% in 0.1541 secs
  75.00% in 0.1812 secs
  90.00% in 0.2119 secs
  95.00% in 0.2340 secs
  99.00% in 0.2845 secs
  99.90% in 0.3701 secs
  99.99% in 0.3701 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0371 secs, 0.0230 secs, 0.0795 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0007 secs

Status code distribution:
  [200] 1000 responses
```

This setup was fast and consistent. Response times stayed between 0.08 and 0.37 seconds, averaging 0.16s. Throughput hit 49.65 req/sec, and even the 99th percentile stayed under 0.3 seconds. CDN compression cut data transfer from 128 KiB to 14.8 KiB per request.

### Self-Hosted with Site CDN Only

CloudFlare CDN for the website, direct access to Convex endpoints.

```sh
oha -n 1000 -c 500 -q 50 https://sh-convex.bitbuddies.me/
Summary:
  Success rate: 100.00%
  Total:        20.9452 secs
  Slowest:      2.5710 secs
  Fastest:      0.1678 secs
  Average:      0.6271 secs
  Requests/sec: 47.7436

  Total data:   17.21 MiB
  Size/request: 17.62 KiB
  Size/sec:     841.16 KiB

Response time histogram:
  0.168 [1]   |
  0.408 [246] |■■■■■■■■■■■■■■■■■■■■■
  0.648 [374] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.889 [251] |■■■■■■■■■■■■■■■■■■■■■
  1.129 [81]  |■■■■■■
  1.369 [10]  |
  1.610 [14]  |■
  1.850 [10]  |
  2.090 [1]   |
  2.331 [9]   |
  2.571 [3]   |

Response time distribution:
  10.00% in 0.3242 secs
  25.00% in 0.4105 secs
  50.00% in 0.5658 secs
  75.00% in 0.7757 secs
  90.00% in 0.9235 secs
  95.00% in 1.1089 secs
  99.00% in 2.2318 secs
  99.90% in 2.5710 secs
  99.99% in 2.5710 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0362 secs, 0.0247 secs, 0.0669 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0026 secs

Status code distribution:
  [200] 1000 responses
```

Performance was decent but showed more variance than the full CDN setup. Most requests (374 out of 1000) finished in 0.4-0.6 seconds, but the 99th percentile hit 2.2 seconds. DNS+dialup time improved to 0.04s compared to 0.16s without any CDN, thanks to the site being behind CloudFlare.

### Cloud Convex Free Tier

Cloud-hosted Convex with the application frontend using CloudFlare.

```sh
oha -n 1000 -c 500 -q 50 https://bitbuddies.me/
Summary:
  Success rate: 100.00%
  Total:        22.5101 secs
  Slowest:      4.5336 secs
  Fastest:      0.2140 secs
  Average:      1.8782 secs
  Requests/sec: 44.4246

  Total data:   17.23 MiB
  Size/request: 17.64 KiB
  Size/sec:     783.66 KiB

Response time histogram:
  0.214 [1]   |
  0.646 [34]  |■■■■■
  1.078 [171] |■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.510 [209] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  1.942 [108] |■■■■■■■■■■■■■■■■
  2.374 [192] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  2.806 [147] |■■■■■■■■■■■■■■■■■■■■■■
  3.238 [77]  |■■■■■■■■■■■
  3.670 [48]  |■■■■■■■
  4.102 [4]   |
  4.534 [9]   |■

Response time distribution:
  10.00% in 0.8744 secs
  25.00% in 1.1786 secs
  50.00% in 1.8554 secs
  75.00% in 2.4471 secs
  90.00% in 3.0030 secs
  95.00% in 3.3444 secs
  99.00% in 3.8191 secs
  99.90% in 4.5336 secs
  99.99% in 4.5336 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0399 secs, 0.0244 secs, 0.1982 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0141 secs

Status code distribution:
  [200] 1000 responses
```

This was the slowest setup, averaging 1.88 seconds per request. Response times varied widely from 0.21s to 4.53s, and throughput dropped to 44.42 req/sec. The inconsistent performance across buckets likely reflects the geographic distance to Convex&apos;s cloud servers.

## Analysis and Insights

### Performance Comparison

The benchmarks show clear differences:

| Metric | Winner | Improvement vs Cloud |
|--------|--------|---------------------|
| Average Response Time | Self-Hosted + Full CDN | **11.75x faster** |
| Fastest Response | Self-Hosted + Full CDN | 2.6x faster |
| Consistency (99th percentile) | Self-Hosted + Full CDN | 13.4x better |
| Throughput | Self-Hosted + Full CDN | 12% higher |

### Why Self-Hosted Performs Better

Several factors explain why self-hosting performs better:

- Geographic proximity: You can place the server near your users
- CDN caching: CloudFlare caches static assets and optimizes API responses
- Dedicated resources: No sharing infrastructure with other tenants
- Network path: Shorter hops when the CDN is configured properly
- SSL termination: CDN handles SSL at the edge, reducing backend load

### The CDN Impact

The data shows how CDN configuration affects performance:

| Configuration | Avg Response | vs No CDN |
|--------------|-------------|-----------|
| Full CDN (Site + API) | 0.16s | 6.1x faster |
| Site CDN Only | 0.63s | 1.6x faster |
| No CDN | 0.98s | baseline |

&lt;Notice type=&quot;warning&quot; title=&quot;CDN Configuration Matters&quot;&gt;
Simply having a CDN isn&apos;t enough. The dramatic performance improvement comes from routing Convex API endpoints through the CDN as well, not just the static website assets.
&lt;/Notice&gt;

### Data Transfer Comparison

| Configuration | Size/Request | Data/sec |
|--------------|-------------|----------|
| Self-Hosted (No CDN) | 128 KiB | 5.90 MiB |
| Self-Hosted (Full CDN) | 14.8 KiB | 736 KiB |
| Self-Hosted (Site CDN) | 17.6 KiB | 841 KiB |
| Cloud Convex | 17.6 KiB | 784 KiB |

The CDN configurations show compression benefits, reducing data transfer by ~88% compared to uncompressed responses.

## When to Choose Each Option

### Choose Self-Hosted + Full CDN When:

- Performance matters for your application
- Your users are concentrated in specific regions
- You want predictable response times
- You&apos;re comfortable managing servers
- Sub-200ms response times are important

### Choose Cloud Convex Free Tier When:

- You&apos;re prototyping or in development
- Traffic is low
- You don&apos;t want to manage infrastructure
- Geographic distribution doesn&apos;t matter
- 1-2 second response times are acceptable

## Recommendations

Based on these benchmarks:

### For Production Applications

1. Self-host Convex on a VPS near your users
2. Use CloudFlare for both website and Convex endpoints
3. Configure caching rules for your API responses
4. Monitor performance with oha

### For Development/Staging

1. Cloud Convex free tier works fine
2. Use self-hosted for performance testing before production
3. Keep development and production configurations similar

### Optimal Infrastructure Setup

| Component | Recommendation |
|-----------|---------------|
| VPS Provider | Hetzner, DigitalOcean, or AWS in user&apos;s region |
| VPS Specs | 4GB RAM minimum for PostgreSQL |
| CDN | CloudFlare (free tier works well) |
| Database | PostgreSQL for production, SQLite for development |
| SSL | Let CDN handle SSL termination |

## Conclusion

Self-hosted Convex with a CDN is about 12x faster than the cloud-hosted free tier. The response time difference is substantial, especially for production applications where users notice latency.

But performance isn&apos;t the only factor. The cloud option handles infrastructure for you, which matters during development or when you don&apos;t have time to manage servers. If sub-second response times aren&apos;t critical, the cloud version works fine.

For production apps where performance matters, setting up self-hosted Convex with a CDN is worth the effort.

&lt;Button link=&quot;https://go.bitdoze.com/convex&quot; text=&quot;Get Started with Convex&quot; /&gt;

## Related Resources

- [How to Self-Host Convex with Dokploy or Docker Compose](https://www.bitdoze.com/convex-self-host/)
- [Website Performance Testing with oha](https://www.bitdoze.com/oha-website-load-testing/)
- [Dokploy Backups to CloudFlare R2](https://www.bitdoze.com/dokploy-backups-cloudflare-r2/)

## FAQ

&lt;Accordion label=&quot;How do I run these benchmarks myself?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Install oha on your system:
- **Mac**: `brew install oha`
- **Linux**: `cargo install oha`
- **Windows**: `winget install hatoo.oha`

Then run:
```bash
oha -n 1000 -c 500 -q 50 https://your-convex-app-url/
```

Adjust the parameters based on your testing needs:
- `-n`: Total number of requests
- `-c`: Concurrent connections
- `-q`: Rate limit (requests per second)
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will my results be similar?&quot; group=&quot;faq&quot;&gt;
Results will vary based on:
- **Geographic distance** between you, CDN edges, and servers
- **Server specifications** (CPU, RAM, network)
- **Database choice** (PostgreSQL vs SQLite)
- **CDN configuration** and caching rules
- **Application complexity** and data being fetched

Run your own benchmarks to get accurate numbers for your specific setup.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is CloudFlare necessary for good performance?&quot; group=&quot;faq&quot;&gt;
While CloudFlare isn&apos;t strictly necessary, a CDN provides significant benefits:
- SSL termination at edge locations
- Geographic distribution of content
- DDoS protection
- Compression and optimization

CloudFlare&apos;s free tier is sufficient for most self-hosted Convex deployments. Alternatives like Fastly, AWS CloudFront, or Bunny.net work equally well.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What about the cost difference?&quot; group=&quot;faq&quot;&gt;
**Cloud Convex Free Tier:**
- Free for up to 1M function calls/month
- 1GB storage included
- No infrastructure costs

**Self-Hosted Convex:**
- VPS: ~$8-20/month (Hetzner, DigitalOcean)
- Domain: ~$1/month
- CloudFlare: Free tier sufficient
- **Total**: ~$10-25/month

Self-hosting becomes cost-effective when you exceed the free tier limits or need better performance.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does self-hosting affect real-time sync performance?&quot; group=&quot;faq&quot;&gt;
Real-time synchronization works identically in both deployments. The benchmark tests measure HTTP request/response performance, which affects:
- Initial page loads
- Data fetching
- Function calls

WebSocket-based real-time updates have different characteristics and weren&apos;t specifically tested here, though they benefit from the same geographic proximity advantages.
&lt;/Accordion&gt;</content:encoded><category>web-development</category><category>self-hosted</category><category>convex</category></item><item><title>How to Get Started Programming with AI: Complete Beginner&apos;s Guide</title><link>https://www.bitdoze.com/ai-programming-beginners-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/ai-programming-beginners-guide/</guid><description>Learn how to start programming with AI assistance. From choosing the right stack to deploying your first project, this guide covers everything beginners need to start building with AI.</description><pubDate>Wed, 12 Nov 2025 00:00:00 GMT</pubDate><content:encoded>Want to build your own website, app, or SaaS product but don&apos;t know where to start? In 2026, AI has made programming more accessible than ever. You don&apos;t need years of coding experience—with the right AI tools and approach, you can start building real projects from day one.

This guide will take you from complete beginner to deploying your first project, step by step. Whether you want to build a blog, a web app, or something more complex, AI can help you code throughout the process.

&lt;Notice type=&quot;success&quot; title=&quot;What You&apos;ll Learn&quot;&gt;
&lt;ListCheck&gt;
- How to choose the right technology stack for your project
- Best AI coding assistants for beginners (free and paid options)
- Setting up your development environment properly
- Creating documentation that helps AI understand your project
- Using MCP servers to supercharge your AI assistant
- Building features incrementally without overwhelming the AI
- Deploying and monitoring your finished project
&lt;/ListCheck&gt;
&lt;/Notice&gt;

## Why Programming with AI is Perfect for Beginners

Traditional programming has a steep learning curve. You&apos;d spend months learning syntax, debugging cryptic errors, and understanding complex concepts before building anything useful. AI changes this.

**With AI assistance, you can:**

&lt;ListCheck&gt;
- **Start building immediately** - Focus on what you want to create, not memorizing syntax
- **Learn by doing** - AI explains code as you build, helping you understand concepts in context
- **Fix errors faster** - AI identifies and fixes bugs that would take hours to debug manually
- **Access expert knowledge** - Get architectural advice and best practices from day one
- **Iterate quickly** - Test ideas and make changes without fear of breaking everything
&lt;/ListCheck&gt;

Think of AI as a senior developer mentor available 24/7.


## Step 1: Choosing the Right Technology Stack
---

The first decision you&apos;ll make is what technology stack to use. Your choice depends on what you&apos;re building.

### For Websites and Blogs: Astro

If you&apos;re building a content-focused website or blog, [Astro](https://astro.build/) works well. It&apos;s fast, beginner-friendly, and suited for sites that don&apos;t need complex interactivity.

**Why Astro?**

&lt;ListCheck&gt;

- **Fast** - Loads faster than WordPress or other traditional CMS platforms
- **Easy to learn** - Simple file structure and markdown-based content
- **Good for SEO** - Static generation means good search engine performance
- **Free hosting** - Deploy to Cloudflare Pages or Vercel at zero cost
- **AI-friendly** - Simple structure makes it easy for AI to help you build

&lt;/ListCheck&gt;

**Good for:**
- Personal blogs
- Portfolio sites
- Documentation sites
- Marketing websites
- Affiliate sites

I wrote a guide on building an Astro blog completely free: [How to Build a Free Blog with Astro &amp; Cloudflare in 30 Minutes](https://www.bitdoze.com/build-astro-blog-free/)

### For Web Applications: TanStack Start

Building something more interactive? TanStack Start is a full-stack React framework that handles both frontend and backend.

**Why TanStack Start?**

&lt;ListCheck&gt;

- **Full-stack framework** - Build both frontend and backend in one project
- **Type-safe** - Catch errors before they happen with TypeScript
- **Modern tooling** - Includes routing, data fetching, and server functions
- **Flexible backends** - Works with Convex, Drizzle + PostgreSQL, or other databases
- **Great DX** - Developer experience is smooth and enjoyable

&lt;/ListCheck&gt;

**Good for:**
- SaaS applications
- Dashboards and admin panels
- Interactive web apps
- Projects with user authentication
- Apps that need a database

Check out my guides:
- [Deploy TanStack Start on Your VPS with Dokploy](https://www.bitdoze.com/tanstack-start-dokploy-deploy/)
- [TanStack Start Getting Started Guide](https://www.bitdoze.com/tanstack-start-get-start/)

### For Svelte Fans: SvelteKit

If you prefer Svelte&apos;s approach to reactivity and want a simpler mental model than React, SvelteKit is a good choice.

**Why SvelteKit?**

&lt;ListCheck&gt;

- **Less boilerplate** - Write less code than React or Vue
- **True reactivity** - No virtual DOM, just reactive variables
- **Built-in features** - Routing, server-side rendering, and more out of the box
- **Great performance** - Smaller bundle sizes and faster runtime
- **Growing ecosystem** - Active community and improving tooling

&lt;/ListCheck&gt;

**Good for:**
- Developers who prefer simpler syntax
- Projects where bundle size matters
- Apps that need high performance
- Teams familiar with Svelte

### Quick Decision Tree

Not sure which to pick? Use this simple guide:

&lt;Tabs&gt;
&lt;Tab name=&quot;Content Site&quot;&gt;

**Choose Astro if:**
&lt;ListCheck&gt;
- You&apos;re building a blog, portfolio, or marketing site
- Content is mostly static (articles, pages)
- You want the fastest possible load times
- You&apos;re completely new to programming
&lt;/ListCheck&gt;
&lt;/Tab&gt;
&lt;Tab name=&quot;Web App&quot;&gt;

**Choose TanStack Start if:**
&lt;ListCheck&gt;
- You need user authentication and databases
- You&apos;re building a SaaS product or dashboard
- You want end-to-end type safety
- You prefer React or want to learn it
&lt;/ListCheck&gt;
&lt;/Tab&gt;
&lt;Tab name=&quot;Simple &amp; Fast&quot;&gt;

**Choose SvelteKit if:**
&lt;ListCheck&gt;
- You want something simpler than React
- You value small bundle sizes
- You&apos;re comfortable learning a different approach
- You want high performance
&lt;/ListCheck&gt;
&lt;/Tab&gt;
&lt;/Tabs&gt;

## Step 2: Set Up Your Code Repository
---
Before writing any code, create a GitHub repository. This gives you version control, backup, and enables automatic deployments.

### Create a GitHub Account

1. Visit [github.com](https://github.com)
2. Sign up for a free account
3. Verify your email address

### Set Up SSH Keys (Recommended)

SSH keys let you push code securely without entering passwords every time:

```bash
# Generate a new SSH key
ssh-keygen -t ed25519 -C &quot;your_email@example.com&quot;

# Start the ssh-agent
eval &quot;$(ssh-agent -s)&quot;

# Add your SSH key
ssh-add ~/.ssh/id_ed25519

# Copy your public key
cat ~/.ssh/id_ed25519.pub
```

Add the public key to GitHub:
1. Go to GitHub Settings → SSH and GPG keys
2. Click &quot;New SSH key&quot;
3. Paste your public key
4. Save

For more details: [Link GitHub with SSH on Mac/Linux](https://www.bitdoze.com/link-github-with-ssh-maco-linux/)

### Create Your Repository

1. Click the &quot;+&quot; icon in GitHub
2. Select &quot;New repository&quot;
3. Name it (e.g., `my-first-ai-project`)
4. Keep it public or private (your choice)
5. Don&apos;t initialize with README (you&apos;ll create it locally)
6. Click &quot;Create repository&quot;

## Step 3: Choose Your AI Coding Assistant
---
This is important. The right AI assistant makes the difference in your learning and building experience.

### Recommended: GitHub Copilot Pro ($10/month)

GitHub Copilot Pro is the best value for money, and it&apos;s what I personally use daily.

**What you get for $10/month:**

&lt;ListCheck&gt;

- **Unlimited GPT-5 mini** - Use as much as you want for coding
- **300 premium requests** - For complex tasks with Claude Sonnet 4.5 or GPT-5
- **Works in Zed, VS Code, and more** - Use your preferred IDE
- **Copilot CLI** - AI assistance directly in your terminal
- **Coding Agents** - Let AI create pull requests autonomously
- **GitHub.com integration** - AI help directly on GitHub

&lt;/ListCheck&gt;

**Recommended IDE:** [Zed](https://zed.dev/) - It&apos;s blazing fast, modern, and has native Copilot integration.

Read my complete guide: [GitHub Copilot Pro: Best $10 AI Coding Plan with Zed IDE &amp; CLI](https://www.bitdoze.com/github-copilot-complete-guide/)

### Free Alternatives

If you want to start completely free, you have good options:

#### 1. Amp Code Free (by Sourcegraph)

**What you get:**

&lt;ListCheck&gt;

- **Completely free** with ad support
- **Unlimited usage** (with some rate limits)
- **Works in VS Code, Cursor, Windsurf**
- **Powerful CLI tool** for terminal work
- **Mixed models** - Open source + frontier models

&lt;/ListCheck&gt;

**Trade-off:** Your code is used to train models. Don&apos;t use for proprietary work.

Learn more: [Amp Code Free: The AI Coding Agent That Works in Your Editor](https://www.bitdoze.com/amp-code-free-ai-coding-agent/)

#### 2. Free Access to Claude Sonnet 4.5 and GPT-5

There are legitimate ways to use premium models for free:

- **Droid CLI** - 20 million free tokens first month
- **Windsurf IDE** - 25 free prompts monthly
- **AgentRouter** - $200 in free API credits

Full details: [How to Use Claude Sonnet 4.5 and GPT-5 for FREE](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/)

### My Recommendation

**For beginners**: Start with **Droid CLI** (20M free tokens) or **Amp Code Free** to learn without spending money.

**When you&apos;re building seriously**: Upgrade to **GitHub Copilot Pro** ($10/month). The investment pays for itself in time saved within the first week.

## Step 4: Create Your Documentation Folder
---
This is important and most beginners skip it. A well-maintained documentation folder helps AI understand your project and make better decisions.

### Why Documentation Matters

AI is powerful, but it doesn&apos;t automatically know:
- How your specific project is structured
- What commands to run for testing
- What conventions you&apos;re following
- What libraries and versions you&apos;re using

With clear documentation, your AI assistant provides help tailored to your project.

### Create a `docs/` Folder

In your project root, create a `docs` folder with these files:

```bash
mkdir docs
```

### Essential Documentation Files

#### 1. Framework Documentation

If you&apos;re using Astro, create `docs/astro.md`:

```markdown
# Astro Framework Guide

## Installation &amp; Setup
npm create astro@latest

## Development Commands
- `npm run dev` - Start dev server (http://localhost:4321)
- `npm run build` - Build for production
- `npm run preview` - Preview production build

## Project Structure
- `src/pages/` - File-based routing
- `src/layouts/` - Reusable page layouts
- `src/components/` - React/Vue/Svelte components
- `src/content/` - Markdown content collections
- `public/` - Static assets

## Key Concepts
- Islands Architecture - Only hydrate interactive components
- Content Collections - Type-safe markdown content
- Zero JS by default - Ship less JavaScript

## Common Patterns
- Use `---` frontmatter for component scripts
- Import components: `import Header from &apos;../components/Header.astro&apos;`
- Access props: `const { title } = Astro.props`
```

#### 2. Stack-Specific Guides

For TanStack Start, create `docs/tanstack-start.md`:

```markdown
# TanStack Start Guide

## Project Structure
- `src/routes/` - File-based routing
- `src/components/` - React components
- `src/server/` - Server-side functions
- `src/lib/` - Utilities and helpers

## Key Features
- Server functions with `createServerFn()`
- Type-safe routing
- Built-in data loading
- SSR and SSG support

## Backend Options
- Convex - Serverless backend
- Drizzle + PostgreSQL - Traditional database
- Supabase - Backend as a service
```

#### 3. Styling Documentation

Create `docs/tailwind.md` if using Tailwind CSS:

```markdown
# Tailwind CSS v4 Guide

## Configuration
- CSS-first config using `@theme` directive
- No more `tailwind.config.js` needed

## Common Patterns
- Use utility classes: `bg-blue-500 text-white p-4`
- Responsive: `sm:text-lg md:text-xl lg:text-2xl`
- Dark mode: `dark:bg-gray-800`
- Hover states: `hover:bg-blue-600`

## Custom Theme
```css
@theme {
  --color-primary-500: #3b82f6;
  --font-sans: &apos;Inter&apos;, sans-serif;
}
```


### Example: Complete Project Documentation

Here&apos;s what good documentation looks like. This is from my TanStack project [bitbuddies.me](https://bitbuddies.me/):

```markdown
# Project Overview

## Tech Stack
- Framework: TanStack Start (React 19)
- Backend: Convex
- Auth: Clerk
- Styling: Tailwind CSS v4 + shadcn/ui
- Type Safety: TypeScript strict mode

## Commands
- `bun run dev` - Development server (port 3000)
- `bun run build` - Production build
- `bun run test` - Run tests
- `bun convex dev` - Start Convex backend

## Code Conventions
- Use React Server Components by default
- Client components must have &apos;use client&apos; directive
- Keep server functions in `src/server/`
- Use Zod for validation
- Follow existing file naming patterns

## Important Notes
- Always run tests before committing
- Use TypeScript strict mode
- Database schema changes require migration
- Environment variables go in `.env.local`
```

## Step 5: Use the AGENTS.md File
---
The `AGENTS.md` file is your AI assistant&apos;s instruction manual for your project. It tells the AI exactly how to work with your codebase.

### Create AGENTS.md in Your Root

```bash
touch AGENTS.md
```

### What to Include

Here&apos;s a template based on my production projects:

```markdown
# Agent Instructions

You don&apos;t need to create any documentation unless I specifically ask. Just provide short summaries of what you did.

## Commands

- **dev**: `npm run dev` (runs on http://localhost:4321)
- **build**: `npm run build`
- **test**: `npm test`

## Architecture

- **Framework**: Astro v5 static site generator
- **Content**: Markdown files in `src/content/posts/`
- **Styling**: Tailwind CSS v4
- **Components**: Reusable components in `src/components/`

## Code Style

- Use TypeScript for type safety
- Follow existing file naming conventions
- Use Tailwind utilities for styling
- Keep components simple and focused

## Important Notes

- Always test changes locally before committing
- Follow existing code patterns in similar files
- Check that all images exist before referencing them
- Run build to verify no errors

## Testing Workflow

1. Make changes
2. Run `npm run dev` to test locally
3. Run `npm run build` to check for errors
4. Review changes carefully
5. Commit with descriptive message

## Common Pitfalls

- Don&apos;t use libraries that aren&apos;t already installed
- Check TypeScript errors before committing
- Verify all imports are correct
- Test responsive design at different screen sizes
```

### Why This Matters

Without `AGENTS.md`, AI makes assumptions. With it, AI follows your project&apos;s specific patterns and conventions. This dramatically improves code quality and reduces errors.

## Step 6: Add MCP Servers to Your AI
---
Model Context Protocol (MCP) servers give your AI superpowers by connecting it to external services and tools.

### What Are MCP Servers?

MCP servers extend what your AI can do. Instead of just writing code, your AI can:
- Search the web for current information
- Access up-to-date framework documentation
- Test your application automatically
- Interact with databases and APIs

### Essential MCP Servers for Beginners

#### 1. Context7 - Up-to-Date Documentation

Context7 provides fresh documentation for frameworks and libraries. Instead of AI using outdated training data, it fetches current docs.

**What it helps with:**
- Latest API references
- Current best practices
- Recent framework updates
- New features and deprecations

#### 2. BrightData MCP - Web Search &amp; Data

BrightData MCP lets your AI search the web and extract data from websites. Perfect for research and gathering information.

**What it helps with:**
- Searching for solutions to problems
- Finding examples and tutorials
- Researching competitors
- Gathering data for your project

Learn more: [BrightData MCP Guide](https://www.bitdoze.com/brightdata-mcp-guide/)

#### 3. Playwright MCP - Application Testing

Playwright MCP enables your AI to test your application automatically by controlling a browser.

**What it helps with:**
- Testing user workflows
- Verifying forms work correctly
- Checking responsive design
- Catching bugs before deployment

GitHub: [playwright-mcp](https://github.com/microsoft/playwright-mcp)

### How to Add MCP Servers

The setup process depends on your AI tool. Here&apos;s how to do it for popular options:

&lt;Accordion label=&quot;Droid CLI / Factory.ai&quot; group=&quot;mcp&quot;&gt;

Create or edit `~/.factory/mcp.json`:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_token&quot;
      }
    },
    &quot;playwright&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@playwright/mcp@latest&quot;]
    }
  }
}
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cursor IDE&quot; group=&quot;mcp&quot;&gt;

1. Open Cursor Settings
2. Go to Tools &amp; Integrations
3. Add Custom MCP
4. Paste configuration:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_token&quot;
      }
    }
  }
}
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Windsurf&quot; group=&quot;mcp&quot;&gt;

Similar to Cursor - add to MCP configuration in settings:

```json
{
  &quot;mcpServers&quot;: {
    &quot;context7&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;context7-mcp&quot;]
    }
  }
}
```

&lt;/Accordion&gt;

### Don&apos;t Overdo It

&lt;Notice type=&quot;warning&quot; title=&quot;Keep It Simple&quot;&gt;

Start with just 2-3 MCP servers. Too many can overwhelm the AI and actually reduce performance. Add more only when you have a specific need.

**Recommended starter set:**
- Context7 (documentation)
- BrightData MCP (web search)
- That&apos;s it!

&lt;/Notice&gt;

For a complete list of available MCP servers, visit: [mcpservers.org](https://mcpservers.org/)

## Step 7: Start Building Features Incrementally
---
This is where most beginners make mistakes. They try to build everything at once, overwhelming both themselves and the AI.

### Think Small and Focused

Break your project into tiny, manageable features. Instead of &quot;build a blog,&quot; think:

1. Create the homepage layout
2. Add a navigation menu
3. Create a single blog post page
4. Add a blog post listing page
5. Implement search functionality
6. Add categories and tags

### Create a Project Architecture First

Before writing code, ask your AI to help plan the structure:

**Good prompt:**
```
I want to build a personal blog with Astro. It should have:
- Homepage with recent posts
- Individual post pages
- About page
- Contact form
- Categories and tags

Please create a project architecture document that outlines:
- Folder structure
- Key components needed
- Content organization
- Routing approach

Don&apos;t write code yet, just plan the structure.
```

This gives you a roadmap and helps you understand what you&apos;re building.

### Work on One Feature at a Time

When building, focus on a single feature completely before moving on:

**Bad approach:**
```
Build the homepage, blog pages, contact form, and about page
```

**Good approach:**
```
Create the homepage layout with:
- Header with site title and navigation
- Hero section with welcome message
- Grid of latest 3 blog posts
- Footer with copyright

Use Tailwind for styling and make it responsive.
```

### Example: Building a Blog Post Page

Here&apos;s how to approach a single feature step by step:

**Step 1: Create the basic page structure**
```
Create a blog post page template at src/pages/blog/[slug].astro
- It should accept a slug parameter
- Display post title, date, and author
- Render markdown content
- Use the main layout
```

**Step 2: Add styling**
```
Style the blog post page:
- Use Tailwind typography plugin for content
- Add a max-width container
- Style the header with gradient
- Make code blocks look good
```

**Step 3: Add metadata**
```
Add SEO metadata to the blog post template:
- Title tag with post title
- Meta description from frontmatter
- Open Graph tags for social sharing
- Canonical URL
```

**Step 4: Test and refine**
```
Test the blog post page with different content:
- Long posts with many headings
- Posts with images
- Posts with code blocks
- Short posts
```

Each step is focused and manageable. AI can handle these easily without getting confused.

### Keep the AI Focused

If you notice the AI starting to add features you didn&apos;t ask for, rein it back:

```
That&apos;s good, but I only asked for the header right now.
Let&apos;s finish the header completely before moving to other sections.
Can you remove the footer and sidebar code and focus only on the header?
```

## Step 8: Review and Understand Every Line of Code
---
This is crucial for learning. Don&apos;t just accept code from AI without understanding it.

### Ask for Explanations

After AI generates code, ask it to explain:

**Good follow-up prompts:**
```
Explain what this component does line by line

Why did you use this approach instead of [alternative]?

What are the potential issues with this code?

How would I modify this to add [feature]?
```

### Request Comments

For complex code, ask AI to add explanations:

```
Add comments to this code explaining what each section does.
Focus on the &quot;why&quot; not just the &quot;what.&quot;
```

### Identify Learning Opportunities

When you see something you don&apos;t understand:

```
I don&apos;t understand this TypeScript syntax: `const { data }: { data: Post[] }`
Can you explain what this means and why we use it?
```

### Keep a Learning Log

Create a `LEARNING.md` file in your project:

```markdown
# Things I&apos;ve Learned

## TypeScript Destructuring
```typescript
const { title, date } = post;
```
This extracts specific properties from an object. More concise than:
```typescript
const title = post.title;
const date = post.date;
```

## Component Props in Astro
Props are passed to components and accessed via `Astro.props`:
```astro
---
const { title } = Astro.props;
---
&lt;h1&gt;{title}&lt;/h1&gt;

## Tailwind Responsive Classes
`sm:`, `md:`, `lg:` prefixes apply styles at breakpoints:
- `sm:` = 640px and up
- `md:` = 768px and up
- `lg:` = 1024px and up
```

This log becomes your personal reference as you learn.

## Step 9: Get a Second Opinion
---
AI can make mistakes or use suboptimal approaches. Getting a second AI&apos;s perspective catches issues early.

### Use a Different AI for Code Review

If you built features with Claude Sonnet 4.5, ask GPT-5 to review. If you used GPT-5, ask Claude to review.

**Code review prompt:**
```
Please review this [component/feature/file] for:

1. Security vulnerabilities
2. Performance issues
3. Code quality and maintainability
4. Potential bugs
5. Best practice violations

Be thorough and critical. I want to improve this code.
```

### Create a Review Document

Ask the second AI to create `REVIEW.md`:

```
After reviewing the entire codebase, create a REVIEW.md file with:

## Security Issues
List any security concerns found

## Performance Optimizations
Suggest improvements for speed and efficiency

## Code Quality
Note areas that could be cleaner or more maintainable

## Best Practices
Identify where we deviate from best practices

## Recommended Changes
Prioritized list of improvements

Be specific with file names and line numbers where relevant.
```

### Iterate Based on Feedback

Don&apos;t just collect feedback—act on it:

```
Based on the review, let&apos;s fix the top 3 security issues first.
Start with [specific issue from review].
```

### Example Review Process

Here&apos;s what a typical review session looks like:

1. **Build feature with AI #1** (e.g., Claude Sonnet 4.5)
2. **Switch to AI #2** (e.g., GPT-5) and share the code
3. **Request detailed review** focusing on security and performance
4. **Review the feedback** and ask questions about anything unclear
5. **Prioritize fixes** starting with security issues
6. **Implement improvements** with AI #1
7. **Verify fixes** with AI #2

This back-and-forth catches issues that a single AI might miss.

## Step 10: Deploy Your Project
---
Time to make your project live! Deployment gets your work on the internet for others to use.

### Push to GitHub

First, make sure all your changes are committed:

```bash
# Stage all changes
git add -A

# Commit with descriptive message
git commit -m &quot;Initial project setup with homepage and blog&quot;

# Push to GitHub
git push origin main
```

### Get a Domain Name (Optional)

While you can use free subdomains, a custom domain looks more professional:

**Where to buy domains:**
- [Namecheap](https://www.namecheap.com/) - $1-15/year
- [Cloudflare](https://www.cloudflare.com/products/registrar/) - At-cost pricing
- [Porkbun](https://porkbun.com/) - Low prices, good service

Choose a `.com` if available, or `.dev` for tech projects.

### Deployment Options

&lt;Tabs&gt;
&lt;Tab name=&quot;Vercel (Easiest)&quot;&gt;

**Best for:** Beginners, Next.js, TanStack Start

**Setup:**
1. Visit [vercel.com](https://vercel.com)
2. Sign up with GitHub
3. Click &quot;New Project&quot;
4. Select your repository
5. Click &quot;Deploy&quot;

**Features:**
- Automatic deployments on git push
- Free SSL certificates
- Global CDN
- Generous free tier
- Zero configuration for most frameworks

**Cost:** Free for personal projects

&lt;/Tab&gt;
&lt;Tab name=&quot;Cloudflare Pages&quot;&gt;

**Best for:** Astro, static sites, React apps

**Setup:**
1. Visit [pages.cloudflare.com](https://pages.cloudflare.com)
2. Sign up or log in
3. &quot;Create a project&quot; → &quot;Connect to Git&quot;
4. Select your repository
5. Configure build settings:
   - Build command: `npm run build`
   - Output directory: `dist`
6. Click &quot;Save and Deploy&quot;

**Features:**
- Unlimited bandwidth
- Free SSL
- Super fast global network
- Great analytics
- Free tier is very generous

**Cost:** Free for most projects

Learn more: [Build a Free Blog with Astro &amp; Cloudflare](https://www.bitdoze.com/build-astro-blog-free/)

&lt;/Tab&gt;
&lt;Tab name=&quot;Self-Hosted (Advanced)&quot;&gt;

**Best for:** Learning DevOps, full control, complex apps

**Setup:**
1. Get a VPS ([Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps), [DigitalOcean](https://go.bitdoze.com/do), Vultr)
2. Install Dokploy for easy deployments
3. Connect your GitHub repository
4. Configure build and deployment
5. Deploy

**Features:**
- Complete control
- Predictable costs
- Can run databases, cron jobs, etc.
- No platform limitations
- Great for production apps

**Cost:** $5-20/month for VPS

Learn more: [Deploy TanStack Start on Your VPS with Dokploy](https://www.bitdoze.com/tanstack-start-dokploy-deploy/)

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Configure Your Domain

After deploying, point your custom domain to your hosting:

**For Vercel:**
1. Go to Project Settings → Domains
2. Add your domain
3. Follow DNS configuration instructions
4. Wait for DNS propagation (5-30 minutes)

**For Cloudflare Pages:**
1. Go to Custom Domains
2. Add your domain
3. Cloudflare handles DNS automatically if you&apos;re using Cloudflare nameservers

**For Self-Hosted:**
1. Add A record pointing to your VPS IP
2. Configure SSL with Let&apos;s Encrypt (Dokploy does this automatically)
3. Set up your domain in Dokploy

### Verify Deployment

After deployment, check that everything works:

&lt;ListCheck&gt;

- Site loads on your domain
- All pages are accessible
- Images load correctly
- Forms work (if applicable)
- Links aren&apos;t broken
- Mobile responsive design works
- SSL certificate is active (https)

&lt;/ListCheck&gt;

## Step 11: Monitor Your Application
---
Once deployed, monitoring helps you catch issues before users complain.

### Application Monitoring Options

&lt;Tabs&gt;
&lt;Tab name=&quot;Uptime Monitoring&quot;&gt;

**Uptime Kuma (Self-Hosted, Free)**

Perfect for checking if your site is online:

- Install on your server or separate VPS
- Set up checks every 60 seconds
- Get notified via email, Slack, Discord, etc.
- View status history and uptime percentage

**Tutorial:** [Uptime Kuma Video Guide](https://www.youtube.com/watch?v=T7izljcBFeE&amp;t=769s)

**Hosted Alternatives:**
- [UptimeRobot](https://uptimerobot.com/) - Free tier: 50 monitors
- [Pingdom](https://www.pingdom.com/) - Paid, reliable
- [Better Uptime](https://betterstack.com/better-uptime) - Beautiful UI, free tier

&lt;/Tab&gt;
&lt;Tab name=&quot;Server Monitoring&quot;&gt;

**If you&apos;re self-hosting:**

Monitor server resources (CPU, RAM, disk):

- **Netdata** - Real-time metrics, beautiful dashboards
- **Prometheus + Grafana** - Industry standard, powerful
- **Glances** - Simple terminal-based monitoring

Learn more: [Server Monitoring Guide](https://www.bitdoze.com/sever-monitoring/)

&lt;/Tab&gt;
&lt;Tab name=&quot;Error Tracking&quot;&gt;

**Sentry (Recommended)**

Catches JavaScript errors and exceptions:

**Setup:**
```bash
npm install @sentry/browser

# Add to your main.js/index.js
import * as Sentry from &quot;@sentry/browser&quot;;

Sentry.init({
  dsn: &quot;your-sentry-dsn&quot;,
  environment: &quot;production&quot;,
});
```

**Features:**
- Real-time error notifications
- Stack traces with context
- User impact tracking
- Performance monitoring
- Free tier: 5,000 events/month

&lt;/Tab&gt;
&lt;Tab name=&quot;Analytics&quot;&gt;

**Privacy-Friendly Options:**

- **Plausible** - Simple, privacy-focused, no cookies
- **Fathom** - Similar to Plausible, lightweight
- **Umami** - Self-hosted, open source

**Features to track:**
- Page views
- Referrer sources
- Popular pages
- User locations (country-level)
- Bounce rate

Avoid Google Analytics for privacy reasons and better performance.

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Set Up Alerts

Configure notifications for critical issues:

**Essential alerts:**
&lt;ListCheck&gt;

- Site is down (uptime monitor)
- Deployment failed (hosting platform)
- JavaScript errors spike (Sentry)
- Server resources high (server monitoring)

&lt;/ListCheck&gt;

**Where to send alerts:**
- Email (always)
- Slack/Discord (for teams)
- SMS (critical issues only)
- PagerDuty (on-call scenarios)

### Regular Health Checks

Weekly or monthly, manually check:

&lt;ListCheck&gt;

- Uptime percentage (should be &gt;99%)
- Error rate (should be low and stable)
- Page load times (should be fast)
- Failed deployments (investigate causes)
- Security updates needed (dependencies)

&lt;/ListCheck&gt;

## Step 12: Continue Adding Features Carefully
---
Your project is live! Now you&apos;ll iterate and improve. But do it carefully to avoid breaking what works.

### Always Work on a Branch

Never commit directly to `main` when your site is live:

```bash
# Create a new feature branch
git checkout -b feature/add-dark-mode

# Make your changes and test locally

# Commit to the branch
git add -A
git commit -m &quot;Add dark mode toggle&quot;

# Push the branch
git push origin feature/add-dark-mode
```

Then create a Pull Request on GitHub and deploy from there.

### Test Locally First

Before deploying new features:

&lt;ListCheck&gt;

1. **Run the dev server** - `npm run dev`
2. **Test the new feature** thoroughly
3. **Check existing features** still work
4. **Test on mobile** sizes
5. **Run the build** - `npm run build`
6. **Fix any errors** that appear
7. **Preview the build** - `npm run preview`

&lt;/ListCheck&gt;

Only after all these checks pass should you deploy.

### Use Staging Environments

For serious projects, have a staging site:

**Setup:**
1. Create a separate branch (e.g., `staging`)
2. Deploy it to a different URL (e.g., `staging.yourdomain.com`)
3. Test new features on staging first
4. Merge to `main` only after verification

Most hosting platforms make this easy:
- Vercel: Automatic preview deployments for branches
- Cloudflare Pages: Can deploy multiple branches
- Self-hosted: Use Dokploy to deploy multiple branches

### Keep Features Small

Remember Step 7? This applies forever:

**Bad:**
```
Add user authentication, profile pages, and social sharing all at once
```

**Good:**
```
Step 1: Add user registration form with email/password
Step 2: Add login functionality
Step 3: Add password reset flow
Step 4: Add email verification
```

Small changes are easier to test, debug, and roll back if needed.

### Document New Features

Update your `AGENTS.md` when adding new patterns:

```markdown
## Recent Additions

### Dark Mode (Added 2025-11-10)
- Theme toggle in header
- Saved to localStorage
- CSS variables in `theme.css`
- Usage: Add `dark:` prefix to Tailwind classes

### User Authentication (Added 2025-11-08)
- Using Clerk for auth
- Protected routes in `src/middleware/auth.ts`
- User object available in `Astro.locals.user`
```

This helps AI (and future you) understand the full project context.

## Common Pitfalls and How to Avoid Them

### Pitfall 1: Overwhelming the AI

**Symptom:** AI generates buggy code or misses requirements

**Solution:** Break tasks into smaller pieces. If AI is struggling, your request is too big.

### Pitfall 2: Not Testing Locally

**Symptom:** Deployments fail or bugs appear in production

**Solution:** Always run `npm run dev` and `npm run build` locally first.

### Pitfall 3: Trusting AI Blindly

**Symptom:** Accumulating technical debt or security issues

**Solution:** Review all code, ask questions, get second opinions.

### Pitfall 4: Too Many Dependencies

**Symptom:** Project becomes slow, bloated, or breaks easily

**Solution:** Only add libraries when necessary. Prefer solutions with fewer dependencies.

### Pitfall 5: No Version Control Discipline

**Symptom:** Lost code, unclear what changed, hard to roll back

**Solution:** Commit often with descriptive messages. Use branches for features.

### Pitfall 6: Ignoring Performance

**Symptom:** Slow load times, poor user experience

**Solution:** Test with Lighthouse, optimize images, minimize JavaScript.

### Pitfall 7: Skipping Documentation

**Symptom:** AI makes wrong assumptions, you forget how things work

**Solution:** Keep `AGENTS.md` and `docs/` updated. Document as you build.


## Real-World Success Example

Let me share my experience to show what&apos;s possible.

### The Project: SmoothieBlenderGuide.com

I recently rebuilt my affiliate site [SmoothieBlenderGuide.com](https://www.smoothieblenderguide.com/) from scratch using AI.

**Starting Point:**
- 40 articles on WordPress
- Slow performance (3-4 second load times)
- $15/month hosting
- Outdated product information

**Goal:**
- Migrate to Astro for speed
- Update all product data from Amazon
- Improve article quality
- Reduce hosting costs to $0

**Tools Used:**
- Factory.ai Droid CLI (40M free tokens)
- BrightData MCP (for Amazon data)
- Claude Sonnet 4.5 (for article writing)

**Process:**
1. Asked AI to create project architecture
2. Set up Astro with Tailwind CSS
3. Used BrightData MCP to fetch current Amazon data
4. Rewrote articles with updated information
5. Created comparison tables from review data
6. Generated optimized images
7. Deployed to Cloudflare Pages

**Results:**
- **40 articles completed** in ~3 hours of actual work
- **Total cost:** Under $2 (mostly AI tokens)
- **Hosting:** $0 (free on Cloudflare Pages)
- **Load times:** Under 1 second (from 3-4 seconds)
- **Better content:** More detailed with current data

**Key Takeaway:** With AI and the right approach, you can build professional-quality projects as a beginner.

Read the full case study: [How to Build AI-Powered Affiliate Websites with Amazon Products](https://www.bitdoze.com/ai-affiliate-websites-amazon/)

## Your Action Plan: Get Started Today

Ready to begin? Here&apos;s your immediate next steps:

### Today (30 minutes)


- [ ] Create a GitHub account
- [ ] Sign up for an AI assistant (Droid CLI or Amp Code Free to start)
- [ ] Decide what you want to build (blog, portfolio, app?)
- [ ] Install Node.js if you haven&apos;t yet


### This Week



- [ ] Choose your stack (Astro for sites, TanStack Start for apps)
- [ ] Create your first repository
- [ ] Initialize your project with AI help
- [ ] Create AGENTS.md file
- [ ] Build your first page



### This Month



- [ ] Complete your first feature end-to-end
- [ ] Set up documentation folder
- [ ] Add 1-2 MCP servers
- [ ] Deploy to production
- [ ] Set up basic monitoring


## Conclusion

Programming with AI in 2025 is fundamentally different from traditional learning. You can start building real projects immediately, learn by doing, and iterate quickly without getting stuck on syntax or obscure errors.

**The key principles:**

1. **Choose the right stack** for what you&apos;re building
2. **Use version control** from day one
3. **Pick a good AI assistant** (start free, upgrade when serious)
4. **Document your project** with AGENTS.md and docs/
5. **Add MCP servers** strategically (don&apos;t overdo it)
6. **Build incrementally** - small focused features
7. **Review and understand** every line of code
8. **Get second opinions** from different AI models
9. **Deploy early and often** to production
10. **Monitor your application** to catch issues
11. **Iterate carefully** without breaking what works

The barrier to entry has never been lower. You don&apos;t need a computer science degree or months of studying. With AI as your coding partner and this guide as your roadmap, you can start building today.

**What will you build first?**

&lt;Notice type=&quot;success&quot; title=&quot;Ready to Start Building?&quot;&gt;

Here are your next steps:

1. **Sign up for an AI assistant** - [Droid CLI](https://go.bitdoze.com/droid-cli) (20M free tokens) or [GitHub Copilot Pro](https://github.com/github-copilot/signup) ($10/month)

2. **Choose your first project** - Blog, portfolio, or simple web app

3. **Follow the setup guide** - Pick Astro or TanStack Start based on your needs

4. **Start building today** - Don&apos;t overthink it, just begin!

&lt;/Notice&gt;

## Related Resources

Continue your learning journey with these guides:

&lt;ListCheck&gt;

- [Build a Free Blog with Astro &amp; Cloudflare](https://www.bitdoze.com/build-astro-blog-free/) - Complete Astro tutorial
- [Deploy TanStack Start on Your VPS](https://www.bitdoze.com/tanstack-start-dokploy-deploy/) - Self-hosting guide
- [GitHub Copilot Pro Complete Guide](https://www.bitdoze.com/github-copilot-complete-guide/) - Best AI coding setup
- [Amp Code Free AI Coding Agent](https://www.bitdoze.com/amp-code-free-ai-coding-agent/) - Free alternative
- [Use Claude Sonnet 4.5 and GPT-5 Free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/) - Free AI access
- [BrightData MCP Guide](https://www.bitdoze.com/brightdata-mcp-guide/) - Web scraping with AI
- [Build AI Affiliate Websites](https://www.bitdoze.com/ai-affiliate-websites-amazon/) - Real-world project case study

&lt;/ListCheck&gt;

The future of programming is collaborative. You + AI = unstoppable. Start building today! 🚀</content:encoded><category>ai</category><category>ai-tools</category><category>programming</category></item><item><title>Configure Dokploy Backups with Cloudflare R2 - Complete Guide</title><link>https://www.bitdoze.com/dokploy-backups-cloudflare-r2/</link><guid isPermaLink="true">https://www.bitdoze.com/dokploy-backups-cloudflare-r2/</guid><description>Learn how to set up automated backups for your Dokploy applications, databases, and volumes using Cloudflare R2 with 10GB free storage.</description><pubDate>Wed, 12 Nov 2025 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;

import imag1 from &quot;../../assets/images/25/11/dokploy-volume-backups.webp&quot;;

If you&apos;re self-hosting applications with Dokploy, you need backups. Running production apps, databases, or personal projects without them puts your data at risk. This guide shows you how to set up automated backups for Dokploy using Cloudflare R2, an S3-compatible storage service with 10GB of free storage.

## Why Backups Matter

Here&apos;s what can happen when you don&apos;t have backups:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Hardware failures** - Disk failures, memory corruption, or complete server breakdowns&lt;/li&gt;
&lt;li&gt;**Human errors** - Accidentally deleting databases, misconfiguring services, or running destructive commands&lt;/li&gt;
&lt;li&gt;**Security incidents** - Ransomware, hacking attempts, or compromised credentials&lt;/li&gt;
&lt;li&gt;**Provider issues** - Outages, data center failures, or account suspensions&lt;/li&gt;
&lt;li&gt;**Software bugs** - Updates, migrations, or configuration changes that corrupt data&lt;/li&gt;
&lt;li&gt;**Compliance requirements** - Some industries require regular backups and disaster recovery plans&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

The rule: **Don&apos;t store backups with the same provider hosting your primary data**. If your VPS provider suspends your account or their data center fails, you need access to your backups.

### Why Use a Different Provider

Even though Hetzner (where I host my VPS) offers affordable S3-compatible storage with their Storage Box, I use Cloudflare R2 for backups. This separation means:

- **Account issues won&apos;t affect backups** - If your VPS provider suspends your account, your backups stay accessible
- **Geographic redundancy** - Data lives in different data centers
- **Provider independence** - You&apos;re not locked into one vendor
- **Financial separation** - Billing problems with one provider don&apos;t affect backup access

&lt;Notice type=&quot;warning&quot; title=&quot;Critical Backup Rule&quot;&gt;
Always store your backups with a different cloud provider than your primary infrastructure. This separation ensures you can recover from provider-level failures or account issues.
&lt;/Notice&gt;

---

## Dokploy Backup Options

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/XUsZA9_gGN0&quot;
  label=&quot;Configure Dokploy Backups with Cloudflare R2 - Complete Guide&quot;
/&gt;


Dokploy covers three backup types. Here&apos;s what each one does and when to use it.

### 1. Dokploy System Backups

These back up your entire Dokploy installation:

**What&apos;s included:**
- PostgreSQL database (dokploy-postgres) with your application configurations
- Dokploy file system (/etc/dokploy) with settings, certificates, and metadata
- Application definitions, environment variables, and deployment history
- Traefik configurations and SSL certificates

**When to use:**
- Before major Dokploy updates or migrations
- After significant configuration changes
- For disaster recovery
- When moving to a new server

**Schedule:** Daily automated backups with 7-day retention

### 2. Database Backups

Individual database backups for your applications:

**Supported databases:**
- PostgreSQL (using pg_dump)
- MySQL (using mysqldump)
- MariaDB (using mariadb-dump)
- MongoDB (using mongodump)

**What&apos;s included:**
- Complete database schema (tables, indexes, constraints)
- All data in compressed format
- User permissions and roles
- Stored procedures and functions

**When to use:**
- Before application updates or schema migrations
- For point-in-time recovery
- When testing major data changes
- For creating development/staging environments

**Schedule:** Every 6 hours for production databases, daily for development

### 3. Volume Backups

Docker volume backups for applications using file-based storage:

**Good for:**
- SQLite databases (n8n, Memos, etc.)
- File uploads and user-generated content
- Configuration files stored in volumes
- Applications without traditional databases

**What&apos;s included:**
- Complete volume contents
- File permissions and ownership
- Directory structure

**When to use:**
- Applications using SQLite or embedded databases
- Services storing important files in volumes
- Content management systems
- Before container updates

**Schedule:** Daily for production volumes, weekly for static content

&lt;Picture
  src={imag1}
  alt=&quot;Dokploy Volume Backups Interface&quot;
/&gt;

---

## Cloudflare R2 for Backups

Cloudflare R2 is an S3-compatible object storage service that works well for backups.

### Free Tier

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**10GB storage** - Free forever&lt;/li&gt;
&lt;li&gt;**Zero egress fees** - You don&apos;t pay for downloads&lt;/li&gt;
&lt;li&gt;**S3 compatible** - Works with S3-compatible tools and applications&lt;/li&gt;
&lt;li&gt;**Global distribution** - Data distributed across Cloudflare&apos;s network&lt;/li&gt;
&lt;li&gt;**Predictable pricing** - No hidden fees&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Cost Comparison

| Provider | Storage (per GB/month) | Egress (per GB) | Free Tier |
|----------|------------------------|-----------------|-----------|
| Cloudflare R2 | $0.015 | **$0.00** | 10GB storage |
| AWS S3 | $0.023 | $0.09 | 5GB (12 months) |
| Hetzner Storage Box | $3.81 (100GB) | $0.00 | None |
| Backblaze B2 | $0.005 | $0.01 | 10GB storage |

Cloudflare R2 has the best combination of free storage and zero egress fees.

### Beyond the Free Tier

If you exceed 10GB, R2&apos;s paid tier costs $0.015 per GB/month ($1.50 for 100GB) with no egress charges, minimum storage duration, or retrieval fees.

---

## Step 1: Configure Cloudflare R2 Bucket

Set up your R2 bucket for Dokploy backups. This takes about 5 minutes.

### 1.1 Create Your R2 Bucket

1. **Log into Cloudflare Dashboard**: Go to [dash.cloudflare.com](https://dash.cloudflare.com)
2. **Navigate to R2**: Click on &quot;R2&quot; in the left sidebar
3. **Create Bucket**:
   - Click &quot;Create bucket&quot; button
   - **Bucket name**: Use something descriptive like `dokploy-backups` or `yourapp-backups`
   - **Location**: Select the region closest to your VPS for faster uploads
     - **WNAM** (Western North America) - Best for US West Coast
     - **ENAM** (Eastern North America) - Best for US East Coast, Europe
     - **WEUR** (Western Europe) - Best for Europe
     - **APAC** (Asia Pacific) - Best for Asia
   - Click &quot;Create bucket&quot;

**Bucket naming rules:**
- 3-63 characters long
- Lowercase letters, numbers, and hyphens only
- Must start and end with a letter or number
- Cannot contain spaces or special characters

### 1.2 Create API Token for Dokploy

Create credentials for Dokploy to access your R2 bucket:

1. **Navigate to API Tokens**: Go back to R2 Object Storage overview
2. **Manage R2 API Tokens**: Click the &quot;Manage R2 API Tokens&quot; button (or select from dropdown)
3. **Create New Token**:
   - Click &quot;Create API token&quot;
   - **Token name**: Enter a descriptive name like &quot;Dokploy Backup Access&quot;
   - **Permissions**: Select &quot;Object Read &amp; Write&quot;
   - **Specify bucket (Optional but Recommended)**:
     - Enable &quot;Apply to specific buckets only&quot;
     - Select your `dokploy-backups` bucket
     - This restricts the token to only this bucket
   - Click &quot;Create API token&quot;

4. **Save Your Credentials**: Copy these immediately (you won&apos;t see them again):

```
Access Key ID: f3811c6d27415a9s6cv943b6743ad784
Secret Access Key: aa55ee40b4049e93b7252bf698408cc22a3c2856d2530s7c1cb7670e318f15e58
```

Store these credentials in a password manager immediately. You cannot retrieve the Secret Access Key again after closing this window.

### 1.3 Get Your R2 Endpoint URL

The endpoint URL is unique to your Cloudflare account:

1. **Find Your Endpoint URL**: On the R2 overview page, you&apos;ll see your endpoint URL
2. **Format**: It looks like this:
```
https://8ah554705io7842d54c499fbee1156c1c.r2.cloudflarestorage.com
```

3. **Copy the entire URL** - You&apos;ll need this exact URL for Dokploy configuration

---

## Step 2: Configure S3 Destination in Dokploy

Connect your Cloudflare R2 bucket to Dokploy as an S3 destination.

### 2.1 Add New S3 Destination

1. **Access Dokploy Dashboard**: Navigate to your Dokploy installation (e.g., `https://app.yourdomain.com`)
2. **Navigate to Destinations**: Click &quot;Settings&quot; → &quot;Destinations&quot; in the sidebar
3. **Create New Destination**: Click &quot;New Destination&quot; → Select &quot;S3 Compatible&quot;

### 2.2 Configure R2 Connection Details

Fill in the form with your Cloudflare R2 credentials:

| Dokploy Field | Cloudflare R2 Value | Example |
|---------------|---------------------|---------|
| **Destination Name** | Choose a friendly name | `Cloudflare R2 Backups` |
| **Access Key ID** | From API token creation | `f3811c6d27415a9s6cv943b6743ad784` |
| **Secret Access Key** | From API token creation | `aa55ee40b4049e93b7252bf698408cc22a3c2856d2530s7c1cb7670e318f15e58` |
| **Region** | Your bucket location | `WNAM`, `ENAM`, `WEUR`, or `APAC` |
| **Endpoint** | Your R2 endpoint URL | `https://8ah554705io7842d54c499fbee1156c1c.r2.cloudflarestorage.com` |
| **Bucket** | Your bucket name | `dokploy-backups` |
| **Force Path Style** | Enable this option | ✓ Checked |

**Configuration Breakdown:**

**Destination Name:**
- This is just a friendly label you&apos;ll see in Dokploy
- Choose something descriptive like &quot;Cloudflare R2 Backups&quot; or &quot;Production Backups&quot;
- This name is local to Dokploy and doesn&apos;t affect your R2 bucket

**Access Key ID &amp; Secret Access Key:**
- These are the credentials from Step 1.2
- Paste them exactly as shown (no extra spaces)
- Dokploy encrypts these values in its database

**Region:**
- Must match the region you selected when creating your R2 bucket
- Common values:
  - `WNAM` - Western North America (California, Oregon, Washington)
  - `ENAM` - Eastern North America (Virginia, Ohio, Montreal)
  - `WEUR` - Western Europe (London, Frankfurt, Paris)
  - `APAC` - Asia Pacific (Singapore, Tokyo, Sydney)

**Endpoint:**
- Your unique R2 endpoint URL from your Cloudflare account
- Must include `https://` at the beginning
- Should end with `.r2.cloudflarestorage.com`
- Do NOT add the bucket name to the endpoint

**Bucket:**
- The exact name you gave your R2 bucket
- Case-sensitive (must match exactly)
- Do not include slashes or paths

**Force Path Style:**
- **Always enable this** for Cloudflare R2
- Uses path-style URLs (`endpoint/bucket/key`) instead of virtual-hosted style (`bucket.endpoint/key`)
- R2 requires this setting to work properly

### 2.3 Test the Connection

Before saving, test that everything works:

1. **Click &quot;Test Connection&quot;** button
2. **Wait for response** (usually 2-5 seconds)
3. **Success message**: You should see &quot;Connection successful&quot; or similar
4. **If it fails**, check:
   - All credentials are correct (no extra spaces)
   - Endpoint URL is complete and correct
   - Region matches your bucket location
   - Force Path Style is enabled
   - Your API token has Object Read &amp; Write permissions

### 2.4 Save the Destination

Once the test succeeds:
1. Click &quot;Save&quot; or &quot;Create Destination&quot;
2. You&apos;ll see your new destination in the list
3. It&apos;s now available for all backup configurations

---

## Step 3: Backup Dokploy System

The Dokploy system backup is critical—it contains your application configurations, settings, and deployment history.

### What Gets Backed Up

When you create a Dokploy system backup, it includes:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**PostgreSQL database** (dokploy-postgres) with all application definitions, configurations, environment variables, and deployment history&lt;/li&gt;
&lt;li&gt;**File system** (/etc/dokploy) containing SSL certificates, Traefik configurations, and metadata&lt;/li&gt;
&lt;li&gt;**User accounts** and authentication settings&lt;/li&gt;
&lt;li&gt;**Git provider connections** and deployment keys&lt;/li&gt;
&lt;li&gt;**All destination configurations** (S3 destinations, Git providers, etc.)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Create Automated Backups

1. **Navigate to Backups**: Click &quot;Web Server&quot; → &quot;Backups&quot; in Dokploy dashboard
2. **Create New Backup**: Click &quot;Create Backup&quot; button
3. **Configure Backup Settings**:

**Backup Configuration:**

| Setting | Recommended Value | Explanation |
|---------|-------------------|-------------|
| **Destination** | Cloudflare R2 Backups | Select the S3 destination you created |
| **Backup Name** | `dokploy-system-{date}` | Auto-generated with timestamp |
| **Schedule (Cron)** | `0 2 * * *` | Daily at 2 AM server time |
| **Enabled** | ✓ Checked | Enable automated backups |
| **Retention** | 7 days | Keep last 7 backups (optional) |

**Understanding Cron Syntax:**

The schedule uses standard cron format: `minute hour day month weekday`

Common schedules:
```
0 2 * * *     # Daily at 2:00 AM
0 */6 * * *   # Every 6 hours
0 2 * * 0     # Weekly on Sunday at 2:00 AM
0 3 1 * *     # Monthly on the 1st at 3:00 AM
*/30 * * * *  # Every 30 minutes
```

**Why 2 AM?** This is typically a low-traffic period for most applications.

4. **Test Manual Backup**: Before relying on automated backups:
   - Click &quot;Run Backup Now&quot; or &quot;Test Backup&quot;
   - Wait for completion (usually 1-3 minutes)
   - Check your R2 bucket to verify the file was uploaded
   - Download and verify the backup is not corrupted

### Backup File Structure

Your backups will be stored in R2 with this structure:
```
dokploy-backups/
  └── dokploy-system-2025-11-12-02-00.zip
      ├── database/
      │   └── dokploy-postgres.sql
      └── filesystem/
          └── etc/
              └── dokploy/
                  └── [all your Dokploy files]
```

**Backup file contents:**
- Compressed ZIP archive
- PostgreSQL SQL dump (database backup)
- Complete /etc/dokploy directory
- Typically 10-50 MB depending on your applications

---

## Step 4: Backup Application Databases

Configure individual database backups for your applications. This is essential for point-in-time recovery and testing.

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisites&quot;&gt;
Before configuring database backups, ensure you have databases created in Dokploy. Learn how to provision databases in our [Dokploy Install Guide](https://www.bitdoze.com/dokploy-install/) or see a practical example in [Deploy TanStack Start on Dokploy](https://www.bitdoze.com/tanstack-start-dokploy-deploy/).
&lt;/Notice&gt;

### 4.1 Configure Database Backup

1. **Navigate to Your Database**: In Dokploy, go to &quot;Databases&quot; → Select your database
2. **Open Backup Tab**: Click on the &quot;Backup&quot; tab
3. **Create Backup Configuration**:

**Backup Settings:**

| Setting | Value | Description |
|---------|-------|-------------|
| **Destination** | Cloudflare R2 Backups | Your S3 destination |
| **Database Name** | Auto-filled | The database you&apos;re backing up |
| **Schedule (Cron)** | `0 */6 * * *` | Every 6 hours for production |
| **Prefix** | `prod-db` or `app-name` | Folder prefix in your bucket |
| **Enabled** | ✓ Checked | Enable automated backups |

**Schedule Recommendations by Environment:**

```bash
# Production database (critical data)
0 */6 * * *    # Every 6 hours (00:00, 06:00, 12:00, 18:00)

# Staging database (moderate importance)
0 2 * * *      # Daily at 2:00 AM

# Development database (low importance)
0 2 * * 0      # Weekly on Sunday at 2:00 AM

# High-frequency production (e-commerce, real-time apps)
0 */3 * * *    # Every 3 hours
```

### 4.2 Test Your Database Backup

Before trusting automated backups:

1. **Click &quot;Test Backup&quot;** button
2. **Monitor Progress**: Watch the backup process in real-time
3. **Verify in R2**: Check your R2 bucket for the backup file
4. **Download and Inspect**: Download the backup and verify it&apos;s not corrupted

**Backup file naming:**
```
dokploy-backups/
  └── prod-db/
      ├── postgres-backup-2025-11-12-00-00.sql.gz
      ├── postgres-backup-2025-11-12-06-00.sql.gz
      ├── postgres-backup-2025-11-12-12-00.sql.gz
      └── postgres-backup-2025-11-12-18-00.sql.gz
```

### 4.3 Understanding Backup Commands

Dokploy uses database-specific commands to create backups:

**PostgreSQL (pg_dump):**
```bash
pg_dump -Fc --no-acl --no-owner -h localhost -U ${databaseUser} \
  --no-password &apos;${database}&apos; | gzip
```

- `-Fc` - Creates custom format (faster restore, compressed)
- `--no-acl` - Excludes access privileges (cleaner restores)
- `--no-owner` - Excludes ownership information
- `| gzip` - Compresses the output (saves 70-90% space)

**MySQL (mysqldump):**
```bash
mysqldump --default-character-set=utf8mb4 -u &apos;root&apos; \
  --password=&apos;${databaseRootPassword}&apos; \
  --single-transaction --no-tablespaces \
  --quick &apos;${database}&apos; | gzip
```

- `--default-character-set=utf8mb4` - Supports emojis and international characters
- `--single-transaction` - Consistent snapshot without locking tables
- `--no-tablespaces` - Avoids permission issues on restore
- `--quick` - Streams rows instead of loading all into memory

**MariaDB (mariadb-dump):**
```bash
mariadb-dump --user=&apos;${databaseUser}&apos; \
  --password=&apos;${databasePassword}&apos; \
  --databases ${database} | gzip
```

**MongoDB (mongodump):**
```bash
mongodump -d &apos;${database}&apos; -u &apos;${databaseUser}&apos; \
  -p &apos;${databasePassword}&apos; \
  --archive --authenticationDatabase=admin --gzip
```

- `--archive` - Outputs to a single archive file
- `--authenticationDatabase=admin` - Uses admin database for authentication
- `--gzip` - Compresses the archive

### 4.4 Multiple Database Backups

If you have multiple databases, configure backups for each:

&lt;Accordion label=&quot;Best Practices for Multiple Databases&quot; group=&quot;db-backup&quot;&gt;

**1. Use Descriptive Prefixes:**
```
prod-web-app/     # Main web application database
prod-analytics/   # Analytics database
prod-auth/        # Authentication database
dev-testing/      # Development database
```

**2. Different Schedules by Priority:**
- **Critical databases**: Every 3-6 hours
- **Important databases**: Daily
- **Development databases**: Weekly

**3. Retention Policies:**
- **Production**: Keep 7+ days of backups
- **Staging**: Keep 3 days
- **Development**: Keep 1 backup

**4. Monitoring:**
- Check backup logs weekly
- Verify backup file sizes (sudden changes indicate issues)
- Test restores monthly for production databases

&lt;/Accordion&gt;

---

## Step 5: Backup Application Volumes

Volume backups are essential for applications that don&apos;t use traditional databases, such as SQLite-based apps or services with file storage.

&lt;Picture
  src={imag1}
  alt=&quot;Dokploy Volume Backups Configuration Interface&quot;
/&gt;

### When to Use Volume Backups

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**SQLite databases** - n8n, Memos, Plausible Analytics, Umami Analytics&lt;/li&gt;
&lt;li&gt;**File-based storage** - Uploaded images, documents, user-generated content&lt;/li&gt;
&lt;li&gt;**Configuration files** - Application settings stored in volumes&lt;/li&gt;
&lt;li&gt;**Embedded databases** - LevelDB, RocksDB, LMDB&lt;/li&gt;
&lt;li&gt;**Static sites** - Generated HTML, CSS, and assets&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### 5.1 Understanding Docker Volumes

Before configuring volume backups, understand your volume structure:

**For Single Applications:**
- Volumes are defined in Advanced → Mounts
- Named directly (e.g., `app-data`, `uploads`)

**For Docker Compose:**
- Volumes defined in `docker-compose.yml`
- Named with pattern: `{appName}_{volumeName}`
- Example: If app is `n8n-kqlble` and volume is `n8n_data`, the full name is `n8n-kqlble_n8n_data`

### 5.2 Practical Example: Backing Up n8n

Here&apos;s how to back up n8n, a workflow automation tool that uses SQLite:

**n8n Docker Compose (for reference):**
```yaml
version: &quot;3.8&quot;
services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:1.83.2
    restart: always
    environment:
      - N8N_HOST=${N8N_HOST}
      - N8N_PORT=${N8N_PORT}
    volumes:
      - n8n_data:/home/node/.n8n

volumes:
  n8n_data:
```

**n8n stores its SQLite database and files in the `n8n_data` volume**, making traditional database backups unsuitable.

### 5.3 Configure Volume Backup

1. **Navigate to Volume Backups**: In your n8n application, find &quot;Volume Backups&quot; section
2. **Create New Volume Backup**: Click &quot;Create Volume Backup&quot;
3. **Configure Settings**:

| Setting | Value | Description |
|---------|-------|-------------|
| **Name** | `n8n-daily-backup` | Descriptive identifier |
| **Destination** | Cloudflare R2 Backups | Your S3 destination |
| **Schedule (Cron)** | `0 3 * * *` | Daily at 3:00 AM |
| **Service Name** | `n8n` | Auto-complete suggests services |
| **Volume Name** | `n8n_data` | Auto-filled after selecting service |
| **Backup Prefix** | `n8n-volumes` | Optional folder in bucket |
| **Turn off Container** | ✓ Checked (Recommended) | See safety section below |
| **Enabled** | ✓ Checked | Enable automated backups |

### 5.4 Safety Considerations: Container On vs. Off

This decision affects backup integrity:

&lt;Accordion label=&quot;Turn Off Container During Backup (Recommended)&quot; group=&quot;volume-safety&quot; expanded=&quot;true&quot;&gt;

**Advantages:**
- **Data consistency** - No risk of corruption from ongoing writes
- **Complete snapshot** - All data is in a known state
- **File integrity** - No partially written files
- **Database safety** - SQLite and other embedded databases are safely closed

**How it works:**
1. Dokploy stops your container gracefully
2. Docker volume is backed up while nothing is writing to it
3. Backup is uploaded to R2
4. Container is restarted automatically

**Downtime:**
- Typically 30 seconds to 2 minutes depending on volume size
- Schedule during low-traffic periods (e.g., 3:00 AM)
- Most services reconnect automatically

**Best for:**
- Production applications where data integrity is critical
- SQLite databases (corruption risk if backed up while running)
- Applications with active file writing
- Financial or transactional systems

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Keep Container Running During Backup&quot; group=&quot;volume-safety&quot;&gt;

**Advantages:**
- **No downtime** - Service remains available
- **Faster** - No container restart overhead
- **User transparency** - No service interruption

**Risks:**
- **Data inconsistency** - Files may be in various states of writing
- **Corruption risk** - SQLite and other databases may produce corrupted backups
- **Incomplete data** - Large files being written may be partially backed up
- **Failed restores** - Backup might be invalid when you need it most

**How it works:**
1. Docker volume is backed up while container is running
2. Any ongoing file writes may cause inconsistencies
3. Backup is uploaded to R2

**Best for:**
- Development environments
- Static content that rarely changes
- Services with proper write-ahead logging
- Applications designed for live backups

**Mitigation strategies:**
- Schedule during known low-activity periods
- Use application-specific backup tools when available
- Test restores regularly to verify integrity

&lt;/Accordion&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;SQLite and Live Backups&quot;&gt;
Never back up SQLite databases with the container running unless the application explicitly supports live backups. SQLite can produce corrupted backups if backed up while in use. Always stop the container or use the application&apos;s built-in backup tool.
&lt;/Notice&gt;

### 5.5 Finding the Correct Volume Name

For Docker Compose applications, volume names follow a specific pattern:

**Pattern:** `{appName}_{volumeName}`

**Examples:**
```
# App: n8n-kqlble, Volume: n8n_data
Full name: n8n-kqlble_n8n_data

# App: memos-prod, Volume: memos_data
Full name: memos-prod_memos_data

# App: plausible-analytics, Volume: db-data
Full name: plausible-analytics_db-data
```

**How to find your app name:**
1. Go to your application in Dokploy
2. Look at the URL or page title for the full app name
3. It usually includes the service name plus a random suffix

**How to find your volume name:**
1. Check your docker-compose.yml file
2. Look under the `volumes:` section at the bottom
3. The volume name is what you defined (e.g., `n8n_data`)

**Verify volume exists:**
```bash
# SSH into your VPS
ssh your-username@your-vps-ip

# List all Docker volumes
docker volume ls

# Look for your volume name pattern
docker volume ls | grep n8n
```

### 5.6 Common Applications Using Volumes

Here are popular self-hosted apps that benefit from volume backups:

| Application | Volume Purpose | Backup Priority |
|-------------|----------------|-----------------|
| **n8n** | SQLite database + workflows | High (daily) |
| **Memos** | SQLite database + attachments | High (daily) |
| **Plausible Analytics** | ClickHouse database | High (daily) |
| **Umami Analytics** | SQLite database | Medium (daily) |
| **FileBrowser** | User uploads + database | High (daily) |
| **Stirling PDF** | Temporary files | Low (weekly) |
| **Uptime Kuma** | SQLite database | High (daily) |
| **Docmost** | Uploads + database | High (daily) |

---

## Step 6: Restore Backups

Having backups is useful only if you can restore them. Here&apos;s how to handle each restoration scenario.

### Restore Dokploy System

System restore is for disaster recovery—when your Dokploy installation is corrupted or you&apos;re migrating to a new server.

&lt;Notice type=&quot;warning&quot; title=&quot;Before You Restore&quot;&gt;
System restoration is destructive—it will **replace all current data** with backup data. Make sure you&apos;re restoring the correct backup and understand what will be replaced.
&lt;/Notice&gt;

**Restoration Process:**

1. **Navigate to Backups**: Click &quot;Web Server&quot; → &quot;Backups&quot;
2. **Click &quot;Restore Backup&quot;** button
3. **Configure Restore**:
   - **Source S3 Bucket**: Select your Cloudflare R2 destination
   - **Search for Backup**: Start typing to see available backups
   - **Select Backup**: Choose the backup file you want to restore
   - **Review Summary**: Check what will be restored

4. **What Happens During Restoration**:
   - Existing `/etc/dokploy` directory is **deleted** and replaced
   - PostgreSQL database (`dokploy-postgres`) is **dropped**
   - All database users are disconnected
   - Backup database is restored from your backup
   - Traefik may restart to apply configurations

5. **Click &quot;Restore&quot;** and wait for completion (usually 2-5 minutes)

6. **Post-Restoration Steps**:
   - You may be logged out—sign in again with your credentials
   - Verify all applications appear in your dashboard
   - Check that environment variables are present
   - Test one application to ensure it works

**If restoring to a different server (migration scenario):**

&lt;Accordion label=&quot;Migration Checklist&quot; group=&quot;restore&quot;&gt;

**1. Update Server IP Address:**
- Go to &quot;Web Server&quot; → &quot;Server&quot; → &quot;Update IP&quot;
- Enter your new server&apos;s IP address
- Save changes

**2. Update DNS Records:**
- Point your domain A records to the new server IP
- Wait for DNS propagation (5-15 minutes typically)

**3. Recreate Traefik.me Domains (if used):**
- If you used Traefik.me domains (e.g., `app.traefik.me`)
- Recreate them in your application settings
- They automatically update with your server IP

**4. Reconfigure Git Providers (if using IP addresses):**
- If your Git provider webhooks used IP addresses
- Update them to use domain names instead
- Or update to the new IP address

**5. Test SSL Certificates:**
- Traefik will automatically provision new Let&apos;s Encrypt certificates
- Check that HTTPS works for all applications
- May take 5-10 minutes for initial certificate issuance

**6. Verify Database Connections:**
- Check that all applications can connect to their databases
- Update any external database connection strings if needed

**7. Run Application Health Checks:**
- Visit each application URL
- Verify functionality
- Check logs for any errors

&lt;/Accordion&gt;

### Restore Database Backups

Database restoration is for recovering specific databases without affecting your entire Dokploy installation.

**Common Restoration Scenarios:**

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Data corruption** - Application bug corrupted your database&lt;/li&gt;
&lt;li&gt;**Accidental deletion** - Dropped tables or deleted critical data&lt;/li&gt;
&lt;li&gt;**Testing recovery** - Verify your backups are valid&lt;/li&gt;
&lt;li&gt;**Cloning to staging** - Create development copy from production backup&lt;/li&gt;
&lt;li&gt;**Rolling back changes** - Undo a bad migration or update&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Restoration Process:**

1. **Navigate to Database**: Go to &quot;Databases&quot; → Select your database
2. **Open Backup Tab**: Click &quot;Backup&quot; tab
3. **Click &quot;Restore&quot;** button
4. **Configure Restoration**:

| Setting | Description |
|---------|-------------|
| **Source S3 Bucket** | Select your Cloudflare R2 destination |
| **Search Backup File** | Start typing—autocompletes with available backups |
| **Database Name** | Auto-filled with current database name |

5. **Understand Nested Folders**:
   - If you use folder prefixes (e.g., `prod-db/`), type the prefix
   - Example: Type `prod-db/` to see backups in that folder
   - Full path: `prod-db/postgres-backup-2025-11-12-06-00.sql.gz`

6. **Select Specific Backup**: Choose the exact backup timestamp you want
7. **Click &quot;Restore Database&quot;**

**What Happens:**
- Current database is **dropped** (all data deleted)
- Backup file is downloaded from R2
- Database is recreated from backup
- Permissions are restored
- Connected applications may briefly lose connection (usually auto-reconnect)

**Verification Steps:**
1. Check database logs for any restore errors
2. Test your application to ensure it works
3. Verify critical data is present
4. Check application logs for database connection errors

&lt;Notice type=&quot;info&quot; title=&quot;Restoration Compatibility&quot;&gt;
Dokploy only guarantees restoration of backups created by its own backup system. Custom backup files or manual dumps may not work with the automated restore process.
&lt;/Notice&gt;

### Restore Volume Backups

Volume restoration recreates a Docker volume from a backup, useful for recovering lost files or rolling back application state.

**Restoration Process:**

1. **Navigate to Application**: Go to your application (e.g., n8n)
2. **Open Volume Backups**: Find &quot;Volume Backups&quot; section
3. **Click &quot;Restore Volume&quot;** button
4. **Configure Restoration**:

| Setting | Value | Description |
|---------|-------|-------------|
| **Source S3 Bucket** | Cloudflare R2 Backups | Your backup destination |
| **Search Backup** | Start typing | Find your volume backup |
| **Target Volume Name** | `n8n-kqlble_n8n_data` | Volume to restore to |

**Critical: Understanding Target Volume Name**

For Docker Compose services, you must use the full volume name: `{appName}_{volumeName}`

**Examples:**
```
# Wrong (will fail)
n8n_data

# Correct
n8n-kqlble_n8n_data
```

**How to find the correct name:**
1. Check your app name in Dokploy (e.g., `n8n-kqlble`)
2. Check your docker-compose.yml for volume name (e.g., `n8n_data`)
3. Combine: `n8n-kqlble_n8n_data`

**Or verify with Docker:**
```bash
# SSH into your VPS
ssh your-username@your-vps-ip

# List volumes for your application
docker volume ls | grep n8n

# Output will show the full volume name
n8n-kqlble_n8n_data
```

**Important Restore Considerations:**

&lt;Notice type=&quot;warning&quot; title=&quot;Volume Restore Prerequisites&quot;&gt;
Before restoring a volume:

1. **Stop the container** - The target volume must not be in use
2. **Remove existing volume** - If it already exists, delete it first
3. **Check available space** - Ensure sufficient disk space for the restore
4. **Back up current state** - If the volume has important data, back it up first

The restore will **fail** if the volume is in use or already exists.
&lt;/Notice&gt;

**Complete Restore Workflow:**

```bash
# 1. Stop your application in Dokploy dashboard
#    (or via command line)
docker compose -f /opt/stacks/n8n-kqlble/docker-compose.yml down

# 2. Remove the existing volume
docker volume rm n8n-kqlble_n8n_data

# 3. Restore via Dokploy UI (as described above)

# 4. Start your application in Dokploy dashboard
#    (or via command line)
docker compose -f /opt/stacks/n8n-kqlble/docker-compose.yml up -d
```

**Verification:**
1. Check container logs for startup errors
2. Access your application to verify functionality
3. Confirm restored data is present and correct
4. Test application features that use the restored data

---

## Advanced Backup Strategies

### Backup Rotation and Retention

Implement a backup rotation strategy to balance storage costs with recovery options:

**3-2-1 Backup Rule:**
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**3** copies of your data (original + 2 backups)&lt;/li&gt;
&lt;li&gt;**2** different storage types (local SSD + cloud storage)&lt;/li&gt;
&lt;li&gt;**1** offsite backup (Cloudflare R2)&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**Retention Schedule Example:**

| Backup Type | Frequency | Retention | Storage Used |
|-------------|-----------|-----------|--------------|
| Hourly | Every 3 hours | 24 hours | ~8 backups |
| Daily | Daily at 2 AM | 7 days | 7 backups |
| Weekly | Sunday at 2 AM | 4 weeks | 4 backups |
| Monthly | 1st of month | 6 months | 6 backups |

This gives you:
- Granular recovery for recent issues (hourly)
- Week-long history for recent problems (daily)
- Monthly snapshots for long-term reference

**Total storage:** ~25 backups × average backup size

### Monitoring Backup Health

Create a monitoring system to ensure backups are working:

&lt;Accordion label=&quot;Backup Monitoring Checklist&quot; group=&quot;monitoring&quot;&gt;

**Weekly Checks:**
- [ ] Verify latest backups exist in R2 bucket
- [ ] Check backup file sizes (sudden changes indicate issues)
- [ ] Review Dokploy backup logs for errors
- [ ] Confirm scheduled backups are running on time

**Monthly Tasks:**
- [ ] Test restore from latest backup
- [ ] Download and verify backup integrity
- [ ] Review storage usage in Cloudflare R2
- [ ] Update backup documentation with any changes

**Quarterly Tests:**
- [ ] Full disaster recovery drill (restore to test server)
- [ ] Document restore time and any issues encountered
- [ ] Update restore procedures based on lessons learned

**Automated Monitoring (Advanced):**
- Set up Uptime Kuma or similar to check for new backup files
- Create alerts if backups haven&apos;t run in expected timeframe
- Use Cloudflare R2 webhooks to notify on new uploads

&lt;/Accordion&gt;

### Encryption and Security

Your backups contain sensitive data—protect them:

**R2 Bucket Security:**
1. **Use unique API tokens** for each application or backup type
2. **Restrict token permissions** to specific buckets only
3. **Enable token expiration** for temporary access
4. **Rotate tokens annually** as a security best practice

**Additional Security Measures:**
- Enable encryption at rest in R2 (automatic with R2)
- Use HTTPS for all transfers (enforced by R2)
- Store API credentials in password manager
- Never commit credentials to Git repositories
- Audit access logs regularly

**Encrypting Backups (Advanced):**
```bash
# Before uploading to R2, encrypt locally
gpg --symmetric --cipher-algo AES256 backup-file.zip

# This creates backup-file.zip.gpg
# Store the passphrase securely (password manager)
```

---

## Troubleshooting Common Issues

### Backup Fails to Upload

**Symptoms:** Backup completes but doesn&apos;t appear in R2

**Solutions:**
1. **Check credentials**: Verify Access Key ID and Secret Access Key
2. **Test connection**: Use &quot;Test Connection&quot; in Dokploy destination settings
3. **Check bucket name**: Ensure it&apos;s spelled exactly correct (case-sensitive)
4. **Verify region**: Must match your bucket&apos;s region (WNAM, ENAM, etc.)
5. **Check endpoint**: Must be your account-specific R2 endpoint URL
6. **Enable Force Path Style**: Required for R2 compatibility

**Debug command (SSH into VPS):**
```bash
# Check Dokploy logs for backup errors
docker logs dokploy | grep -i backup

# Look for S3 upload errors
docker logs dokploy | grep -i &quot;failed\|error&quot;
```

### Backup Takes Too Long

**Symptoms:** Backup runs for hours or times out

**Solutions:**
1. **Check volume size**: Large volumes take longer
   ```bash
   # Check volume size
   docker system df -v | grep your-volume-name
   ```
2. **Network speed**: Slow upload speeds to R2
3. **Database size**: Large databases need more time
4. **Compress before upload**: Ensure compression is enabled
5. **Schedule during off-hours**: Less contention for resources

**Optimization tips:**
- Split large volumes into multiple smaller volumes
- Exclude temporary files from backups
- Increase server bandwidth if consistently slow
- Use volume snapshots for instant backups (advanced)

### Restore Fails

**Symptoms:** Restore starts but fails to complete

**Solutions:**
1. **Check disk space**: Ensure sufficient space for restore
   ```bash
   df -h
   ```
2. **Verify backup integrity**: Download backup and check if it&apos;s corrupted
3. **Stop conflicting services**: Ensure no containers are using the target volume
4. **Database permissions**: Ensure Dokploy can drop/create databases
5. **Network stability**: Slow or interrupted downloads from R2

**For database restores:**
```bash
# Check database logs
docker logs dokploy-postgres

# Verify database is accessible
docker exec -it dokploy-postgres psql -U postgres -l
```

### Out of Free Storage (10GB Exceeded)

**Symptoms:** Backups stop working, R2 returns quota errors

**Solutions:**
1. **Check current usage**:
   - Go to Cloudflare R2 dashboard
   - View storage usage statistics

2. **Implement retention policies**:
   - Delete old backups manually
   - Set up lifecycle rules (if R2 supports them)
   - Reduce backup frequency for less critical data

3. **Optimize backups**:
   - Ensure compression is enabled
   - Exclude unnecessary files from volume backups
   - Use database-specific backup tools for better compression

4. **Upgrade plan**:
   - R2 paid tier is $0.015/GB/month ($1.50 for 100GB)
   - Still cheaper than most alternatives
   - No egress fees (saves money on restores)

**Calculate your storage needs:**
```
Dokploy system backup:    10 MB × 7 days = 70 MB
Database backups:         50 MB × 4/day × 7 days = 1,400 MB
Volume backup (n8n):      200 MB × 7 days = 1,400 MB
Volume backup (other):    100 MB × 7 days = 700 MB
------------------------------------------------------
Total:                    ~3.5 GB (fits in free tier)
```

---

## Conclusions

You&apos;ve set up an automated backup system for your Dokploy infrastructure using Cloudflare R2:

✅ **Cloudflare R2 configured** with 10GB free storage and zero egress fees

✅ **Dokploy system backups** protecting your complete installation and configurations

✅ **Database backups** for all your application databases with automated schedules

✅ **Volume backups** for SQLite databases and file-based storage

✅ **Restore procedures** tested and documented for disaster recovery

✅ **Separate provider strategy** ensuring backups survive provider-level failures

### Key Takeaways

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;**Always keep backups with a different provider** than your VPS to survive account or provider issues&lt;/li&gt;
&lt;li&gt;**Test your restores regularly** - backups are only useful if they actually work when you need them&lt;/li&gt;
&lt;li&gt;**Stop containers for volume backups** when backing up SQLite or embedded databases to prevent corruption&lt;/li&gt;
&lt;li&gt;**Monitor backup health** with weekly checks of your R2 bucket and backup logs&lt;/li&gt;
&lt;li&gt;**Implement retention policies** to balance storage costs with recovery needs&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

### Next Steps

Now that your backups are configured:

1. **Schedule a disaster recovery drill** - Test a full Dokploy system restore on a test server
2. **Document your procedures** - Keep notes on restore times and any issues you encounter
3. **Set up monitoring** - Use Uptime Kuma or similar to alert if backups stop running
4. **Review monthly** - Check storage usage and backup health regularly

### Related Resources

If you&apos;re new to Dokploy or want to expand your self-hosting setup:

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-install/&quot; text=&quot;Install Dokploy Guide&quot; /&gt;
&lt;Button link=&quot;https://www.bitdoze.com/tanstack-start-dokploy-deploy/&quot; text=&quot;Deploy Apps on Dokploy&quot; /&gt;
&lt;Button link=&quot;https://www.bitdoze.com/dokploy-update-docker-compose/&quot; text=&quot;Update Docker Compose Apps&quot; /&gt;

Your data is now protected with automated, offsite backups.</content:encoded><category>self-hosting</category><category>dokploy</category><category>backups</category><category>cloudflare</category></item><item><title>How to Self-Host Your Newsletter with Notifuse - Complete Guide</title><link>https://www.bitdoze.com/notifuse-self-host-newsletter/</link><guid isPermaLink="true">https://www.bitdoze.com/notifuse-self-host-newsletter/</guid><description>Learn how to self-host your own newsletter platform using Notifuse. Complete guide with Docker Compose and Dokploy deployment options.</description><pubDate>Wed, 12 Nov 2025 00:00:00 GMT</pubDate><content:encoded>Self-hosting a newsletter platform gives you control over subscriber data and email delivery costs. [Notifuse](https://www.notifuse.com/) is an open-source tool that handles both newsletters and transactional emails. It&apos;s a solid alternative to Mailchimp or Listmonk if you want to run things on your own hardware.

This guide covers setting up Notifuse using Docker Compose or Dokploy.

## Notifuse features

Notifuse is built for developers who want to manage their own email infrastructure. It runs on your server, so you own the data and can customize the setup as needed.

&lt;ListCheck&gt;
- **MJML visual builder**: Design responsive emails with drag-and-drop. It handles the CSS quirks for Gmail, Outlook, and Apple Mail.
- **Multi-tenant workspaces**: You can manage multiple projects or clients from one instance, each with its own domain and database isolation.
- **Dynamic segmentation**: Filter your audience with real-time rules and custom attributes.
- **A/B testing**: Test subject lines or content variations to see what works better.
- **Marketing and transactional emails**: Use the same platform for weekly updates and automated system emails.
- **ESP integrations**: Connects to Amazon SES, Mailgun, Postmark, and others.
- **SMTP relay**: Includes an SMTP server to bridge older apps into the platform.
- **API and webhooks**: RESTful APIs for programmatic contact management and sending.
- **History timeline**: See every interaction a contact has had with your emails.
&lt;/ListCheck&gt;

## Why use Notifuse?

**Versus SaaS (Mailchimp, ConvertKit)**:
- No monthly subscription fees.
- You keep all your data on your own server.
- No limits on contact numbers or sending volume (beyond what your SMTP provider allows).

**Versus Listmonk**:
- Better visual editor (MJML-based vs basic HTML).
- Native support for multi-tenancy.
- More advanced segmentation and built-in A/B testing.

**Versus Sendy**:
- Free and open-source (no upfront license fee).
- More modern interface.
- Includes a visual builder out of the box.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/kzULOnAuS0o&quot;
  label=&quot;Self-Host Your Newsletter with Notifuse&quot;
/&gt;


## What you&apos;ll need

&lt;ListCheck&gt;
- **A server**: 2GB RAM and 2 CPU cores is usually enough.
- **A domain**: For the web interface (e.g., `newsletter.yourdomain.com`).
- **An SMTP provider**: To actually send the mail.
  - **Brevo**: Good for testing (300/day free).
  - **Mail.Baby**: Cheap pay-as-you-go ($1/month + $0.20 per 1k emails).
  - **Amazon SES**: Most cost-effective at scale ($0.10 per 1k emails).
- **Docker**: If you&apos;re doing a manual install.
&lt;/ListCheck&gt;

&lt;Button text=&quot;Try Hetzner Cloud&quot; link=&quot;https://go.bitdoze.com/hetzner&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;
&lt;Button text=&quot;Try Hostinger VPS&quot; link=&quot;https://go.bitdoze.com/hostinger-vps&quot; variant=&quot;solid&quot; color=&quot;green&quot; size=&quot;lg&quot; external={true} icon=&quot;rocket-launch&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;SMTP tips&quot;&gt;
Start with **Brevo&apos;s free tier** to test the setup. Move to **Mail.Baby** or **Amazon SES** once you&apos;re ready to send to a larger list.
&lt;/Notice&gt;

## Option 1: Deploy with Dokploy

Dokploy is a PaaS that simplifies Docker deployments. It has a pre-built Notifuse template that configures the database and networking for you. If you don&apos;t have it yet, see the [Dokploy installation guide](https://www.bitdoze.com/dokploy-install/).

### 1. Install Dokploy

Run this on your VPS:

```sh
curl -sSL https://dokploy.com/install.sh | sh
```

### 2. Use the template

1. Log in to Dokploy and create a new project.
2. Click **Templates** and search for **Notifuse**.
3. Click **Deploy**.

### 3. Enter configuration

Fill out the form:
- **Domain**: Your subdomain (e.g., `newsletter.yourdomain.com`).
- **SMTP details**: Host, port, username, and password from your provider.
- **Admin email**: Your main login email.

**Security keys**:
Generate your own PASETO keys at [paseto.notifuse.com](https://paseto.notifuse.com/). Don&apos;t use the defaults for production.

### 4. Set DNS records

Point an A record to your server&apos;s IP address:
- **Type**: A
- **Name**: newsletter
- **Value**: Your VPS IP

### 5. Finalize deployment

Click **Deploy**. Dokploy will pull the images, set up PostgreSQL 17, and handle SSL via Let&apos;s Encrypt. Once finished, you can log in at your chosen domain.

## Option 2: Deploy with Docker Compose

If you want more control, you can use Docker Compose manually.

### 1. Install Docker

```sh
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
```

### 2. Set up the project

```sh
mkdir ~/notifuse &amp;&amp; cd ~/notifuse
```

### 3. Create docker-compose.yml

```yaml
services:
  api:
    image: notifuse/notifuse:latest
    ports:
      - &apos;8080:8080&apos;
      - &apos;587:587&apos;
    environment:
      - SERVER_PORT=8080
      - SERVER_HOST=0.0.0.0
      - ENVIRONMENT=production
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_USER=postgres
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_NAME=notifuse_system
      - DB_SSLMODE=disable
      - PASETO_PRIVATE_KEY=${PASETO_PRIVATE_KEY}
      - PASETO_PUBLIC_KEY=${PASETO_PUBLIC_KEY}
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./data:/app/data
    restart: unless-stopped
    networks:
      - notifuse-network

  postgres:
    image: postgres:17-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data
    restart: unless-stopped
    networks:
      - notifuse-network
    healthcheck:
      test: [&apos;CMD-SHELL&apos;, &apos;pg_isready -U postgres&apos;]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres-data:

networks:
  notifuse-network:
    driver: bridge
```

### 4. Add .env file

```ini
DB_PASSWORD=your_secure_password
PASETO_PRIVATE_KEY=your_private_key
PASETO_PUBLIC_KEY=your_public_key
```

### 5. Start the services

```sh
docker-compose up -d
```

### 6. Reverse proxy (Nginx)

Install Nginx and Certbot:

```sh
sudo apt install nginx certbot python3-certbot-nginx -y
```

Create `/etc/nginx/sites-available/notifuse`:

```nginx
server {
    listen 80;
    server_name newsletter.yourdomain.com;

    location / {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
```

Enable it and get an SSL certificate:

```sh
sudo ln -s /etc/nginx/sites-available/notifuse /etc/nginx/sites-enabled/
sudo systemctl reload nginx
sudo certbot --nginx -d newsletter.yourdomain.com
```

## Initial setup

Visit your domain and follow the wizard:

1. **Admin email**: Set your login.
2. **API endpoint**: Your full URL (e.g., `https://newsletter.yourdomain.com`).
3. **SMTP**: Enter your provider details.
4. **Account**: Create your admin password.

## Using Notifuse

### Workspaces
Everything in Notifuse happens inside a workspace. Create one first (e.g., &quot;Personal Blog&quot;) to start managing contacts.

### Contacts and Lists
You can import contacts via CSV or add them manually. Set up lists to group subscribers and use double opt-in to keep your list clean.

### Templates and Campaigns
The MJML builder lets you drag and drop elements. Once a template is ready, create a campaign, select your list, and send or schedule it.

### Transactional API
You can send automated emails using the REST API:

```bash
curl -X POST https://newsletter.yourdomain.com/api/v1/send \
  -H &quot;Authorization: Bearer YOUR_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;to&quot;: &quot;user@example.com&quot;,
    &quot;subject&quot;: &quot;Welcome!&quot;,
    &quot;template&quot;: &quot;welcome-email&quot;,
    &quot;variables&quot;: { &quot;name&quot;: &quot;John&quot; }
  }&apos;
```

## Cost comparison (10k subscribers)

- **Notifuse**: ~$5 for a VPS + ~$5 for SMTP = **~$10/month**
- **Mailchimp**: **~$150/month**
- **ConvertKit**: **~$120/month**

## Maintenance

### Updates
In Dokploy, click **Redeploy**. For Docker Compose:

```sh
docker-compose pull &amp;&amp; docker-compose up -d
```

### Backups
Back up the PostgreSQL database regularly:

```sh
docker exec &lt;container_name&gt; pg_dump -U postgres notifuse_system &gt; backup.sql
```

## Summary

Notifuse is a powerful, cheaper alternative to SaaS newsletter platforms. It gives you full control over your data and is easy to set up with Dokploy or Docker Compose. Pair it with a cheap SMTP provider like Amazon SES or Mail.Baby to run a professional newsletter for the cost of a basic VPS.

## FAQ

&lt;Accordion label=&quot;Can I migrate from Mailchimp?&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;
Yes. Export your contacts as a CSV and import them into Notifuse. You&apos;ll need to recreate your templates in the MJML builder.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Is there a sending limit?&quot; group=&quot;faq&quot;&gt;
Notifuse has no limits. Your limits will come from your SMTP provider (e.g., Amazon SES or Mail.Baby).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Do I need to be a developer?&quot; group=&quot;faq&quot;&gt;
The interface is user-friendly, but you&apos;ll need basic server knowledge to set it up. Once running, it&apos;s all point-and-click.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can it handle transactional emails?&quot; group=&quot;faq&quot;&gt;
Yes, it&apos;s built for both marketing campaigns and automated system emails via API.
&lt;/Accordion&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category><category>newsletter</category></item><item><title>How to Use Docker or Podman to Create Safe Environments for AI CLI Tools</title><link>https://www.bitdoze.com/docker-podman-ai-cli-tools-safe-environment/</link><guid isPermaLink="true">https://www.bitdoze.com/docker-podman-ai-cli-tools-safe-environment/</guid><description>Learn how to set up isolated Docker or Podman containers to safely run AI coding agents like Amp, Factory.ai, Claude CLI, and more without affecting your host system.</description><pubDate>Wed, 05 Nov 2025 00:00:00 GMT</pubDate><content:encoded>&lt;Notice type=&quot;info&quot; title=&quot;Why Containerize AI CLI Tools?&quot;&gt;
Running AI coding agents in containers keeps them isolated from your host system. This prevents dependency conflicts and lets you manage multiple AI tools with different configurations. You can test tools without worrying about breaking anything on your machine.
&lt;/Notice&gt;

AI coding assistants like Amp, Factory.ai, Claude CLI, Gemini CLI, and OpenCode CLI help write code faster. But installing all these tools directly on your computer can cause problems - conflicting Node.js versions, messy Python environments, permission errors. It gets worse when you want multiple tools running at once.

Containers fix this. Each tool lives in its own isolated environment, separate from your system.

## Why Use Containers for AI CLI Tools?

&lt;ListCheck&gt;
- **Isolation**: Keep AI tools away from your system and other projects
- **Reproducibility**: Team members get identical setups
- **Safety**: Try experimental tools without risking your main machine
- **Flexibility**: Run different configurations with various API keys
- **Easy cleanup**: Delete containers when done - no leftovers
- **Version control**: Keep different tool versions separate
- **IDE integration**: Edit files on your host while tools run in containers
&lt;/ListCheck&gt;


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/w3NHf5vkMUk&quot;
  label=&quot;Run AI Tools Safely with Docker! (Amp, Factory AI, Gemini &amp; More)&quot;
/&gt;


## Prerequisites

Before starting, ensure you have one of the following installed:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

**Docker Desktop** (recommended for beginners):
- **macOS**: [Download Docker Desktop for Mac](https://www.docker.com/products/docker-desktop/)
- **Windows**: [Download Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/)
- **Linux**: [Install Docker Engine](https://docs.docker.com/engine/install/)

After installation, verify:
```bash
docker --version
docker compose version
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

**Podman Desktop** (Docker alternative):
- **macOS**: [Download Podman Desktop for Mac](https://podman-desktop.io/downloads)
- **Windows**: [Download Podman Desktop for Windows](https://podman-desktop.io/downloads)
- **Linux**: Install via package manager

```bash
# Fedora/RHEL/CentOS
sudo dnf install podman podman-compose

# Ubuntu/Debian
sudo apt install podman podman-compose

# macOS (via Homebrew)
brew install podman podman-compose
```

For macOS/Windows, initialize the Podman machine:
```bash
podman machine init
podman machine start
```

Verify installation:
```bash
podman --version
podman compose version
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Container Architecture Overview

We&apos;ll use the **nikolaik/python-nodejs** base image, which includes:

&lt;ListCheck&gt;
- **Node.js 25** with npm and yarn (via Corepack)
- **Python 3.14** with pip, pipenv, poetry, and uv
- **Non-root user** (`pn`) for security
- **Starship prompt** for a nicer terminal
- **Auto-seeding** of config files on first run
&lt;/ListCheck&gt;

## Step 1: Create Project Structure

First, create the necessary directories:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
mkdir -p ~/docker-ai-tools ~/dev-home ~/websites
cd ~/docker-ai-tools
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

```bash
mkdir -p ~/podman-ai-tools ~/dev-home ~/websites
cd ~/podman-ai-tools
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

**Directory explanation**:
- `~/docker-ai-tools` or `~/podman-ai-tools`: Container config files
- `~/dev-home`: Persistent home directory for the container user (configs, installed CLI tools)
- `~/websites`: Your project files (mounted into container)

&lt;Notice type=&quot;warning&quot; title=&quot;Important: Directory Permissions&quot;&gt;
Ensure these directories have proper permissions. On Linux, you may need to adjust ownership:

```bash
chmod 777 ~/dev-home ~/websites
```

On macOS and Windows, Docker/Podman Desktop handles permissions automatically.
&lt;/Notice&gt;

## Step 2: Create the Containerfile/Dockerfile

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

Create a file named `Dockerfile` in `~/docker-ai-tools/`:

```dockerfile
# Dockerfile
# Base image with Node.js 25, Python 3.14, and package managers
FROM nikolaik/python-nodejs:python3.14-nodejs25-bookworm

SHELL [&quot;/bin/bash&quot;, &quot;-c&quot;]

# The image already has a non-root user &quot;pn&quot;
USER root

# Install system dependencies first
RUN apt-get update &amp;&amp; apt-get install -y \
    git \
    jq \
    curl \
    vim \
    nano \
    htop \
    build-essential \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# Install Starship prompt system-wide
# This works even when /home/pn is bind-mounted from host
RUN curl -sS https://starship.rs/install.sh | sh -s -- -y \
 &amp;&amp; mkdir -p /opt/skeleton/.config \
 &amp;&amp; starship preset catppuccin-powerline -o /opt/skeleton/.config/starship.toml

# Create minimal Bash configuration with Starship
RUN cat &gt; /opt/skeleton/.bashrc &lt;&lt;&apos;BRC&apos;
# Bash initialized for pn user
# Enable Node Corepack and Yarn
corepack enable &gt;/dev/null 2&gt;&amp;1 || true
corepack prepare yarn@stable --activate &gt;/dev/null 2&gt;&amp;1 || true

# Quality of life alias
alias python=python3

# Initialize Starship prompt
if command -v starship &gt;/dev/null 2&gt;&amp;1; then
  eval &quot;$(starship init bash)&quot;
fi
BRC

# Entrypoint script to seed dotfiles on first run
RUN cat &gt; /usr/local/bin/boot.sh &lt;&lt;&apos;SH&apos;
#!/usr/bin/env bash
set -euo pipefail

# Seed ~/.bashrc if missing (e.g., empty bind-mounted /home/pn)
if [ ! -f &quot;/home/pn/.bashrc&quot; ]; then
  cp /opt/skeleton/.bashrc /home/pn/.bashrc
fi

# Seed Starship config if missing
mkdir -p /home/pn/.config
if [ ! -f &quot;/home/pn/.config/starship.toml&quot; ]; then
  cp /opt/skeleton/.config/starship.toml /home/pn/.config/starship.toml
fi

# Default to interactive bash if no command provided
if [ $# -eq 0 ]; then
  set -- bash
fi
exec &quot;$@&quot;
SH
RUN chmod +x /usr/local/bin/boot.sh

# Switch to non-root user
USER pn
WORKDIR /home/pn/app

ENTRYPOINT [&quot;/usr/local/bin/boot.sh&quot;]
CMD [&quot;bash&quot;]
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

Create a file named `Containerfile` in `~/podman-ai-tools/`:

```dockerfile
# Containerfile
# Base image with Node.js 25, Python 3.14, and package managers
FROM nikolaik/python-nodejs:python3.14-nodejs25-bookworm

SHELL [&quot;/bin/bash&quot;, &quot;-c&quot;]

# The image already has a non-root user &quot;pn&quot;
USER root

# Install system dependencies first
RUN apt-get update &amp;&amp; apt-get install -y \
    git \
    jq \
    curl \
    vim \
    nano \
    htop \
    build-essential \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# Install Starship prompt system-wide
# This works even when /home/pn is bind-mounted from host
RUN curl -sS https://starship.rs/install.sh | sh -s -- -y \
 &amp;&amp; mkdir -p /opt/skeleton/.config \
 &amp;&amp; starship preset catppuccin-powerline -o /opt/skeleton/.config/starship.toml

# Create minimal Bash configuration with Starship
RUN cat &gt; /opt/skeleton/.bashrc &lt;&lt;&apos;BRC&apos;
# Bash initialized for pn user
# Enable Node Corepack and Yarn
corepack enable &gt;/dev/null 2&gt;&amp;1 || true
corepack prepare yarn@stable --activate &gt;/dev/null 2&gt;&amp;1 || true

# Quality of life alias
alias python=python3

# Initialize Starship prompt
if command -v starship &gt;/dev/null 2&gt;&amp;1; then
  eval &quot;$(starship init bash)&quot;
fi
BRC

# Entrypoint script to seed dotfiles on first run
RUN cat &gt; /usr/local/bin/boot.sh &lt;&lt;&apos;SH&apos;
#!/usr/bin/env bash
set -euo pipefail

# Seed ~/.bashrc if missing (e.g., empty bind-mounted /home/pn)
if [ ! -f &quot;/home/pn/.bashrc&quot; ]; then
  cp /opt/skeleton/.bashrc /home/pn/.bashrc
fi

# Seed Starship config if missing
mkdir -p /home/pn/.config
if [ ! -f &quot;/home/pn/.config/starship.toml&quot; ]; then
  cp /opt/skeleton/.config/starship.toml /home/pn/.config/starship.toml
fi

# Default to interactive bash if no command provided
if [ $# -eq 0 ]; then
  set -- bash
fi
exec &quot;$@&quot;
SH
RUN chmod +x /usr/local/bin/boot.sh

# Switch to non-root user
USER pn
WORKDIR /home/pn/app

ENTRYPOINT [&quot;/usr/local/bin/boot.sh&quot;]
CMD [&quot;bash&quot;]
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

### What This Containerfile Does

&lt;ListCheck&gt;
- **Starts from nikolaik/python-nodejs**: Has Node.js and Python pre-configured
- **Installs essential tools**: git, jq, curl, vim, and build tools
- **Installs Starship**: Customizable shell prompt
- **Creates skeleton configs**: Auto-seeds dotfiles on first container start
- **Runs as non-root**: Uses the `pn` user, which is more secure
- **Persistent home**: Your configs stay through container restarts
&lt;/ListCheck&gt;

## Step 3: Create Compose Configuration

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

Create `docker-compose.yml` in `~/docker-ai-tools/`:

```yaml
services:
  ai-tools:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: ai-tools
    restart: unless-stopped
    tty: true
    stdin_open: true
    environment:
      - TZ=America/New_York  # Change to your timezone
    volumes:
      - ${HOME}/dev-home:/home/pn:rw
      - ${HOME}/websites:/home/pn/app/websites:rw
    # Add ports only if needed for OAuth or local servers
    # ports:
    #   - &quot;3000:3000&quot;
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

Create `podman-compose.yml` in `~/podman-ai-tools/`:

```yaml
services:
  ai-tools:
    build:
      context: .
      dockerfile: Containerfile
    container_name: ai-tools
    restart: unless-stopped
    tty: true
    stdin_open: true
    environment:
      - TZ=America/New_York  # Change to your timezone
    volumes:
      - ${HOME}/dev-home:/home/pn:z
      - ${HOME}/websites:/home/pn/app/websites:z
    # Add ports only if needed for OAuth or local servers
    # ports:
    #   - &quot;3000:3000&quot;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Volume Mounting Explained&quot;&gt;
- **`~/dev-home:/home/pn`**: Stores user configs, installed CLIs, and dotfiles
- **`~/websites:/home/pn/app/websites`**: Your project files (you can edit these from host)
- **SELinux context (`:z`)**: Only needed for Podman on Linux with SELinux installed
- **Read-write (`:rw`)**: Default for Docker, shown here for clarity
&lt;/Notice&gt;

### When to Expose Ports

Most AI CLI tools don&apos;t require exposed ports. However, you may need to uncomment the `ports` section if:

&lt;ListCheck&gt;
- A CLI tool requires **OAuth authentication** via localhost callback
- You&apos;re running a **local development server** inside the container
- A tool needs to open a **browser-based UI** for authentication
&lt;/ListCheck&gt;

## Step 4: Build and Start the Container

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
cd ~/docker-ai-tools

# Build the image (use --no-cache for fresh build)
docker compose build --no-cache

# Start the container in detached mode
docker compose up -d

# Enter the container
docker exec -it ai-tools bash
```

To stop the container:
```bash
docker compose down
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

```bash
cd ~/podman-ai-tools

# Ensure Podman machine is running (macOS/Windows)
podman machine start

# Build the image (use --no-cache for fresh build)
podman compose build --no-cache

# Start the container in detached mode
podman compose up -d

# Enter the container
podman exec -it ai-tools bash
```

To stop the container:
```bash
podman compose down
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Step 5: Verify Container Environment

Once inside the container, run these checks:

```bash
# Verify user
whoami
# Output: pn

# Check Node.js and package managers
node -v          # v25.x
npm -v           # 10.x
yarn -v          # 4.x

# Check Python and package managers
python3 -V       # 3.14.x
pip --version    # 24.x
poetry --version # 1.8.x
pipenv --version # 2024.x
uv --version     # 0.x

# Verify Starship prompt
which starship
# Output: /usr/local/bin/starship

# Check mounted directories
ls -la ~/app/websites
ls -la ~
```

&lt;Notice type=&quot;success&quot; title=&quot;Success!&quot;&gt;
If all commands return expected versions, your container is ready for AI CLI tools installation.
&lt;/Notice&gt;

## Step 6: Install AI CLI Tools

Now you can safely install any AI coding assistant inside the container:

&lt;Tabs&gt;
&lt;Tab name=&quot;cURL Installers&quot;&gt;

```bash
# Amp / Sourcegraph Agent
curl -fsSL https://ampcode.com/install.sh | bash

# Factory.ai CLI
curl -fsSL https://app.factory.ai/cli | sh

# Claude CLI
curl -fsSL https://claude.ai/install.sh | bash

# OpenCode CLI
curl -fsSL https://opencode.ai/install | bash
```

&lt;/Tab&gt;
&lt;Tab name=&quot;npm Installers&quot;&gt;

```bash
# Gemini CLI
npm install -g @google/gemini-cli

# GitHub Copilot CLI (requires GitHub account)
npm install -g @githubnext/github-copilot-cli
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Root Access Required&quot;&gt;

Some tools require root access during installation. To run as root:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
# Exit the container first, then:
docker exec -it --user root ai-tools bash

# Install tool as root, then exit
# Re-enter as normal user
docker exec -it ai-tools bash
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

```bash
# Exit the container first, then:
podman exec -it --user root ai-tools bash

# Install tool as root, then exit
# Re-enter as normal user
podman exec -it ai-tools bash
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Authenticate with CLI Tools

After installation, authenticate with each tool:

```bash
# Example: Amp CLI
amp login

# Example: Factory.ai
factory login

# Example: Claude CLI
claude login
```

&lt;Notice type=&quot;warning&quot; title=&quot;OAuth Authentication&quot;&gt;
If a tool requires browser-based OAuth and fails to connect, you may need to expose a port. Edit your compose file to add:

```yaml
ports:
  - &quot;3000:3000&quot;  # Or whichever port the tool uses
```

Then rebuild: `docker compose up -d` or `podman compose up -d`
&lt;/Notice&gt;

## Working with Projects

Your `~/websites` directory is mounted at `/home/pn/app/websites` inside the container. Here&apos;s what that means:

&lt;ListCheck&gt;
- **Edit files on your host** using VS Code, Cursor, or any IDE
- **Run AI tools in the container** to analyze and modify files
- **Changes sync** between host and container
- **Git operations** work from either side
&lt;/ListCheck&gt;

### Example Workflow

```bash
# On your host machine
cd ~/websites
mkdir my-new-project
cd my-new-project
git init

# Inside the container
cd ~/app/websites/my-new-project

# Use AI tools
amp &quot;Create a Next.js app with TypeScript&quot;
factory &quot;Add authentication with Supabase&quot;
```

## Configuring AI Tools Inside Container

Your configurations persist in `~/dev-home`, which maps to `/home/pn` in the container.

### Example: Factory.ai Custom Models

Edit the Factory config:

```bash
# Inside container
nano ~/.factory/config.json
```

Add custom models (from your Factory.ai setup):

```json
{
  &quot;custom_models&quot;: [
    {
      &quot;model_display_name&quot;: &quot;Claude Sonnet 4.5&quot;,
      &quot;model&quot;: &quot;claude-sonnet-4-5-20250929&quot;,
      &quot;base_url&quot;: &quot;https://api.anthropic.com&quot;,
      &quot;api_key&quot;: &quot;your-api-key-here&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;,
      &quot;max_tokens&quot;: 8192
    },
    {
      &quot;model_display_name&quot;: &quot;GPT-5 Codex&quot;,
      &quot;model&quot;: &quot;gpt-5-codex&quot;,
      &quot;base_url&quot;: &quot;https://api.openai.com/v1&quot;,
      &quot;api_key&quot;: &quot;your-openai-key-here&quot;,
      &quot;provider&quot;: &quot;openai&quot;,
      &quot;max_tokens&quot;: 8192
    },
    {
      &quot;model_display_name&quot;: &quot;Qwen 3 (Local Ollama)&quot;,
      &quot;model&quot;: &quot;qwen3:14b&quot;,
      &quot;base_url&quot;: &quot;http://localhost:11434/v1&quot;,
      &quot;api_key&quot;: &quot;ollama&quot;,
      &quot;provider&quot;: &quot;generic-chat-completion-api&quot;,
      &quot;max_tokens&quot;: 4096
    }
  ]
}
```

&lt;Accordion label=&quot;Factory.ai Provider Types Explained&quot; group=&quot;factory-providers&quot;&gt;

**Three provider types supported:**

1. **`anthropic`**: For Claude models via Anthropic&apos;s official API
   - Base URL: `https://api.anthropic.com`
   - Uses Messages API (v1/messages)

2. **`openai`**: For GPT models via OpenAI&apos;s official API
   - Base URL: `https://api.openai.com/v1`
   - Uses Responses API (required for GPT-5)

3. **`generic-chat-completion-api`**: For open-source models
   - Works with: OpenRouter, Fireworks, Together AI, Ollama, vLLM
   - Uses OpenAI Chat Completions API format

&lt;/Accordion&gt;

### Accessing Host Files from Container

All your dotfiles and configs in `~/dev-home` are accessible:

```bash
# Inside container
ls -la ~/.factory/     # Factory.ai configs
ls -la ~/.amp/         # Amp configs
ls -la ~/.config/      # Other tool configs
cat ~/.bashrc          # Shell configuration
```

Edit these files from your **host** using any editor:

```bash
# On host machine
code ~/dev-home/.factory/config.json
code ~/dev-home/.bashrc
code ~/dev-home/.config/starship.toml
```

Changes take effect immediately in the container!

## Advanced Configuration

### Running Local AI Models with Ollama

To use local models inside your container:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

1. Install Ollama in the container:
```bash
docker exec -it ai-tools bash
curl -fsSL https://ollama.com/install.sh | sh
```

2. Update compose file to expose Ollama port:
```yaml
ports:
  - &quot;11434:11434&quot;
```

3. Start Ollama service:
```bash
ollama serve &amp;
ollama pull qwen3:14b
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

1. Install Ollama in the container:
```bash
podman exec -it ai-tools bash
curl -fsSL https://ollama.com/install.sh | sh
```

2. Update compose file to expose Ollama port:
```yaml
ports:
  - &quot;11434:11434&quot;
```

3. Start Ollama service:
```bash
ollama serve &amp;
ollama pull qwen3:14b
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Multiple Container Configurations

You can create separate containers for different projects:

```bash
# Project 1 with GPT models
~/docker-ai-tools-gpt/
  ├── docker-compose.yml
  └── Dockerfile

# Project 2 with Claude models
~/docker-ai-tools-claude/
  ├── docker-compose.yml
  └── Dockerfile
```

Change the `container_name` in each compose file to avoid conflicts.

### Sharing Containers with Teams

Commit your configuration to Git:

```bash
cd ~/docker-ai-tools
git init
git add Dockerfile docker-compose.yml
git commit -m &quot;Add AI tools container config&quot;
```

Team members can then:

```bash
git clone &lt;your-repo&gt;
cd &lt;your-repo&gt;
docker compose up -d
docker exec -it ai-tools bash
```

## IDE Integration

While AI tools run in the container, you can use your favorite IDE on the host:

### VS Code with Remote Containers

1. Install **Remote - Containers** extension
2. Open `~/websites/your-project` in VS Code
3. Click &quot;Reopen in Container&quot; (or configure `.devcontainer.json`)

### Direct File Editing

Simply edit files in `~/websites` - changes sync automatically:

```bash
# On host
code ~/websites/my-project

# In container, AI tools see changes immediately
cd ~/app/websites/my-project
amp &quot;Refactor this component&quot;
```

## Troubleshooting

&lt;Accordion label=&quot;Permission Denied Errors&quot; group=&quot;troubleshooting&quot;&gt;

If you see permission errors accessing `~/dev-home` or `~/websites`:

**On Linux with SELinux:**
```bash
# Ensure :z is in podman-compose.yml volumes
chcon -Rt svirt_sandbox_file_t ~/dev-home ~/websites
```

**On macOS/Windows:**
```bash
# Ensure directories exist and are readable
chmod 755 ~/dev-home ~/websites
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Container Won&apos;t Start&quot; group=&quot;troubleshooting&quot;&gt;

Check container logs:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;
```bash
docker compose logs
docker logs ai-tools
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;
```bash
podman compose logs
podman logs ai-tools
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Common issues:
- Port already in use: Change port in compose file
- Image pull failed: Check internet connection
- Volume mount failed: Verify directory paths exist

&lt;/Accordion&gt;

&lt;Accordion label=&quot;AI Tool Authentication Fails&quot; group=&quot;troubleshooting&quot;&gt;

If OAuth or browser-based auth doesn&apos;t work:

1. **Expose required port** in compose file
2. **Restart container**: `docker compose up -d` or `podman compose up -d`
3. **Use manual token auth** if available (check tool docs)
4. **Copy auth URL** and paste in host browser

Example for Factory.ai:
```bash
# Inside container
factory login
# Copy the URL, paste in host browser, complete auth
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Changes Not Syncing Between Host and Container&quot; group=&quot;troubleshooting&quot;&gt;

Verify volume mounts:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;
```bash
docker inspect ai-tools | grep -A 10 Mounts
```
&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;
```bash
podman inspect ai-tools | grep -A 10 Mounts
```
&lt;/Tab&gt;
&lt;/Tabs&gt;

Ensure paths are correct:
```bash
# Should show your host directories
~/dev-home -&gt; /home/pn
~/websites -&gt; /home/pn/app/websites
```

&lt;/Accordion&gt;

## Installing Missing Commands in the Container

Sometimes an AI CLI tool may require a command or utility that&apos;s not included in the base image. For example, you might see errors like:

```bash
bash: git: command not found
bash: jq: command not found
bash: curl: command not found
```

You have two options to resolve this:

### Option 1: Install Temporarily (Quick Fix)

Install the command directly in the running container as root:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
# Enter container as root
docker exec -it --user root ai-tools bash

# Install missing packages (Debian/Ubuntu based)
apt-get update
apt-get install -y git jq curl vim htop

# Exit and re-enter as normal user
exit
docker exec -it ai-tools bash
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

```bash
# Enter container as root
podman exec -it --user root ai-tools bash

# Install missing packages (Debian/Ubuntu based)
apt-get update
apt-get install -y git jq curl vim htop

# Exit and re-enter as normal user
exit
podman exec -it ai-tools bash
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Temporary Installation&quot;&gt;
Commands installed this way will be **lost when the container is recreated**. Use Option 2 for permanent installation by updating the Dockerfile/Containerfile.

**Important**: Always run `apt-get update` before `apt-get install` when installing packages temporarily, otherwise you&apos;ll get &quot;Unable to locate package&quot; errors.
&lt;/Notice&gt;

### Option 2: Update Dockerfile and Rebuild (Permanent)

For permanent installation, update your Dockerfile/Containerfile:

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

Edit your `Dockerfile`:

```dockerfile
# Dockerfile
FROM nikolaik/python-nodejs:python3.14-nodejs25-bookworm

SHELL [&quot;/bin/bash&quot;, &quot;-c&quot;]

USER root

# Install system dependencies BEFORE Starship
RUN apt-get update &amp;&amp; apt-get install -y \
    git \
    jq \
    curl \
    vim \
    htop \
    build-essential \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# Install Starship prompt system-wide
RUN curl -sS https://starship.rs/install.sh | sh -s -- -y \
 &amp;&amp; mkdir -p /opt/skeleton/.config \
 &amp;&amp; starship preset catppuccin-powerline -o /opt/skeleton/.config/starship.toml

# ... rest of Dockerfile remains the same ...
```

Then rebuild:

```bash
cd ~/docker-ai-tools

# Stop the current container
docker compose down

# Rebuild with no cache
docker compose build --no-cache

# Start the new container
docker compose up -d

# Enter the container
docker exec -it ai-tools bash

# Verify new commands are available
git --version
jq --version
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

Edit your `Containerfile`:

```dockerfile
# Containerfile
FROM nikolaik/python-nodejs:python3.14-nodejs25-bookworm

SHELL [&quot;/bin/bash&quot;, &quot;-c&quot;]

USER root

# Install system dependencies BEFORE Starship
RUN apt-get update &amp;&amp; apt-get install -y \
    git \
    jq \
    curl \
    vim \
    htop \
    build-essential \
    &amp;&amp; rm -rf /var/lib/apt/lists/*

# Install Starship prompt system-wide
RUN curl -sS https://starship.rs/install.sh | sh -s -- -y \
 &amp;&amp; mkdir -p /opt/skeleton/.config \
 &amp;&amp; starship preset catppuccin-powerline -o /opt/skeleton/.config/starship.toml

# ... rest of Containerfile remains the same ...
```

Then rebuild:

```bash
cd ~/podman-ai-tools

# Stop the current container
podman compose down

# Rebuild with no cache
podman compose build --no-cache

# Start the new container
podman compose up -d

# Enter the container
podman exec -it ai-tools bash

# Verify new commands are available
git --version
jq --version
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Common Packages You Might Need

&lt;ListCheck&gt;
- **`git`**: Version control (many AI tools need this)
- **`curl` / `wget`**: Download files and make HTTP requests
- **`jq`**: Parse and manipulate JSON
- **`vim` / `nano`**: Quick text editors
- **`htop`**: System monitoring
- **`build-essential`**: C/C++ compilers and build tools
- **`rsync`**: File synchronization
- **`zip` / `unzip`**: Archive utilities
- **`tree`**: Directory structure visualization
- **`postgresql-client`**: PostgreSQL command-line tools
&lt;/ListCheck&gt;

### Example: AI Tool Requires Git

If Factory.ai or Amp fails with &quot;git not found&quot;:

```bash
# Quick fix (temporary) - MUST include apt-get update
docker exec -it --user root ai-tools bash
apt-get update &amp;&amp; apt-get install -y git
exit

# Permanent fix: Already included in our Dockerfile!
# Git is pre-installed in the Dockerfile above
# If you used the Dockerfile from this guide, git is already there
```

&lt;Notice type=&quot;info&quot; title=&quot;Rebuild vs Reinstall&quot;&gt;
After rebuilding the container:
- **System packages** (git, jq, vim, etc.): Automatically included ✓
- **AI CLI tools**: Need to be reinstalled (amp, factory, claude, etc.)
- **Authentication**: Need to re-authenticate with `login` commands
- **Configs in ~/dev-home**: Preserved ✓ (because it&apos;s a mounted volume)
- **Projects in ~/websites**: Preserved ✓ (because it&apos;s a mounted volume)

Your project files and configurations remain intact! Only the container itself is recreated with the updated base system.
&lt;/Notice&gt;

## Security Best Practices

&lt;ListCheck&gt;
- **Never commit API keys** to Git - use environment variables instead
- **Rotate API keys** regularly, especially in shared containers
- **Use read-only mounts** for sensitive config: `~/config:/config:ro`
- **Limit container resources** with `--memory` and `--cpus` flags
- **Run as non-root** (already configured with user `pn`)
- **Keep base image updated**: Rebuild periodically with `--no-cache`
&lt;/ListCheck&gt;

### Environment Variables for API Keys

Instead of hardcoding API keys, use environment variables:

```yaml
# docker-compose.yml or podman-compose.yml
environment:
  - OPENAI_API_KEY=${OPENAI_API_KEY}
  - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
```

Create `.env` file (don&apos;t commit this!):

```bash
OPENAI_API_KEY=sk-your-key-here
ANTHROPIC_API_KEY=sk-ant-your-key-here
```

## Cleaning Up

### Remove Container and Images

&lt;Tabs&gt;
&lt;Tab name=&quot;Docker&quot;&gt;

```bash
# Stop and remove container
docker compose down

# Remove images
docker rmi ai-tools

# Clean up unused images and volumes
docker system prune -a
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Podman&quot;&gt;

```bash
# Stop and remove container
podman compose down

# Remove images
podman rmi ai-tools

# Clean up unused images and volumes
podman system prune -a
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Data Persistence&quot;&gt;
Your `~/dev-home` and `~/websites` directories remain intact after removing containers. Only delete these if you want to completely reset.
&lt;/Notice&gt;

### Start Fresh

To completely reset your environment:

```bash
# Remove container and images (Docker)
docker compose down
docker rmi ai-tools

# Remove container and images (Podman)
podman compose down
podman rmi ai-tools

# Optional: Remove persistent data
rm -rf ~/dev-home/*  # Careful! This removes all CLI configs

# Rebuild
docker compose build --no-cache
docker compose up -d
```

## Comparison: Docker vs Podman

| Feature | Docker | Podman |
|---------|--------|--------|
| **Architecture** | Client-server (daemon) | Daemonless |
| **Root requirement** | Daemon runs as root | Can run rootless |
| **Compose syntax** | `docker compose` | `podman compose` |
| **Desktop GUI** | Docker Desktop | Podman Desktop |
| **Compatibility** | Industry standard | OCI-compliant |
| **macOS/Windows** | VM-based | VM-based |
| **Linux** | Native | Native |
| **SELinux** | Basic support | Advanced support |

**When to choose Docker:**
- You&apos;re already familiar with Docker
- Your team uses Docker
- You need maximum compatibility

**When to choose Podman:**
- You prefer daemonless architecture
- You need rootless containers
- You&apos;re on Fedora/RHEL/CentOS

## Real-World Usage Examples

### Example 1: Factory.ai with Custom Models

```bash
# Inside container
cd ~/app/websites/my-app

# Start Factory with custom model
factory

# In Factory prompt, select your custom model
/model
# Choose &quot;Claude Sonnet 4.5&quot; from Custom models

# Give instructions
&quot;Add user authentication with Supabase, including sign-up, login, and protected routes&quot;
```

### Example 2: Amp with Project Context

```bash
# Inside container
cd ~/app/websites/nextjs-blog

# Create AGENTS.md for context
cat &gt; AGENTS.md &lt;&lt;&apos;EOF&apos;
# Project: Next.js Blog

## Tech Stack
- Next.js 15
- TypeScript
- Tailwind CSS
- MDX for blog posts

## Architecture
- App Router
- Server Components by default
- Client Components only when needed
EOF

# Use Amp with context
amp &quot;Add a comments section using Supabase&quot;
```

### Example 3: Multiple AI Tools in Sequence

```bash
# Use Amp for initial implementation
amp &quot;Create a React component for a product card&quot;

# Use Claude CLI for refinement
claude &quot;Review the ProductCard component and suggest performance improvements&quot;

# Use Factory for testing
factory &quot;Generate unit tests for ProductCard.tsx&quot;
```

## Best Practices for AI CLI Tools in Containers

&lt;ListCheck&gt;
- **Keep one container per project** or project type
- **Document your setup** in the project&apos;s README
- **Use `.dockerignore` or `.containerignore`** to skip unnecessary files
- **Mount only necessary directories** for better performance
- **Set resource limits** to prevent memory issues
- **Update base images** for security patches
- **Back up your `~/dev-home`** configs periodically
&lt;/ListCheck&gt;

## Frequently Asked Questions

&lt;Accordion label=&quot;Can I use multiple AI tools simultaneously?&quot; group=&quot;faq&quot;&gt;

Yes! All tools are installed in the same container and can be used together:

```bash
# Run different tools for different tasks
amp &quot;Implement feature X&quot;
claude &quot;Review the code for feature X&quot;
factory &quot;Generate tests for feature X&quot;
```

You can even pipe outputs between tools or use them in sequence for better results.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will this slow down my AI tools?&quot; group=&quot;faq&quot;&gt;

No significant performance impact. Container overhead is minimal for CLI tools since:
- File I/O is nearly native speed with bind mounts
- CPU and memory are shared with host (no VM overhead on Linux)
- Network requests go directly to AI APIs
- Only Docker Desktop on macOS/Windows uses a lightweight VM

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I access the container from multiple terminals?&quot; group=&quot;faq&quot;&gt;

Absolutely! Open as many terminal sessions as you need:

```bash
# Terminal 1
docker exec -it ai-tools bash
cd ~/app/websites/project1
amp &quot;Work on feature A&quot;

# Terminal 2 (same time)
docker exec -it ai-tools bash
cd ~/app/websites/project2
factory &quot;Work on feature B&quot;
```

Each terminal session is independent but shares the same container environment.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I update AI CLI tools?&quot; group=&quot;faq&quot;&gt;

Most AI CLIs have built-in update commands:

```bash
# Inside container
amp update
factory update
claude update

# For npm-based tools
npm update -g @google/gemini-cli
```

Container rebuilds aren&apos;t necessary unless you want to update Node.js, Python, or the base OS.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use this setup in CI/CD?&quot; group=&quot;faq&quot;&gt;

Yes! This container setup is perfect for CI/CD:

```yaml
# .github/workflows/ai-review.yml
name: AI Code Review
on: [pull_request]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build AI tools container
        run: docker compose build
      - name: Run AI review
        run: |
          docker compose up -d
          docker exec ai-tools amp &quot;Review this PR for code quality&quot;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What if I need a different Node.js or Python version?&quot; group=&quot;faq&quot;&gt;

Modify the base image tag in your Dockerfile/Containerfile:

```dockerfile
# Python 3.12 with Node.js 22
FROM nikolaik/python-nodejs:python3.12-nodejs22-bookworm

# Python 3.13 with Node.js 24
FROM nikolaik/python-nodejs:python3.13-nodejs24-bookworm
```

Check [nikolaik/python-nodejs tags](https://hub.docker.com/r/nikolaik/python-nodejs/tags) for available versions.

&lt;/Accordion&gt;

## Conclusion

Using Docker or Podman to containerize AI CLI tools gives you a safe, isolated environment. This setup has clear advantages:

&lt;ListCheck&gt;
- **Isolation**: Keep your host system safe
- **Flexibility**: Run different configurations at once
- **Portability**: Share same setup across teams
- **Safety**: Experiment without breaking anything
- **Integration**: Edit files on host, run tools in container
- **Persistence**: Configs and projects stay through container restarts
&lt;/ListCheck&gt;

Whether you use Amp, Factory.ai, Claude CLI, or any other AI coding assistant, this containerized approach lets you experiment and build without worrying about breaking your development environment.

&lt;Notice type=&quot;success&quot; title=&quot;Ready to Start?&quot;&gt;
Pick your preferred container platform (Docker or Podman), follow the setup steps, and start using AI coding tools in a safe environment. Your host system stays clean, your projects stay organized, and you can always start fresh with a rebuild.

**Next steps:**
1. Build your container using the tabs above
2. Install your favorite AI CLI tools
3. Start coding with AI assistance
4. Share your setup with your team
&lt;/Notice&gt;

For more AI coding assistant guides, check out:
- [Amp Code: Free AI Coding Agent Guide](/amp-code-free-ai-coding-agent/)</content:encoded><category>self-hosting</category><category>docker</category><category>ai-tools</category></item><item><title>Deploy TanStack Start on Your VPS with Dokploy - Complete Guide</title><link>https://www.bitdoze.com/tanstack-start-dokploy-deploy/</link><guid isPermaLink="true">https://www.bitdoze.com/tanstack-start-dokploy-deploy/</guid><description>Learn how to deploy TanStack Start applications with Drizzle ORM and PostgreSQL on your own VPS using Dokploy. Full tutorial from setup to production.</description><pubDate>Wed, 29 Oct 2025 00:00:00 GMT</pubDate><content:encoded>TanStack Start is a full-stack React framework that handles server-side rendering, type-safe APIs, and modern development patterns. While Vercel and Netlify are convenient for deployment, self-hosting on your VPS gives you control over your infrastructure, predictable costs, and no platform restrictions.

This guide shows how to deploy a TanStack Start app with Drizzle ORM and PostgreSQL on your VPS using Dokploy. I&apos;ll walk through each step from creating your app to production deployment.

## Why Self-Host with Dokploy?

**Dokploy Setup Video**

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EaOvNN-RJgI&quot;
  label=&quot;Self-Hosting Made Easy: Secure VPS + HTTPS + Dokploy Setup&quot;
/&gt;

Here&apos;s why this setup works well:

- **Full Control**: Own your infrastructure and data without vendor lock-in
- **Cost Effective**: Predictable monthly costs instead of pay-per-request
- **No Limits**: No execution time limits, bandwidth restrictions, or function size constraints
- **Database Included**: Managed PostgreSQL 17 with automatic backups
- **Easy Deployment**: Git push to deploy, like Vercel or Heroku
- **Modern Stack**: TanStack Start + Drizzle ORM for type-safe, full-stack development

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisite: Install Dokploy&quot;&gt;
Before starting, make sure your Dokploy server is installed and configured. Follow the complete setup guide: &lt;a href=&quot;https://www.bitdoze.com/dokploy-install/&quot; rel=&quot;noopener&quot;&gt;Dokploy Install – Ditch Vercel/Heroku and Self-Host Your SaaS&lt;/a&gt;. This includes VPS setup, security hardening with CrowdSec, and Dokploy installation. For updating deployed apps, see &lt;a href=&quot;https://www.bitdoze.com/dokploy-update-docker-compose/&quot; rel=&quot;noopener&quot;&gt;How to Update Docker Compose Stacks in Dokploy&lt;/a&gt;.
&lt;/Notice&gt;



## What You&apos;ll Need

- **GitHub Account**: For hosting your code repository
- **VPS with Dokploy**: A server with Dokploy installed (follow the link above)
- **Domain/Subdomain**: Pointed to your VPS IP address
- **Node.js 20+**: Installed on your local development machine
- **Basic Terminal Knowledge**: Familiarity with command-line operations

---

## Video with Setting Everything on Dokploy

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/K3O2Cjq6Iho&quot;
  label=&quot;Deploy TanStack Start on Your VPS with Dokploy&quot;
/&gt;

## Step 1: Set Up Your GitHub Account

If you don&apos;t already have a GitHub account, create one at [github.com](https://github.com). You&apos;ll use this to host your application code and enable automatic deployments.

### Configure SSH Keys (Recommended)

Set up SSH keys for secure authentication:

```sh
# Generate a new SSH key (if you don&apos;t have one)
ssh-keygen -t ed25519 -C &quot;your_email@example.com&quot;

# Start the ssh-agent
eval &quot;$(ssh-agent -s)&quot;

# Add your SSH key to the agent
ssh-add ~/.ssh/id_ed25519

# Copy your public key
cat ~/.ssh/id_ed25519.pub
```

**What these commands do:**
- `ssh-keygen` creates a new SSH key pair using Ed25519 (more secure and faster than RSA)
- `ssh-agent` manages your SSH keys in the background
- `ssh-add` adds your private key to the agent
- `cat` displays your public key, which you&apos;ll add to GitHub under Settings → SSH and GPG keys

---

## Step 2: Create a GitHub Repository

Create a new repository on GitHub for your TanStack Start application:

1. Go to [github.com/new](https://github.com/new)
2. Name your repository (e.g., `tanstack-dokploy-app`)
3. Keep it **private** or **public** (your choice)
4. **Don&apos;t initialize** with README, .gitignore, or license (we&apos;ll create these locally)
5. Click &quot;Create repository&quot;

Keep the repository URL handy - you&apos;ll need it later to push your code.

---

## Step 3: Provision PostgreSQL 17 in Dokploy

Let&apos;s set up the database before creating the application. This way the database is ready when you need it during development.

### Create the Database in Dokploy

1. **Access Dokploy Dashboard**: Navigate to your Dokploy URL (e.g., `https://app.yourdomain.com`)
2. **Navigate to Databases**: Click &quot;Databases&quot; in the sidebar
3. **Create New Database**: Click &quot;New Database&quot; → Select &quot;PostgreSQL&quot;
4. **Configure Database**:
   - **Version**: Select `17` (latest stable version)
   - **Database Name**: `saas-app` (or your preferred name)
   - **Username**: `saas-app` (matches the database name for simplicity)
   - **Password**: Generate a strong password (Dokploy can generate one for you)
   - **Enable External Port**: Check this option
   - **External Port**: Set to `5432` (PostgreSQL default port)

5. **Create**: Click create and wait for the database to provision

You&apos;ll receive a connection string that looks like this:

```
postgresql://saas-app:123FFF4545FFFaaaa@91.98.95.196:5432/saas-app
```

**Connection String Breakdown:**
- `postgresql://` - Protocol
- `saas-app:` - Username
- `123FFF4545FFFaaaa@` - Password
- `91.98.95.196:` - Your VPS IP address
- `5432/` - PostgreSQL port
- `saas-app` - Database name

### Open Port 5432 in Your Firewall

If you followed the [Dokploy installation guide](https://www.bitdoze.com/dokploy-install/) and set up iptables with CrowdSec, allow PostgreSQL connections:

```sh
# SSH into your VPS
ssh your-username@your-vps-ip

# Allow PostgreSQL port
sudo iptables -A INPUT -p tcp --dport 5432 -j ACCEPT

# Make the rule persistent
sudo netfilter-persistent save
```

**What these commands do:**
- `iptables -A INPUT` adds a new rule to the INPUT chain (incoming traffic)
- `-p tcp` specifies the TCP protocol
- `--dport 5432` targets port 5432 (PostgreSQL&apos;s default port)
- `-j ACCEPT` accepts/allows the traffic
- `netfilter-persistent save` writes the rule to disk so it survives reboots

**Security Note**: Opening port 5432 to the internet allows external connections. For production:
- Use internal Docker networking if your app runs on the same server
- Restrict access to specific IP addresses using `-s your.ip.address`
- Use connection pooling and SSL/TLS for production databases

---

## Step 4: Create Your TanStack Start Application

Now let&apos;s create the application using TanStack Start&apos;s scaffolding tool. This sets up a complete React application with modern tooling.

### Initialize the Project

Run the following command in your terminal:

```sh
npm create @tanstack/start@latest
```

**What this command does:**
- `npm create` is a shorthand for `npx create-&lt;package&gt;`
- Downloads and runs the latest TanStack Start scaffolding tool
- Uses `@latest` to ensure you get the newest version
- Provides an interactive CLI to configure your project

### Configuration Options

During setup, you&apos;ll be prompted with several questions. Here are the recommended choices:

```
◇  What would you like to name your project?
│  tanstack-dokploy-test
│
◇  Would you like to use Tailwind CSS?
│  Yes
│
◇  Select toolchain
│  Biome
│
◇  What add-ons would you like for your project?
│  Drizzle, Shadcn, tRPC, Query
│
◇  Would you like any examples?
│  none
│
◇  Drizzle: Database Provider
│  PostgreSQL
```

**Configuration Breakdown:**

- **Project Name**: `tanstack-dokploy-test` - This becomes your directory name
- **Tailwind CSS**: `Yes` - Modern utility-first CSS framework for styling
- **Toolchain**: `Biome` - Fast, modern linter and formatter (alternative to ESLint + Prettier)
- **Add-ons**:
  - **Drizzle**: Type-safe ORM for database operations
  - **Shadcn**: Beautiful, accessible React components
  - **tRPC**: End-to-end type-safe APIs
  - **Query**: TanStack Query for data fetching and caching
- **Examples**: `none` - Start with a clean slate
- **Database**: `PostgreSQL` - Matches our Dokploy database

### Navigate to Your Project

```sh
cd tanstack-dokploy-test
```

### Start Development Server (Optional Test)

Before configuring the database, test that everything works:

```sh
npm run dev
```

**What happens:**
- Vite starts a development server (usually on `http://localhost:3000`)
- Hot Module Replacement (HMR) is enabled for instant updates
- The app compiles and serves your React application
- Press `Ctrl+C` to stop the server

---

## Step 5: Configure Drizzle with Your Database

Now we&apos;ll connect Drizzle ORM to your PostgreSQL database and set up the schema.

### Create Environment File

Create a `.env` file in your project root:

```sh
touch .env
```

Add your database connection string:

```sh
# .env
DATABASE_URL=&quot;postgresql://saas-app:123FFF4545FFFaaaa@91.98.95.196:5432/saas-app&quot;

# Some Drizzle CLI tools use this variable name
DRIZZLE_DATABASE_URL=&quot;${DATABASE_URL}&quot;
```

**Important**: Replace the connection string with your actual credentials from Step 3.

**Why two variables?**
- `DATABASE_URL` is the standard convention used by most tools
- `DRIZZLE_DATABASE_URL` is specifically for Drizzle CLI commands
- Setting both ensures compatibility

### Add .env to .gitignore

Make sure your `.env` file is in `.gitignore` to avoid committing secrets:

```sh
echo &quot;.env&quot; &gt;&gt; .gitignore
```

### Run Drizzle Commands

Now execute the Drizzle setup commands:

```sh
# Generate migrations and TypeScript types
npm run db:generate

# Push schema to database
npm run db:push

# Open Drizzle Studio (optional)
npm run db:studio
```

**Command Explanations:**

**1. `npm run db:generate`**
- Analyzes your schema files (in `src/db/schema.ts` or similar)
- Generates SQL migration files
- Creates TypeScript types for type-safe queries
- Outputs to `drizzle/` directory

**2. `npm run db:push`**
- Reads your schema definitions
- Connects to your PostgreSQL database
- Creates tables, columns, indexes, and constraints
- Syncs your database structure with your code
- **Idempotent**: Safe to run multiple times

**3. `npm run db:studio`**
- Starts Drizzle Studio on `http://localhost:4983`
- Visual database browser in your web browser
- Lets you view/edit data, inspect schema, run queries
- Great for development and debugging

### Troubleshooting Database Connection

If `db:push` fails, check:

1. **Connection String**: Verify credentials and IP address
2. **Firewall**: Ensure port 5432 is open (`sudo iptables -L | grep 5432`)
3. **Database Running**: Check Dokploy dashboard that PostgreSQL is active
4. **Network**: Test connection with `telnet your-vps-ip 5432`

---

## Step 6: Prepare for Production with Nitro

TanStack Start needs a server runtime for production. We&apos;ll use Nitro with Node.js preset, which Dokploy can run.

### 6.1 Install Nitro V2 Plugin

```sh
npm install @tanstack/nitro-v2-vite-plugin
```

**What is Nitro?**
- Universal server framework by Unjs
- Supports multiple platforms (Node.js, Cloudflare Workers, Vercel, etc.)
- Handles SSR, API routes, and asset serving
- Optimizes bundle size and performance

### 6.2 Configure Vite with Nitro

Open `vite.config.ts` and update it with the Nitro plugin:

```ts
// vite.config.ts
import { defineConfig } from &quot;vite&quot;;
import { tanstackStart } from &quot;@tanstack/react-start/plugin/vite&quot;;
import viteReact from &quot;@vitejs/plugin-react&quot;;
import viteTsConfigPaths from &quot;vite-tsconfig-paths&quot;;
import tailwindcss from &quot;@tailwindcss/vite&quot;;
import { nitroV2Plugin } from &quot;@tanstack/nitro-v2-vite-plugin&quot;;

const config = defineConfig({
  plugins: [
    // Enables TypeScript path aliases from tsconfig.json
    // Allows imports like @/components instead of ../../components
    viteTsConfigPaths({
      projects: [&quot;./tsconfig.json&quot;],
    }),

    // Tailwind CSS with Vite integration
    tailwindcss(),

    // TanStack Start core plugin - handles routing, SSR, etc.
    tanstackStart(),

    // Nitro plugin for production server
    // preset: &quot;node-server&quot; creates a Node.js compatible server
    nitroV2Plugin({
      preset: &quot;node-server&quot;,
    }),

    // React plugin for Fast Refresh and JSX transformation
    viteReact(),
  ],
});

export default config;
```

**Configuration Breakdown:**

- **viteTsConfigPaths**: Maps TypeScript path aliases (`@/components`) to actual file paths
- **tailwindcss**: Processes Tailwind utilities at build time
- **tanstackStart**: Core plugin that enables routing, SSR, and server functions
- **nitroV2Plugin**: Builds the production server with `preset: &quot;node-server&quot;`
- **viteReact**: Adds React Fast Refresh and JSX support

**Output**: After build, Nitro generates `.output/server/index.mjs` - a standalone Node.js server.

### 6.3 Update package.json Scripts

Ensure your `package.json` has the correct build and start commands:

```json
{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;vite&quot;,
    &quot;build&quot;: &quot;vite build&quot;,
    &quot;start&quot;: &quot;node .output/server/index.mjs&quot;
  }
}
```

**Script Purposes:**
- `dev`: Runs Vite development server with HMR
- `build`: Creates production-optimized build in `.output/`
- `start`: Runs the production Node.js server

### 6.4 Remove package-lock.json (Optional)

Dokploy uses Nixpacks or Railpacks for builds, which auto-detects package managers. To ensure it uses npm consistently:

```sh
rm -f package-lock.json
```

**Why remove it?**
- If you have multiple lockfiles (pnpm-lock.yaml, yarn.lock, package-lock.json), Nixpacks might get confused
- Removing `package-lock.json` forces npm to be used
- You can regenerate it with `npm install`

**Note**: This step is optional if you&apos;re only using npm and not mixing package managers.

---

## Step 7: Push Your Code to GitHub

Now that your application is ready, let&apos;s push it to GitHub.

### Initialize Git and Commit

```sh
# Stage all files for commit
git add -A

# Create initial commit
git commit -m &quot;first commit&quot;

# Rename branch to main (GitHub&apos;s default)
git branch -M main

# Add your GitHub repository as remote
git remote add origin git@github.com:yourusername/tanstack-dokploy-test.git

# Push to GitHub
git push -u origin main
```

**Command Explanations:**

**1. `git add -A`**
- Stages all changes (new files, modifications, deletions)
- `-A` is shorthand for `--all`
- Prepares files for the commit

**2. `git commit -m &quot;first commit&quot;`**
- Creates a commit with the message &quot;first commit&quot;
- `-m` flag allows inline message instead of opening an editor
- Commits the staged changes to your local repository

**3. `git branch -M main`**
- Renames current branch to `main`
- `-M` forces the rename even if branch exists
- Aligns with GitHub&apos;s default branch name

**4. `git remote add origin git@github.com:yourusername/tanstack-dokploy-test.git`**
- Adds GitHub repository as remote named &quot;origin&quot;
- `git@github.com:` uses SSH authentication
- Replace `yourusername/tanstack-dokploy-test` with your actual repository

**5. `git push -u origin main`**
- Pushes local `main` branch to `origin` remote
- `-u` (or `--set-upstream`) sets up tracking
- Future pushes can use just `git push`

### Troubleshooting Git Push

**SSH Authentication Failed:**
```sh
# Use HTTPS instead
git remote set-url origin https://github.com/yourusername/tanstack-dokploy-test.git
```

**Permission Denied:**
- Verify SSH key is added to GitHub (Settings → SSH and GPG keys)
- Test SSH: `ssh -T git@github.com`

---

## Step 8: Deploy to Dokploy with Nixpacks or Railpacks

Time to deploy your application to production.

### 8.1 Connect GitHub to Dokploy

1. **Navigate to Providers**: In Dokploy dashboard, go to &quot;Providers&quot; → &quot;Git&quot;
2. **Connect GitHub**:
   - Click &quot;Connect GitHub&quot;
   - Choose GitHub App (recommended) or Personal Access Token
   - Authorize Dokploy to access your repositories
3. **Verify Connection**: You should see your repositories listed

### 8.2 Create New Application

1. **Go to Applications**: Click &quot;Applications&quot; in the sidebar
2. **New Application**: Click &quot;New Application&quot; → &quot;From Git&quot;
3. **Select Repository**: Choose `yourusername/tanstack-dokploy-test`
4. **Configure Build Settings**:

**Basic Settings:**
- **Name**: `tanstack-dokploy-app`
- **Branch**: `main`
- **Builder**: Select **Railpacks**
- **Root Directory**: `./` (project root)

**Build Configuration:**
- **Build Command**: `npm run build`
- **Start Command**: `npm start`
- **Internal Port**: `3000`

**What is Railpacks?**
- Railway&apos;s open-source build system
- Auto-detects languages and frameworks
- Generates optimized Docker images
- Alternative to Dockerfile/Buildpacks

### 8.3 Configure Environment Variables

Add the following environment variables in Dokploy:

```sh
DATABASE_URL=postgresql://saas-app:123FFF4545FFFaaaa@91.98.95.196:5432/saas-app
DRIZZLE_DATABASE_URL=postgresql://saas-app:123FFF4545FFFaaaa@91.98.95.196:5432/saas-app
NODE_ENV=production
PORT=3000
```

**Environment Variable Purposes:**

- **DATABASE_URL**: Connection string for your application to connect to PostgreSQL
- **DRIZZLE_DATABASE_URL**: Used by Drizzle CLI tools (for migrations if needed)
- **NODE_ENV**: Tells Node.js this is production (enables optimizations)
- **PORT**: Port your app listens on (must match Internal Port setting)

### 8.4 Add Domain

1. **Navigate to Domains**: In your application settings, find &quot;Domains&quot;
2. **Add Domain**: Enter your domain (e.g., `app.yourdomain.com`)
3. **Enable HTTPS**: Dokploy automatically provisions Let&apos;s Encrypt SSL certificates
4. **DNS Verification**: Ensure your domain&apos;s A record points to your VPS IP

**Traefik Handles:**
- Automatic HTTPS with Let&apos;s Encrypt
- Reverse proxy to your application
- Load balancing (if you scale)

### 8.5 Deploy

1. **Click Deploy**: Trigger the initial deployment
2. **Monitor Logs**: Watch the build process in real-time
3. **Wait for Completion**: First build takes 2-5 minutes

**Build Process Steps:**
1. Dokploy clones your GitHub repository
2. Nixpacks detects Node.js and installs dependencies
3. Runs `npm run build` to create production bundle
4. Creates Docker image with Node.js runtime
5. Starts container with `npm start`
6. Traefik routes traffic to your container

### 8.6 Run Database Migrations (First Deploy Only)

For the first deployment, you need to push your schema to production:

**Option 1: Local Push**
```sh
# From your local machine, use production DATABASE_URL
DATABASE_URL=&quot;postgresql://saas-app:123FFF4545FFFaaaa@91.98.95.196:5432/saas-app&quot; npm run db:push
```

**Option 2: In Dokploy**
- Navigate to your app&apos;s &quot;Console&quot; tab
- Run: `npm run db:push`

**Option 3: Pre-deploy Hook (Advanced)**
- Add a pre-deploy script in Dokploy settings
- Automatically runs migrations before each deployment

---

## How It All Works Together

Let&apos;s break down the deployment architecture:

### Request Flow

1. **User visits** `https://app.yourdomain.com`
2. **DNS resolves** to your VPS IP address
3. **Traefik receives** the HTTPS request (port 443)
4. **SSL termination** - Traefik decrypts with Let&apos;s Encrypt certificate
5. **Route matching** - Traefik forwards to your container on port 3000
6. **TanStack Start** handles the request:
   - Server-side rendering for initial page load
   - API routes for data fetching
   - tRPC for type-safe API calls
7. **Drizzle ORM** queries PostgreSQL for data
8. **Response sent** back through Traefik to user

### Component Responsibilities

- **Dokploy**: Orchestration, deployments, database management
- **Railpacks**: Builds your application into a Docker image
- **Docker**: Isolates your application in containers
- **Traefik**: Reverse proxy, HTTPS, routing
- **PostgreSQL 17**: Data persistence
- **GitHub**: Source control and deployment trigger

---

## Troubleshooting Common Issues

### Build Fails

**Error: Cannot find module**
```sh
# Ensure all dependencies are in package.json
npm install --save &lt;missing-package&gt;
git commit -am &quot;Add missing dependency&quot;
git push
```

**Error: Node version mismatch**
```json
// Add to package.json
{
  &quot;engines&quot;: {
    &quot;node&quot;: &quot;&gt;=20.0.0&quot;
  }
}
```

Or add environment variable in Dokploy:
```sh
NODE_VERSION=20
```

### Application Won&apos;t Start

**Error: Port already in use**
- Verify `PORT=3000` in environment variables
- Check Internal Port is set to `3000` in Dokploy

**Error: Connection refused**
- Check container logs in Dokploy
- Verify `npm start` command is correct
- Ensure `.output/server/index.mjs` exists after build

### Database Connection Issues

**Error: Connection timeout**
```sh
# Verify firewall on VPS
sudo iptables -L | grep 5432

# Test connection from your container
# In Dokploy console:
telnet 91.98.95.196 5432
```

**Error: Authentication failed**
- Double-check DATABASE_URL credentials
- Verify database user exists in Dokploy
- Check for special characters in password (URL encode if needed)


### SSL Certificate Issues

**Error: Certificate provisioning failed**
- Ensure DNS A record points to VPS IP
- Wait 5-10 minutes for DNS propagation
- Check Traefik logs in Dokploy
- Verify port 80 and 443 are open

---

## Performance Optimization Tips

### 1. Database Connection Pooling

Use connection pooling for production to handle concurrent requests:

```ts
// src/db/index.ts
import { drizzle } from &apos;drizzle-orm/node-postgres&apos;;
import { Pool } from &apos;pg&apos;;

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // Maximum pool size
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

export const db = drizzle(pool);
```

### 2. Use Internal Networking

Instead of connecting via public IP, use Docker internal networking:

```sh
# If database is on same server, use Docker network name
DATABASE_URL=postgresql://saas-app:password@postgres-service:5432/saas-app
```

This is faster and doesn&apos;t expose database port publicly.

### 3. Enable Caching

Add caching headers for static assets:

```ts
// In your server configuration
app.use(&apos;/_build/*&apos;, (req, res, next) =&gt; {
  res.setHeader(&apos;Cache-Control&apos;, &apos;public, max-age=31536000, immutable&apos;);
  next();
});
```

### 4. Monitor Resources

Use Dokploy&apos;s monitoring to track:
- CPU usage
- Memory consumption
- Response times
- Database connections

---

## Security Best Practices

### 1. Restrict Database Access

Only allow connections from your VPS:

```sh
# Replace with your VPS internal IP
sudo iptables -I INPUT -p tcp --dport 5432 -s 10.0.0.0/8 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 5432 -j DROP
sudo netfilter-persistent save
```

### 2. Use Environment Variables

Never hardcode secrets:

```ts
// ❌ Bad
const apiKey = &quot;sk_live_abc123&quot;;

// ✅ Good
const apiKey = process.env.API_KEY;
```

### 3. Enable CORS Properly

Restrict API access to your domain:

```ts
// src/server.ts
app.use(cors({
  origin: process.env.FRONTEND_URL,
  credentials: true,
}));
```

### 4. Regular Updates

Keep dependencies updated:

```sh
# Check for outdated packages
npm outdated

# Update packages
npm update

# For major version updates
npx npm-check-updates -u
npm install
```



## Conclusion

You&apos;ve deployed a full-stack TanStack Start application on your VPS using Dokploy. This setup gives you:

✅ **Complete Control**: Your infrastructure, your rules

✅ **Modern Stack**: TanStack Start, Drizzle ORM, PostgreSQL 17

✅ **Type Safety**: End-to-end TypeScript with tRPC

✅ **Production Ready**: HTTPS, monitoring, backups

✅ **Cost Effective**: Fixed monthly VPS cost

✅ **Scalable**: Easy to add more resources or containers

This deployment approach combines the convenience of platforms like Vercel with the control and cost-effectiveness of self-hosting. You aren&apos;t limited by serverless timeouts, bandwidth costs, or vendor lock-in.

If you&apos;d rather use a managed database instead of running Postgres yourself, I have a companion guide on [building a TanStack Start app with Bunny Database and Drizzle](/tanstack-start-bunny-database-drizzle/), which swaps Postgres for managed libSQL that idles to zero when unused.

## Getting Started with Dokploy

If you haven&apos;t set up Dokploy yet, start here:

&lt;Button link=&quot;https://www.bitdoze.com/dokploy-install/&quot; text=&quot;Install Dokploy Guide&quot; /&gt;

The installation guide covers everything from VPS setup to security hardening with CrowdSec, ensuring your self-hosted infrastructure is production-ready.

Happy deploying! 🚀</content:encoded><category>web-development</category><category>tanstack</category><category>dokploy</category><category>deployment</category></item><item><title>ASUS Master Thunderbolt 5 Dock DC510 Review: Real-World Testing &amp; Performance</title><link>https://www.bitdoze.com/asus-thunderbolt-5-dock-dc510-review/</link><guid isPermaLink="true">https://www.bitdoze.com/asus-thunderbolt-5-dock-dc510-review/</guid><description>In-depth review of the ASUS Master Thunderbolt 5 Dock DC510 after one week of real-world testing with MacBook M1 Pro and Mac Mini M4 Pro, including SSD performance benchmarks.</description><pubDate>Mon, 27 Oct 2025 00:00:00 GMT</pubDate><content:encoded>After a week with the **ASUS Master Thunderbolt 5 Dock DC510**, this is one of the most capable Thunderbolt 5 docks I&apos;ve used. With 13 ports, M.2 NVMe SSD expansion, triple 4K display support at 144Hz, and fast data transfer speeds, the dock turns your laptop into a workstation with a single cable.

This review covers my real-world experience testing the dock with a MacBook M1 Pro and Mac Mini M4 Pro, including performance benchmarks with a WD_BLACK SN850X NVMe drive.

&lt;Notice type=&quot;info&quot; title=&quot;Quick Take&quot;&gt;
The ASUS DC510 is a solid Thunderbolt 5 dock for creators and professionals. It has good SSD speeds and lots of ports, but the active cooling fan is noticeable under load.
&lt;/Notice&gt;

## Quick Verdict

**Rating: 8.5/10**

The ASUS Master Thunderbolt 5 Dock DC510 works well for power users who need lots of ports and SSD expansion. It handles dual 4K monitors easily, offers fast data transfer speeds, and has features like RGB lighting. The main drawback is the active cooling noise, which can be distracting in quiet places.

&lt;Button text=&quot;Check ASUS DC510 Price&quot; link=&quot;https://go.bitdoze.com/asus-dc510&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

## My Testing Setup

Here&apos;s what I used for testing:

- **Laptops**: MacBook M1 Pro, Mac Mini M4 Pro
- **Monitors**: 2x 4K displays (ASUS OLED 144Hz capable)
- **Added Storage**: WD_BLACK 2TB SN850X NVMe SSD
- **Testing Period**: 1 week of daily use
- **Purchase Location**: Greek retailer, delivered to Romania in 1 week
- **Price Paid**: €420 (~$460 USD)

## Video Review
&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/c69fT4kx9IQ&quot;
  label=&quot;ASUS Master Thunderbolt 5 Dock DC510 Review: Real-World Testing &amp; Performance&quot;
/&gt;



## ASUS Master Thunderbolt 5 Dock DC510 Overview

&lt;AmazonProduct
  productName=&quot;ASUS Master Thunderbolt 5 Dock DC510&quot;
  productDescription=&quot;13-in-1 Thunderbolt 5 docking station with M.2 SSD slot, triple 4K display support at 144Hz, 2.5GbE Ethernet, and 140W power delivery. Features RGB ambient lighting and active cooling.&quot;
  productFeatures={[
    &quot;3x Thunderbolt 5 ports (80Gbps)&quot;,
    &quot;M.2 NVMe PCIe 4.0 2280 SSD slot&quot;,
    &quot;Triple 4K @144Hz or Dual 8K @60Hz&quot;,
    &quot;2.5 Gigabit Ethernet&quot;,
    &quot;SD &amp; microSD card readers (UHS-II)&quot;,
    &quot;4x USB-A ports (3x 10Gbps, 1x 5Gbps)&quot;,
    &quot;140W PD passthrough charging&quot;,
    &quot;180W power adapter included&quot;,
    &quot;RGB ambient lighting&quot;
  ]}
  productLink=&quot;https://go.bitdoze.com/asus-dc510&quot;
  productImage=&quot;https://m.media-amazon.com/images/I/41NccYPuXSL._AC_SL1500_.jpg&quot;
  productRating={4.5}
  importantConsiderations={[
    &quot;Active cooling can be loud under load&quot;,
    &quot;Availability limited in some regions&quot;,
    &quot;Premium pricing compared to alternatives&quot;
  ]}
  pros={[
    &quot;Excellent SSD performance (5800 MB/s read)&quot;,
    &quot;Easy toolless SSD installation&quot;,
    &quot;Handles dual 4K at high refresh rates&quot;,
    &quot;Ports on both front and back&quot;,
    &quot;Includes Thunderbolt 5 cable&quot;,
    &quot;Compact power adapter&quot;,
    &quot;SSD cooling pad included&quot;
  ]}
  cons={[
    &quot;Active cooling noise noticeable&quot;,
    &quot;Fan spins up even with single monitor&quot;,
    &quot;Hard to find in stock&quot;,
    &quot;No 10GbE Ethernet option&quot;
  ]}
/&gt;

## Technical Specifications

| Specification | Details |
|--------------|---------|
| **Model** | ASUS Master Thunderbolt 5 Dock DC510 |
| **Thunderbolt Ports** | 3x Thunderbolt 5 (80Gbps) |
| **USB-A Ports** | 3x USB 3.2 Gen 2 (10Gbps), 1x USB 3.0 (5Gbps) |
| **Display Support** | Triple 4K @144Hz / Dual 8K @60Hz |
| **Storage Expansion** | M.2 NVMe PCIe 4.0 2280 slot |
| **Card Readers** | SD 4.0 (UHS-II) + microSD (UHS-II) |
| **Networking** | 2.5 Gigabit Ethernet (RJ45) |
| **Audio** | 3.5mm combo jack |
| **Power Delivery** | 140W PD passthrough |
| **Power Adapter** | 180W (20V/9A) |
| **Security** | Kensington lock slot |
| **Dimensions** | 220 x 96.7 x 38.1 mm |
| **Weight** | 765g |
| **Cooling** | Active cooling with fan |
| **Lighting** | RGB LED ambient lighting |
| **OS Support** | Windows 10+, macOS |
| **Cable Length** | 100cm Thunderbolt 5 cable included |

## Setup Experience: Easy and Quick

Setting up the ASUS DC510 was straightforward. Here&apos;s how it went:

### Unboxing

The package includes:
- ASUS Master Thunderbolt 5 Dock DC510
- 180W power adapter (reasonably sized)
- 1-meter Thunderbolt 5 cable
- User manual and warranty card
- SSD cooling pad (pre-installed)

&lt;Notice type=&quot;success&quot; title=&quot;Pro Tip&quot;&gt;
The Thunderbolt 5 cable is included, saving you $30-50 you&apos;d spend on a separate certified cable.
&lt;/Notice&gt;

### Installing the NVMe SSD

One of the standout features is the toolless M.2 SSD installation:

1. **Remove magnetic top cover** - Simply lift the aluminum cover
2. **Insert SSD** - Slide the WD_BLACK SN850X into the M.2 2280 slot
3. **Secure and close** - Push down gently and replace the cover
4. **Format the drive** - Connect dock, format in APFS (macOS) or NTFS (Windows)

**Total time: Under 2 minutes**

The cooling pad ensures the SSD stays at optimal temperatures even during intensive file transfers.

## Real-World Performance Testing

### Display Performance: Dual 4K Excellence

I tested the dock with two 4K monitors on both Mac systems:

#### MacBook M1 Pro
- **Monitor 1**: ASUS OLED 4K @ 144Hz
- **Monitor 2**: Standard 4K @ 60Hz
- **Result**: Flawless performance, no stuttering or lag

#### Mac Mini M4 Pro
- **Monitor 1**: ASUS OLED 4K @ 165Hz (pushed to maximum)
- **Monitor 2**: Standard 4K @ 60Hz
- **Result**: Stable, smooth rendering

&lt;Notice type=&quot;info&quot; title=&quot;Display Compatibility&quot;&gt;
The DC510 supports up to three 4K displays at 144Hz with DSC (Display Stream Compression) enabled on compatible devices. For dual 8K @60Hz, your laptop must support DSC with 3:1 compression ratio.
&lt;/Notice&gt;

### SSD Performance: Impressive Speeds

I added a [WD_BLACK 2TB SN850X NVMe](https://amzn.to/3WoV1vu) to test the dock&apos;s storage capabilities.

#### Blackmagic Disk Speed Test Results

![ASUS Master Thunderbolt 5 Dock DC510 Blackmagic Disk Speed Test Results](../../assets/images/25/10/black-test.webp)


| Test Type | Read Speed | Write Speed |
|-----------|-----------|-------------|
| **Blackmagic** | ~5,800 MB/s | ~4,300 MB/s |

#### FIO Benchmark Results: WD_BLACK in DC510

| Block Size | Read MB/s | Write MB/s | Total MB/s | IOPS Read | IOPS Write | IOPS Total |
|------------|-----------|------------|------------|-----------|------------|------------|
| **4k** | 47.4 | 47.4 | 94.8 | 12,126 | 12,138 | 24,264 |
| **64k** | 729.1 | 730.0 | 1,459.1 | 11,666 | 11,679 | 23,345 |
| **512k** | 2,065.7 | 2,072.5 | 4,138.2 | 4,131 | 4,145 | 8,276 |
| **1m** | 2,510.5 | 2,570.1 | 5,080.6 | 2,510 | 2,570 | 5,080 |

#### Comparison: M4 Pro Mini Internal SSD

| Block Size | Read MB/s | Write MB/s | Total MB/s | IOPS Read | IOPS Write | IOPS Total |
|------------|-----------|------------|------------|-----------|------------|------------|
| **4k** | 35.2 | 35.3 | 70.5 | 9,016 | 9,040 | 18,056 |
| **64k** | 351.7 | 352.1 | 703.8 | 5,627 | 5,633 | 11,260 |
| **512k** | 784.2 | 786.8 | 1,571.0 | 1,568 | 1,573 | 3,141 |
| **1m** | 1,080.8 | 1,106.5 | 2,187.3 | 1,080 | 1,106 | 2,186 |

**Analysis**: The external SSD in the DC510 significantly outperforms the internal M4 Pro Mini SSD in larger block sizes, showing the power of Thunderbolt 5&apos;s bandwidth. For 4K video editing and large file operations, the DC510&apos;s SSD performance is outstanding.

&lt;AmazonProduct
  productName=&quot;WD_BLACK 2TB SN850X NVMe SSD&quot;
  productDescription=&quot;High-performance Gen4 PCIe M.2 2280 NVMe SSD with up to 7,300 MB/s speeds, perfect for the ASUS DC510&apos;s M.2 slot.&quot;
  productFeatures={[
    &quot;PCIe Gen4 x4 NVMe interface&quot;,
    &quot;Up to 7,300 MB/s read speeds&quot;,
    &quot;M.2 2280 form factor&quot;,
    &quot;2TB capacity&quot;,
    &quot;Gaming-optimized performance&quot;
  ]}
  productLink=&quot;https://amzn.to/3WoV1vu&quot;
  productImage=&quot;https://m.media-amazon.com/images/I/61u-w0nMDTL._AC_SL1500_.jpg&quot;
  productRating={4.7}
  pros={[
    &quot;Excellent real-world performance&quot;,
    &quot;Reliable gaming SSD&quot;,
    &quot;Good value for 2TB capacity&quot;
  ]}
  cons={[
    &quot;Can run warm under sustained load&quot;,
    &quot;Not quite hitting advertised peak speeds in DC510&quot;
  ]}
/&gt;

### Port Flexibility: Front and Back Access

The DC510&apos;s port layout is well thought out:

**Front Panel:**
- 1x USB-A 3.0 (5Gbps)
- SD card reader (UHS-II)
- microSD card reader (UHS-II)
- Audio combo jack

**Rear Panel:**
- 2x Thunderbolt 5 ports
- 3x USB-A 3.2 Gen 2 (10Gbps)
- 2.5GbE Ethernet
- Power button with LED indicator
- DC power input
- Kensington lock slot

This layout makes it easy to access frequently used ports (SD cards, front USB) while keeping cable management clean with rear connections.

## What I Like About the ASUS DC510

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;**Fast SSD speeds** - Achieving 5,800 MB/s read speeds makes this useful for video editors and content creators&lt;/li&gt;
    &lt;li&gt;**Simple SSD installation** - The toolless magnetic cover design works well&lt;/li&gt;
    &lt;li&gt;**Dual 4K monitor support** - Handled my monitors at high refresh rates without issues&lt;/li&gt;
    &lt;li&gt;**Smart port placement** - Front and rear ports give good flexibility&lt;/li&gt;
    &lt;li&gt;**Includes everything** - Thunderbolt 5 cable in the box is welcome&lt;/li&gt;
    &lt;li&gt;**Compact power brick** - The 180W adapter is manageable&lt;/li&gt;
    &lt;li&gt;**SSD cooling pad** - Keeps the NVMe drive at safe temperatures&lt;/li&gt;
    &lt;li&gt;**RGB lighting** - Subtle ambient lighting adds style&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

## What Could Be Better

While the DC510 is excellent, there are some areas for improvement:

### Active Cooling Noise

The most significant drawback is the **active cooling fan**. Here&apos;s what I experienced:

- **With 1 monitor**: Fan starts spinning intermittently
- **With 2 monitors**: Fan runs more frequently
- **During file transfers**: Fan clearly audible
- **Noise level**: Noticeable in quiet office environments

**Comparison**: On the Mac Mini M4 Pro with Thunderbolt 5, the cooler doesn&apos;t start as frequently, suggesting the DC510&apos;s cooling curve is quite aggressive.

&lt;Notice type=&quot;warning&quot; title=&quot;Cooling Consideration&quot;&gt;
If you work in a quiet environment or record audio/video, the fan noise may be distracting. Consider your use case and environment before purchasing.
&lt;/Notice&gt;

### Limited Availability

Finding the DC510 in stock can be challenging. I had to order mine from a Greek retailer and wait a week for delivery to Romania. Check multiple retailers if you&apos;re interested.

### No 10GbE Option

While 2.5 Gigabit Ethernet is solid, power users with 10GbE networks might prefer the CalDigit TS5-Plus or similar docks with 10GbE support.

## Who Should Buy the ASUS DC510?

### Perfect For:

- **Content creators** working with 4K/8K video files
- **Photographers** who need fast SD card transfers
- **Developers** requiring multiple displays and fast storage
- **Mac users** with M1 Pro/Max/Ultra or M4 Pro/Max chips
- **Professionals** who want expandable storage in their dock

### Consider Alternatives If:

- You need absolute silence (look at passively cooled docks)
- You require 10 Gigabit Ethernet
- You&apos;re on a tight budget (check out Kensington SD5000T5)
- You prefer gaming-focused RGB (consider Razer Thunderbolt 5 Dock Chroma)

## ASUS DC510 vs Razer Thunderbolt 5 Dock Chroma

If you&apos;re deciding between these two popular Thunderbolt 5 docks, here&apos;s a quick comparison:

| Feature | ASUS DC510 | Razer Chroma |
|---------|-----------|--------------|
| **Ethernet** | 2.5 GbE ✅ | 1 GbE |
| **Card Readers** | SD + microSD ✅ | SD only |
| **USB-A Ports** | 4 ports ✅ | 2 ports |
| **Power Adapter** | 180W | 250W ✅ |
| **RGB Lighting** | Ambient LED | Razer Chroma ✅ |
| **Gaming Focus** | Professional | Gaming ✅ |
| **Price** | €420 | Similar |

**Read the full comparison**: [ASUS DC510 vs Razer Thunderbolt 5 Dock Chroma](https://www.bitdoze.com/asus-vs-razer-thunderbolt-5-comparison/)

## Frequently Asked Questions

&lt;Accordion label=&quot;Does the ASUS DC510 work with Windows laptops?&quot; group=&quot;faq&quot;&gt;
Yes, the DC510 works with any Windows 10 or later laptop with Thunderbolt 4 or Thunderbolt 5 support. You&apos;ll get full functionality including display output, charging, and data transfer.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Thunderbolt 4 cables with this dock?&quot; group=&quot;faq&quot;&gt;
Yes, Thunderbolt 5 is backward compatible with Thunderbolt 4 cables. However, to achieve the full 80Gbps bandwidth, you should use a certified Thunderbolt 5 cable (included with the dock).
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What size NVMe SSD can I install?&quot; group=&quot;faq&quot;&gt;
The DC510 supports M.2 2280 NVMe SSDs (PCIe 4.0). You can install drives up to 8TB capacity. Popular options include the WD_BLACK SN850X, Samsung 990 Pro, or any PCIe 4.0 M.2 drive.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How hot does the dock get during use?&quot; group=&quot;faq&quot;&gt;
The dock stays reasonably cool thanks to active cooling. The aluminum chassis helps dissipate heat, and the SSD cooling pad keeps the NVMe drive at safe temperatures. During intensive use, the dock is warm but not uncomfortably hot.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I turn off the RGB lighting?&quot; group=&quot;faq&quot;&gt;
Yes, you can control the ambient LED lighting through the dock&apos;s settings or by using the power button. The lighting is subtle and not as prominent as gaming-focused RGB.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does it support dual 8K displays?&quot; group=&quot;faq&quot;&gt;
Yes, the DC510 can drive dual 8K displays at 60Hz if your laptop supports DSC (Display Stream Compression) with a 3:1 ratio. Most modern high-end laptops with Thunderbolt 5 support this feature.
&lt;/Accordion&gt;

## Alternatives to Consider

Looking at other options? Here are some alternatives:

### Razer Thunderbolt 5 Dock Chroma
- **Price**: Similar to ASUS DC510
- **Best For**: Gamers and RGB enthusiasts
- **Key Difference**: Full Razer Chroma RGB, gaming aesthetic
- [Check Razer Dock Price](https://amzn.to/3IjvS20)

### CalDigit TS5-Plus
- **Price**: ~$650-700
- **Best For**: Maximum connectivity needs
- **Key Difference**: 20 ports, 10 Gigabit Ethernet
- [See CalDigit TS5-Plus](https://www.bitdoze.com/best-thunderbolt-5-docks-guide/#1-caldigit-ts5-plus-20-port-powerhouse--top-pick)

### Kensington SD5000T5
- **Price**: ~$400-500
- **Best For**: Budget-conscious buyers
- **Key Difference**: Lower price, passive cooling
- [View Kensington Dock](https://www.bitdoze.com/best-thunderbolt-5-docks-guide/#1-kensington-sd5000t5-thunderbolt-5-triple-4k-docking-station)

**Complete comparison**: Check out our [Best Thunderbolt 5 Docks Guide](https://www.bitdoze.com/best-thunderbolt-5-docks-guide/) for detailed comparisons of all major Thunderbolt 5 docks.

## Final Thoughts

After a week of testing, the **ASUS Master Thunderbolt 5 Dock DC510** ranks among the top Thunderbolt 5 docks. The SSD performance, dual 4K monitor support at high refresh rates, and comprehensive connectivity make it good for creative professionals and power users.

The speeds I achieved with the WD_BLACK SN850X (5,800 MB/s read, 4,300 MB/s write) show the dock handles demanding workloads. The toolless SSD installation is practical, and having storage in your dock is convenient.

However, the active cooling noise is a real concern. If you work in quiet environments or record audio regularly, this might be problematic. Otherwise, the ASUS DC510 is worth the €420 price.

**Rating: 8.5/10**

### Pros
✅ Outstanding SSD performance
✅ Easy toolless SSD installation
✅ Excellent dual 4K monitor support
✅ 2.5GbE Ethernet
✅ SD + microSD readers
✅ Includes Thunderbolt 5 cable
✅ Comprehensive port selection

### Cons
❌ Active cooling can be loud
❌ Limited availability
❌ No 10GbE option

&lt;Button text=&quot;Buy ASUS DC510 Now&quot; link=&quot;https://go.bitdoze.com/asus-dc510&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

**Related Articles:**
- [Best Thunderbolt 5 Docks 2025: Complete Guide](https://www.bitdoze.com/best-thunderbolt-5-docks-guide/)
- [ASUS DC510 vs Razer Thunderbolt 5 Dock Chroma](https://www.bitdoze.com/asus-vs-razer-thunderbolt-5-comparison/)

&lt;Notice type=&quot;info&quot; title=&quot;Affiliate Disclosure&quot;&gt;
This article contains affiliate links to Amazon and other retailers. If you make a purchase through these links, we may earn a small commission at no extra cost to you. This helps support our testing and content creation.
&lt;/Notice&gt;</content:encoded><category>gadgets</category><category>thunderbolt</category><category>docks</category></item><item><title>ASUS DC510 vs Razer Thunderbolt 5 Dock Chroma: Which Should You Buy?</title><link>https://www.bitdoze.com/asus-vs-razer-thunderbolt-5-comparison/</link><guid isPermaLink="true">https://www.bitdoze.com/asus-vs-razer-thunderbolt-5-comparison/</guid><description>Detailed comparison of ASUS Master Thunderbolt 5 Dock DC510 and Razer Thunderbolt 5 Dock Chroma, including specs, ports, performance, and value analysis to help you choose the right dock.</description><pubDate>Mon, 27 Oct 2025 00:00:00 GMT</pubDate><content:encoded>Choosing between the **ASUS Master Thunderbolt 5 Dock DC510** and the **Razer Thunderbolt 5 Dock Chroma** can be challenging—both are premium Thunderbolt 5 docking stations with good features. However, they cater to slightly different audiences and priorities.

After testing the ASUS DC510 extensively and analyzing the Razer Chroma&apos;s specifications, I&apos;ve created this comparison to help you decide which dock fits your needs, whether you&apos;re a content creator, developer, gamer, or professional user.

&lt;Notice type=&quot;info&quot; title=&quot;Quick Recommendation&quot;&gt;
**ASUS DC510** wins for professionals needing faster Ethernet (2.5GbE) and more USB-A ports. **Razer Chroma** is good for gamers who want customizable RGB lighting and Razer ecosystem integration.
&lt;/Notice&gt;

## Quick Comparison Table

| Feature | ASUS DC510 | Razer Chroma | Winner |
|---------|-----------|--------------|--------|
| **Price** | ~€420 / $460 | ~$400-450 | 🟰 Tie |
| **Total Ports** | 13 ports | 11 ports | ✅ ASUS |
| **Thunderbolt 5** | 3 ports | 3 ports | 🟰 Tie |
| **USB-A Ports** | 4 ports | 2 ports | ✅ ASUS |
| **USB-C Ports** | - | 1 port (10Gbps) | ✅ Razer |
| **Ethernet** | 2.5 GbE | 1 GbE | ✅ ASUS |
| **SD Card** | SD + microSD | SD only | ✅ ASUS |
| **M.2 SSD Slot** | Yes (PCIe 4.0) | Yes (PCIe Gen4x4) | 🟰 Tie |
| **Max SSD Size** | 2TB tested | Up to 8TB | ✅ Razer |
| **Display Support** | 3x 4K @144Hz | 3x 4K @144Hz | 🟰 Tie |
| **Power Delivery** | 140W | 140W | 🟰 Tie |
| **Power Adapter** | 180W | 250W | ✅ Razer |
| **RGB Lighting** | Ambient LED | Razer Chroma | ✅ Razer |
| **Audio** | 3.5mm combo | 3.5mm (7.1 support) | ✅ Razer |
| **Dimensions** | 220x96.7x38mm | Slightly larger | ✅ ASUS |
| **Weight** | 765g | ~800g | ✅ ASUS |
| **Cooling** | Active (loud) | Active | 🟰 Tie |
| **TB5 Cable** | Yes (100cm) | Yes | 🟰 Tie |
| **Best For** | Professionals | Gamers | - |

## Detailed Feature Comparison

### Connectivity: Ports and Expansion

&lt;Tabs&gt;
&lt;Tab name=&quot;ASUS DC510&quot;&gt;

**Front Panel:**
- 1x USB-A 3.0 (5Gbps)
- 1x SD card reader (UHS-II)
- 1x microSD card reader (UHS-II)
- 1x Audio combo jack

**Rear Panel:**
- 3x Thunderbolt 5 (80Gbps each)
- 3x USB-A 3.2 Gen 2 (10Gbps)
- 1x 2.5 Gigabit Ethernet
- 1x M.2 NVMe PCIe 4.0 slot
- 1x DC power input
- 1x Kensington lock

**Total: 13 ports + M.2 slot**

&lt;/Tab&gt;
&lt;Tab name=&quot;Razer Chroma&quot;&gt;

**Front Panel:**
- 1x UHS-II SD Card Slot
- 1x Microphone/Headphone Combo Port (7.1 Surround)
- 1x M.2 Slot (PCIe Gen4x4)

**Rear Panel:**
- 3x Thunderbolt 5 (1 upstream, 2 downstream)
- 2x USB-A 3.2 Gen 2 (10Gb/s)
- 1x USB-C 3.2 Gen 2 (10Gb/s)
- 1x 1 Gigabit Ethernet (RJ45)
- 1x 250W Power Port

**Total: 11 ports + M.2 slot**

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Winner: ASUS DC510
The ASUS offers more total ports (13 vs 11) and includes both SD and microSD readers, making it more versatile for photographers and content creators.

---

### Network Performance: 2.5GbE vs 1GbE

| Specification | ASUS DC510 | Razer Chroma |
|--------------|-----------|--------------|
| **Ethernet Speed** | 2.5 Gigabit | 1 Gigabit |
| **Max Throughput** | 2,500 Mbps | 1,000 Mbps |
| **File Transfer (1GB)** | ~3.2 seconds | ~8 seconds |
| **Best For** | Large file transfers | Standard networking |

&lt;Notice type=&quot;success&quot; title=&quot;Network Speed Advantage&quot;&gt;
The ASUS DC510&apos;s 2.5GbE provides **2.5x faster network speeds** than Razer&apos;s 1GbE. This is crucial for:
- NAS file transfers
- Cloud backups
- Network storage access
- Professional video workflows
&lt;/Notice&gt;

### Winner: ASUS DC510
If you work with large files over the network or have a 2.5GbE+ home/office network, the ASUS is the clear winner.

---

### Storage Expansion: M.2 SSD Performance

Both docks feature M.2 NVMe slots, but with differences:

| Feature | ASUS DC510 | Razer Chroma |
|---------|-----------|--------------|
| **Slot Type** | M.2 2280 PCIe 4.0 | M.2 PCIe Gen4x4 |
| **Installation** | Toolless magnetic cover | Easy access |
| **Cooling** | SSD cooling pad included | Active cooling |
| **Tested Speed (Read)** | 5,800 MB/s | N/A |
| **Tested Speed (Write)** | 4,300 MB/s | N/A |
| **Max Capacity** | 8TB (2TB tested) | Up to 8TB |

**Real-World Performance (ASUS DC510 with WD_BLACK SN850X):**

| Block Size | Read MB/s | Write MB/s | IOPS Total |
|------------|-----------|------------|------------|
| 4k | 47.4 | 47.4 | 24,264 |
| 64k | 729.1 | 730.0 | 23,345 |
| 512k | 2,065.7 | 2,072.5 | 8,276 |
| 1m | 2,510.5 | 2,570.1 | 5,080 |

### Winner: Tie
Both offer good M.2 expansion. ASUS has proven performance with cooling pad; Razer officially supports up to 8TB.

---

### Display Support: Multi-Monitor Setups

Both docks offer identical display capabilities:

| Configuration | ASUS DC510 | Razer Chroma |
|--------------|-----------|--------------|
| **Triple 4K** | ✅ 4K @144Hz | ✅ 4K @144Hz |
| **Dual 8K** | ✅ 8K @60Hz | ✅ 8K @60Hz |
| **Single 8K** | ✅ 8K @60Hz | ✅ 8K @60Hz |
| **DSC Required** | For 8K/Triple 4K | For 8K/Triple 4K |

**Tested on ASUS DC510:**
- MacBook M1 Pro: Dual 4K @144Hz ✅
- Mac Mini M4 Pro: Dual 4K @165Hz ✅

### Winner: Tie
Both handle multi-monitor setups identically. Choose based on other features.

---

### Power Delivery and Charging

| Specification | ASUS DC510 | Razer Chroma |
|--------------|-----------|--------------|
| **PD Passthrough** | 140W | 140W |
| **Power Adapter** | 180W | 250W |
| **Adapter Size** | Compact | Larger |
| **Charging Speed** | Fast | Fast |
| **Supports** | Most laptops | Most laptops |

Both provide 140W Power Delivery, sufficient for:
- MacBook Pro 16&quot; M3 Max
- Dell XPS 15/17
- ThinkPad X1 Extreme
- Most high-performance laptops

&lt;Notice type=&quot;info&quot; title=&quot;Power Delivery Note&quot;&gt;
The 140W PD is enough for most laptops. The Razer&apos;s 250W adapter provides more overhead for the dock&apos;s internal components, especially when using multiple peripherals and the M.2 SSD simultaneously.
&lt;/Notice&gt;

### Winner: Tie
Both offer identical 140W PD charging for your laptop.

---

### RGB Lighting and Aesthetics

&lt;Tabs&gt;
&lt;Tab name=&quot;ASUS DC510&quot;&gt;

**Design Philosophy: Professional**
- Aluminum and plastic construction
- Ambient RGB LED strip (underside)
- Subtle, minimalist lighting
- Black finish
- Rectangular design (220x96.7x38mm)

**Lighting Control:**
- Basic on/off
- Single color ambient glow
- Professional aesthetic

**Best For:** Office environments, clean setups

&lt;/Tab&gt;
&lt;Tab name=&quot;Razer Chroma&quot;&gt;

**Design Philosophy: Gaming**
- Gaming-oriented design
- Full Razer Chroma RGB
- 16.8 million colors
- Syncs with other Razer gear
- Customizable effects

**Lighting Control:**
- Razer Synapse software
- Multiple effects and patterns
- Ecosystem synchronization
- Per-zone control

**Best For:** Gaming setups, RGB enthusiasts

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Winner: Razer Chroma
If RGB lighting and gaming aesthetics matter to you, Razer&apos;s Chroma ecosystem is hard to beat.

---

### Cooling and Noise Levels

| Aspect | ASUS DC510 | Razer Chroma |
|--------|-----------|--------------|
| **Cooling Type** | Active fan | Active fan |
| **Noise Level** | Noticeable | Moderate |
| **Fan Behavior** | Starts with 1 monitor | Load-dependent |
| **Quiet Operation** | ❌ Can be loud | ⚠️ Moderate |
| **SSD Cooling** | Dedicated cooling pad | Shared cooling |

**ASUS DC510 Experience:**
- Fan spins up frequently
- Noticeable in quiet environments
- More aggressive cooling curve
- Even with single monitor

**Razer Chroma:**
- Active cooling present
- Generally quieter operation
- Gaming users less sensitive to noise

### Winner: Razer Chroma
Based on user reports, the Razer tends to be quieter during typical use.

---

### USB Port Configuration

#### USB-A Ports

| Dock | 10Gbps Ports | 5Gbps Ports | Total |
|------|-------------|-------------|-------|
| **ASUS DC510** | 3x USB-A 3.2 Gen 2 | 1x USB-A 3.0 | 4 ports |
| **Razer Chroma** | 2x USB-A 3.2 Gen 2 | - | 2 ports |

#### USB-C Ports

| Dock | Additional USB-C |
|------|------------------|
| **ASUS DC510** | None (3x TB5) |
| **Razer Chroma** | 1x USB-C 3.2 Gen 2 (10Gbps) |

### Winner: ASUS DC510
More USB-A ports (4 vs 2) is better for peripherals, external drives, keyboards, and mice. However, Razer includes an additional USB-C port.

---

### Card Reader Capabilities

| Feature | ASUS DC510 | Razer Chroma |
|---------|-----------|--------------|
| **SD Card** | ✅ UHS-II | ✅ UHS-II |
| **microSD Card** | ✅ UHS-II | ❌ No |
| **Max Speed** | ~312 MB/s | ~312 MB/s |
| **Front Access** | ✅ Yes | ✅ Yes |

### Winner: ASUS DC510
The dual card reader (SD + microSD) is more versatile for photographers using drones, action cameras, and smartphones.

---

## Use Case Recommendations

### Best for Content Creators: ASUS DC510

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;**2.5GbE Ethernet** for faster NAS transfers&lt;/li&gt;
    &lt;li&gt;**Dual card readers** (SD + microSD) for cameras and drones&lt;/li&gt;
    &lt;li&gt;**4 USB-A ports** for multiple peripherals&lt;/li&gt;
    &lt;li&gt;**Professional aesthetic** suitable for client meetings&lt;/li&gt;
    &lt;li&gt;**Proven SSD performance** (5,800 MB/s read tested)&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Button text=&quot;Check ASUS DC510 Price&quot; link=&quot;https://go.bitdoze.com/asus-dc510&quot; size=&quot;md&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### Best for Gamers: Razer Thunderbolt 5 Dock Chroma

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;**Razer Chroma RGB** syncs with your gaming setup&lt;/li&gt;
    &lt;li&gt;**7.1 surround sound** support on audio jack&lt;/li&gt;
    &lt;li&gt;**Gaming aesthetic** matches RGB peripherals&lt;/li&gt;
    &lt;li&gt;**Razer ecosystem** integration with other Razer devices&lt;/li&gt;
    &lt;li&gt;**Thunderbolt Share** for dual PC gaming setups&lt;/li&gt;
    &lt;li&gt;**Quieter operation** better for streaming&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

&lt;Button text=&quot;Check Razer Chroma Price&quot; link=&quot;https://amzn.to/3IjvS20&quot; size=&quot;md&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### Best for Developers: ASUS DC510

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;**2.5GbE** for faster Docker image pulls and Git operations&lt;/li&gt;
    &lt;li&gt;**More USB-A ports** for development hardware&lt;/li&gt;
    &lt;li&gt;**Professional look** for office/remote work&lt;/li&gt;
    &lt;li&gt;**Dual monitors** at high refresh rates&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

---

### Best for Mac Users: ASUS DC510

&lt;ListCheck&gt;
  &lt;ul&gt;
    &lt;li&gt;**Tested compatibility** with M1 Pro and M4 Pro&lt;/li&gt;
    &lt;li&gt;**Dual 4K @165Hz** confirmed working&lt;/li&gt;
    &lt;li&gt;**SD + microSD** for iPhone and camera transfers&lt;/li&gt;
    &lt;li&gt;**Matches MacBook aesthetic** better&lt;/li&gt;
  &lt;/ul&gt;
&lt;/ListCheck&gt;

---

## Price and Value Analysis

| Factor | ASUS DC510 | Razer Chroma |
|--------|-----------|--------------|
| **MSRP** | ~€420 / $460 | ~$400-450 |
| **Availability** | Limited (Europe) | Wider availability |
| **Ports per Dollar** | $35.38 per port | $40.91 per port |
| **Value Rating** | ⭐⭐⭐⭐½ | ⭐⭐⭐⭐ |

### Winner: ASUS DC510
More ports and 2.5GbE provide better value for professional users. Razer offers better value for gamers who prioritize RGB and ecosystem.

---

## Complete Specifications Comparison

### ASUS Master Thunderbolt 5 Dock DC510

&lt;AmazonProduct
  productName=&quot;ASUS Master Thunderbolt 5 Dock DC510&quot;
  productDescription=&quot;13-in-1 professional Thunderbolt 5 dock with 2.5GbE, dual card readers, and M.2 SSD expansion.&quot;
  productFeatures={[
    &quot;13 ports total&quot;,
    &quot;3x Thunderbolt 5 (80Gbps)&quot;,
    &quot;2.5 Gigabit Ethernet&quot;,
    &quot;SD + microSD card readers&quot;,
    &quot;4x USB-A ports&quot;,
    &quot;M.2 NVMe PCIe 4.0 slot&quot;,
    &quot;140W Power Delivery&quot;,
    &quot;RGB ambient lighting&quot;
  ]}
  productLink=&quot;https://go.bitdoze.com/asus-dc510&quot;
  productImage=&quot;https://m.media-amazon.com/images/I/41NccYPuXSL._AC_SL1500_.jpg&quot;
  productRating={4.5}
  pros={[
    &quot;2.5GbE for fast networking&quot;,
    &quot;Dual SD card readers&quot;,
    &quot;4 USB-A ports&quot;,
    &quot;Professional design&quot;,
    &quot;Good SSD speeds&quot;
  ]}
  cons={[
    &quot;Active cooling can be loud&quot;,
    &quot;Limited availability&quot;,
    &quot;No USB-C port (besides TB5)&quot;
  ]}
/&gt;

---

### Razer Thunderbolt 5 Dock Chroma

&lt;AmazonProduct
  productName=&quot;Razer Thunderbolt 5 Dock Chroma&quot;
  productDescription=&quot;11-port gaming-focused Thunderbolt 5 dock with Razer Chroma RGB, M.2 expansion, and 7.1 audio support.&quot;
  productFeatures={[
    &quot;11 ports total&quot;,
    &quot;3x Thunderbolt 5&quot;,
    &quot;Razer Chroma RGB (16.8M colors)&quot;,
    &quot;M.2 PCIe Gen4x4 slot (up to 8TB)&quot;,
    &quot;1 Gigabit Ethernet&quot;,
    &quot;7.1 surround sound support&quot;,
    &quot;140W Power Delivery&quot;,
    &quot;Thunderbolt Share enabled&quot;
  ]}
  productLink=&quot;https://amzn.to/3IjvS20&quot;
  productImage=&quot;https://m.media-amazon.com/images/I/61QAuG3KeOL._AC_SL1500_.jpg&quot;
  productRating={4.6}
  pros={[
    &quot;Razer Chroma RGB ecosystem&quot;,
    &quot;7.1 surround audio&quot;,
    &quot;Gaming aesthetic&quot;,
    &quot;USB-C port included&quot;,
    &quot;Quieter operation&quot;,
    &quot;Better availability&quot;
  ]}
  cons={[
    &quot;Only 1GbE Ethernet&quot;,
    &quot;Fewer USB-A ports&quot;,
    &quot;No microSD reader&quot;,
    &quot;Gaming-focused design&quot;
  ]}
/&gt;

---

## Head-to-Head: Feature Checklist

| Feature | ASUS DC510 | Razer Chroma |
|---------|:----------:|:------------:|
| **Thunderbolt 5** | ✅ | ✅ |
| **Triple 4K @144Hz** | ✅ | ✅ |
| **Dual 8K @60Hz** | ✅ | ✅ |
| **M.2 SSD Slot** | ✅ | ✅ |
| **140W PD** | ✅ | ✅ |
| **TB5 Cable Included** | ✅ | ✅ |
| **Active Cooling** | ✅ | ✅ |
| **2.5 GbE Ethernet** | ✅ | ❌ |
| **1 GbE Ethernet** | ❌ | ✅ |
| **4+ USB-A Ports** | ✅ | ❌ |
| **USB-C Port** | ❌ | ✅ |
| **SD Card Reader** | ✅ | ✅ |
| **microSD Reader** | ✅ | ❌ |
| **Advanced RGB** | ❌ | ✅ |
| **7.1 Audio Support** | ❌ | ✅ |
| **Thunderbolt Share** | ❌ | ✅ |
| **Professional Design** | ✅ | ❌ |
| **Gaming Design** | ❌ | ✅ |

---

## Final Verdict

### Choose ASUS DC510 If You:

- Need **2.5 Gigabit Ethernet** for network-intensive work
- Use **SD and microSD cards** regularly (photographer/videographer)
- Want **more USB-A ports** (4 vs 2)
- Prefer a **professional aesthetic**
- Work in content creation, development, or professional fields
- Have a home/office network faster than 1Gbps

### Choose Razer Chroma If You:

- Prioritize **Razer Chroma RGB** lighting
- Already own **Razer peripherals** and want ecosystem sync
- Need **7.1 surround sound** support
- Prefer a **gaming aesthetic**
- Want **quieter operation**
- Use **USB-C peripherals** (has extra USB-C port)
- Stream or game regularly

---

## Summary Comparison

| Category | Winner | Why |
|----------|--------|-----|
| **Professional Use** | 🏆 ASUS DC510 | 2.5GbE, more ports, dual card readers |
| **Gaming** | 🏆 Razer Chroma | RGB ecosystem, 7.1 audio, gaming design |
| **Content Creation** | 🏆 ASUS DC510 | Dual card readers, faster network |
| **Value** | 🏆 ASUS DC510 | More ports per dollar |
| **Aesthetics** | 🏆 Razer Chroma | Customizable RGB |
| **Noise Level** | 🏆 Razer Chroma | Quieter cooling |
| **Port Count** | 🏆 ASUS DC510 | 13 vs 11 ports |
| **Network Speed** | 🏆 ASUS DC510 | 2.5GbE vs 1GbE |
| **Availability** | 🏆 Razer Chroma | Easier to find |

---

## Related Articles

Want to see more Thunderbolt 5 dock options?

- [Best Thunderbolt 5 Docks 2025: Complete Guide](https://www.bitdoze.com/best-thunderbolt-5-docks-guide/) - Compare all major TB5 docks
- [ASUS Master Thunderbolt 5 Dock DC510 Review](https://www.bitdoze.com/asus-thunderbolt-5-dock-dc510-review/) - Full testing and benchmarks

---

## Conclusion

Both the **ASUS Master Thunderbolt 5 Dock DC510** and **Razer Thunderbolt 5 Dock Chroma** are excellent Thunderbolt 5 docking stations, but they serve different audiences:

**ASUS DC510** is the better choice for professionals, content creators, and developers who need faster networking (2.5GbE), more USB-A ports, and dual card readers. It&apos;s the more versatile professional dock.

**Razer Chroma** excels for gamers and RGB enthusiasts who want customizable lighting, gaming aesthetics, and integration with the Razer ecosystem. It&apos;s also quieter during operation.

Choose based on your primary use case—both will deliver good Thunderbolt 5 performance.

&lt;Notice type=&quot;info&quot; title=&quot;Affiliate Disclosure&quot;&gt;
This article contains affiliate links. If you purchase through these links, we may earn a small commission at no extra cost to you, helping us create more in-depth comparisons and reviews.
&lt;/Notice&gt;</content:encoded><category>gadgets</category><category>thunderbolt</category><category>docks</category></item><item><title>Dokploy Install - Ditch Vercel, Heroku and Self-Host Your SaaS</title><link>https://www.bitdoze.com/dokploy-install/</link><guid isPermaLink="true">https://www.bitdoze.com/dokploy-install/</guid><description>Dokploy install and presentation an alternative to serverless like Vercel, Heroku, etc</description><pubDate>Tue, 21 Oct 2025 00:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/24/05/dokploy-app.png&quot;;

[Dokploy](https://dokploy.com/) is an open-source, self-hostable Platform as a Service (PaaS) that simplifies deploying and managing applications and databases using Docker and Traefik. It&apos;s a free alternative to platforms like Vercel, Heroku, and Netlify for developers who prefer managing their own infrastructure.


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Dokploy Features

- **Applications**: Deploy any type of application (Node.js, PHP, Python, Go, Ruby, etc.) with ease.
- **Databases**: Create and manage databases with support for MySQL, PostgreSQL, MongoDB, MariaDB, Redis, and more.
- **Docker Management**: Easily deploy and manage Docker containers.
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage.
- **Database Backups**: Automate backups with support for multiple storage destinations.

You can check [Dokploy Deploy Apps with Docker Compose](https://www.bitdoze.com/dokploy-docker-compose-app/) if you want to see how you can deploy any application with Docker Compose in Dokploy. For updating Docker Compose apps, see [How to Update Docker Compose Stacks in Dokploy](https://www.bitdoze.com/dokploy-update-docker-compose/).

## Install Dokploy

### Video

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EaOvNN-RJgI&quot;
  label=&quot;Dokploy Installation 2&quot;
/&gt;

### Older Video
&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/XohTt3lh9qg&quot;
  label=&quot;Dokploy Installation&quot;
/&gt;


&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

### Setup A VPS

To get started with Dokploy, you need a Virtual Private Server (VPS).
In the video, we go into detail about how you can do that on Hetzner. You can check this [Hetzner Review](https://www.wpdoze.com/hetzner-cloud-review/) for more details if you are not aware of Hetzner and what it can do.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Hetzner €⁠20 Free&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

```sh
ssh root@your_vps_ip
```

### Update System Packages

Update your system packages before installing Dokploy:

```sh
sudo apt update &amp;&amp; sudo apt upgrade -y
```

This updates the package lists and upgrades installed packages.

### Create a Sudo User for SSH Access

For security, use a dedicated user account instead of root for daily operations. Create a user named `dragos` with sudo privileges:

```sh
# Create new user
adduser dragos

# Add the user to the sudo group
usermod -aG sudo dragos
```

Follow the prompts to set a password and optionally fill in user information.

### Configure Passwordless Sudo

To allow the `dragos` user to execute sudo commands without entering a password (since you&apos;re using SSH key authentication), add the user to the sudoers file:

```sh
# Add dragos to sudoers with NOPASSWD
echo &quot;dragos ALL=(ALL) NOPASSWD:ALL&quot; | sudo tee /etc/sudoers.d/dragos

# Set proper permissions on the sudoers file
sudo chmod 0440 /etc/sudoers.d/dragos
```

This allows you to run sudo commands without password prompts when authenticated via SSH key.

### Copy SSH Key from Root to New User

To enable SSH key authentication for the new user, copy the authorized keys from root:

```sh
# Create .ssh directory for the new user
mkdir -p /home/dragos/.ssh

# Copy the authorized keys
cp /root/.ssh/authorized_keys /home/dragos/.ssh/

# Set proper ownership and permissions
chown -R dragos:dragos /home/dragos/.ssh
chmod 700 /home/dragos/.ssh
chmod 600 /home/dragos/.ssh/authorized_keys
```

### Test SSH Connection with New User

Before disabling root access, verify that you can connect with the new user. Open a **new terminal window** (keep your current session open) and test:

```sh
ssh dragos@your_vps_ip
```

Once connected, verify sudo access:

```sh
sudo whoami
```

This should return `root`, confirming sudo privileges work correctly. **Important:** Do not close your root session until you&apos;ve confirmed the new user works!

### Disable Root SSH Access

After confirming the new user can connect and has sudo privileges, disable root SSH access:

```sh
sudo nano /etc/ssh/sshd_config
```

Find and modify the following line:

```
PermitRootLogin no
```

or

You can allow only certain IP to connect with root in case you need this for the second Dokploy node:

```
Match User root
    PermitRootLogin yes
    AllowUsers root@&lt;ip-address&gt;
```

Save the file (Ctrl+X, then Y, then Enter) and restart the SSH service:

```sh
sudo systemctl restart ssh
```

### Limit SSH Session Timeout

Set a 15-minute timeout for idle SSH sessions:

```sh
sudo nano /etc/ssh/sshd_config
```

Add or modify these lines:

```
ClientAliveInterval 900
ClientAliveCountMax 0
```

This disconnects idle sessions after 15 minutes (900 seconds). Restart SSH to apply:

```sh
sudo systemctl restart ssh
```

### Add SWAP

Add 2GB of swap space to improve VPS performance:

```sh
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo &apos;/swapfile none swap sw 0 0&apos; | sudo tee -a /etc/fstab
```

### Secure Server with CrowdSec

CrowdSec is an open-source security solution that protects your server from brute-force attacks, port scans, and other malicious activities. It effectively protects SSH access.

#### Install CrowdSec

Add the CrowdSec repository and install the package:

```sh
curl -s https://install.crowdsec.net | sudo sh
sudo apt update &amp;&amp; sudo apt install crowdsec
```

#### Install Firewall Bouncer with iptables

The firewall bouncer uses iptables to block malicious IPs detected by CrowdSec. This also ensures iptables is installed:

```sh
sudo apt install crowdsec-firewall-bouncer-iptables -y
```

After installation, verify that CrowdSec has created its iptables chains:

```sh
sudo iptables -L
```

You should see output showing the `CROWDSEC_CHAIN` has been created and is active:

```
Chain INPUT (policy ACCEPT)
target     prot opt source               destination
CROWDSEC_CHAIN  all  --  anywhere             anywhere

Chain CROWDSEC_CHAIN (1 references)
target     prot opt source               destination
DROP       all  --  anywhere             anywhere             match-set crowdsec-blacklists src
```

#### Configure Firewall Rules

Configure iptables to allow only essential services. Allow SSH (22), HTTP (80), HTTPS (443), and Dokploy (3000):

```sh
# Allow established connections
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

# Allow loopback
sudo iptables -A INPUT -i lo -j ACCEPT

# Allow SSH (port 22)
sudo iptables -A INPUT -p tcp --dport 22 -j ACCEPT

# Allow HTTP (port 80)
sudo iptables -A INPUT -p tcp --dport 80 -j ACCEPT

# Allow HTTPS (port 443)
sudo iptables -A INPUT -p tcp --dport 443 -j ACCEPT

# Allow Dokploy (port 3000)
sudo iptables -A INPUT -p tcp --dport 3000 -j ACCEPT

# Drop all other incoming traffic
sudo iptables -P INPUT DROP
```

**Note:** We use `-P INPUT DROP` to set the default policy rather than adding a DROP rule at the end. This ensures CrowdSec&apos;s chain remains at the top and processes traffic first.

#### Make Firewall Rules Persistent

To ensure your firewall rules persist after reboot, install `iptables-persistent`:

```sh
# Install iptables-persistent
sudo apt install iptables-persistent -y
```

During installation, you&apos;ll be asked if you want to save current IPv4 and IPv6 rules. Select **Yes** for both.

After installation, save the current rules:

```sh
# Save current rules
sudo netfilter-persistent save
```

To update rules:

```sh
# After making changes to iptables
sudo netfilter-persistent save
sudo netfilter-persistent reload
```

#### Verify CrowdSec Installation

Check that CrowdSec is running and monitoring your SSH service:

```sh
# Check CrowdSec status
sudo systemctl status crowdsec

# List active collections (should include crowdsecurity/sshd)
sudo cscli collections list

# Verify the firewall bouncer is active
sudo cscli bouncers list
```

The `crowdsecurity/sshd` collection is automatically installed and will monitor your SSH logs for suspicious activity.

#### Test CrowdSec Protection

Verify CrowdSec is protecting your server by checking the iptables rules it manages:

```sh
# View CrowdSec managed rules
sudo iptables -L crowdsec-chain -n -v

# View all firewall rules
sudo iptables -L -n -v
```

When CrowdSec blocks an IP, it appears in these chains.

#### Useful CrowdSec Commands

Monitor CrowdSec activity:

```sh
# View active blocks/bans
sudo cscli decisions list

# Check recent security alerts
sudo cscli alerts list

# View security metrics
sudo cscli metrics

# Monitor logs in real-time
sudo tail -f /var/log/crowdsec.log
```

CrowdSec will now actively protect your server from SSH brute-force attacks and other malicious activities by automatically blocking offending IP addresses.

### Install Dokploy

After securing your VPS, adding swap, and protecting with CrowdSec, install Dokploy:

```sh
curl -sSL https://dokploy.com/install.sh | sh
```

This downloads and runs the Dokploy installation script, setting up Dokploy and its dependencies.

### Point Your Domain or Subdomain to Dokploy

To access your Dokploy instance via a custom domain or subdomain, you need to configure your DNS settings:

1. **Log in to your DNS provider**: Access the DNS management console of your domain registrar.
2. **Create an A Record**: Point your domain or subdomain to the IP address of your VPS.

For example, to point `app.yourdomain.com` to your VPS:

```
Type: A
Name: app
Value: your_vps_ip
TTL: 3600
```

## Start Deploying Apps

&lt;Picture src={img1} alt=&quot;Dokploy App&quot; /&gt;

With Dokploy installed and your domain configured, you can start deploying applications. Dokploy supports a wide range of applications and databases, making it easy to manage your projects from a single platform.

1. **Access Dokploy Dashboard**: Open your web browser and navigate to `http://your-ip-from-your-vps:3000`.
2. **Create an Admin Account**: Follow the on-screen instructions to set up your administrative account.
3. **Deploy Applications**: Use the Dokploy dashboard to deploy and manage your applications and databases.

### Conclusions

Dokploy offers a powerful and flexible solution for developers looking to self-host their applications. By following this guide, you&apos;ve not only set up Dokploy but also implemented crucial security measures including:

- **System Updates**: Keeping your server patched and secure
- **User Management**: Using a dedicated sudo user instead of root
- **SSH Hardening**: Disabling root access and implementing session timeouts
- **CrowdSec Protection**: Actively monitoring and blocking malicious activities

By leveraging Docker and Traefik, Dokploy simplifies the deployment process while providing robust features for application and database management. Combined with proper security practices, you now have a production-ready platform for self-hosting your applications safely and efficiently.

Wondering how Dokploy compares to other self-hosted PaaS options? Check our [Coolify vs Dokploy vs Kamal 2](/coolify-vs-dokploy-vs-kamal-2/) comparison to see which tool fits your setup.</content:encoded><category>self-hosting</category><category>dokploy</category><category>self-hosted</category></item><item><title>GitHub Copilot Pro: Best $10 AI Coding Plan with Zed IDE &amp; CLI</title><link>https://www.bitdoze.com/github-copilot-complete-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/github-copilot-complete-guide/</guid><description>Why GitHub Copilot Pro at $10/month is a good AI coding assistant. Get unlimited GPT-5 mini, 300 premium requests, Zed IDE support, CLI access, and coding agents in one plan.</description><pubDate>Mon, 20 Oct 2025 00:00:00 GMT</pubDate><content:encoded>GitHub Copilot Pro at **$10/month** offers good value for an AI coding tool. For the price of a couple of coffees, you get unlimited access to GPT-5 mini, work with AI across multiple IDEs (including the fast Zed), use the CLI, and leverage coding agents—all in one subscription.

&lt;Notice type=&quot;success&quot; title=&quot;Why Copilot Pro Is the Best Choice&quot;&gt;

**For just $10/month you get:**
- ✅ **Unlimited GPT-5 mini** - Use as much as you want
- ✅ **300 premium requests** - For advanced models like Claude Sonnet 4.5
- ✅ **Zed IDE support** - Modern, fast, native integration
- ✅ **VS Code, JetBrains, and more** - Work in your favorite editor
- ✅ **Copilot CLI** - AI assistance in your terminal
- ✅ **Coding Agents** - Let AI create pull requests for you
- ✅ **All interfaces** - Chat, code completion, and command line

&lt;/Notice&gt;

If you&apos;re exploring AI coding tools, also check out [Amp Code free AI coding agent](https://www.bitdoze.com/amp-code-free-ai-coding-agent/) or learn about [using Claude Sonnet 4.5 and GPT-5 for free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/).


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/WB10h_fSkeE&quot;
  label=&quot;The Only AI Combo You Need: Speed, Power, and Automation&quot;
/&gt;

## Why $10/month Copilot Pro Offers Good Value

Copilot Pro provides strong value at $10. Here&apos;s why:

### Unlimited GPT-5 Mini

&lt;ListCheck&gt;

- **Completely free to use** - Doesn&apos;t count against your 300 premium requests
- **No daily limits** - Use it 24/7 for all your coding
- **Fast and capable** - Handles most coding tasks well
- **Available everywhere** - Works in Zed, VS Code, CLI, and Copilot Chat

&lt;/ListCheck&gt;

Most of your daily coding—refactoring, writing functions, debugging, documentation—works fine with GPT-5 mini. Save your 300 premium requests for complex tasks.

### Work Everywhere: One Subscription, Multiple Tools

The beauty of Copilot Pro is flexibility. Your $10 subscription works across:

&lt;Tabs&gt;
&lt;Tab name=&quot;Zed IDE&quot;&gt;

**The fastest, modern option**

- Native macOS performance
- Instant AI suggestions
- Minimal interface
- Access to all Copilot models
- Good for serious developers

**Enable latest models**: Visit https://github.com/settings/copilot/features

&lt;/Tab&gt;
&lt;Tab name=&quot;VS Code&quot;&gt;

**Most popular, feature-complete**

- Inline code suggestions
- Full Chat sidebar
- Edit mode and Agent mode
- Extensions ecosystem
- Works on all platforms

&lt;/Tab&gt;
&lt;Tab name=&quot;Terminal (CLI)&quot;&gt;

**AI in your command line**

```bash
# Install
npm install -g @github/copilot


# Use interactively
copilot

# Or directly
copilot -p &quot;Create a Next.js app with auth&quot;
```

Perfect for DevOps and terminal workflows.

&lt;/Tab&gt;
&lt;Tab name=&quot;Other IDEs&quot;&gt;

**Also supported:**
- JetBrains (IntelliJ, PyCharm, WebStorm)
- Visual Studio
- Eclipse
- Xcode
- Vim/Neovim

&lt;/Tab&gt;
&lt;/Tabs&gt;

### 300 Premium Requests Last Longer Than You Think

While unlimited GPT-5 mini handles daily tasks, you get 300 premium requests monthly for:

- **Complex algorithms** - Use Claude Sonnet 4.5 (1 request each)
- **Architecture decisions** - Get AI assistance
- **Code reviews** - Deep analysis of pull requests
- **Autonomous agents** - 1 request per complete PR creation

**Smart usage example:**
- 20 complex coding sessions with Claude Sonnet 4.5 = 20 requests
- 30 coding agent sessions (auto-create PRs) = 30 requests
- 50 CLI commands for complex tasks = 50 requests
- **Total: 100 requests used, 200 remaining**

## The Power of GitHub Copilot Pro

### 1. Code Completion in Your IDE

Real-time suggestions as you type:

&lt;ListCheck&gt;

- **Ghost text suggestions** appear inline as you code
- **Tab to accept** - Instant code completion
- **Multiple suggestions** - Cycle through alternatives
- **Context-aware** - Understands your entire project
- **Works offline cached** - Basic completions without internet

&lt;/ListCheck&gt;

### 2. Copilot Chat: Your AI Pair Programmer

Available in all supported IDEs and on GitHub.com:

&lt;Accordion label=&quot;What You Can Do with Chat&quot; group=&quot;features&quot;&gt;

**Ask questions:**
```
How do I implement authentication with JWT tokens in Express?
```

**Generate code:**
```
Create a React component for a pricing table with 3 tiers
```

**Refactor and improve:**
```
Refactor this function to use async/await instead of callbacks
```

**Debug issues:**
```
Why is this function returning undefined?
```

**Write tests:**
```
Generate unit tests for the UserService class
```

&lt;/Accordion&gt;

### 3. Copilot CLI: Terminal-Based AI

The CLI works well for DevOps and terminal workflows:

&lt;Accordion label=&quot;Local Development Tasks&quot; group=&quot;cli&quot;&gt;

```bash
# Make code changes
copilot -p &quot;Add input validation to user-auth.js&quot;

# Create new features
copilot -p &quot;Create a REST API endpoint for user profiles&quot;

# Git operations
copilot -p &quot;Create a feature branch and commit current changes&quot;

# Build apps from scratch
copilot -p &quot;Create a Node.js CLI tool that analyzes log files&quot;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;GitHub Integration&quot; group=&quot;cli&quot;&gt;

```bash
# View your work
copilot -p &quot;List all my open pull requests&quot;

# Create issues
copilot -p &quot;Create an issue for the login bug I found&quot;

# Create PRs
copilot -p &quot;Create a PR that updates the README&quot;

# Review code
copilot -p &quot;Review PR #123 and check for security issues&quot;
```

&lt;/Accordion&gt;

### 4. Coding Agents: Autonomous Development

Copilot can write code and create pull requests for you:

**From GitHub Issues:**
1. Open an issue
2. Assign it to @copilot
3. Copilot analyzes and codes
4. Creates a pull request

**From Chat:**
```
Create a PR that adds dark mode support to the app
```

Copilot will:
- Find relevant files
- Make necessary changes
- Test if possible
- Create the PR with description

&lt;Notice type=&quot;info&quot; title=&quot;My Real-World Experience&quot;&gt;

I tested the [Copilot Agents infrastructure](https://github.com/copilot/agents) for hands-off automation with GitHub Actions.

**Result**: Not worth it. Cost ~$0.50 per request and didn&apos;t perform reliably. The manual agent approach through Chat or CLI works much better and is included in your $10 subscription.

**Best practice**: Use agents for well-defined tasks through Chat or CLI, review the changes, then approve. Works great this way.

&lt;/Notice&gt;

## Why Zed IDE + Copilot Pro Works Well

Zed is a fast, modern IDE built by the creators of Atom. Combined with Copilot Pro, it&apos;s a solid development environment:

&lt;ListCheck&gt;

- **Fast** - Native performance, no Electron
- **Built for AI** - Copilot integration feels native
- **Modern UX** - Clean, distraction-free interface
- **All models available** - Access latest AI models
- **Collaborative** - Built-in multiplayer coding
- **macOS optimized** - Works well for Mac users

&lt;/ListCheck&gt;

### Setting Up Zed with Copilot

```bash
# Install Zed
brew install zed

# Enable Copilot in Zed settings
# Sign in with GitHub

# Enable latest models
# Visit: https://github.com/settings/copilot/features
# Enable &quot;Model Choice&quot; and preview features
```

You now have a fast AI coding setup.

## Available AI Models

Your Copilot Pro subscription includes access to multiple models:

### Included Models (Unlimited, Free)

- **GPT-5 mini** ⭐ - Your daily driver
- **GPT-4.1** - Balanced performance
- **GPT-4o** - Optimized for coding

### Premium Models (Use Your 300 Requests)

| Model | Premium Requests | Best For |
|-------|-----------------|----------|
| Claude Sonnet 4.5 | 1× | Complex algorithms, architecture |
| Claude Sonnet 4 | 1× | Production code, critical features |
| GPT-5 | 1× | Advanced reasoning |
| Gemini 2.0 Flash | 0.25× | Quick questions (efficient!) |
| Claude Haiku 4.5 | 0.33× | Fast responses |
| Claude Opus 4 | 10× | Only for critical decisions |

**Strategy**: Use GPT-5 mini for everything, switch to Claude Sonnet 4.5 for complex problems. You&apos;ll rarely need more expensive models.


## Getting Started (5 Minutes)

### Step 1: Sign Up for Copilot Pro

1. Visit [GitHub Copilot](https://github.com/github-copilot/signup)
2. Choose **Pro Plan** ($10/month)
3. Start 30-day free trial (no credit card for trial)

### Step 2: Install in Your IDE

&lt;Tabs&gt;
&lt;Tab name=&quot;Zed (Recommended)&quot;&gt;

```bash
# Install Zed
brew install zed

# Open Zed
# Settings → Extensions → Enable Copilot
# Sign in with GitHub
```

&lt;/Tab&gt;
&lt;Tab name=&quot;VS Code&quot;&gt;

1. Open VS Code
2. Extensions → Search &quot;GitHub Copilot&quot;
3. Install &quot;GitHub Copilot&quot; + &quot;GitHub Copilot Chat&quot;
4. Sign in with GitHub

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Step 3: Install CLI (Optional but Powerful)

```bash
# Install GitHub CLI
brew install gh

# Install Copilot CLI extension
gh extension install github/gh-copilot

# Test it
copilot -p &quot;Show me how to use this CLI&quot;
```

### Step 4: Enable Latest Models

Visit https://github.com/settings/copilot/features and enable:
- Model choice
- Preview features
- Latest models

## Best Practices: Maximize Your $10/month

### Use GPT-5 Mini for Daily Work

&lt;ListCheck&gt;

- **Write functions** - &quot;Create a user validation function&quot;
- **Refactor code** - &quot;Simplify this nested logic&quot;
- **Add features** - &quot;Add error handling here&quot;
- **Write tests** - &quot;Generate tests for this component&quot;
- **Documentation** - &quot;Add JSDoc comments&quot;

&lt;/ListCheck&gt;

### Save Premium Requests for Complex Tasks

&lt;ListCheck&gt;

- **System architecture** - Use Claude Sonnet 4.5
- **Performance optimization** - Deep analysis needed
- **Security reviews** - Critical code examination
- **Complex algorithms** - Advanced reasoning
- **Production debugging** - When stakes are high

&lt;/ListCheck&gt;

### Keyboard Shortcuts (VS Code/Zed)

- `Tab` - Accept AI suggestion
- `Cmd/Ctrl + I` - Open inline chat
- `Cmd/Ctrl + K` - Quick command
- `Alt + ]` - Next suggestion
- `Alt + [` - Previous suggestion

## Real-World Workflow Example

Here&apos;s how I use Copilot Pro daily:

**Morning (8 AM - 12 PM)**: VS Code with GPT-5 mini
- Write new features
- Refactor old code
- Write tests
- **Premium requests used**: 0

**Afternoon (1 PM - 5 PM)**: Zed IDE + Claude Sonnet 4.5
- Review architecture decisions (2 requests)
- Optimize database queries (1 request)
- Complex algorithm implementation (2 requests)
- **Premium requests used**: 5

**Evening (6 PM - 7 PM)**: Copilot CLI
- Deploy updates via terminal
- Create PRs from CLI (1 request)
- Automate DevOps tasks
- **Premium requests used**: 1

**Daily Total**: 6 premium requests (180/month pace, well under 300 limit)

## Frequently Asked Questions

&lt;Accordion label=&quot;Is $10/month really worth it?&quot; group=&quot;faq&quot;&gt;

Absolutely. Consider this:
- Saves 5+ hours per week (conservative estimate)
- Your hourly rate × 5 hours = Much more than $10
- Access to cutting-edge AI models
- Works across all your tools
- Native GitHub integration

If you code professionally, it pays for itself in the first hour of use.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use Copilot with Zed for free?&quot; group=&quot;faq&quot;&gt;

You need a Copilot subscription (Pro, Business, or Enterprise) to use Copilot in any IDE, including Zed. The free Copilot tier gives you 50 premium requests/month to try it out.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens if I use all 300 premium requests?&quot; group=&quot;faq&quot;&gt;

You can still use Copilot with unlimited GPT-5 mini, GPT-4.1, and GPT-4o for the rest of the month. These included models handle most coding tasks perfectly fine.

You can also buy additional premium requests at $0.04 each if needed.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Copilot send my code to external servers?&quot; group=&quot;faq&quot;&gt;

Your code is processed by GitHub/Microsoft&apos;s AI infrastructure but is not used to train public models or shared with others. Enterprise plans offer additional privacy controls.

Code snippets are sent to generate suggestions but your entire codebase doesn&apos;t leave your machine.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I cancel anytime?&quot; group=&quot;faq&quot;&gt;

Yes, Copilot Pro is a monthly subscription you can cancel anytime. No long-term contracts or cancellation fees.

&lt;/Accordion&gt;

## Why This Is a Good AI Coding Setup

Copilot Pro + Zed IDE works well at $10/month:

### The Math Makes Sense

&lt;ListCheck&gt;

- **Unlimited GPT-5 mini** - Solid value
- **300 premium requests** - Enough for most users
- **CLI access** - Useful for automation
- **Coding agents** - Good for PRs
- **Multi-IDE support** - Flexibility across tools

&lt;/ListCheck&gt;

### The Workflow Is Flexible

- **Zed for speed** - When you need performance
- **VS Code for extensions** - When you need specific tools
- **CLI for automation** - When you&apos;re in the terminal
- **Chat for questions** - When you need answers
- **Agents for tedious tasks** - When you want automation

One subscription, multiple tools.

### The AI Works Well

GPT-5 mini handles most coding tasks. When you need more power, Claude Sonnet 4.5 is available. New models are added regularly.

### It Improves Over Time

GitHub continues to add:
- New AI models
- Better IDE integrations
- More CLI capabilities
- Improved agents

Your $10 subscription gets updates.

## Conclusion: Get Copilot Pro

If you&apos;re a developer who codes regularly, **GitHub Copilot Pro at $10/month is a good choice**. It&apos;s solid value for AI coding.

&lt;Notice type=&quot;success&quot; title=&quot;Get Started Now&quot;&gt;

**Start your free 30-day trial:**
1. Visit [GitHub Copilot Pro](https://github.com/github-copilot/signup)
2. Choose Pro plan ($10/month after trial)
3. Install in Zed, VS Code, or your favorite IDE
4. Start coding with AI assistance

No credit card required for trial. Cancel anytime.

&lt;/Notice&gt;

The combination of unlimited GPT-5 mini, 300 premium requests, Zed IDE support, CLI access, and coding agents in one $10 subscription provides good functionality. Whether you&apos;re building web apps, writing scripts, or working on complex systems, Copilot Pro adapts to your workflow.

Consider Copilot Pro for an AI coding assistant.

## Related Articles

- [Amp Code Free AI Coding Agent](https://www.bitdoze.com/amp-code-free-ai-coding-agent/)
- [Best Open-Source LLMs as Claude Alternatives](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)
- [Use Claude Sonnet 4.5 and GPT-5 Free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/)
- [Building AI Affiliate Websites](https://www.bitdoze.com/ai-affiliate-websites-amazon/)</content:encoded><category>ai</category><category>ai-tools</category><category>github</category></item><item><title>Amp Code Free: The AI Coding Agent That Works in Your Editor</title><link>https://www.bitdoze.com/amp-code-free-ai-coding-agent/</link><guid isPermaLink="true">https://www.bitdoze.com/amp-code-free-ai-coding-agent/</guid><description>Discover Amp Code, the AI coding agent from Sourcegraph. Learn about Amp Free with unlimited access, the CLI tool, IDE integration, and how it compares to paid alternatives.</description><pubDate>Thu, 16 Oct 2025 01:00:00 GMT</pubDate><content:encoded>Amp Code by Sourcegraph runs directly in your editor and terminal, giving you access to AI models. Amp Free mode offers unlimited access supported by ads, making AI coding assistance available to everyone.

&lt;Notice type=&quot;success&quot; title=&quot;What You&apos;ll Discover&quot;&gt;

- **Free unlimited AI coding** with Amp Free mode (ad-supported)
- **VS Code, Cursor, and Windsurf integration** for your workflow
- **CLI tool** for terminal-based development
- **Advanced features** like Oracle (GPT-5), subagents, and custom tools
- **Smart mode** with large token usage for complex projects

&lt;/Notice&gt;

If you&apos;re exploring AI tools for development, you might also want to check out how to [use Claude Sonnet 4.5 and GPT-5 for free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/) or learn about [building AI affiliate websites](https://www.bitdoze.com/ai-affiliate-websites-amazon/).

## What is Amp Code?

Amp is a coding agent built by [Sourcegraph](https://sourcegraph.com/) that adds AI to your development workflow. Unlike simple code completion tools, Amp can read your codebase, execute commands, edit files, and work on complex tasks.



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/6LtxUE54Llw&quot;
  label=&quot;Amp Free Mode: Genius or Creepy?&quot;
/&gt;


### Core Principles

Amp has four key differences from other AI coding tools:

&lt;ListCheck&gt;

- **Large token usage**: No artificial limits on context or output. If your task needs millions of tokens, Amp uses them.
- **Best models available**: You don&apos;t pick models—Amp uses capable models like Claude Sonnet 4.5.
- **Direct model access**: Full access to what these models can do, not limited versions.
- **Automatic updates**: Stays current with new models as they&apos;re released.

&lt;/ListCheck&gt;

## Amp Free vs Smart Mode

Amp offers two distinct modes to fit different needs and budgets:

### Amp Free Mode

Amp Free is completely free of charge, supported by ads and training data sharing. It uses a mix of top open-source models, frontier models with limited context windows, and pre-release models in testing.

**Key Features:**

&lt;ListCheck&gt;

- **Completely free** to use for work or personal projects
- **Unlimited usage** (some rate limits apply)
- **Ad-supported** with tasteful developer-focused advertisements
- **Training data sharing** required (models train on your usage)
- **Interactive use** in editor or CLI
- **Not available** for enterprise workspaces

&lt;/ListCheck&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Training Data Requirement&quot;&gt;

To make Amp Free sustainable, your code and conversations are used to train models. If you&apos;re working with proprietary code or don&apos;t want this, use Amp Smart mode instead.

&lt;/Notice&gt;

### Amp Smart Mode (Paid)

Smart mode is Amp&apos;s paid tier with full model usage.

**Key Features:**

&lt;ListCheck&gt;

- **Claude Sonnet 4.5** with up to 1 million token context
- **Large token usage** for complex tasks
- **No training data sharing** (zero data retention available for Enterprise)
- **Oracle access** (GPT-5 for complex reasoning)
- **Subagents** for parallel task execution
- **Custom tools** via Model Context Protocol (MCP)

&lt;/ListCheck&gt;

**Pricing:**

- Pay-as-you-go based on actual model usage
- Direct pass-through of API costs with no markup
- Most users get $10 USD in free credits to start
- Workspace credits pool usage across team members

| Mode | Cost | Models | Training | Best For |
| --- | --- | --- | --- | --- |
| **Free** | $0 | Mixed OSS + Frontier | Required | Learning, personal projects |
| **Smart** | Pay-as-you-go | Claude 4.5, GPT-5 | Optional | Professional development |
| **Enterprise** | 50% markup | Best available | Never | Teams, proprietary code |

## Amp IDE Extension: VS Code, Cursor, and Windsurf

The Amp IDE extension integrates with your code editor. It works with VS Code and compatible editors like Cursor and Windsurf.

### Installation

**For VS Code:**
1. Visit [ampcode.com](https://ampcode.com/) and sign in
2. Install the [Amp extension from VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=sourcegraph.amp)
3. Authenticate with your Amp account
4. Start coding with AI assistance

**For Cursor or Windsurf:**
The same extension works in these VS Code-compatible editors. Follow the installation instructions on the Amp dashboard.

### Key Features in the IDE

&lt;Accordion label=&quot;Thread Management&quot; group=&quot;features&quot;&gt;

Amp organizes conversations as **threads**—think of them like Git branches for AI conversations. Each thread maintains its own context, file changes, and conversation history.

- **Create new threads** for different tasks or features
- **Switch between threads** to work on multiple things
- **Share threads** with your team (if in a workspace)
- **Track file changes** made by the agent
- **Revert changes** individually or all at once

&lt;/Accordion&gt;

&lt;Accordion label=&quot;AGENTS.md Files&quot; group=&quot;features&quot;&gt;

Amp automatically looks for `AGENTS.md` files in your project to understand:

- Build and test commands
- Architecture and conventions
- Common pitfalls to avoid
- How to run and review code

Place `AGENTS.md` in your project root or subdirectories. Amp includes them automatically when working in those areas. You can also use `$HOME/.config/AGENTS.md` for personal preferences.

Example `AGENTS.md`:

```markdown
# Project Guide for AI Agents

## Commands
- **dev**: `npm run dev`
- **build**: `npm run build`
- **test**: `npm test`

## Architecture
- React + TypeScript frontend
- Node.js + Express backend
- PostgreSQL database

## Important Notes
- Always run tests before committing
- Use TypeScript strict mode
- Follow existing code patterns
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Context Management&quot; group=&quot;features&quot;&gt;

Amp tracks your context window usage and provides tools to manage it:

- **Hover over context indicator** to see which files are loaded
- **Compact Thread** to summarize and reduce context usage
- **New Thread with Summary** for fresh start with context carryover
- **File Changes view** shows what the agent modified
- View `AGENTS.md` files currently in use with `/agent-files`

&lt;/Accordion&gt;

### Practical Example: Building with Amp

Here&apos;s how a typical Amp workflow looks in your IDE:

```
You: &quot;Fix all the TypeScript errors in this file&quot;

Amp:
- Reads the file and diagnostics
- Identifies the type errors
- Fixes each error with proper types
- Runs TypeScript compiler to verify
- Shows you the changes made
```

Or for more complex tasks:

```
You: &quot;Look at localhost:3000 and make the header more minimal&quot;

Amp:
- Takes a screenshot of the localhost URL
- Analyzes the current header design
- Suggests improvements
- Modifies the CSS/components
- Takes another screenshot to verify
- Iterates until it looks right
```

&lt;Notice type=&quot;info&quot; title=&quot;Pro Tip&quot;&gt;

Use `Cmd/Ctrl+Enter` to submit messages in Amp. This deliberate action encourages you to write better prompts and get better results.

&lt;/Notice&gt;

## Amp CLI: Terminal-Based AI Assistant

The Amp CLI adds AI coding assistance to your terminal—useful for developers who work in the command line or need to integrate AI into scripts and workflows.

### Installation

```bash
# Using the install script (Linux, macOS, WSL)
curl -fsSL https://ampcode.com/install.sh | bash

# Or using npm
npm install -g @sourcegraph/amp

# Or using pnpm
pnpm add -g @sourcegraph/amp

# Or using Yarn
yarn global add @sourcegraph/amp
```

### Interactive Mode

Run `amp` without arguments for interactive mode:

```bash
$ amp
# Will prompt for login on first run
# Then start an interactive session
```

You can pipe input to start with context:

```bash
$ echo &quot;commit all my changes&quot; | amp
```

### Execute Mode

Use `-x` or `--execute` for non-interactive mode (requires paid credits):

```bash
$ amp -x &quot;what files in this folder are markdown files?&quot;
README.md
AGENTS.md
docs/getting-started.md
```

Combine with pipes for useful workflows:

```bash
$ cat package.json | amp -x &quot;what package manager is used?&quot;
npm

$ git diff | amp -x &quot;write a commit message for these changes&quot;
fix: resolve TypeScript errors in authentication module
```

### Slash Commands

The CLI supports powerful slash commands:

&lt;Tabs&gt;
&lt;Tab name=&quot;Basic Commands&quot;&gt;

- `/help` - Show help and hotkeys
- `/new` - Start a new thread
- `/continue` - Continue an existing thread
- `/quit` - Exit Amp

&lt;/Tab&gt;
&lt;Tab name=&quot;Project Commands&quot;&gt;

- `/generate-agent-file` - Create AGENTS.md for your project
- `/agent-files` - List AGENTS.md files in use
- `/permissions` - Edit permission rules
- `/compact` - Reduce context usage

&lt;/Tab&gt;
&lt;Tab name=&quot;Advanced&quot;&gt;

- `/queue [message]` - Queue message for later
- `/dequeue` - Restore queued messages
- `/editor` - Open $EDITOR to write prompt

&lt;/Tab&gt;
&lt;/Tabs&gt;

### Custom Slash Commands

Create your own slash commands by adding scripts to `.agents/commands` or `~/.config/amp/commands`:

**Markdown files** become prompt templates:
```bash
# .agents/commands/pr-review.md becomes /pr-review
```

**Executables** run and send output to Amp:
```bash
#!/usr/bin/env bash
# ~/.config/amp/commands/outline
# Becomes /outline command

tree -L 2 &quot;$@&quot;
```

### Shell Mode

Execute shell commands directly in the CLI:

```bash
# Regular shell mode (included in context)
.ls -la

# Incognito mode (not included in context)
$pwd
```

### IDE Integration

The CLI can connect directly to your IDE:

```bash
# For VS Code or Neovim with Amp installed
$ amp --ide

# For JetBrains IDEs
$ amp --jetbrains
```

This gives Amp access to:
- Currently open files
- Selected code
- IDE diagnostics
- Direct file editing with undo support

## Advanced Features

### Oracle: Second Opinion from GPT-5

Amp includes access to an &quot;Oracle&quot; tool that uses GPT-5 for complex reasoning and analysis. While Claude Sonnet 4.5 works for day-to-day coding, sometimes you need deeper analysis.

**Example usage:**

```
&quot;Use the oracle to review the last commit&apos;s changes. I want to make sure
the actual logic hasn&apos;t changed, only the implementation.&quot;
```

```
&quot;Analyze how these two functions work, then ask the oracle to figure out
how we can refactor the duplication while keeping it backwards compatible.&quot;
```

The main agent decides autonomously when to consult the oracle, but you can explicitly request it for better results on complex problems.

### Subagents for Parallel Work

Amp can spawn subagents to work on independent tasks in parallel. Each subagent has its own context window and can use tools like file editing and terminal commands.

**When to use subagents:**

&lt;ListCheck&gt;

- Converting multiple files to a new pattern (e.g., CSS to Tailwind)
- Running tests and fixing failures across different modules
- Independent feature implementations
- Tasks with extensive output you don&apos;t need in main context

&lt;/ListCheck&gt;

**Example:**

```
&quot;Use 3 subagents to convert these CSS files to Tailwind. One subagent per file.&quot;
```

Each subagent works independently and reports back when done, keeping your main thread clean and focused.

### Custom Tools with MCP

Amp supports the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) for adding custom tools. Configure MCP servers in your settings:

```json
&quot;amp.mcpServers&quot;: {
  &quot;playwright&quot;: {
    &quot;command&quot;: &quot;npx&quot;,
    &quot;args&quot;: [&quot;-y&quot;, &quot;@playwright/mcp@latest&quot;, &quot;--headless&quot;]
  },
  &quot;linear&quot;: {
    &quot;url&quot;: &quot;https://mcp.linear.app/sse&quot;
  }
}
```

This lets Amp interact with external services, APIs, and tools specific to your workflow.

### Amp Tab: AI-Powered Completions

Amp Tab is an experimental completion engine that anticipates your next actions:

- Multi-line code suggestions
- Edits in other files based on context
- Semantic understanding of your changes
- Language server diagnostics integration

Enable in settings: `&quot;amp.tab.enabled&quot;: true`

Press `Tab` to accept suggestions and jump to additional edits.

## Real-World Example Prompts

Here are concrete prompts you can try with Amp:

&lt;Accordion label=&quot;Code Fixes &amp; Improvements&quot; group=&quot;examples&quot; expanded=&quot;true&quot;&gt;

```
&quot;Fix all the TypeScript errors in this file&quot;

&quot;Run the tests and fix any failing ones&quot;

&quot;Review this API design and suggest improvements&quot; (uses Oracle)
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Feature Development&quot; group=&quot;examples&quot;&gt;

```
&quot;Add a dark mode toggle to this React component&quot;

&quot;Plan how to add real-time chat to this app, but don&apos;t write code yet&quot;

&quot;Find where user authentication is handled in this codebase&quot;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Git &amp; Version Control&quot; group=&quot;examples&quot;&gt;

```
&quot;git blame this file and tell me who added that function&quot;

&quot;Check git diff --staged and remove the debug statements&quot;

&quot;Look at the last commit and help me change this feature&quot;
```

&lt;/Accordion&gt;

&lt;Accordion label=&quot;CLI &amp; Automation&quot; group=&quot;examples&quot;&gt;

```bash
# Command line usage
amp -x &apos;what files in this folder are markdown?&apos;

# With context from commands
git log --oneline -10 | amp -x &quot;summarize recent changes&quot;

# Visual debugging
amp -x &quot;Look at localhost:3000 and make the header more minimal&quot;
```

&lt;/Accordion&gt;

## Workspaces: Team Collaboration

Amp Workspaces provide collaborative environments where teams can share knowledge and threads.

### Key Features

&lt;ListCheck&gt;

- **Thread sharing** - All threads visible to workspace members by default
- **Pooled billing** - Shared credit pool for the team
- **Leaderboard** - Track activity and contributions
- **Learning from others** - Browse and learn from teammate&apos;s threads
- **SSO support** - Enterprise workspaces can enable single sign-on

&lt;/ListCheck&gt;

### Privacy Controls

Threads can be:
- **Workspace-shared** (default for workspace members)
- **Private** (only visible to you)
- **Public** (visible to anyone with link)

Change visibility anytime through the sharing menu.

## Comparison with Other AI Coding Tools

| Feature | Amp Free | Amp Smart | Other Free Tools |
| --- | --- | --- | --- |
| **Cost** | Free (ads) | Pay-as-you-go | Free with limits |
| **Context Window** | Limited | 1M+ tokens | Usually limited |
| **Model Selection** | Auto (mixed) | Claude 4.5 | User picks |
| **Training Data** | Required | Optional | Varies |
| **IDE Integration** | ✓ | ✓ | Limited |
| **CLI Tool** | ✓ | ✓ | Rare |
| **Subagents** | ✗ | ✓ | ✗ |
| **Oracle (GPT-5)** | ✗ | ✓ | ✗ |
| **Custom Tools** | Limited | ✓ | ✗ |
| **Enterprise** | ✗ | ✓ | ✗ |

## Getting Started with Amp

Ready to try Amp? Here&apos;s your roadmap:

### Step 1: Choose Your Mode

**For Learning &amp; Personal Projects:**
- Start with **Amp Free** mode
- Get unlimited usage with ads
- Good for experimenting and learning

**For Professional Development:**
- Use **Amp Smart** mode
- Get $10 free credits to start
- Pay only for what you use

### Step 2: Install Amp

**IDE Extension:**
1. Visit [ampcode.com](https://ampcode.com/) and sign up
2. Install extension for VS Code, Cursor, or Windsurf
3. Authenticate and start coding

**CLI Tool:**
```bash
curl -fsSL https://ampcode.com/install.sh | bash
amp  # Login and start
```

### Step 3: Create AGENTS.md

Help Amp understand your project:

```bash
# In your project root
amp /generate-agent-file
```

Or create manually with:
- Build/test commands
- Architecture overview
- Important conventions
- Common pitfalls

### Step 4: Start Coding

Try these starter prompts:

```
&quot;Run the tests and fix any failures&quot;

&quot;Look at this file and explain what it does&quot;

&quot;Help me add error handling to this function&quot;
```

&lt;Notice type=&quot;success&quot; title=&quot;Best Practices&quot;&gt;

- **Be explicit** - Say &quot;do X&quot; instead of &quot;can you do X?&quot;
- **Keep it focused** - One task per thread works best
- **Provide context** - Tell Amp which files or commands matter
- **Use AGENTS.md** - Guide Amp on how to test and build
- **Start fresh** - New thread if context gets cluttered
- **Review work** - Tell Amp how to verify its changes

&lt;/Notice&gt;

## Integrations and Extensions

Amp integrates with many other AI and development tools you might already be using:

- Learn about [MCP servers in BrightData](https://www.bitdoze.com/brightdata-mcp-guide/) for web scraping capabilities
- Explore [best open-source LLMs](https://www.bitdoze.com/best-open-source-llms-claude-alternative/) as Claude alternatives
- Check out other [AI tools and resources](https://www.bitdoze.com/resources/) for development

## Frequently Asked Questions

&lt;Accordion label=&quot;Is Amp Free really unlimited?&quot; group=&quot;faq&quot;&gt;

Yes, Amp Free offers unlimited interactive usage with some rate limits to prevent abuse. You can use it as much as you need for personal projects and learning. The free tier is supported by ads and training data sharing.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What happens to my code in Amp Free?&quot; group=&quot;faq&quot;&gt;

In Amp Free mode, your code and conversations are used to train AI models. This is how the free tier remains sustainable. If you&apos;re working with proprietary or sensitive code, use Amp Smart mode instead, which offers zero data retention options.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;How much does Amp Smart cost?&quot; group=&quot;faq&quot;&gt;

Amp Smart uses pay-as-you-go pricing based on actual model usage. Costs are passed through directly with no markup for individuals and teams (50% markup for Enterprise). Most complex tasks cost a few cents to a few dollars depending on token usage.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I switch between Free and Smart mode?&quot; group=&quot;faq&quot;&gt;

Yes! Switch modes anytime with `/mode free` or `/mode smart` in the CLI, or select the mode in your IDE&apos;s prompt field. Your threads and history are preserved across modes.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does Amp work offline?&quot; group=&quot;faq&quot;&gt;

No, Amp requires an internet connection to access AI models. However, the IDE integration and file editing work locally, so you maintain full control of your code.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;What editors does Amp support?&quot; group=&quot;faq&quot;&gt;

Amp works with VS Code, Cursor, Windsurf, and other VS Code-compatible editors. There&apos;s also experimental support for Neovim and JetBrains IDEs (IntelliJ, WebStorm, GoLand, etc.).

&lt;/Accordion&gt;

## Conclusion

Amp Code provides AI capabilities for your development workflow—whether you prefer working in an IDE or the command line. With Amp Free, you get unlimited access to AI coding assistance without paying, while Amp Smart offers access to the best models for professional development.

**Key Takeaways:**

✅ **Amp Free** - Unlimited AI coding with ads, perfect for learning
✅ **Amp Smart** - Unconstrained Claude 4.5 access, pay only what you use
✅ **IDE Integration** - Works in VS Code, Cursor, Windsurf seamlessly
✅ **Powerful CLI** - Terminal-based AI for scripts and automation
✅ **Advanced Features** - Oracle (GPT-5), subagents, custom tools via MCP
✅ **Team Collaboration** - Workspaces with thread sharing and pooled billing

Whether you&apos;re building side projects, learning to code, or working on production applications, Amp provides the AI assistance you need at a price point that works for you.

&lt;Button
  text=&quot;Get Started with Amp Code&quot;
  url=&quot;https://ampcode.com/&quot;
  size=&quot;lg&quot;
  color=&quot;purple&quot;
  variant=&quot;solid&quot;
  icon=&quot;arrow-right&quot;
  iconPosition=&quot;right&quot;
/&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Continue Learning&quot;&gt;

Explore more AI tools and development resources:
- [How to Use Claude Sonnet 4.5 and GPT-5 for Free](https://www.bitdoze.com/use-claude-sonnet-4-5-gpt-5-free/)
- [Best Open-Source LLMs as Claude Alternatives](https://www.bitdoze.com/best-open-source-llms-claude-alternative/)
- [Build AI Affiliate Websites with Amazon](https://www.bitdoze.com/ai-affiliate-websites-amazon/)
- [BrightData MCP Server Guide](https://www.bitdoze.com/brightdata-mcp-guide/)
- [More AI Resources](https://www.bitdoze.com/resources/)

&lt;/Notice&gt;

Ready to try AI-powered coding? Install Amp today and start building with AI models!</content:encoded><category>ai</category><category>ai-tools</category><category>devops</category></item><item><title>AI-Powered Affiliate Websites with Amazon Products</title><link>https://www.bitdoze.com/ai-affiliate-websites-amazon/</link><guid isPermaLink="true">https://www.bitdoze.com/ai-affiliate-websites-amazon/</guid><description>Use AI tools like Claude Sonnet 4.5 with Factory.ai Droid CLI and BrightData MCP to create affiliate websites with Amazon products. Case study: 40 articles for under $2.</description><pubDate>Mon, 06 Oct 2025 00:00:00 GMT</pubDate><content:encoded>Creating an affiliate website usually means hours of product research, writing reviews, comparing specifications, and updating content. What if AI handled most of the writing while you focused on strategy?

I migrated my affiliate site [SmoothieBlenderGuide.com](https://www.smoothieblenderguide.com/) from WordPress to Astro, and rewrote 40 articles using AI. The cost was around $2 for 40 articles. Here&apos;s how.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Vi8WJrhyZCo&quot;
  label=&quot;How I Used FREE Sonnet 4.5 + Droid CLI + Amazon Product MCP to Move 40 Posts!&quot;
/&gt;


## The Cost Breakdown

Let&apos;s look at numbers:

- 5 million tokens used from Factory.ai Droid CLI (free within their tier)
- $1 credit used from BrightData MCP (out of $10 free credit)
- 40 articles fully rewritten and optimized
- Time investment: 2-3 hours of work
- WordPress hosting: Eliminated (now hosted free on Cloudflare Pages)

Hiring writers costs $50-100 per article, so that would be $2,000-4,000. Cheap content mills would cost $500-800 for 40 articles of questionable quality.

## Why This Works

This isn&apos;t about churning out low-quality content. Here&apos;s what makes this method work:

- Claude Sonnet 4.5 writes informative, well-structured content
- BrightData MCP provides real Amazon product data (prices, ratings, reviews, specs)
- Astro delivers fast sites
- Free hosting on Cloudflare Pages
- Git-based workflow for updates

These tools create affiliate content that&apos;s fast, accurate, and helpful to readers.

## What You&apos;ll Need

### Astro Theme

You need a theme built for affiliate content. The [Bitdoze Astro Theme](https://github.com/bitdoze/bitdoze-astro-theme) works well:

- Built-in Amazon product widgets
- Fast loading times
- Responsive design
- MDX support
- Free and open source

&lt;Button text=&quot;Get Bitdoze Astro Theme&quot; link=&quot;https://github.com/bitdoze/bitdoze-astro-theme&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

### Factory.ai Droid CLI

[Factory.ai Droid CLI](https://go.bitdoze.com/droid-cli) is your command-line AI assistant:

- 20-40 million free tokens per month
- Access to Claude Sonnet 4.5 and GPT-5
- MCP integration
- Custom model configuration
- CLI-based for automation

Claude Sonnet 4.5 through Droid CLI writes detailed, informative content that doesn&apos;t sound like typical AI spam.

&lt;Button text=&quot;Sign Up for Droid CLI&quot; link=&quot;https://go.bitdoze.com/droid-cli&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

### BrightData MCP

[BrightData MCP](https://go.bitdoze.com/brightdata) connects your AI to Amazon data:

- 5,000 free requests per month
- Real-time Amazon product data
- Product reviews and ratings
- Search functionality
- Cached data for reliability

&lt;Button text=&quot;Get BrightData Account&quot; link=&quot;https://go.bitdoze.com/brightdata&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

Three MCP functions for affiliate sites:

1. `web_data_amazon_product` - Get product information
2. `web_data_amazon_product_reviews` - Fetch customer reviews
3. `web_data_amazon_product_search` - Search for products by keyword

## Step-by-Step Setup

### Step 1: Install the Astro Theme

Set up your Astro blog. I&apos;ve written a guide for this:

&lt;Button text=&quot;Read: Build Astro Blog Free&quot; link=&quot;https://www.bitdoze.com/build-astro-blog-free/&quot; variant=&quot;outline&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

Quick version:

```bash
# Fork the Bitdoze Astro Theme on GitHub
# Clone your fork
git clone https://github.com/YOUR-USERNAME/your-blog.git
cd your-blog

# Install dependencies
npm install

# Start dev server
npm run dev
```

Visit `http://localhost:4321` to see your blog.

### Step 2: Configure BrightData MCP with Droid CLI

Create or edit the MCP configuration file at `~/.factory/mcp.json`:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_api_key_here&quot;,
        &quot;PRO_MODE&quot;: &quot;true&quot;
      }
    }
  }
}
```

&lt;Notice type=&quot;info&quot; title=&quot;Getting Your API Key&quot;&gt;
Log into your BrightData account, navigate to API settings, and generate a new API token. Copy it into the configuration above.
&lt;/Notice&gt;

### Step 3: Create AGENTS.md

This file tells the AI how your project works. Create `AGENTS.md` in your project root:

```markdown
# Agents Guide for Smoothie Blender Guide

Date: 01 October 2025

## Commands

- **dev**: `npm run dev` - Start development server on localhost:4321
- **build**: `npm run build` - Build for production
- **preview**: `npm run preview` - Preview production build
- **no tests**: No test scripts configured in package.json

## Architecture

- **Astro v5** blog site with MDX, RSS, sitemap generation
- **Content Collections**: posts/, authors/, pages/, about/ in src/content/
- **Config**: site.ts, menu.json, social.json in src/config/
- **Layouts**: Layout.astro (main), PostLayout.astro (blog posts)
- **Styling**: Tailwind CSS v4 with @tailwindcss/typography
- **Search**: Client-side with Fuse.js
- **Assets**: Images in src/assets/, public/ for static files

## Code Style

- **TypeScript**: Strict mode via astro/tsconfigs/strict
- **Path aliases**: @components/_, @layouts/_, @config/_, @utils/_, @styles/_, @assets/_
- **Content schema**: Zod validation in src/content/config.ts
- **Naming**: kebab-case for files, camelCase for variables, PascalCase for components
- **Imports**: Use path aliases, group by external/internal
- **Types**: Define schema with Zod for content collections
- **Frontmatter**: Required title, optional meta_title, description, image, authors[], categories[], tags[]

## Content Guidelines for Smoothie Blender Articles

- **Focus**: Informative, practical content about smoothie blenders, recipes, nutrition, maintenance
- **Structure**: Use clear headings, bullet points, step-by-step instructions where applicable
- **SEO**: Include relevant smoothie/blender keywords naturally in titles and content
- **Helpful tone**: Write as an expert guide helping readers make informed decisions
- **Product reviews**: Include pros/cons, specifications, comparison tables
- **Recipes**: List ingredients, nutritional benefits, preparation steps
- **Tags**: Don&apos;t use more then 3 tags per article.
- **Links**: Include internal links in article as natural as possible, aim for 3 to5 links per article, the public/links.txt has the list with articles.
- **Widgets**: Include in article the widgets created under widget section, don&apos;t use to much to not make the article not readable.
- **Image**: Create an svg image for the mdx article and store it in the assets/images. Make it simple and look nice without to much elements and a short text that is bigger and visible on any device with maximum 5 words. Use a nice background with a lighter colour that looks nice 16:9 format.
- **Amazon Products**: You add the amazon products with the needed details for the box: `&lt;AmazonProduct productName=&quot;Blender Name&quot; productDescription=&quot;Description&quot; productFeatures={[&quot;Feature 1&quot;, &quot;Feature 2&quot;]} productLink=&quot;https://amazon.com/dp/ASIN&quot; productImage=&quot;https://example.com/image.jpg&quot; productRating={4.5} importantConsiderations={[&quot;Note 1&quot;, &quot;Note 2&quot;]} pros={[&quot;Pro 1&quot;, &quot;Pro 2&quot;]} cons={[&quot;Con 1&quot;, &quot;Con 2&quot;]} /&gt;` the image is the one from amazon and the link should be with &quot;https://amazon.com/dp/ASIN&quot;


## Available Widgets (import from @components/widgets/)

- **Accordion**: `&lt;Accordion label=&quot;FAQ Title&quot; group=&quot;faq&quot; expanded=&quot;true&quot;&gt;content&lt;/Accordion&gt;`
- **Button**: `&lt;Button text=&quot;Click Here&quot; link=&quot;/url&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; icon=&quot;arrow-right&quot; /&gt;`
- **Notice**: `&lt;Notice type=&quot;info|success|warning|error&quot; title=&quot;Important&quot;&gt;content&lt;/Notice&gt;`
- **ListCheck**: `&lt;ListCheck&gt;&lt;ul&gt;&lt;li&gt;Checkmark item 1&lt;/li&gt;&lt;li&gt;Item 2&lt;/li&gt;&lt;/ul&gt;&lt;/ListCheck&gt;`
- **YouTubeEmbed**: `&lt;YouTubeEmbed url=&quot;https://youtube.com/embed/...&quot; label=&quot;Video Title&quot; /&gt;`
- **Tabs/Tab**: `&lt;Tabs&gt;&lt;Tab name=&quot;Tab 1&quot;&gt;content&lt;/Tab&gt;&lt;Tab name=&quot;Tab 2&quot;&gt;content&lt;/Tab&gt;&lt;/Tabs&gt;`
- **AmazonProduct**: `&lt;AmazonProduct productName=&quot;Blender Name&quot; productDescription=&quot;Description&quot; productFeatures={[&quot;Feature 1&quot;, &quot;Feature 2&quot;]} productLink=&quot;https://amazon.com/dp/ASIN&quot; productImage=&quot;https://example.com/image.jpg&quot; productRating={4.5} importantConsiderations={[&quot;Note 1&quot;, &quot;Note 2&quot;]} pros={[&quot;Pro 1&quot;, &quot;Pro 2&quot;]} cons={[&quot;Con 1&quot;, &quot;Con 2&quot;]} /&gt;`

```

## Available Widgets

- **AmazonProduct**: Product review boxes with pros/cons
- **Notice**: Callout boxes for important information
- **ListCheck**: Checkmark lists for features/benefits
- **Accordion**: Expandable FAQ sections
- **Button**: Call-to-action buttons


Save this file in your project root. It becomes the AI&apos;s instruction manual.

### Step 4: Start Creating Content

Now comes the fun part. Open your terminal in your project directory and start Droid CLI:

```bash
droid
```

Once Droid is running, use this prompt pattern:

```
Please write a comprehensive roundup article about the best [PRODUCT CATEGORY].
Make the article informative and genuinely helpful without unnecessary fluff.

Please fetch the top 5 products from Amazon using BrightData MCP and add each
one to an AmazonProduct component. Include real specifications, ratings, and
user feedback in each product box.

Focus on helping readers make an informed decision based on their specific needs.
```

For example:

```
Please write a comprehensive roundup article about the best blenders for smoothies.
Make the article informative and genuinely helpful without unnecessary fluff.

Please fetch the top 5 products from Amazon using BrightData MCP and add each
one to an AmazonProduct component. Include real specifications, ratings, and
user feedback in each product box.

Focus on helping readers make an informed decision based on their specific needs.
```

### What Happens Next

The AI will:

1. Search Amazon for products
2. Fetch product data (prices, ratings, reviews, specs)
3. Write an article with structure
4. Create AmazonProduct widgets
5. Generate an SVG featured image
6. Save everything to the correct directories

![Droid CLI Article](../../assets/images/25/10/droid-ai-article.png)

You&apos;ll get an MDX file ready to review.

## Content Quality Differences

Good AI Affiliate Content:
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Provides comparisons and insights&lt;/li&gt;
&lt;li&gt;Uses real product data and reviews&lt;/li&gt;
&lt;li&gt;Helps readers make decisions&lt;/li&gt;
&lt;li&gt;Includes specific use cases&lt;/li&gt;
&lt;li&gt;Structured with clear headings&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

Bad AI Affiliate Content:
- Generic statements
- No real product data
- Keyword stuffing
- No helpful context
- Templated content

The key is using Claude Sonnet 4.5 with real data to create useful content.

## Publishing Your Affiliate Site

Once your content is ready:

1. Review the AI articles
2. Make edits or additions
3. Commit to Git:

```bash
git add .
git commit -m &quot;Add affiliate articles&quot;
git push
```

If you followed the [Astro blog setup guide](https://www.bitdoze.com/build-astro-blog-free/), your site deploys to Cloudflare Pages. Free hosting, SSL, and global CDN included.

&lt;Notice type=&quot;success&quot; title=&quot;Deploy Automatically&quot;&gt;
After connecting to Cloudflare Pages, each Git push triggers deployment. Changes go live in minutes.
&lt;/Notice&gt;

## My Migration Results

Here&apos;s what happened when I migrated SmoothieBlenderGuide.com:

**Before (WordPress):**
- Hosting: $15/month
- Slow load times (3-4 seconds)
- Plugin updates and security concerns
- Limited customization without expensive themes
- Difficult content management

**After (Astro + AI):**
- Hosting: $0/month (Cloudflare Pages)
- Fast load times (under 1 second)
- No maintenance
- Full customization control
- Git-based content workflow

**Content Quality:**
- More product comparisons
- Current product information
- Better structured articles
- Improved SEO
- Consistent formatting

## Tips for Success

### 1. Review AI Output

Review what the AI generates. Claude Sonnet 4.5 is good, but you should:

- Verify product information
- Check affiliate links work
- Ensure recommendations match your expertise
- Add personal insights
- Fix formatting issues

### 2. Keep Product Data Fresh

Amazon product data changes. Update articles every 3-6 months:

```
Please update the Amazon products in [article-name.mdx] with current data
from BrightData MCP. Check for price changes, new reviews, and availability.
```

### 3. Add Personal Touches

Add your unique experience:

- Product testing results
- Use-case recommendations
- Customer questions you&apos;ve seen
- Your photos or videos
- Expert insights

### 4. Scale Strategically

Build systematically:

1. Start with 10-15 articles
2. Monitor traffic
3. Expand on successful topics
4. Create supporting content
5. Build internal links

### 5. Optimize for SEO

Handle strategy while AI handles content:

- Target long-tail keywords
- Build topical authority
- Create comparison articles
- Answer user questions
- Build backlinks

## Cost Breakdown: Running Your Affiliate Site

Monthly costs for a professional AI-powered affiliate site:

- **Hosting**: $0 (Cloudflare Pages)
- **Domain**: $12/year (≈$1/month)
- **Factory.ai Droid CLI**: $0 (free tier sufficient)
- **BrightData MCP**: $0 (5,000 free requests/month)
- **Total**: ~$1/month

Compare that to:
- WordPress hosting: $15-50/month
- Hiring writers: $50-100/article
- SEO tools: $100-300/month
- Theme/plugins: $50-200/year

You&apos;re saving thousands while maintaining higher quality and performance.

## Common Questions

### Is this ethical?

Yes, if done right. You&apos;re using AI as a research and writing assistant, not to spam the internet. The key is:

- Creating genuinely helpful content
- Disclosing affiliate relationships
- Providing accurate information
- Adding your own expertise and insights

### Will Google penalize AI content?

Google doesn&apos;t penalize AI content specifically. They penalize:
- Low-quality content
- Content with no value
- Spam and keyword stuffing
- Misleading information

If your AI-generated content is helpful, accurate, and well-researched, you&apos;re fine.

### Can I scale this?

Absolutely. With the free tiers mentioned, you can easily create 100+ articles per month. The limiting factor is your strategy and quality control, not the tools.

### What about Amazon Associates rules?

Make sure to:
- Properly disclose affiliate relationships
- Keep product information accurate
- Follow Amazon&apos;s linking policies
- Don&apos;t make false claims
- Include required disclaimers

## Next Steps

Ready to build your AI-powered affiliate site?

1. **Set up your Astro blog** using the [free blog guide](https://www.bitdoze.com/build-astro-blog-free/)
2. **Sign up for Factory.ai Droid CLI** at [go.bitdoze.com/droid-cli](https://go.bitdoze.com/droid-cli)
3. **Get BrightData account** at [go.bitdoze.com/brightdata](https://go.bitdoze.com/brightdata)
4. **Configure your MCP** and create AGENTS.md
5. **Start creating content** with the prompts above

The barrier to entry for affiliate marketing has never been lower. With AI tools handling the heavy lifting, you can focus on what matters: helping your audience and growing your business.

## Conclusion

Building affiliate websites doesn&apos;t have to be expensive or time-consuming. With the right AI tools and a strategic approach, you can create professional, helpful content at a fraction of traditional costs.

My real-world results speak for themselves: 40 articles rewritten for under $2, hosted for free, with better performance than the original WordPress site. The tools are here, they&apos;re accessible, and they&apos;re incredibly powerful.

The question isn&apos;t whether you can build an AI-powered affiliate site. The question is: what are you waiting for?

---

**Additional Resources:**

- [Build Astro Blog Free Guide](https://www.bitdoze.com/build-astro-blog-free/)
- [Bitdoze Astro Theme GitHub](https://github.com/bitdoze/bitdoze-astro-theme)
- [Factory.ai Droid CLI](https://go.bitdoze.com/droid-cli)
- [BrightData MCP](https://go.bitdoze.com/brightdata)
- [SmoothieBlenderGuide.com](https://www.smoothieblenderguide.com/) (live example)</content:encoded><category>web-development</category><category>astro</category><category>ai</category></item><item><title>BrightData MCP: Complete Guide to AI-Powered Web Scraping (5000 Free Requests)</title><link>https://www.bitdoze.com/brightdata-mcp-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/brightdata-mcp-guide/</guid><description>Learn how to use BrightData MCP with AI tools like Claude, Cursor, and Droid CLI for powerful web scraping. Get 5000 free requests monthly to extract Amazon products, LinkedIn profiles, social media data, and more.</description><pubDate>Mon, 06 Oct 2025 00:00:00 GMT</pubDate><content:encoded>What if your AI assistant could access real-time data from Amazon, LinkedIn, Instagram, or any website without getting blocked? That&apos;s exactly what BrightData MCP does, and the best part is you get **5,000 free requests every month** and **10 USD for PRO Mode**



BrightData MCP (Model Context Protocol) is a game-changing tool that connects AI agents like Claude, Cursor, and Factory.ai Droid CLI to the web. It handles bot detection, bypasses geo-restrictions, and provides structured data from major platforms automatically.

In this comprehensive guide, you&apos;ll learn how to set up and use BrightData MCP with different AI tools, understand the difference between Rapid (free) and Pro modes, and discover practical use cases that can transform how you work with web data.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/Y2EZzZKxZdQ&quot;
  label=&quot;Droid CLI + BrightData MCP = Real Web Access for AI (Free Setup)&quot;
/&gt;

## What is BrightData MCP?

BrightData MCP is a Model Context Protocol server that gives AI agents and LLMs direct access to web data. Think of it as a supercharged web scraper that your AI can control directly through natural language.

**Key capabilities:**
&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Search engines (Google, Bing, Yandex) results extraction&lt;/li&gt;
&lt;li&gt;Structured data from 40+ platforms (Amazon, LinkedIn, Instagram, Facebook, TikTok, etc.)&lt;/li&gt;
&lt;li&gt;Browser automation for complex interactions&lt;/li&gt;
&lt;li&gt;Bypass bot detection and CAPTCHA automatically&lt;/li&gt;
&lt;li&gt;Access geo-restricted content from any location&lt;/li&gt;
&lt;li&gt;Convert any webpage to clean Markdown or HTML&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

**The game-changer:** Unlike traditional web scraping that requires coding, infrastructure, and constant maintenance, BrightData MCP lets you simply ask your AI to fetch data, and it handles everything.

## Rapid (Free) vs Pro Mode: What&apos;s the Difference?

BrightData MCP offers two tiers to match different needs:

### Rapid Mode (Free)

Perfect for everyday browsing and content research:
- **5,000 free requests per month**
- Search engine scraping (Google, Bing, Yandex)
- Webpage scraping as Markdown
- No credit card required

- Ideal for content research, competitor analysis, and general data gathering

### Pro Mode

Advanced capabilities for serious data extraction:
- Structured data from 40+ platforms
- **10 USD for PRO Mode**
- Browser automation (click, type, screenshot, navigate)
- Amazon products, reviews, and search
- LinkedIn profiles, companies, jobs, posts
- Social media data (Instagram, Facebook, TikTok, X/Twitter)
- E-commerce platforms (Walmart, eBay, Etsy, Best Buy, Home Depot)
- Real estate (Zillow), travel (Booking.com), news (Reuters)
- Requires paid plan after free tier

&lt;Notice type=&quot;info&quot; title=&quot;Free Tier Recommendation&quot;&gt;
Start with Rapid mode to test BrightData MCP. For most content research, affiliate sites, and data gathering tasks, the free 5,000 requests/month is plenty.
&lt;/Notice&gt;

## What Can You Build with BrightData MCP?

The possibilities are extensive. Here are real-world applications:

### Affiliate Marketing
- Extract Amazon product details, reviews, and pricing
- Compare products across multiple e-commerce platforms
- Automate product research for roundup articles
- Monitor price changes and availability
- Build product comparison databases

**See it in action:** Check out [how I built an AI affiliate website with Amazon products](https://www.bitdoze.com/ai-affiliate-websites-amazon/) using BrightData MCP.

### Competitive Research
- Monitor competitor pricing and product offerings
- Track social media engagement and content strategies
- Analyze LinkedIn company profiles and employee counts
- Extract competitor blog content and SEO strategies
- Monitor Google rankings and SERP features

### Lead Generation
- Extract LinkedIn profiles by industry or role
- Find company information from Crunchbase or ZoomInfo
- Gather contact information from business directories
- Monitor job listings on LinkedIn or Indeed
- Build targeted prospect lists automatically

### Content Research
- Gather data for data-driven articles
- Extract quotes and statistics from authoritative sources
- Monitor trending topics across platforms
- Research product specifications for reviews
- Collect user-generated content (reviews, testimonials)

### Market Intelligence
- Monitor real estate listings (Zillow)
- Track hotel pricing and availability (Booking.com)
- Analyze app store reviews (Google Play, Apple App Store)
- Follow financial news (Reuters, Yahoo Finance)
- Track social media trends and sentiment

## Available BrightData MCP Tools

BrightData MCP provides 60+ specialized tools organized by category:

### Search &amp; Basic Scraping (Rapid - Free)
- `search_engine` - Scrape Google, Bing, or Yandex results
- `scrape_as_markdown` - Convert any webpage to clean Markdown
- `scrape_batch` - Scrape multiple URLs simultaneously
- `session_stats` - Monitor your usage and requests

### Amazon Data Extraction (Pro)
- `web_data_amazon_product` - Product details, specs, pricing
- `web_data_amazon_product_reviews` - Customer reviews and ratings
- `web_data_amazon_product_search` - Search results with products

### LinkedIn Intelligence (Pro)
- `web_data_linkedin_person_profile` - Individual profiles
- `web_data_linkedin_company_profile` - Company information
- `web_data_linkedin_job_listings` - Job postings
- `web_data_linkedin_posts` - Content and engagement data
- `web_data_linkedin_people_search` - Find people by criteria

### Social Media Data (Pro)
- Instagram: profiles, posts, reels, comments
- Facebook: posts, marketplace listings, reviews, events
- TikTok: profiles, posts, shop data, comments
- X/Twitter: post data and engagement
- YouTube: videos, profiles, comments

### E-commerce Platforms (Pro)
- Walmart products and sellers
- eBay product listings
- Home Depot products
- Zara products
- Etsy products
- Best Buy products

### Browser Automation (Pro)
- `scraping_browser_navigate` - Go to any URL
- `scraping_browser_click` - Interact with elements
- `scraping_browser_type` - Fill forms
- `scraping_browser_screenshot` - Capture page visuals
- `scraping_browser_get_html` - Extract page HTML
- `scraping_browser_wait_for` - Wait for dynamic content

### Additional Data Sources (Pro)
- Crunchbase company data
- ZoomInfo business intelligence
- Google Maps reviews
- Google Shopping product data
- App Store data (Google Play, Apple App Store)
- Real estate (Zillow)
- Travel (Booking.com)
- News (Reuters)
- GitHub repositories
- Yahoo Finance
- Reddit posts

## Setting Up BrightData MCP: Step-by-Step

### Prerequisites

Before you begin:

1. **Create a BrightData account** at [go.bitdoze.com/brightdata](https://go.bitdoze.com/brightdata)
2. **Get your API token** from the [user settings page](https://brightdata.com/cp/setting/users)
3. **Install Node.js** (required for most setups) from [nodejs.org](https://nodejs.org)

&lt;Notice type=&quot;success&quot; title=&quot;Free Credit&quot;&gt;
New BrightData users receive free credit for testing. The free tier includes 5,000 requests/month with no credit card required.
&lt;/Notice&gt;

### Option 1: Factory.ai Droid CLI (Recommended)

Factory.ai Droid CLI is the best way to use BrightData MCP. You get 20-40 million free AI tokens monthly plus access to Claude Sonnet 4.5 and GPT-5.

**Step 1: Sign up for Droid CLI**

&lt;Button text=&quot;Get Droid CLI Free&quot; link=&quot;https://go.bitdoze.com/droid-cli&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

**Step 2: Configure BrightData MCP**

Create or edit `~/.factory/mcp.json`:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_api_token_here&quot;,
        &quot;PRO_MODE&quot;: &quot;true&quot;
      }
    }
  }
}
```

Replace `your_brightdata_api_token_here` with your actual API token from BrightData.

**Step 3: Start using it**

Open terminal and run:
```bash
droid
```

Then simply ask your AI:
```
Search Amazon for &quot;wireless headphones&quot; and show me the top 5 products
with prices, ratings, and reviews.
```

The AI will use BrightData MCP to fetch real product data and present it to you.

### Option 2: Claude Code

Claude Code from Anthropic provides a streamlined MCP integration.

**Quick Install:**

```bash
claude mcp add --transport sse brightdata &quot;https://mcp.brightdata.com/sse?token=YOUR_API_TOKEN&quot;
```

Replace `YOUR_API_TOKEN` with your BrightData API token.

**Verify installation:**

```bash
claude mcp list
```

You should see:
```
brightdata: https://mcp.brightdata.com/sse?token=xxxxx (SSE) - ✓ Connected
```

**Using Pro mode with Claude Code:**

Add `&amp;pro=1` to enable Pro features:

```bash
claude mcp add --transport sse brightdata &quot;https://mcp.brightdata.com/sse?token=YOUR_API_TOKEN&amp;pro=1&quot;
```

### Option 3: Cursor IDE

Cursor provides a visual interface for MCP configuration.

**Step 1: Open MCP Settings**

In Cursor, go to:
- Click the gear icon
- Tools &amp; Integrations
- Add Custom MCP

**Step 2: Add Configuration**

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_api_token_here&quot;,
        &quot;PRO_MODE&quot;: &quot;true&quot;
      }
    }
  }
}
```

**Step 3: Save and Restart**

Save the configuration and restart Cursor. BrightData MCP will now be available to the AI.

### Option 4: Windsurf or Other MCP-Compatible Tools

Most MCP-compatible tools use similar configuration. Add to your MCP config file:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_brightdata_api_token_here&quot;,
        &quot;PRO_MODE&quot;: &quot;true&quot;
      }
    }
  }
}
```

## Practical Examples: Real-World Usage

### Example 1: Building Product Comparison Articles

**Goal:** Create a roundup article about wireless headphones with real Amazon data.

**Prompt for your AI:**
```
Search Amazon for &quot;wireless noise cancelling headphones&quot; and extract
the top 5 products. For each product, I need:
- Product name and model
- Current price
- Average rating and review count
- Key features
- Pros and cons from reviews

Format this as a comparison table.
```

The AI will use `web_data_amazon_product_search` to find products, then `web_data_amazon_product` and `web_data_amazon_product_reviews` to gather details.

**Result:** Complete product data ready for your affiliate article, fetched in seconds.

### Example 2: Competitor Research

**Goal:** Analyze a competitor&apos;s LinkedIn presence.

**Prompt:**
```
Extract information from this LinkedIn company profile:
https://www.linkedin.com/company/example-company

I need:
- Employee count
- Recent posts and engagement
- Company description
- Specialties
- Posted jobs
```

The AI uses `web_data_linkedin_company_profile`, `web_data_linkedin_posts`, and `web_data_linkedin_job_listings`.

**Result:** Complete competitive intelligence gathered automatically.

### Example 3: Social Media Content Research

**Goal:** Understand what content performs well in your niche.

**Prompt:**
```
Analyze these Instagram posts for engagement patterns:
[URLs to competitor Instagram posts]

For each post, extract:
- Like count
- Comment count
- Caption text
- Posting time
- Hashtags used
```

Uses `web_data_instagram_posts` to gather data across multiple posts.

**Result:** Data-driven insights into what content resonates with your audience.

### Example 4: Real Estate Market Analysis

**Goal:** Track properties in a specific area.

**Prompt:**
```
Extract listings from this Zillow search:
[Zillow search URL for your target area]

Show me:
- Property addresses
- Prices
- Square footage
- Bedrooms/bathrooms
- Days on market
```

Uses `web_data_zillow_properties_listing` for structured real estate data.

**Result:** Real estate market data for analysis or lead generation.

### Example 5: Automated Price Monitoring

**Goal:** Monitor competitor pricing across platforms.

**Prompt:**
```
Compare pricing for [Product Name] across:
- Amazon
- Walmart
- eBay
- Best Buy

For each, show current price, availability, and shipping info.
```

Uses multiple tools (`web_data_amazon_product`, `web_data_walmart_product`, `web_data_ebay_product`, `web_data_bestbuy_products`) to gather pricing.

**Result:** Real-time price comparison data for strategic decisions.

## Advanced Configuration Options

### Remote MCP Server Configuration

When using remote MCP endpoints (like with Claude Code), you can customize behavior with query parameters:

**Custom zone names:**
```
?unlocker=my_zone_name
```
Use a specific BrightData zone for web scraping.

**Custom browser zone:**
```
?browser=my_browser_zone
```
Use a specific zone for browser automation.

**Enable Pro mode:**
```
?pro=1
```
Activate advanced features.

**Example full URL:**
```
https://mcp.brightdata.com/sse?token=YOUR_TOKEN&amp;unlocker=my_zone&amp;pro=1
```

### Local MCP Environment Variables

For local installations (Droid CLI, Cursor, Windsurf), customize with environment variables:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;npx&quot;,
      &quot;args&quot;: [&quot;-y&quot;, &quot;@brightdata/mcp&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_token&quot;,
        &quot;PRO_MODE&quot;: &quot;true&quot;,
        &quot;WEB_UNLOCKER_ZONE&quot;: &quot;my_scraping_zone&quot;,
        &quot;BROWSER_ZONE&quot;: &quot;my_browser_zone&quot;
      }
    }
  }
}
```

**Available environment variables:**
- `API_TOKEN` - Your BrightData API token (required)
- `PRO_MODE` - Enable Pro features (&quot;true&quot; or &quot;false&quot;)
- `WEB_UNLOCKER_ZONE` - Custom zone for web scraping
- `BROWSER_ZONE` - Custom zone for browser automation

## Use Cases by Industry

### E-commerce &amp; Retail
- Product research and comparison
- Price monitoring and alerts
- Inventory tracking
- Review analysis and sentiment monitoring
- Competitor product catalog analysis

### Marketing &amp; SEO
- SERP tracking and analysis
- Competitor content audits
- Backlink research
- Social media monitoring
- Content gap analysis

### Real Estate
- Property listing aggregation
- Market trend analysis
- Comparative market analysis (CMA)
- Lead generation from listings
- Property valuation data

### Recruitment &amp; HR
- Candidate sourcing from LinkedIn
- Competitor hiring trend analysis
- Job market intelligence
- Salary benchmarking
- Company culture research

### Financial Services
- Market research and analysis
- Competitor product monitoring
- News monitoring and alerts
- Company research and due diligence
- Consumer sentiment analysis

### Travel &amp; Hospitality
- Hotel pricing and availability
- Review monitoring across platforms
- Competitor rate analysis
- Destination research and trending locations
- Event and festival monitoring

## Cost Breakdown: What You&apos;ll Actually Pay

Let&apos;s look at realistic costs for different usage scenarios:

### Scenario 1: Content Creator / Affiliate Marketer

**Monthly usage:**
- 50 Amazon product searches
- 200 individual product data fetches
- 100 review extractions
- 150 general webpage scrapes

**Total requests:** ~500/month
**Cost:** $0 (within free tier)

### Scenario 2: Market Research Professional

**Monthly usage:**
- 500 LinkedIn profile extractions
- 300 company profile fetches
- 200 social media data pulls
- 1,000 web page scrapes

**Total requests:** ~2,000/month
**Cost:** $0 (within free tier)

### Scenario 3: E-commerce Business

**Monthly usage:**
- 2,000 product data fetches across platforms
- 1,000 review extractions
- 500 price monitoring checks
- 1,500 competitor analysis scrapes

**Total requests:** ~5,000/month
**Cost:** $0 (exactly at free tier limit)

### Scenario 4: Data Intelligence Agency

**Monthly usage:**
- 10,000+ requests across various data sources
- Browser automation for complex sites
- Large-scale competitive intelligence
- Real-time data feeds

**Cost:** Variable, typically $100-500/month depending on volume

&lt;Notice type=&quot;success&quot; title=&quot;Cost Comparison&quot;&gt;
Traditional web scraping infrastructure (proxies, servers, maintenance) typically costs $200-1,000/month. BrightData MCP&apos;s free tier eliminates this entirely for most users.
&lt;/Notice&gt;

## Best Practices for BrightData MCP

### 1. Cache Results When Possible

Don&apos;t re-fetch data you already have. Store results locally:

```
Extract this Amazon product data and save it to a JSON file.
Only re-fetch if the file is older than 24 hours.
```

### 2. Batch Related Requests

Instead of multiple prompts, combine requests:

```
For these 10 Amazon product URLs:
[list of URLs]

Extract all product data in one batch and create a comparison table.
```

### 3. Use Specific Tools for Better Results

Rather than generic scraping, use platform-specific tools:

**Less efficient:**
```
Scrape data from this Amazon product page
```

**More efficient:**
```
Use web_data_amazon_product to extract structured data from this ASIN: B08XYZ123
```

### 4. Leverage Structured Data

When available, BrightData&apos;s structured data tools are faster and more reliable than scraping HTML:

- Amazon products → `web_data_amazon_product`
- LinkedIn profiles → `web_data_linkedin_person_profile`
- Instagram posts → `web_data_instagram_posts`

### 5. Monitor Your Usage

Regularly check your request count:

```
Show me my BrightData MCP session stats
```

This helps you stay within free tier limits or optimize paid usage.

### 6. Set Reasonable Timeouts

Some data sources are slower than others. Set appropriate timeouts (180 seconds recommended) to avoid premature request cancellations.

### 7. Validate and Filter Data

Always treat scraped data as untrusted:

```
Extract product data from [URL] and validate that:
- Price is a number
- Rating is between 0-5
- Reviews count is reasonable
- Product name is not empty
```

## Troubleshooting Common Issues

### Issue: &quot;spawn npx ENOENT&quot; Error

**Problem:** System can&apos;t find the `npx` command.

**Solution:** Use full Node.js path in your configuration:

```json
{
  &quot;mcpServers&quot;: {
    &quot;brightdata-mcp&quot;: {
      &quot;command&quot;: &quot;/usr/local/bin/node&quot;,
      &quot;args&quot;: [&quot;node_modules/@brightdata/mcp/index.js&quot;],
      &quot;env&quot;: {
        &quot;API_TOKEN&quot;: &quot;your_token&quot;
      }
    }
  }
}
```

Find your Node path with:
- **macOS/Linux:** `which node`
- **Windows:** `where node`

### Issue: Timeout Errors

**Problem:** Requests timing out before completing.

**Solution:** Increase timeout in your AI tool settings (recommended: 180 seconds minimum).

### Issue: Rate Limiting

**Problem:** Hitting rate limits too quickly.

**Solution:**
1. Implement request delays between batches
2. Cache results to avoid re-fetching
3. Upgrade to paid tier if needed

### Issue: Incomplete Data

**Problem:** Some fields missing from extracted data.

**Solution:**
- Use platform-specific tools instead of generic scraping
- Check if the webpage structure changed
- Verify the URL is accessible
- Try re-fetching after a short delay

### Issue: Authentication Required

**Problem:** Some pages require login to access.

**Solution:**
- For public data, ensure you&apos;re using the correct URL
- For private data, BrightData MCP cannot access password-protected content
- Consider using browser automation with authenticated sessions (advanced)

## Frequently Asked Questions

&lt;Accordion label=&quot;Is BrightData MCP legal to use?&quot; group=&quot;faq&quot;&gt;
Yes, when used responsibly. BrightData MCP scrapes public data only. Always:
- Respect robots.txt directives
- Follow website terms of service
- Don&apos;t scrape private or password-protected content
- Rate-limit your requests appropriately
- Use data ethically and legally
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Will I be charged after the free tier?&quot; group=&quot;faq&quot;&gt;
No. BrightData operates on pay-as-you-go. You&apos;re only charged if you explicitly upgrade and use more than the free 5,000 requests/month. No surprise charges.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I use BrightData MCP for commercial projects?&quot; group=&quot;faq&quot;&gt;
Yes. The free tier can be used for commercial projects. For larger-scale commercial usage, paid plans offer higher limits and additional features.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How do I target specific countries?&quot; group=&quot;faq&quot;&gt;
Create a zone in your BrightData Control Panel with specific country targeting, then reference it in your MCP configuration using `WEB_UNLOCKER_ZONE` or `BROWSER_ZONE` environment variables.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Does BrightData MCP work with GPT or other LLMs?&quot; group=&quot;faq&quot;&gt;
Yes. Any tool that supports Model Context Protocol (MCP) can use BrightData MCP. This includes:
- Claude Desktop and Claude Code
- Cursor IDE
- Windsurf
- Factory.ai Droid CLI
- Custom applications built on MCP
&lt;/Accordion&gt;

&lt;Accordion label=&quot;What&apos;s the difference between BrightData MCP and traditional scraping?&quot; group=&quot;faq&quot;&gt;
Traditional scraping requires:
- Writing and maintaining scraping code
- Managing proxies and infrastructure
- Handling bot detection manually
- Dealing with CAPTCHA
- Constant updates when sites change

BrightData MCP handles all of this automatically through simple natural language requests to your AI.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Can I scrape data from my competitors?&quot; group=&quot;faq&quot;&gt;
Yes, if it&apos;s publicly available data. Scraping public product pages, pricing, and reviews is generally acceptable. However, don&apos;t:
- Scrape private/authenticated areas
- Violate terms of service
- Use data to harm or defame
- Ignore rate limits or robots.txt
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How accurate is the structured data?&quot; group=&quot;faq&quot;&gt;
Very accurate. BrightData maintains structured data collectors for 40+ platforms and updates them regularly. The data often comes from cached, verified sources, making it more reliable than raw scraping.
&lt;/Accordion&gt;

## Real-World Success Story

I recently used BrightData MCP to migrate and rebuild my affiliate site [SmoothieBlenderGuide.com](https://www.smoothieblenderguide.com/). Here&apos;s what happened:

**The Challenge:**
- 40 articles needed migration from WordPress to Astro
- All product data needed updating with current Amazon information
- Articles required restructuring for better SEO
- Time was limited

**The Solution:**
Used Factory.ai Droid CLI with BrightData MCP to:
1. Fetch current Amazon product data for all recommended blenders
2. Extract customer reviews for pros/cons
3. Rewrite articles with updated information
4. Create product comparison tables
5. Generate optimized featured images

**The Results:**
- **40 articles completed** in about 3 hours of actual work
- **Total cost:** Under $2 (mostly AI tokens, barely touched BrightData free tier)
- **Hosting:** Eliminated $15/month WordPress hosting, now free on Cloudflare Pages
- **Performance:** Page load times dropped from 3-4 seconds to under 1 second
- **Quality:** More comprehensive, data-driven articles with accurate information

Read the full case study: [How to Build AI-Powered Affiliate Websites with Amazon Products](https://www.bitdoze.com/ai-affiliate-websites-amazon/)

## Getting Started Checklist

Ready to use BrightData MCP? Here&apos;s your action plan:

&lt;ListCheck&gt;
&lt;ul&gt;
&lt;li&gt;Create BrightData account at &lt;a href=&quot;https://go.bitdoze.com/brightdata&quot;&gt;go.bitdoze.com/brightdata&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Get your API token from user settings&lt;/li&gt;
&lt;li&gt;Choose your AI tool (Droid CLI recommended)&lt;/li&gt;
&lt;li&gt;Install Node.js if needed&lt;/li&gt;
&lt;li&gt;Configure BrightData MCP with your API token&lt;/li&gt;
&lt;li&gt;Test with a simple request (search or scrape)&lt;/li&gt;
&lt;li&gt;Explore Pro features with structured data tools&lt;/li&gt;
&lt;li&gt;Build your first automated workflow&lt;/li&gt;
&lt;/ul&gt;
&lt;/ListCheck&gt;

## Conclusion

BrightData MCP transforms how AI agents interact with the web. Whether you&apos;re building affiliate sites, conducting market research, generating leads, or analyzing competitors, having your AI directly access web data changes everything.

**Key takeaways:**

1. **Free tier is generous:** 5,000 requests/month covers most individual and small business needs
2. **Setup is simple:** 5 minutes to configure with any MCP-compatible tool
3. **Real data, real time:** Access structured data from 40+ major platforms
4. **No infrastructure:** No proxies, no servers, no maintenance headaches
5. **Natural language:** Just ask your AI what you need, it handles the rest

The barrier to entry for web data extraction has never been lower. With BrightData MCP and modern AI tools, you can build sophisticated data-driven applications without writing a single line of scraping code.

**Start building today:**

&lt;Button text=&quot;Get BrightData Free Account&quot; link=&quot;https://go.bitdoze.com/brightdata&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

&lt;Button text=&quot;Get Factory.ai Droid CLI&quot; link=&quot;https://go.bitdoze.com/droid-cli&quot; variant=&quot;solid&quot; color=&quot;blue&quot; size=&quot;md&quot; /&gt;

---

**Related Resources:**

- [Build AI-Powered Affiliate Websites with Amazon Products](https://www.bitdoze.com/ai-affiliate-websites-amazon/)
- [Build Free Astro Blog Guide](https://www.bitdoze.com/build-astro-blog-free/)
- [BrightData MCP GitHub Repository](https://github.com/brightdata/brightdata-mcp)
- [BrightData MCP Documentation](https://docs.brightdata.com/mcp-server/overview)
- [Smithery MCP Playground](https://smithery.ai/server/@luminati-io/brightdata-mcp/tools)</content:encoded><category>ai</category><category>mcp</category></item><item><title>Best 32-Inch OLED Monitors 2026: Buying Guide &amp; Comparison</title><link>https://www.bitdoze.com/best-32-inch-oled-monitors-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/best-32-inch-oled-monitors-guide/</guid><description>Guide to 32-inch OLED monitors, comparing budget and premium options with specs, coatings, and connectivity details.</description><pubDate>Mon, 29 Sep 2025 00:00:00 GMT</pubDate><content:encoded>I reviewed the [ASUS ROG Strix OLED XG32UCWG](https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/) thoroughly, and here&apos;s what I learned about choosing a 32-inch OLED monitor. Whether you want an affordable OLED display or something premium for professional work, this guide covers the options worth considering.

The 32-inch OLED market has seen major growth in 2025-2026. There are solid options whether you&apos;re budget-conscious or looking for high-end features.

## Quick Comparison

| **Category** | **Best Budget** | **Best Premium** | **Best Productivity** |
|--------------|----------------|------------------|---------------------|
| **Monitor** | MSI MAG 321CUP QD-OLED | ASUS PG32UCDMR QD-OLED | ASUS XG32UCWG WOLED |
| **Price Range** | ~$800-900 | ~$1,200-1,400 | ~$1,000-1,100 |
| **Best For** | Gaming on budget | Premium gaming | Work &amp; gaming |

## OLED Technology Basics

Before looking at specific models, you should know the two main OLED types:

### WOLED (White OLED)
- Uses white organic compounds with color filters
- **Works better for**: Text clarity, productivity work, longevity
- **Characteristics**: Excellent black levels, good text rendering, lower peak brightness
- **Examples**: ASUS XG32UCWG, ASUS PG32UCDP, ASRock PGO32UFS

### QD-OLED (Quantum Dot OLED)
- Uses quantum dots for color production
- **Works better for**: Gaming, HDR content, color accuracy
- **Characteristics**: Higher peak brightness, more vivid colors, costs more
- **Examples**: MSI MAG 321CUP, GIGABYTE MO32U, ASUS PG32UCDMR, Samsung LS32FG810SNXZA

---

## Budget OLED Monitors

### 1. MSI MAG 321CUP QD-OLED

![MSI MAG 321CUP QD-OLED](https://m.media-amazon.com/images/I/81RQyMSGDnL._AC_SL1500_.jpg)

The MSI MAG 321CUP is an affordable entry into QD-OLED technology. You get vibrant colors and deep blacks without paying premium prices.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ QD-OLED (Quantum Dot) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Semi-Glossy |
| **USB Type-C** | ❌ No |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 2x DP 1.4 |
| **VESA Mount** | 100x100mm |

#### Key Highlights
- **QD-OLED technology** delivers incredibly vibrant colors
- **165Hz refresh rate** perfect for competitive gaming
- **Excellent color accuracy** with 99% DCI-P3 coverage
- **ClearMR 9000** certification for motion clarity
- **OLED Care 2.0** reduces burn-in risks

&lt;Notice type=&quot;info&quot; title=&quot;Best Value QD-OLED&quot;&gt;
The MSI MAG 321CUP has QD-OLED technology at a lower price than most, which makes it a good entry point for OLED gaming without going over budget.
&lt;/Notice&gt;

&gt; **Best For**: Gamers who want QD-OLED colors without spending too much, users who don&apos;t need USB-C connectivity

&lt;Button text=&quot;Check MSI MAG 321CUP Price&quot; link=&quot;https://amzn.to/4gNJGyj&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 2. GIGABYTE MO32U QD-OLED

![GIGABYTE MO32U QD-OLED](https://m.media-amazon.com/images/I/71WCecwakbL._AC_SL1500_.jpg)

GIGABYTE&apos;s MO32U brings QD-OLED to the mainstream market. It has decent build quality and useful features.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ QD-OLED (Quantum Dot) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Semi-Glossy |
| **USB Type-C** | ❌ No |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4 |
| **VESA Mount** | 100x100mm |

#### Key Highlights
- **QD-OLED panel** with good image quality
- **Tactical features** for gaming
- **GIGABYTE OLED Care** with AI-based protection
- **KVM functionality** built-in
- **3-year warranty** including burn-in coverage

&gt; **Best For**: Users who want QD-OLED quality with gaming features and OLED protection

&lt;Button text=&quot;Check GIGABYTE MO32U Price&quot; link=&quot;https://amzn.to/4gPmrUH&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

### 3. INNOCN 32&quot; 4K OLED Monitor

![INNOCN 32&quot; 4K OLED Monitor](https://m.media-amazon.com/images/I/81KgSMvm97L._AC_SL1500_.jpg)


The INNOCN is an affordable entry into OLED technology.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ WOLED (White OLED) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Glossy |
| **USB Type-C** | ❌ No |
| **HDR** | HDR400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4 |
| **VESA Mount** | 100x100mm |

#### Key Highlights
- **Cheapest 32&quot; OLED option**
- **WOLED technology** with decent text clarity
- **165Hz refresh rate** works for most gaming
- **Good value** for the price
- **Decent build quality**

&gt; **Best For**: Budget buyers who want OLED benefits at the lowest price

&lt;Button text=&quot;Check INNOCN Monitor Price&quot; link=&quot;https://amzn.to/46N7RbP&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

### 4. ASRock PGO32UFS WOLED

ASRock&apos;s gaming monitor has dual-mode functionality and a built-in Wi-Fi antenna. These are unusual features at this price.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ WOLED (White OLED) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 240Hz (4K) / 480Hz (FHD) |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Anti-Glare |
| **USB Type-C** | ✅ Yes (65W PD) |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 2x DP 1.4, USB-C |
| **Special Features** | Integrated Wi-Fi Antenna, KVM |

#### Key Highlights
- **Dual-mode display**: 4K@240Hz or FHD@480Hz
- **65W USB-C PD** charges most laptops
- **Integrated Wi-Fi antenna** (compatible with Wi-Fi 4/5/6/6E/7)
- **KVM switch** functionality
- **Anti-glare coating** for bright environments

&lt;Notice type=&quot;warning&quot; title=&quot;Availability&quot;&gt;
This model may not be sold worldwide. Please contact your local dealer for availability in your region.
&lt;/Notice&gt;

&gt; **Best For**: Users who want dual-mode flexibility, USB-C charging, and the Wi-Fi antenna feature

&lt;Button text=&quot;Visit ASRock PGO32UFS&quot; link=&quot;https://pg.asrock.com/Monitors/PGO32UFS/index.asp&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

## Premium OLED Monitors

### 1. ASUS ROG Strix OLED XG32UCWG (WOLED TrueBlack Glossy) ⭐ Top Pick

![ASUS ROG Strix OLED XG32UCWG](https://m.media-amazon.com/images/I/91-Yblt4GQL._AC_SL1500_.jpg)



**My Choice** - This is the monitor I reviewed extensively and use daily for productivity and gaming.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ WOLED (White OLED) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz (4K) / 330Hz (FHD) |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | ✨ **TrueBlack Glossy™** |
| **USB Type-C** | ⚠️ Yes (15W PD only) |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3, Delta E &lt; 2 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4, USB-C |
| **Stand** | Compact (45% smaller) |

#### Key Highlights
- **TrueBlack Glossy coating** - 38% less reflections than standard glossy
- **Exceptional text clarity** with Clear Pixel Edge algorithm
- **Dual-mode**: 4K@165Hz or FHD@330Hz via hotkey
- **OLED Care Pro** with Neo Proximity Sensor
- **Compact stand** saves 45% desk space
- **Factory calibrated** with Delta E &lt; 2

#### Why I Chose This Monitor
I tested this monitor for productivity work with my MacBook Pro M1, and it performed well. The TrueBlack Glossy coating shows sharp text without distracting reflections, even with a window in front of my desk. The 15W USB-C won&apos;t charge laptops, but the display quality and productivity features make it my top pick.

**Pros:**
- ✅ Best text clarity among all options
- ✅ Minimal reflections despite glossy coating
- ✅ Perfect for productivity and coding
- ✅ Excellent macOS compatibility
- ✅ Dual-mode flexibility

**Cons:**
- ❌ Only 15W USB-C (won&apos;t charge laptops)
- ❌ Lower peak brightness than QD-OLED
- ❌ Premium pricing

&lt;Notice type=&quot;success&quot; title=&quot;My Recommendation&quot;&gt;
After using the XG32UCWG daily for weeks, the TrueBlack Glossy coating and text clarity make it my top choice for mixed use.
&lt;/Notice&gt;

&gt; **Read My Full Review**: [ASUS ROG Strix OLED XG32UCWG Detailed Review](https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/)

&lt;Button text=&quot;Get the XG32UCWG on Amazon&quot; link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

---

### 2. ASUS ROG Swift OLED PG32UCDP (WOLED Matte)

![ASUS ROG Strix OLED PG32UCDP](https://m.media-amazon.com/images/I/91-Yblt4GQL._AC_SL1500_.jpg)


This is the matte version of the XG32UCWG, suitable for bright environments with stronger USB-C.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ WOLED (White OLED) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 240Hz (4K) / 480Hz (FHD) |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | 🎯 **Matte Anti-Glare** |
| **USB Type-C** | ✅ Yes (90W PD) |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3, Delta E &lt; 2 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4, USB-C |
| **Stand** | Ergonomic adjustable |

#### Key Highlights
- **Matte coating** minimizes reflections in bright rooms
- **90W USB-C PD** charges laptops while working
- **Higher refresh rates**: 4K@240Hz or FHD@480Hz
- **OLED Care Pro** with comprehensive protection
- **AI Assistant** with Dynamic features
- **Uniform brightness** setting available

#### Comparison vs XG32UCWG
- ✅ **Better**: 90W USB-C, higher refresh rates, better for bright rooms
- ❌ **Trade-off**: Slightly softer text due to matte coating
- 💰 **Similar pricing** tier

&gt; **Best For**: Users in bright rooms who need laptop charging and prefer matte displays

&lt;Button text=&quot;Get the PG32UCDP on Amazon&quot; link=&quot;https://amzn.to/3Iv6hTM&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

### 3. ASUS ROG Swift OLED PG32UCDMR (QD-OLED Premium)

![ASUS ROG Strix OLED PG32UCDMR](https://m.media-amazon.com/images/I/913Z-4-0WoL._AC_SL1500_.jpg)


This is a premium option with QD-OLED technology for vivid colors and brightness.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | 🌟 **QD-OLED** (Quantum Dot) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 240Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Semi-Glossy |
| **USB Type-C** | ✅ Yes (90W PD) |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3, Delta E &lt; 2 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4, USB-C |
| **Special** | Custom heatsink + graphene |

#### Key Highlights
- **QD-OLED panel** - brightest and most vibrant colors
- **Custom heatsink** with graphene film cooling
- **240Hz at 4K** for premium gaming
- **90W USB-C PD** for laptop charging
- **Premium build quality** throughout
- **Advanced thermal management**

#### QD-OLED Advantages
- 🔆 **Higher peak brightness** than WOLED
- 🎨 **More vibrant colors** in HDR content
- ✨ **Better for bright HDR scenes**
- 💎 **Premium visual experience**

&gt; **Best For**: Users wanting the absolute best image quality and willing to pay premium pricing

&lt;Button text=&quot;Get the PG32UCDMR on Amazon&quot; link=&quot;https://amzn.to/4nno4LC&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

### 4. Samsung Odyssey OLED G8 (LS32FG810SNXZA)

![Samsung Odyssey OLED G8 (LS32FG810SNXZA)](https://m.media-amazon.com/images/I/81kpeJO2dCL._AC_SL1500_.jpg)


Samsung&apos;s QD-OLED gaming monitor with smart TV features.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ QD-OLED (Quantum Dot) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 240Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Semi-Glossy |
| **USB Type-C** | ❌ No |
| **HDR** | HDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4 |
| **Special** | Samsung OLED Safeguard+ |

#### Key Highlights
- **QD-OLED technology** with vibrant colors
- **Samsung OLED Safeguard+** protection
- **240Hz at 4K** for premium gaming
- **AMD FreeSync Premium Pro**
- **Samsung ecosystem integration**

#### Unique Features
- **Samsung ecosystem integration**
- **Advanced thermal system**
- **Premium brand recognition**
- **Ergonomic design**

&gt; **Best For**: Samsung ecosystem users wanting QD-OLED without USB-C requirements

&lt;Button text=&quot;Get Samsung LS32FG810SNXZA&quot; link=&quot;https://amzn.to/42UMxjb&quot; size=&quot;lg&quot; color=&quot;red&quot; variant=&quot;solid&quot; /&gt;

---

### 5. LG UltraGear 32GS95UE

![ LG UltraGear 32GS95UE](https://m.media-amazon.com/images/I/91SjI09BTLL._AC_SL1500_.jpg)


LG&apos;s WOLED with useful features and high refresh rates.

#### Specifications

| Feature | Details |
|---------|---------|
| **Panel Type** | ✅ WOLED (White OLED) |
| **Resolution** | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 240Hz |
| **Response Time** | 0.03ms (GTG) |
| **Coating** | Semi-Glossy |
| **USB Type-C** | ✅ Yes (90W PD) |
| **HDR** | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4, USB-C |
| **Special** | Advanced cooling system |

#### Key Highlights
- **240Hz at 4K** for premium gaming
- **90W USB-C PD** for laptop charging
- **AMD FreeSync Premium Pro**
- **G-SYNC compatible**
- **Comprehensive OLED protection**

#### Unique Features
- **LG&apos;s established OLED expertise**
- **Excellent build quality**
- **Strong warranty support**
- **Good price-to-performance ratio**

&gt; **Best For**: Users who want a reliable brand with premium WOLED features

&lt;Button text=&quot;Get LG 32GS95UE&quot; link=&quot;https://amzn.to/4nTrcPe&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

---

## Complete Monitor Specifications Comparison

### **Budget-Friendly Options ($700-$900)**

| **Specification** | [**MSI MAG 321CUP QD-OLED**](#1-msi-mag-321cup-qd-oled) | [**GIGABYTE MO32U QD-OLED**](#2-gigabyte-mo32u-qd-oled) | [**INNOCN 32&quot; 4K OLED**](#3-innocn-32-4k-oled-monitor) | [**ASRock PGO32UFS WOLED**](#4-asrock-pgo32ufs-woled) |
|-------------------|----------------------------|----------------------------|------------------|-------------------|
| **Panel Technology** | ✅ QD-OLED | ✅ QD-OLED | ✅ WOLED | ✅ WOLED |
| **Resolution** | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz | 165Hz | 165Hz | 240Hz (4K) / 480Hz (FHD) |
| **Response Time** | 0.03ms (GTG) | 0.03ms (GTG) | 0.03ms (GTG) | 0.03ms (GTG) |
| **Display Coating** | Semi-Glossy | Semi-Glossy | Glossy | Anti-Glare |
| **USB-C Port** | ❌ No | ❌ No | ❌ No | ✅ Yes |
| **USB-C Power Delivery** | ❌ No | ❌ No | ❌ No | ✅ 65W PD |
| **Dual Mode** | ❌ Single Mode | ❌ Single Mode | ❌ Single Mode | ✅ 4K@240Hz / FHD@480Hz |
| **HDR Certification** | DisplayHDR True Black 400 | DisplayHDR True Black 400 | HDR400 | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 | 99% DCI-P3 | 99% DCI-P3 | 99% DCI-P3 |
| **Color Accuracy** | Delta E &lt; 2 | Delta E &lt; 2 | Delta E &lt; 3 | Delta E &lt; 2 |
| **VESA Mount** | 100x100mm | 100x100mm | 100x100mm | 100x100mm |
| **Connectivity** | 2x HDMI 2.1, 2x DP 1.4 | 2x HDMI 2.1, 1x DP 1.4 | 2x HDMI 2.1, 1x DP 1.4 | 2x HDMI 2.1, 2x DP 1.4, USB-C |
| **Special Features** | OLED Care 2.0, ClearMR 9000 | KVM, OLED Care, AI Protection | Budget-friendly entry | Wi-Fi Antenna, KVM Switch |
| **Warranty Coverage** | Standard + Burn-in | 3-year + Burn-in | Standard | Standard + Burn-in |
| **Price Range** | ~$800-900 | ~$850-950 | ~$700-800 | ~$900-1000 |
| **Best For** | QD-OLED on budget | Gaming features | Most affordable OLED | Dual-mode + USB-C |

### **Premium Options ($1,000-$1,400)**

| **Specification** | [**ASUS XG32UCWG**](#1-asus-rog-strix-oled-xg32ucwg-woled-trueblack-glossy--top-pick) ⭐ | [**ASUS PG32UCDP**](#2-asus-rog-swift-oled-pg32ucdp-woled-matte) | [**ASUS PG32UCDMR**](#3-asus-rog-swift-oled-pg32ucdmr-qd-oled-premium) | [**Samsung LS32FG810SNXZA**](#4-samsung-odyssey-oled-g8-ls32fg810snxza) | [**LG 32GS95UE**](#5-lg-ultragear-32gs95ue) |
|-------------------|-------------------------|-------------------------|----------------------------|----------------------------|----------------|
| **Panel Technology** | ✅ WOLED | ✅ WOLED | 🌟 **QD-OLED** | ✅ QD-OLED | ✅ WOLED |
| **Resolution** | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) | 4K UHD (3840 x 2160) |
| **Refresh Rate** | 165Hz (4K) / 330Hz (FHD) | 240Hz (4K) / 480Hz (FHD) | 240Hz | 240Hz | 240Hz |
| **Response Time** | 0.03ms (GTG) | 0.03ms (GTG) | 0.03ms (GTG) | 0.03ms (GTG) | 0.03ms (GTG) |
| **Display Coating** | ✨ **TrueBlack Glossy™** | 🎯 **Matte Anti-Glare** | Semi-Glossy | Semi-Glossy | Semi-Glossy |
| **USB-C Port** | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
| **USB-C Power Delivery** | ⚠️ 15W (insufficient) | ✅ 90W PD | ✅ 90W PD | ❌ No | ✅ 90W PD |
| **Dual Mode** | ✅ 4K@165Hz / FHD@330Hz | ✅ 4K@240Hz / FHD@480Hz | ❌ Single Mode | ❌ Single Mode | ❌ Single Mode |
| **HDR Certification** | DisplayHDR True Black 400 | DisplayHDR True Black 400 | DisplayHDR True Black 400 | HDR True Black 400 | DisplayHDR True Black 400 |
| **Color Gamut** | 99% DCI-P3 | 99% DCI-P3 | 99% DCI-P3 | 99% DCI-P3 | 99% DCI-P3 |
| **Color Accuracy** | Delta E &lt; 2 | Delta E &lt; 2 | Delta E &lt; 2 | Delta E &lt; 2 | Delta E &lt; 2 |
| **VESA Mount** | 100x100mm | 100x100mm | 100x100mm | 100x100mm | 100x100mm |
| **Connectivity** | 2x HDMI 2.1, 1x DP 1.4, USB-C | 2x HDMI 2.1, 1x DP 1.4, USB-C | 2x HDMI 2.1, 1x DP 1.4, USB-C | 2x HDMI 2.1, 1x DP 1.4 | 2x HDMI 2.1, 1x DP 1.4, USB-C |
| **Special Features** | OLED Care Pro, Compact Stand | AI Assistant, Dynamic Features | Custom Heatsink + Graphene | OLED Safeguard+, Samsung Ecosystem | Advanced Cooling System |
| **Text Clarity** | 🏆 **Excellent** (Clear Pixel Edge) | ✅ **Very Good** (Matte) | ✅ **Good** (QD-OLED) | ✅ **Good** (QD-OLED) | ✅ **Very Good** |
| **Brightness (HDR Peak)** | 450 nits | 450 nits | 🔆 **1000+ nits** | 🔆 **1000+ nits** | 450 nits |
| **Stand Features** | 45% Smaller Footprint | Ergonomic Adjustable | Premium Adjustable | Standard Adjustable | Standard Adjustable |
| **Warranty Coverage** | Standard + Burn-in | Standard + Burn-in | Premium + Burn-in | Standard + Burn-in | Standard + Burn-in |
| **Price Range** | ~$1,000-1,100 | ~$1,100-1,200 | ~$1,200-1,400 | ~$1,300-1,500 | ~$1,200-1,400 |
| **Best For** | 💼 **Productivity + Gaming** | 🎮 **Bright Rooms + Gaming** | 🌟 **Ultimate Premium** | Samsung Ecosystem | Brand Reliability |

### **Quick Decision Matrix**

| **Priority** | **Best Choice** | **Alternative** | **Budget Option** |
|--------------|-----------------|-----------------|-------------------|
| **Text Clarity** | [ASUS XG32UCWG](#1-asus-rog-strix-oled-xg32ucwg-woled-trueblack-glossy--top-pick) (TrueBlack Glossy) | [ASUS PG32UCDP](#2-asus-rog-swift-oled-pg32ucdp-woled-matte) (Matte) | [ASRock PGO32UFS](#4-asrock-pgo32ufs-woled) (Anti-Glare) |
| **Laptop Charging** | [ASUS PG32UCDP](#2-asus-rog-swift-oled-pg32ucdp-woled-matte) (90W) | [LG 32GS95UE](#5-lg-ultragear-32gs95ue) (90W) | [ASRock PGO32UFS](#4-asrock-pgo32ufs-woled) (65W) |
| **Gaming Performance** | [ASUS PG32UCDP](#2-asus-rog-swift-oled-pg32ucdp-woled-matte) (480Hz FHD) | [ASRock PGO32UFS](#4-asrock-pgo32ufs-woled) (480Hz FHD) | [MSI MAG 321CUP](#1-msi-mag-321cup-qd-oled) (165Hz) |
| **Color Vibrancy** | [ASUS PG32UCDMR](#3-asus-rog-swift-oled-pg32ucdmr-qd-oled-premium) (QD-OLED) | [Samsung LS32FG810SNXZA](#4-samsung-odyssey-oled-g8-ls32fg810snxza) (QD-OLED) | [MSI MAG 321CUP](#1-msi-mag-321cup-qd-oled) (QD-OLED) |
| **Budget Value** | [MSI MAG 321CUP](#1-msi-mag-321cup-qd-oled) (~$800) | [INNOCN 32&quot; 4K](#3-innocn-32-4k-oled-monitor) (~$700) | [GIGABYTE MO32U](#2-gigabyte-mo32u-qd-oled) (~$850) |
| **Bright Rooms** | [ASUS PG32UCDP](#2-asus-rog-swift-oled-pg32ucdp-woled-matte) (Matte) | [ASRock PGO32UFS](#4-asrock-pgo32ufs-woled) (Anti-Glare) | [ASUS PG32UCDMR](#3-asus-rog-swift-oled-pg32ucdmr-qd-oled-premium) (Higher Peak Brightness) |

---

## Key Feature Breakdown

### Display Coatings Explained

The coating type affects your viewing experience:

#### **✅ TrueBlack Glossy™ (ASUS XG32UCWG)**
- **Pros**: Sharp text, 38% less reflections than standard glossy
- **Cons**: Still shows some reflections in bright environments
- **Best For**: Controlled lighting, productivity work, sharp text

#### **✅ Matte Anti-Glare (ASUS PG32UCDP, ASRock PGO32UFS)**
- **Pros**: Minimal reflections, great for bright rooms
- **Cons**: Slightly softer image quality
- **Best For**: Bright offices, rooms with windows

#### **✅ Semi-Glossy (Most QD-OLED models)**
- **Pros**: Balance between sharpness and reflection control
- **Cons**: Moderate reflections
- **Best For**: Most gaming setups

&lt;Notice type=&quot;info&quot; title=&quot;Coating Recommendations&quot;&gt;
**Bright Room**: Choose matte anti-glare coatings (PG32UCDP, PGO32UFS)
**Controlled Lighting**: TrueBlack Glossy™ (XG32UCWG) offers the sharpest image
**Balanced Setup**: Semi-glossy QD-OLED options work well in most environments
&lt;/Notice&gt;

### USB-C and Power Delivery

USB-C connectivity differs across models:

| **Power Delivery** | **Models** | **Capability** |
|-------------------|------------|----------------|
| **❌ No USB-C** | MSI MAG 321CUP, GIGABYTE MO32U, Samsung LS32FG810SNXZA | Traditional connections only |
| **⚠️ 15W PD** | ASUS XG32UCWG | Insufficient for laptop charging |
| **✅ 65W PD** | ASRock PGO32UFS | Charges most laptops |
| **✅ 90W PD** | ASUS PG32UCDP, PG32UCDMR, LG 32GS95UE | Charges all laptops including MacBook Pro |

&lt;Notice type=&quot;warning&quot; title=&quot;MacBook Pro Users&quot;&gt;
For MacBook Pro charging, you need at least 65W power delivery. The ASUS XG32UCWG&apos;s 15W is insufficient for laptop charging, though it works for display output.
&lt;/Notice&gt;

---

## Which Monitor Should You Choose?

### For Productivity &amp; Coding 💼
**Winner**: [ASUS ROG Strix OLED XG32UCWG](https://amzn.to/3VBnjCA)
- Best text clarity among all options
- TrueBlack Glossy coating reduces eye strain
- Perfect for extended work sessions
- Excellent macOS compatibility
- Dual-mode for occasional gaming

**Alternative**: [PG32UCDP](https://amzn.to/3Iv6hTM) if you need matte coating + 90W charging

---

### For Competitive Gaming 🎮
**Winner**: [ASRock PGO32UFS](https://pg.asrock.com/Monitors/PGO32UFS/index.asp) or [ASUS PG32UCDP](https://amzn.to/3Iv6hTM)
- 480Hz FHD mode for competitive gaming
- Ultra-fast 0.03ms response time
- Variable refresh rate support
- Advanced OLED protection

**Budget Alternative**: [LG 32GS95UE](https://amzn.to/4nTrcPe) for premium features at better value

---

### For Content Creation 🎨
**Winner**: [ASUS ROG Swift OLED PG32UCDMR](https://amzn.to/4nno4LC)
- QD-OLED technology for maximum color vibrancy
- 99% DCI-P3 coverage with Delta E &lt; 2 accuracy
- Custom cooling system for sustained performance
- 90W USB-C PD for laptop workflows

**Budget Alternative**: [MSI MAG 321CUP](https://amzn.to/4gNJGyj) for QD-OLED on budget

---

### Best Value Overall 💰
**Winner**: [MSI MAG 321CUP QD-OLED](https://amzn.to/4gNJGyj)
- QD-OLED technology at accessible price
- 165Hz gaming performance
- Excellent color accuracy
- OLED Care 2.0 protection

**Runner-up**: [ASUS XG32UCWG](https://amzn.to/3VBnjCA) for productivity focus

---

### For Laptop Users 💻
**Winner**: [ASUS PG32UCDP](https://amzn.to/3Iv6hTM) or [LG 32GS95UE](https://amzn.to/4nTrcPe)
- 90W USB-C PD charges laptops while working
- Clean single-cable setup
- Excellent display quality
- Professional color accuracy

**Budget Option**: [ASRock PGO32UFS](https://pg.asrock.com/Monitors/PGO32UFS/index.asp) with 65W PD

---

### For Mixed Use (Gaming + Work) ⚡
**Winner**: [ASUS ROG Strix OLED XG32UCWG](https://amzn.to/3VBnjCA)
- Good text clarity for productivity
- Dual-mode flexibility (4K@165Hz / FHD@330Hz)
- TrueBlack Glossy coating
- Professional color accuracy
- Good gaming performance

**Read why**: [My detailed XG32UCWG review](https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/)

---

## Gaming Performance Rankings

### Competitive Gaming Rankings

| **Rank** | **Monitor** | **Competitive Score** | **Key Advantages** |
|----------|-------------|----------------------|------------------|
| **🥇 1st** | ASRock PGO32UFS | 9.8/10 | 480Hz FHD mode, anti-glare |
| **🥈 2nd** | ASUS PG32UCDP | 9.5/10 | 480Hz FHD mode, matte coating |
| **🥉 3rd** | Samsung LS32FG810SNXZA | 9.2/10 | 240Hz QD-OLED, vibrant colors |
| **4th** | ASUS PG32UCDMR | 9.0/10 | 240Hz QD-OLED, premium features |
| **5th** | LG 32GS95UE | 8.8/10 | 240Hz WOLED, good balance |

### Casual Gaming Rankings

| **Rank** | **Monitor** | **Casual Score** | **Key Advantages** |
|----------|-------------|------------------|------------------|
| **🥇 1st** | ASUS PG32UCDMR | 9.6/10 | Best QD-OLED visuals, premium features |
| **🥈 2nd** | Samsung LS32FG810SNXZA | 9.4/10 | Excellent QD-OLED, Samsung ecosystem |
| **🥉 3rd** | MSI MAG 321CUP | 9.2/10 | Great QD-OLED value |
| **4th** | GIGABYTE MO32U | 9.0/10 | Solid QD-OLED performance |
| **5th** | ASUS XG32UCWG | 8.7/10 | Good WOLED gaming, great productivity |

---

## OLED Technology Deep Dive

### WOLED vs QD-OLED: What&apos;s the Difference?

#### WOLED (White OLED)
**Used in**: XG32UCWG, PG32UCDP, INNOCN, ASRock, LG

**How it works**: Uses white organic light-emitting diodes with color filters

**Advantages:**
- ✅ Excellent text clarity with Clear Pixel Edge algorithms
- ✅ Better longevity compared to first-generation OLEDs
- ✅ Lower manufacturing costs
- ✅ Less color fringing on text
- ✅ Better for productivity work

**Disadvantages:**
- ❌ Lower peak brightness than QD-OLED
- ❌ Less vibrant in some HDR scenarios
- ❌ Slightly less &quot;wow factor&quot; in media content

#### QD-OLED (Quantum Dot OLED)
**Used in**: MSI MAG 321CUP, GIGABYTE MO32U, PG32UCDMR, Samsung G81SF

**How it works**: Uses quantum dots for color production

**Advantages:**
- ✅ Higher peak brightness capabilities
- ✅ More vibrant colors in HDR content
- ✅ Wider color volume
- ✅ Better HDR performance
- ✅ Impressive visual impact

**Disadvantages:**
- ❌ Higher cost to manufacture
- ❌ More color fringing on text (though improved)
- ❌ Premium pricing
- ❌ May show rainbow effect in bright ambient light

---

## Complete Comparison Table

| Monitor | Panel | Coating | Refresh Rate | USB-C | Power | Best For |
|---------|-------|---------|--------------|-------|-------|----------|
| **MSI MAG 321CUP** | QD-OLED | Semi-Glossy | 165Hz | ❌ | N/A | Budget QD-OLED |
| **GIGABYTE MO32U** | QD-OLED | Semi-Glossy | 165Hz | ❌ | N/A | Gaming features |
| **INNOCN 32&quot;** | WOLED | Glossy | 165Hz | ❌ | N/A | Budget WOLED |
| **ASRock PGO32UFS** | WOLED | Anti-Glare | 240/480Hz | ✅ | 65W | Dual-mode value |
| **XG32UCWG** ⭐ | WOLED | TrueBlack Glossy | 165/330Hz | ⚠️ | 15W | Productivity |
| **PG32UCDP** | WOLED | Matte | 240/480Hz | ✅ | 90W | Bright rooms |
| **PG32UCDMR** | QD-OLED | Semi-Glossy | 240Hz | ✅ | 90W | Premium choice |
| **Samsung G81SF** | QD-OLED | Semi-Glossy | 240Hz | ❌ | N/A | Samsung ecosystem |
| **LG 32GS95UE** | WOLED | Semi-Glossy | 240Hz | ✅ | 90W | Balanced premium |

---

## Step-by-Step Decision Framework

### Step 1: Identify Your Primary Use Case

**Productivity/Office Work (70%+ of time)**
- Text clarity is critical
- Multiple applications open simultaneously
- Long work sessions (6+ hours)
- Static UI elements (taskbars, toolbars)

**Recommended**: [XG32UCWG](https://amzn.to/3VBnjCA) or [PG32UCDP](https://amzn.to/3Iv6hTM)

**Why**: WOLED panels have better text rendering, good burn-in protection for static elements, and factory calibration for accurate colors.

**Gaming (70%+ of time)**
- Fast-paced competitive titles
- Story-driven AAA games
- Console gaming
- High refresh rate priority

**Recommended**: [PG32UCDP](https://amzn.to/3Iv6hTM) or [LG 32GS95UE](https://amzn.to/4nTrcPe)

**Why**: 240Hz+ refresh rates, AMD FreeSync Premium Pro and G-SYNC compatibility work well, and motion clarity is good.

**Content Creation (Video/Photo Editing)**
- Color accuracy critical
- HDR content creation
- Wide color gamut needed
- Vibrancy matters

**Recommended**: [PG32UCDMR](https://amzn.to/4nno4LC) or [MSI MAG 321CUP](https://amzn.to/4gNJGyj)

**Why**: QD-OLED has wider color volume, higher peak brightness for HDR, 99% DCI-P3 coverage, and Delta E &lt; 2 accuracy.

**Mixed Use (Equal Gaming + Work)**
- Need versatility
- Both text clarity and gaming performance matter
- Willing to compromise slightly on each

**Recommended**: [XG32UCWG](https://amzn.to/3VBnjCA) or [ASRock PGO32UFS](https://pg.asrock.com/Monitors/PGO32UFS/index.asp)

**Why**: Dual-mode capability, decent text clarity, solid gaming performance, and balanced features.

### Step 2: Set Your Budget

**Under $1,000** (Budget OLED Experience)
- [MSI MAG 321CUP](https://amzn.to/4gNJGyj) - $800-900
- [GIGABYTE MO32U](https://amzn.to/4gPmrUH) - $850-950
- [INNOCN 32&quot;](https://amzn.to/46N7RbP) - $700-800

**What You Get**: OLED experience with true blacks and vibrant colors. Some trade-offs in build quality, features, or connectivity.

**$1,000 - $1,500** (Mid-range)
- [XG32UCWG](https://amzn.to/3VBnjCA) - $1,000-1,100
- [LG 32GS95UE](https://amzn.to/4nTrcPe) - $1,200-1,400
- [PG32UCDP](https://amzn.to/3Iv6hTM) - $1,100-1,200

**What You Get**: Premium OLED experience, good protection features, better build quality, and advanced features.

**$1,500+** (Premium)
- [PG32UCDMR](https://amzn.to/4nno4LC) - $1,200-1,400
- [Samsung G81SF](https://amzn.to/42UMxjb) - $1,300-1,500

**What You Get**: High-end OLED options, QD-OLED, premium build, and good warranty coverage.

---

## Common Questions

### Will OLED Burn-in Be a Problem?
Modern OLED monitors have good protection:
- **Pixel cleaning cycles** - Automatic recalibration
- **Logo detection and dimming** - Reduces static element brightness
- **Screen savers and pixel shift** - Subtle movement prevents static burn-in
- **Taskbar detection** - Dynamic brightness adjustment
- **3-year warranties** covering burn-in (most brands)

&lt;Notice type=&quot;success&quot; title=&quot;Real-World Experience&quot;&gt;
After using OLED for productivity with static elements, I haven&apos;t had burn-in issues with OLED Care Pro features enabled. Modern protection makes burn-in less of a concern than early OLED generations.
&lt;/Notice&gt;

### Is Glossy Really Usable in Bright Rooms?
Yes. The XG32UCWG&apos;s TrueBlack Glossy coating has minimal reflections, even with a window in front of my desk. The 38% reduction in reflections vs standard glossy helps. Matte still works better if you have multiple light sources or bright overhead lighting.

### Do I Really Need USB-C?
**Yes, if you:**
- Use a laptop as primary device
- Want single-cable setup
- Need to charge laptop while working (requires 65W+ models)

**No, if you:**
- Use desktop PC primarily
- Don&apos;t mind separate charger
- Want to save money on other features

**My setup**: XG32UCWG with 15W USB-C for display display, separate MagSafe charger. The setup requires one extra cable but has better display quality.

---

## Final Recommendations

### 🏆 Overall Best: ASUS ROG Strix OLED XG32UCWG
Good balance of productivity and gaming, solid text clarity, and OLED protection.

&lt;Button text=&quot;Get the XG32UCWG&quot; link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

[Read Full Review](https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/)

### 💰 Best Value: MSI MAG 321CUP QD-OLED
QD-OLED technology at an accessible price point with excellent gaming performance.

&lt;Button text=&quot;Check MSI MAG 321CUP&quot; link=&quot;https://amzn.to/4gNJGyj&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

### 🎮 Best for Gaming: ASUS PG32UCDP / ASRock PGO32UFS
240Hz/480Hz dual-mode for competitive advantage, excellent motion clarity.

&lt;Button text=&quot;Check PG32UCDP&quot; link=&quot;https://amzn.to/3Iv6hTM&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

### 💼 Best for Productivity: ASUS XG32UCWG
Exceptional text clarity with TrueBlack Glossy coating, perfect for long work sessions.

### 🌟 Premium Choice: ASUS PG32UCDMR
High image quality with QD-OLED technology and premium features.

&lt;Button text=&quot;Get the PG32UCDMR&quot; link=&quot;https://amzn.to/4nno4LC&quot; size=&quot;lg&quot; color=&quot;red&quot; variant=&quot;solid&quot; /&gt;

---

## Where to Buy - Quick Links

### **Budget Options**
- [MSI MAG 321CUP QD-OLED](https://amzn.to/4gNJGyj) - Best QD-OLED value
- [GIGABYTE MO32U QD-OLED](https://amzn.to/4gPmrUH) - Solid alternative
- [INNOCN 32M2V Monitor](https://amzn.to/46N7RbP) - Budget WOLED option
- [ASRock PGO32UFS](https://pg.asrock.com/Monitors/PGO32UFS/index.asp) - High refresh WOLED

### **Premium Options**
- [ASUS XG32UCWG TrueBlack](https://amzn.to/3VBnjCA) - Best productivity WOLED
- [ASUS PG32UCDP Matte](https://amzn.to/3Iv6hTM) - Best bright room option
- [ASUS PG32UCDMR QD-OLED](https://amzn.to/4nno4LC) - Premium QD-OLED
- [Samsung LS32FG810SNXZA](https://amzn.to/42UMxjb) - Samsung QD-OLED
- [LG 32GS95UE](https://amzn.to/4nTrcPe) - Premium WOLED

&lt;Notice type=&quot;info&quot; title=&quot;Purchase Timing&quot;&gt;
OLED monitor prices fluctuate significantly. Check current pricing using the links above, and consider waiting for sales events like Black Friday or Amazon Prime Day for the best deals.
&lt;/Notice&gt;

---

## Conclusion

The 32-inch OLED monitor market has good options across price ranges. Whether you want text clarity for productivity (XG32UCWG), vibrant colors for content creation (PG32UCDMR), competitive gaming performance (PG32UCDP), or good value (MSI MAG 321CUP), there&apos;s a suitable monitor.

After using the XG32UCWG for productivity, I can say OLED technology has improved the computing experience. The contrast, blacks, and text clarity make it hard to go back to LCD displays.

**Ready to make the jump to OLED?** Use this guide to understand your priorities, match them to the right monitor, and enjoy OLED displays. Any of these monitors should work well.

---

*This guide reflects the market as of January 2026. Monitor specifications and pricing may change. Verify current specifications and pricing before purchase.*</content:encoded><category>gadgets</category><category>monitors</category><category>oled</category><category>gaming</category></item><item><title>ASUS ROG Strix OLED XG32UCWG Review - OLED Monitor Experience</title><link>https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/</link><guid isPermaLink="true">https://www.bitdoze.com/asus-rog-strix-oled-xg32ucwg-review/</guid><description>ASUS ROG Strix OLED XG32UCWG Review - review of this 32-inch 4K WOLED gaming monitor with TrueBlack Glossy technology, productivity focus, and MacBook Pro compatibility.</description><pubDate>Fri, 26 Sep 2025 00:00:00 GMT</pubDate><content:encoded>**Rating: ⭐⭐⭐⭐⭐ (4.6/5)**

The [ASUS ROG Strix OLED XG32UCWG](https://amzn.to/3VBnjCA) uses WOLED technology with TrueBlack Glossy coating. It&apos;s my first OLED monitor, used for productivity and DevOps work with a MacBook Pro M1. This 32-inch 4K display performed well in most areas, though OLED has some limitations.

&lt;Button text=&quot;Check XG32UCWG&quot;  link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

## ASUS ROG Strix OLED XG32UCWG Video Review

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/AYqm6mD5TPw&quot;
  label=&quot;ASUS ROG Strix OLED XG32UCWG Review&quot;
/&gt;

&gt;The complete list with the [Best 32 inch OLED Monitors](https://www.bitdoze.com/best-32-inch-oled-monitors-guide/) can be checked to see which are the best.

## Design &amp; Build Quality

The XG32UCWG has a modern design that works for professional or gaming setups. The compact stand takes up 45% less desk space than previous-generation XG monitors.

### Physical Specifications:
- **Screen Size**: 32-inch (31.5-inch viewable)
- **Resolution**: 4K (3840 x 2160) native, FHD dual-mode capability
- **Panel Type**: WOLED with TrueBlack Glossy coating
- **Refresh Rate**: 165Hz at 4K, 330Hz at FHD (dual-mode)
- **Response Time**: 0.03ms (GTG)
- **Contrast Ratio**: 1,500,000:1
- **Color Coverage**: 99% DCI-P3, Delta E &lt; 2

### Ergonomics &amp; Connectivity:
- **Height Adjustment**: 0-80mm range
- **Tilt**: -5° to +20°
- **Swivel**: -15° to +15°
- **VESA Mount**: Compatible
- **Ports**: DisplayPort 1.4 (DSC), HDMI 2.1, USB-C (15W PD)

&lt;Notice type=&quot;info&quot; title=&quot;MacBook Pro Users&quot;&gt;
The USB-C port provides only 15W power delivery, which is insufficient for charging MacBook Pro models. You&apos;ll need a separate power adapter for your laptop while using the USB-C connection for display output.
&lt;/Notice&gt;


&lt;Button text=&quot;Check XG32UCWG&quot;  link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

## Understanding OLED Technologies: WOLED vs QD-OLED

Before looking at performance, it helps to understand the different OLED technologies in ASUS&apos;s monitor lineup.

### WOLED (White OLED) Technology
The XG32UCWG uses **WOLED** technology, which employs white organic light-emitting diodes with color filters to produce the final image. This third-generation WOLED implementation offers:

- **True black levels** with infinite contrast
- **Excellent text clarity** with Clear Pixel Edge algorithm
- **Latest RGWB subpixel layout + Clear Pixel Edge** reduce color fringing on text
- **Better longevity** compared to first-generation OLEDs
- **Lower manufacturing costs** than QD-OLED

### QD-OLED (Quantum Dot OLED) Technology
For comparison, QD-OLED monitors like the **PG32UCDMR** use quantum dots for color production:

- **Brighter overall output**
- **More vibrant colors** in some scenarios
- **Higher peak brightness capabilities**
- **Premium pricing**

### ASUS OLED Monitor Comparison

ASUS offers three distinct 32-inch OLED approaches:

1. **[XG32UCWG (WOLED TrueBlack Glossy)](https://amzn.to/3VBnjCA)**
   - Best for: Productivity, text clarity, budget-conscious buyers
   - Coating: TrueBlack Glossy for sharp imagery

2. **[PG32UCDP (WOLED Matte Anti-Glare)](https://amzn.to/3Iv6hTM)**
   - Best for: Bright environments, reduced reflections
   - Coating: Matte anti-glare finish
   - Dual-mode: 4K up to 240Hz or FHD up to 480Hz
   - USB-C Power Delivery: up to 90W (charges laptops)

3. **[PG32UCDMR (QD-OLED)](https://amzn.to/4nno4LC)**
   - Best for: Maximum color vibrancy, premium gaming
   - Coating: Semi-glossy finish

## Display Coatings: Glossy vs Matte Explained

The display coating affects your viewing experience, and ASUS has different approaches in their OLED lineup.

### TrueBlack Glossy (XG32UCWG)
The XG32UCWG&apos;s **TrueBlack Glossy** coating provides:
- **Zero-haze surface** for sharp images
- **38% reduction in reflections** compared to previous glossy WOLEDs
- **Good text clarity** for productivity work
- **Deep blacks** that look good in bright rooms

**My Experience**: I was concerned about using a glossy display in a bright room with windows, but the TrueBlack Glossy coating worked well. On sunny days with the window in front of my desk, reflections were minimal and didn&apos;t interfere with productivity work.

### Matte Anti-Glare (PG32UCDP)
The matte coating offers:
- **Reduced reflections** in very bright environments
- **Slightly softer image quality** due to the coating layer
- **Better performance** under direct lighting
- **Preference for some users** who prioritize reflection control over sharpness

### Semi-Glossy QD-OLED (PG32UCDMR)
The QD-OLED approach provides:
- **Balance between sharpness and reflection control**
- **Good color from** quantum dot technology
- **Higher brightness** options
- **Good visual experience**

&lt;Button text=&quot;Check XG32UCWG&quot;  link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;


## Performance Analysis

### Text Clarity and Productivity Performance

After using a Dell UltraSharp IPS monitor, the text clarity on the XG32UCWG is noticeably better. The Clear Pixel Edge algorithm reduces the green and red fringing on WOLED displays, making text readable for long work sessions.

**Productivity Strengths:**
- **Sharp text** better than IPS displays
- **Comfortable viewing** for 8+ hour work sessions
- **No eye strain** in dark mode applications
- **Good scaling** on macOS, at non-native resolutions
- **Deep blacks** improve contrast in code editors and terminals

### Gaming Performance

I bought the monitor primarily for productivity, but it also performs well for gaming:

#### Dual-Mode Capability
The useful feature is the **dual-mode functionality**:
- **4K @ 165Hz**: Good for AAA titles and single-player games
- **FHD @ 330Hz**: Works well for competitive FPS gaming
- **Quick switching**: Hotkey toggle between modes

#### Response Time and Input Lag
- **0.03ms GTG response time** reduces ghosting
- **Low input lag** for competitive gaming
- **G-SYNC compatible** for tear-free gameplay
- **AMD FreeSync Premium Pro** support

#### Color and HDR Performance
- **99% DCI-P3 coverage** creates good gaming visuals
- **VESA DisplayHDR 400 True Black** certification
- **True 10-bit color depth** for accurate color reproduction
- **Infinite contrast ratio** for good gaming experiences

### Brightness Considerations

The main limitation of WOLED technology is peak brightness. Compared to the Dell IPS monitor in my setup, the XG32UCWG produces noticeably lower brightness levels. While this doesn&apos;t impact usability, users coming from high-brightness IPS displays may need an adjustment period.

**Brightness Characteristics:**
- **Lower peak brightness** than IPS alternatives
- **Adequate for most environments** including moderately bright rooms
- **HDR content** benefits from true blacks despite lower peak brightness
- **Uniform brightness setting** helps maintain consistency

&lt;Notice type=&quot;warning&quot; title=&quot;Brightness and Reflections&quot;&gt;
WOLED peak brightness is lower than many IPS or QD‑OLED displays. However, the XG32UCWG’s TrueBlack Glossy coating reduces ambient reflections by about 38% versus prior glossy WOLEDs, so perceived contrast remains excellent even in brighter rooms.
&lt;/Notice&gt;

&lt;Button text=&quot;Check XG32UCWG&quot;  link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;


## Advanced Features

### ASUS OLED Care Pro

The OLED Care Pro suite helps with longevity:

#### Neo Proximity Sensor
- **Automatic detection** when user steps away
- **Black screen transition** to reduce burn-in
- **Customizable detection distance**
- **Quick content return** when user returns

#### Additional Protection Features:
- **Pixel cleaning**: Automatic recalibration process
- **Screen move**: Subtle pixel shifting to reduce static burn-in
- **Logo brightness adjustment**: Automatic detection and dimming of static logos
- **Taskbar detection**: Dynamic brightness adjustment for Windows taskbar

### DisplayWidget Center

The monitor management software provides:
- **Mouse-based OSD control** that removes physical button navigation
- **Firmware update notifications** with direct update capability
- **Multi-screen management**
- **Configuration import/export** for easy setup sharing

### AI Assistant Features

The integrated AI Assistant includes:
- **Dynamic Crosshair**: Automatic color adjustment for better visibility
- **Dynamic Shadow Boost**: Enhanced visibility in dark game areas
- **AI Visual**: Scene detection with optimized settings

### Auto KVM Functionality

I don&apos;t use this feature, but the Auto KVM provides:
- **Smooth device switching** with single keyboard/mouse
- **Good for streaming setups**
- **Multi-device workflow support**

## MacBook Pro Compatibility

I use a MacBook Pro, and the XG32UCWG works well:

### Connection and Display Quality:
- **USB-C connectivity** works with MacBook Pro M1
- **Good scaling** at non-native resolutions
- **Color accuracy** works for professional tasks
- **Multiple refresh rate options** in macOS settings
- **HDR support** when enabled in system preferences

### Limitations:
- **15W power delivery** insufficient for MacBook Pro charging
- **Requires separate power adapter** for the laptop
- **No Thunderbolt 3/4 support** (standard USB-C only)

## Price and Value Analysis

### Current Market Position
[Check current price](https://amzn.to/3VBnjCA) — the XG32UCWG has good value in the 32-inch 4K OLED segment.

### Value Proposition:
**Strengths:**
- **Good pricing** for WOLED technology
- **Dual-mode functionality** adds options
- **Good feature set** with OLED Care Pro
- **3-year warranty**
- **Good color accuracy**

**Consider Alternatives If:**
- You need maximum brightness (consider QD-OLED options)
- You prefer matte coatings (consider PG32UCDP)
- Budget is primary concern (consider smaller OLED options)

### Alternative Recommendations:

&lt;Button text=&quot;WOLED Matte Option - PG32UCDP&quot; link=&quot;https://amzn.to/3Iv6hTM&quot; size=&quot;md&quot; color=&quot;green&quot; variant=&quot;outline&quot; /&gt;

&lt;Button text=&quot;QD-OLED Premium - PG32UCDMR&quot; link=&quot;https://amzn.to/4nno4LC&quot; size=&quot;md&quot; color=&quot;purple&quot; variant=&quot;outline&quot; /&gt;

## Quick Notes

- Console Gaming: 9.2/10
- Office: 8.5/10
- Editing: 9.0/10
- Brightness: 7.0/10
- Response Time: 9.8/10
- HDR Picture: 9.0/10
- SDR Picture: 9.9/10

&lt;Notice type=&quot;success&quot; title=&quot;Console Gaming 9.2/10&quot;&gt;
Fantastic for PS5/Xbox at 4K with True Black and up to 165Hz; motion clarity and contrast are standout.
&lt;/Notice&gt;

## Ratings Breakdown

### **Console Gaming: ⭐⭐⭐⭐⭐ (9.2/10)**
Good performance for PlayStation 5 and Xbox Series X gaming with 4K@165Hz support and true black levels improving immersion.

### **Office Work: ⭐⭐⭐⭐⭐ (8.5/10)**
Good text clarity and comfortable viewing for long productivity sessions, though brightness limitations affect the score.

### **Photo/Video Editing: ⭐⭐⭐⭐⭐ (9.0/10)**
Good color accuracy with 99% DCI-P3 coverage and Delta E &lt; 2 works for creative tasks.

### **Brightness: ⭐⭐⭐⭐ (7.0/10)**
The main weakness of WOLED technology - adequate for most environments but noticeably dimmer than IPS alternatives.

### **Response Time: ⭐⭐⭐⭐⭐ (9.8/10)**
Good 0.03ms response time reduces ghosting and works for competitive gaming.

### **HDR Picture Quality: ⭐⭐⭐⭐⭐ (9.0/10)**
True blacks and infinite contrast ratio provide excellent HDR experience despite lower peak brightness.

### **SDR Picture Quality: ⭐⭐⭐⭐⭐ (9.9/10)**
Good SDR performance with deep blacks, accurate colors, and sharp text rendering.

## Final Verdict

### **Overall Rating: ⭐⭐⭐⭐⭐ (4.6/5)**

The ASUS ROG Strix OLED XG32UCWG brings OLED technology to productivity-focused users while keeping gaming capability. The TrueBlack Glossy coating helps with reflections, and the dual-mode functionality adds options not common in this segment.

### **Who Should Buy the XG32UCWG:**
- **Productivity professionals** wanting sharp text clarity
- **Creative professionals** needing accurate color reproduction
- **Gamers** wanting both 4K detail and high-refresh competitive gaming
- **Mac users** looking for external display options
- **First-time OLED buyers** wanting protection features

### **Who Should Consider Alternatives:**
- **Bright environment users** (consider matte PG32UCDP)
- **Maximum brightness seekers** (consider QD-OLED PG32UCDMR)
- **Budget-conscious buyers** (consider smaller OLED options)
- **Heavy static content users** with burn-in concerns

&lt;Notice type=&quot;success&quot; title=&quot;Recommendation&quot;&gt;
For productivity-focused users starting with OLED, the XG32UCWG offers a good balance of features, protection, and performance. The TrueBlack Glossy coating helps with reflection concerns while delivering the sharp text clarity and high contrast that makes OLED technology useful.
&lt;/Notice&gt;

### **Long-term Outlook**
While newer **tandem OLED** technology may improve brightness and longevity, the XG32UCWG&apos;s current feature set and ASUS&apos;s OLED Care Pro protection make it a good option for users who want OLED technology now.

**Final Recommendation**: The ASUS ROG Strix OLED XG32UCWG is a good choice for its intended audience, mixing productivity and gaming features while introducing users to OLED technology.

&lt;Button text=&quot;Get the XG32UCWG on Amazon&quot; link=&quot;https://amzn.to/3VBnjCA&quot; size=&quot;xl&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

---

*This review reflects my first week of real‑world use primarily for productivity with a MacBook Pro M1, supplemented by some gaming and technical checks. I’ll update this post after extended use.*</content:encoded><category>gadgets</category><category>monitors</category><category>oled</category></item><item><title>Add Localization(i18n) to Your Astro Project (Complete Guide)</title><link>https://www.bitdoze.com/astro-i18n-localization/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-i18n-localization/</guid><description>Building multilingual websites is essential in today&apos;s global digital landscape. While Astro doesn&apos;t provide built-in URL localization out of the box, this comprehensive guide will show you how to implement a complete internationalization (i18n) system with SEO-friendly URLs, dynamic routing, and seamless language switching.</description><pubDate>Fri, 05 Sep 2025 00:00:00 GMT</pubDate><content:encoded>This guide shows how to build a localization system for Astro with the following features:

- SEO-friendly URLs (e.g., `/about` becomes `/ro/despre`)
- Static generation at build time using dynamic routing
- Language-specific content files
- Translation system with namespace support
- Language switching that preserves the current page context
- Multilingual blog with posts in different languages
- Component localization with ARIA attributes
- SEO meta tags and hreflang attributes

The examples use English and Romanian, but the system works with any number of languages.

This approach is based on the [Astro i18n Starter](https://github.com/Scorpio3310/astro-i18n-starter.git).

## Prerequisites

Before starting, ensure you have:

- Node.js 18+ installed
- Basic understanding of Astro and TypeScript
- Familiarity with file-based routing concepts

## Quick Start

Try the demo first to see localization in action:

### 1. Clone the Working Example

```bash
# Option 1: Start from scratch (recommended for learning)
npm create astro@latest my-multilingual-site
cd my-multilingual-site

# Option 2: Clone the complete example
git clone https://github.com/Scorpio3310/astro-i18n-starter.git
cd astro-i18n-starter
npm install
npm run dev
```

### 2. Test the Demo

Visit `http://localhost:4321` and:
- Click the language dropdown (top right)
- Switch between English and Romanian
- Notice how URLs change: `/about` ↔ `/ro/despre`
- Try the blog section with cross-language linking

### 3. Understand the Structure

The demo shows you:
- ✅ Dynamic routing with `[...index].astro`
- ✅ Translation files in `src/locales/`
- ✅ Route mappings in `src/i18n/routes.ts`
- ✅ Language switching component
- ✅ SEO-friendly URLs

Now let&apos;s build this system step by step!

## Project Setup

Create a new Astro project and install dependencies:

```bash
npm create astro@latest my-multilingual-site
cd my-multilingual-site
npm install @astrojs/mdx @astrojs/sitemap
```

### Configure Astro

Update `astro.config.mjs`:

```javascript
import { defineConfig } from &quot;astro/config&quot;;
import mdx from &quot;@astrojs/mdx&quot;;
import sitemap from &quot;@astrojs/sitemap&quot;;

export default defineConfig({
    site: process.env.PRODUCTION_DOMAIN || &quot;http://localhost:4321&quot;,
    integrations: [
        mdx(),
        sitemap({
            customPages: [
                process.env.PRODUCTION_DOMAIN || &quot;http://localhost:4321&quot;,
                (process.env.PRODUCTION_DOMAIN || &quot;http://localhost:4321&quot;) + &quot;/ro/&quot;,
            ],
            changefreq: &quot;monthly&quot;,
            priority: 0.7,
            lastmod: new Date(),
        }),
    ],
});
```

### 3. Create Environment File

Create `.env` in your project root:

```bash
# .env
PRODUCTION_DOMAIN=&quot;https://your-domain.com&quot;
```

## Core i18n System Setup

### Directory Structure

```bash
mkdir -p src/i18n src/locales/en src/locales/ro
```

Each language gets a folder under `src/locales/`, and `src/i18n/` contains the system logic.

### Configure Languages

Create `src/i18n/ui.ts`:

```typescript
/**
 * Dynamically import all locale JSON files
 * Loads files from /src/locales/[lang]/[namespace].json
 */
const localeModules = import.meta.glob(&quot;/src/locales/**/*.json&quot;, {
    eager: true,
});

/**
 * Available languages with display names
 */
export const languages = {
    en: &quot;English&quot;,
    ro: &quot;Română&quot;,
};

/**
 * Default language for fallback translations
 */
export const defaultLang = &quot;en&quot;;

/**
 * Whether to show default language in URLs (/en/about vs /about)
 */
export const showDefaultLang = false;

/**
 * UI translations object with nested structure: lang.namespace.key
 * Built from locale files automatically
 * Example: ui.en.common.nav_home -&gt; &quot;Home&quot;
 */
export const ui = Object.entries(localeModules).reduce(
    (acc, [path, module]) =&gt; {
        const pathParts = path.split(&quot;/&quot;);
        const lang = pathParts[3]; // Extract language from path
        const namespace = pathParts[4].replace(&quot;.json&quot;, &quot;&quot;); // Extract filename as namespace
        const translations = (module as any).default || module;

        if (!acc[lang]) {
            acc[lang] = {};
        }

        // Create nested structure: lang.namespace.key
        acc[lang][namespace] = translations;
        return acc;
    },
    {} as Record&lt;string, Record&lt;string, Record&lt;string, string&gt;&gt;&gt;
);

/**
 * Type for translation keys
 * Supports both formats:
 * - &quot;namespace:key&quot; → t(&quot;common:menu.list.home&quot;)
 * - Direct keys → t(&quot;menu.list.home&quot;) (assumes &quot;common&quot; namespace)
 */
export type TranslationKey = string;
```

&gt; Note: Make sure all JSON files are valid. A syntax error will break the translation system.

### Route Translations

Create `src/i18n/routes.ts`:

```typescript
/**
 * Route translations for different languages
 * Maps original route names to localized URLs
 * Example: &quot;about&quot; -&gt; &quot;despre&quot; for Romanian
 * English routes use original names (not included here)
 */
export const routes: Record&lt;string, Record&lt;string, string&gt;&gt; = {
    ro: {
        about: &quot;despre&quot;,
        blog: &quot;blog&quot;,
        contact: &quot;contact&quot;,
        services: &quot;servicii&quot;,
        pages: &quot;pagini&quot;,
        &quot;page-1&quot;: &quot;pagina-1&quot;,
        &quot;page-2&quot;: &quot;pagina-2&quot;,
    },
};
```

&gt; Note: Only add routes that differ from English. English routes use original names and don&apos;t need to be listed.

### Utility Functions

Create `src/i18n/utils.ts`:

```typescript
import { ui, defaultLang, showDefaultLang, type TranslationKey } from &quot;./ui&quot;;
import { routes } from &quot;./routes&quot;;
import { getCollection } from &quot;astro:content&quot;;

//---------------------------------- EXPORTS ----------------------------------//
/**
 * Extracts language code from URL path
 * Example: &quot;/ro/despre&quot; -&gt; &quot;ro&quot;, &quot;/about&quot; -&gt; &quot;en&quot; (defaultLang)
 */
export function getLangFromUrl(url: URL) {
    const [, lang] = url.pathname.split(&quot;/&quot;);
    if (lang in ui) return lang as keyof typeof ui;
    return defaultLang;
}

/**
 * Returns translation function for specific language
 * Supports namespace:key format (e.g., &quot;common:nav.home&quot;)
 * Falls back to defaultLang if translation not found
 */
export function useTranslations(lang: keyof typeof ui) {
    return function t(
        key: TranslationKey,
        params?: Record&lt;string, string | number&gt;
    ) {
        let namespace: string;
        let translationKey: string;

        // If no colon, assume &quot;common&quot; namespace
        if (!key.includes(&quot;:&quot;)) {
            namespace = &quot;common&quot;;
            translationKey = key;
        } else {
            [namespace, translationKey] = key.split(&quot;:&quot;);
            if (!namespace || !translationKey) {
                return key;
            }
        }

        // Support nested object access with dot notation (e.g., &quot;languages.en&quot;)
        const getNestedValue = (obj: any, path: string): any =&gt; {
            return path
                .split(&quot;.&quot;)
                .reduce((current, key) =&gt; current?.[key], obj);
        };

        const translation =
            getNestedValue(ui[lang]?.[namespace], translationKey) ||
            getNestedValue(ui[defaultLang]?.[namespace], translationKey) ||
            key;

        return params &amp;&amp; typeof translation === &quot;string&quot;
            ? interpolateParams(translation, params)
            : translation;
    };
}

/**
 * Returns path translation function for specific language
 * Translates routes like &quot;about&quot; -&gt; &quot;despre&quot; for Romanian
 */
export function useTranslatedPath(lang: keyof typeof ui) {
    return function translatePath(path: string, l: string = lang) {
        // Split path into segments
        const segments = path.split(&quot;/&quot;).filter((segment) =&gt; segment);

        // Translate each segment individually
        const translatedSegments = segments.map((segment) =&gt; {
            const hasTranslation =
                defaultLang !== l &amp;&amp;
                routes[l] !== undefined &amp;&amp;
                routes[l][segment] !== undefined;
            return hasTranslation ? routes[l][segment] : segment;
        });

        const translatedPath = &quot;/&quot; + translatedSegments.join(&quot;/&quot;);

        return !showDefaultLang &amp;&amp; l === defaultLang
            ? translatedPath
            : `/${l}${translatedPath}`;
    };
}

/**
 * Switches current URL to target language while preserving content linking
 * Handles blog posts with different slugs per language
 */
export async function switchLanguageUrl(
    currentUrl: URL,
    targetLang: string
): Promise&lt;string&gt; {
    const pathname = currentUrl.pathname;
    const pathParts = pathname.split(&quot;/&quot;).filter((p) =&gt; p);

    // Remove current language prefix if exists
    const currentLang = getLangFromUrl(currentUrl);
    if (pathParts[0] === currentLang &amp;&amp; currentLang !== defaultLang) {
        pathParts.shift();
    }

    // Handle root page
    if (pathParts.length === 0) {
        return targetLang === defaultLang ? &quot;/&quot; : `/${targetLang}/`;
    }

    const baseRoute = pathParts[0];
    const slug = pathParts[1];

    // Handle blog post with content linking
    if (slug &amp;&amp; isBlogRoute(baseRoute)) {
        return await handleBlogPostTranslation(
            currentLang,
            targetLang,
            baseRoute,
            slug,
            currentUrl.pathname
        );
    }

    // Handle other routes by translating all route segments
    const translatedSegments = pathParts.map((segment) =&gt; {
        return translateRouteName(segment, targetLang);
    });

    const newPath = translatedSegments.join(&quot;/&quot;);
    return targetLang === defaultLang
        ? `/${newPath}`
        : `/${targetLang}/${newPath}`;
}

//---------------------------------- FUNCTIONS ----------------------------------//
/**
 * Replaces {{key}} placeholders in text with provided parameters
 */
function interpolateParams(
    text: string,
    params: Record&lt;string, string | number&gt;
): string {
    return Object.entries(params).reduce(
        (result, [key, value]) =&gt;
            result.replace(new RegExp(`{{${key}}}`, &quot;g&quot;), String(value)),
        text
    );
}

/**
 * Builds content links automatically from blog posts with linkedContent frontmatter
 * Returns mapping of linkedContent -&gt; { lang: &quot;lang/slug&quot; }
 */
export async function buildContentLinks(): Promise&lt;
    Record&lt;string, Record&lt;string, string&gt;&gt;
&gt; {
    const allPosts = await getCollection(
        &quot;blog&quot;,
        (entry) =&gt; !entry.data.isDraft
    );
    const links: Record&lt;string, Record&lt;string, string&gt;&gt; = {};

    allPosts.forEach((post) =&gt; {
        const { linkedContent } = post.data;
        if (linkedContent) {
            const [lang] = post.id.split(&quot;/&quot;);

            if (!links[linkedContent]) {
                links[linkedContent] = {};
            }
            links[linkedContent][lang] = post.id;
        }
    });

    return links;
}

/**
 * Finds content group for given collection ID using dynamic content links
 */
async function findContentGroup(collectionId: string): Promise&lt;string | null&gt; {
    const dynamicLinks = await buildContentLinks();
    return (
        Object.entries(dynamicLinks).find(([, links]) =&gt;
            Object.values(links).includes(collectionId)
        )?.[0] || null
    );
}

/**
 * Checks if route is a blog route in any language
 */
function isBlogRoute(route: string): boolean {
    return route === &quot;blog&quot;;
}

/**
 * Converts language to collection ID format (defaultLang -&gt; &quot;en&quot;)
 */
function getLangCode(lang: string): string {
    return lang === defaultLang ? &quot;en&quot; : lang;
}

/**
 * Handles language switching for blog posts using content links mapping
 * Maps between different slugs per language (e.g., ai-trends &lt;-&gt; tendinte-ai)
 */
async function handleBlogPostTranslation(
    currentLang: string,
    targetLang: string,
    baseRoute: string,
    slug: string,
    fallbackPath: string
): Promise&lt;string&gt; {
    const currentPostId = `${getLangCode(currentLang)}/${slug}`;
    const contentGroup = await findContentGroup(currentPostId);

    if (contentGroup) {
        const dynamicLinks = await buildContentLinks();
        const targetPostId =
            dynamicLinks[contentGroup]?.[getLangCode(targetLang)];

        if (targetPostId) {
            const targetSlug = targetPostId.split(&quot;/&quot;)[1];
            const targetRouteName = translateRouteName(baseRoute, targetLang);
            const targetPath = `/${targetRouteName}/${targetSlug}`;

            return targetLang === defaultLang
                ? targetPath
                : `/${targetLang}${targetPath}`;
        }
    }

    return fallbackPath;
}

/**
 * Finds original route name from translated route
 * Example: &quot;despre&quot; -&gt; &quot;about&quot;
 */
function getOriginalRouteName(routeName: string): string {
    for (const routeMap of Object.values(routes)) {
        const original = Object.entries(routeMap).find(
            ([, translated]) =&gt; translated === routeName
        )?.[0];
        if (original) return original;
    }
    return routeName;
}

/**
 * Translates route name to target language
 * Example: &quot;about&quot; + &quot;ro&quot; -&gt; &quot;despre&quot;
 */
function translateRouteName(routeName: string, targetLang: string): string {
    const originalRoute = getOriginalRouteName(routeName);
    return targetLang === defaultLang
        ? originalRoute
        : routes[targetLang]?.[originalRoute] || originalRoute;
}
```

## Translation Files

Start with common translations (navigation, footer) and add page-specific ones as you build.

### English Common Translations

Create `src/locales/en/common.json`:

```json
{
    &quot;menu&quot;: {
        &quot;list&quot;: {
            &quot;home&quot;: &quot;Home&quot;,
            &quot;about&quot;: &quot;About&quot;,
            &quot;blog&quot;: &quot;Blog&quot;,
            &quot;contact&quot;: &quot;Contact&quot;,
            &quot;services&quot;: &quot;Services&quot;,
            &quot;pages&quot;: &quot;Pages&quot;,
            &quot;page-1&quot;: &quot;Page 1&quot;,
            &quot;page-2&quot;: &quot;Page 2&quot;
        },
        &quot;languagesText&quot;: {
            &quot;selectLanguage&quot;: &quot;Select Language&quot;
        },
        &quot;languages&quot;: {
            &quot;en&quot;: &quot;English&quot;,
            &quot;ro&quot;: &quot;Română&quot;
        }
    },
    &quot;footer&quot;: {
        &quot;description&quot;: &quot;Astro Multilingual Website&quot;,
        &quot;name&quot;: &quot;Your Company&quot;,
        &quot;copy&quot;: &quot;Copyright&quot;,
        &quot;made&quot;: &quot;Made with {{what}}&quot;,
        &quot;allRightsReserved&quot;: &quot;All rights reserved&quot;
    },
    &quot;pageNotFound&quot;: {
        &quot;head&quot;: {
            &quot;title&quot;: &quot;🔍 404&quot;,
            &quot;description&quot;: &quot;Oops! This page went on vacation&quot;
        },
        &quot;title&quot;: &quot;🔍 404 - Oops! This page went on vacation&quot;,
        &quot;link&quot;: &quot;Back to homepage&quot;
    }
}
```

### 2. Romanian Common Translations

Create `src/locales/ro/common.json`:

```json
{
    &quot;menu&quot;: {
        &quot;list&quot;: {
            &quot;home&quot;: &quot;Acasă&quot;,
            &quot;about&quot;: &quot;Despre&quot;,
            &quot;blog&quot;: &quot;Blog&quot;,
            &quot;contact&quot;: &quot;Contact&quot;,
            &quot;services&quot;: &quot;Servicii&quot;,
            &quot;pages&quot;: &quot;Pagini&quot;,
            &quot;page-1&quot;: &quot;Pagina 1&quot;,
            &quot;page-2&quot;: &quot;Pagina 2&quot;
        },
        &quot;languagesText&quot;: {
            &quot;selectLanguage&quot;: &quot;Selectează Limba&quot;
        },
        &quot;languages&quot;: {
            &quot;en&quot;: &quot;Engleză&quot;,
            &quot;ro&quot;: &quot;Română&quot;
        }
    },
    &quot;footer&quot;: {
        &quot;description&quot;: &quot;Site Web Astro Multilingv&quot;,
        &quot;name&quot;: &quot;Compania Ta&quot;,
        &quot;copy&quot;: &quot;Drepturi de autor&quot;,
        &quot;made&quot;: &quot;Realizat cu {{what}}&quot;,
        &quot;allRightsReserved&quot;: &quot;Toate drepturile rezervate&quot;
    },
    &quot;pageNotFound&quot;: {
        &quot;head&quot;: {
            &quot;title&quot;: &quot;🔍 404&quot;,
            &quot;description&quot;: &quot;Ups! Această pagină a plecat în vacanță&quot;
        },
        &quot;title&quot;: &quot;🔍 404 - Ups! Această pagină a plecat în vacanță&quot;,
        &quot;link&quot;: &quot;Înapoi la pagina principală&quot;
    }
}
```

### 3. Page-Specific Translations

Create `src/locales/en/main.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Welcome to Our Multilingual Site&quot;,
        &quot;description&quot;: &quot;A modern multilingual website built with Astro and i18n support&quot;,
        &quot;keywords&quot;: &quot;astro, multilingual, i18n, internationalization, website&quot;
    },
    &quot;title&quot;: &quot;Welcome to Our Multilingual Site&quot;,
    &quot;description&quot;: &quot;Building global connections through localized experiences&quot;,
    &quot;intro&quot;: &quot;This website demonstrates complete localization capabilities including URL translation, content management, and seamless language switching.&quot;,
    &quot;features&quot;: [
        {
            &quot;title&quot;: &quot;🌐 Multilingual Support&quot;,
            &quot;description&quot;: &quot;Complete localization system with URL translation&quot;
        },
        {
            &quot;title&quot;: &quot;🚀 Performance Optimized&quot;,
            &quot;description&quot;: &quot;Static generation for lightning-fast loading&quot;
        },
        {
            &quot;title&quot;: &quot;📱 Responsive Design&quot;,
            &quot;description&quot;: &quot;Perfect experience across all devices&quot;
        }
    ]
}
```

Create `src/locales/ro/main.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Bun venit pe site-ul nostru multilingv&quot;,
        &quot;description&quot;: &quot;Un site web modern multilingv construit cu Astro și suport i18n&quot;,
        &quot;keywords&quot;: &quot;astro, multilingv, i18n, internaționalizare, site web&quot;
    },
    &quot;title&quot;: &quot;Bun venit pe site-ul nostru multilingv&quot;,
    &quot;description&quot;: &quot;Construim conexiuni globale prin experiențe localizate&quot;,
    &quot;intro&quot;: &quot;Acest site web demonstrează capabilitățile complete de localizare, inclusiv traducerea URL-urilor, managementul conținutului și comutarea fără probleme a limbilor.&quot;,
    &quot;features&quot;: [
        {
            &quot;title&quot;: &quot;🌐 Suport Multilingv&quot;,
            &quot;description&quot;: &quot;Sistem complet de localizare cu traducerea URL-urilor&quot;
        },
        {
            &quot;title&quot;: &quot;🚀 Optimizat pentru Performanță&quot;,
            &quot;description&quot;: &quot;Generare statică pentru încărcare ultra-rapidă&quot;
        },
        {
            &quot;title&quot;: &quot;📱 Design Responsiv&quot;,
            &quot;description&quot;: &quot;Experiență perfectă pe toate dispozitivele&quot;
        }
    ]
}
```

## Dynamic Routing

Astro&apos;s file-based routing doesn&apos;t support URL localization. Use dynamic parameters to create localized URLs.

### Home Page Setup

Create `src/pages/[...index].astro`:

```astro
---
import { useTranslations } from &quot;../i18n/utils&quot;;
import Layout from &quot;../layouts/Layout.astro&quot;;

export function getStaticPaths() {
    return [
        // English route: /
        {
            params: { index: &quot;/&quot; },
            props: { lang: &quot;en&quot; },
        },
        // Romanian route: /ro/
        {
            params: { index: &quot;ro/&quot; },
            props: { lang: &quot;ro&quot; },
        },
    ];
}

const { lang } = Astro.props;
const t = useTranslations(lang);
---
```

&gt; Important: The `params` values must match the URL structure you want. `index: &quot;/&quot;` creates the root path, and `index: &quot;ro/&quot;` creates `/ro/`.

```astro
---
&lt;Layout
    title={t(&quot;main:head.title&quot;)}
    description={t(&quot;main:head.description&quot;)}
    lang={lang}
&gt;
    &lt;main&gt;
        &lt;section class=&quot;hero&quot;&gt;
            &lt;h1&gt;{t(&quot;main:title&quot;)}&lt;/h1&gt;
            &lt;p class=&quot;subtitle&quot;&gt;{t(&quot;main:description&quot;)}&lt;/p&gt;
            &lt;p class=&quot;intro&quot;&gt;{t(&quot;main:intro&quot;)}&lt;/p&gt;
        &lt;/section&gt;

        &lt;section class=&quot;features&quot;&gt;
            {t(&quot;main:features&quot;).map((feature) =&gt; (
                &lt;div class=&quot;feature-card&quot;&gt;
                    &lt;h3&gt;{feature.title}&lt;/h3&gt;
                    &lt;p&gt;{feature.description}&lt;/p&gt;
                &lt;/div&gt;
            ))}
        &lt;/section&gt;
    &lt;/main&gt;
&lt;/Layout&gt;
---
```

### About Page with Dynamic Routing

Create `src/pages/[about]/[...index].astro`:

```astro
---
import Layout from &quot;../../layouts/Layout.astro&quot;;

export function getStaticPaths() {
    return [
        // English route: /about
        {
            params: { about: &quot;about&quot;, index: undefined },
            props: { lang: &quot;en&quot; },
        },
        // Romanian route: /ro/despre
        {
            params: { about: &quot;ro&quot;, index: &quot;despre&quot; },
            props: { lang: &quot;ro&quot; },
        },
    ];
}

const { lang } = Astro.props;

// Dynamically import the correct content file based on language
const { Content, frontmatter } = await import(`./_about-${lang}.mdx`);
```

&gt; Note: The dynamic import pattern `_about-${lang}.mdx` requires files to follow this naming convention.

```astro
&lt;Layout
    title={frontmatter?.title}
    description={frontmatter?.description}
    lang={lang}
&gt;
    &lt;main&gt;
        &lt;Content /&gt;
    &lt;/main&gt;
&lt;/Layout&gt;
```

### 3. Create About Content Files

Create `src/pages/[about]/_about-en.mdx`:

```mdx
---
title: &quot;About Us - Building Global Connections&quot;
description: &quot;Learn about our mission to create inclusive, multilingual digital experiences that connect people across cultures and languages.&quot;
keywords: &quot;about us, company mission, multilingual, global, internationalization&quot;
---

# About Us

We are dedicated to building inclusive digital experiences that transcend language barriers and connect people across cultures.

## Our Mission

Creating seamless multilingual websites that provide authentic localized experiences for users around the world.

## What We Do

- **Multilingual Website Development**: Building sites that speak your users&apos; language
- **Localization Consulting**: Helping businesses expand globally through proper i18n implementation
- **Cultural Adaptation**: Ensuring content resonates with local audiences

## Why Localization Matters

In today&apos;s interconnected world, speaking your audience&apos;s language isn&apos;t just about translation—it&apos;s about creating meaningful connections that drive engagement and business growth.

For more insights on building performant websites, check out our guide on [Astro SSG Build Optimization](https://www.bitdoze.com/astro-ssg-build-optimization/).
```

Create `src/pages/[about]/_about-ro.mdx`:

```mdx
---
title: &quot;Despre Noi - Construim Conexiuni Globale&quot;
description: &quot;Aflați despre misiunea noastră de a crea experiențe digitale incluzive și multilingve care conectează oamenii din diferite culturi și limbi.&quot;
keywords: &quot;despre noi, misiunea companiei, multilingv, global, internaționalizare&quot;
---

# Despre Noi

Suntem dedicați construirii unor experiențe digitale incluzive care transcend barierele lingvistice și conectează oamenii din diferite culturi.

## Misiunea Noastră

Crearea de site-uri web multilingve fără cusur care oferă experiențe localizate autentice pentru utilizatorii din întreaga lume.

## Ce Facem

- **Dezvoltarea Site-urilor Web Multilingve**: Construim site-uri care vorbesc limba utilizatorilor tăi
- **Consultanță în Localizare**: Ajutăm afacerile să se extindă la nivel global prin implementarea corectă a i18n
- **Adaptarea Culturală**: Ne asigurăm că conținutul rezonează cu audiențele locale

## De Ce Contează Localizarea

În lumea interconectată de astăzi, a vorbi limba audiențe tale nu înseamnă doar traducere—înseamnă să creezi conexiuni semnificative care stimulează angajamentul și creșterea afacerii.

Pentru mai multe informații despre construirea site-urilor web performante, consultați ghidul nostru despre [Optimizarea Build-ului Astro SSG](https://www.bitdoze.com/astro-ssg-build-optimization/).
```

## Pages with Localization

### Base Layout

Create `src/layouts/Layout.astro`:

```astro
---
import Header from &quot;../components/Header.astro&quot;;
import Footer from &quot;../components/Footer.astro&quot;;
import { getLangFromUrl } from &quot;../i18n/utils&quot;;

interface Props {
    title: string;
    description: string;
    lang?: string;
    keywords?: string;
}

const { title, description, lang, keywords } = Astro.props;
const currentLang = lang || getLangFromUrl(Astro.url);
---

&lt;!doctype html&gt;
&lt;html lang={currentLang}&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;UTF-8&quot; /&gt;
        &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
        &lt;title&gt;{title}&lt;/title&gt;
        &lt;meta name=&quot;description&quot; content={description} /&gt;
        {keywords &amp;&amp; &lt;meta name=&quot;keywords&quot; content={keywords} /&gt;}

        &lt;!-- Hreflang tags for SEO --&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href={`${Astro.site}`} /&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;ro&quot; href={`${Astro.site}ro/`} /&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;x-default&quot; href={`${Astro.site}`} /&gt;

        &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/favicon.svg&quot; /&gt;
        &lt;meta name=&quot;generator&quot; content={Astro.generator} /&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;Header /&gt;
        &lt;slot /&gt;
        &lt;Footer /&gt;
    &lt;/body&gt;
&lt;/html&gt;

&lt;style is:global&gt;
    /* Your global styles here */
    body {
        font-family: system-ui, sans-serif;
        margin: 0;
        padding: 0;
        line-height: 1.6;
    }

    .hero {
        text-align: center;
        padding: 4rem 2rem;
    }

    .features {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
        gap: 2rem;
        padding: 2rem;
        max-width: 1200px;
        margin: 0 auto;
    }

    .feature-card {
        padding: 2rem;
        border: 1px solid #e5e7eb;
        border-radius: 8px;
        text-align: center;
    }
&lt;/style&gt;
```

### 2. Create Navigation Data

Create `src/data/navigationData.ts`:

```typescript
export interface NavigationItem {
    label: string;
    href: string;
    children?: NavigationItem[];
}

const navigationData: NavigationItem[] = [
    {
        label: &quot;menu.list.home&quot;,
        href: &quot;/&quot;,
        children: [],
    },
    {
        label: &quot;menu.list.about&quot;,
        href: &quot;/about&quot;,
        children: [],
    },
    {
        label: &quot;menu.list.blog&quot;,
        href: &quot;/blog&quot;,
        children: [],
    },
    {
        label: &quot;menu.list.pages&quot;,
        href: &quot;/pages&quot;,
        children: [
            {
                label: &quot;menu.list.page-1&quot;,
                href: &quot;/pages/page-1&quot;,
            },
            {
                label: &quot;menu.list.page-2&quot;,
                href: &quot;/pages/page-2&quot;,
            },
        ],
    },
    {
        label: &quot;menu.list.contact&quot;,
        href: &quot;/contact&quot;,
        children: [],
    },
];

export default navigationData;
```

## Language Switcher

### Language Picker Component

This component includes ARIA labels and keyboard navigation.

Create `src/components/LanguagePicker.astro`:

```astro
---
import {
    switchLanguageUrl,
    getLangFromUrl,
    useTranslations,
} from &quot;../i18n/utils&quot;;
import { languages } from &quot;../i18n/ui&quot;;

// Get current language
const currentLang = getLangFromUrl(Astro.url);
const t = useTranslations(currentLang);

// Pre-generate URLs for all languages
const languageUrls = await Promise.all(
    Object.entries(languages).map(async ([lang, label]) =&gt; {
        const targetUrl = await switchLanguageUrl(Astro.url, lang);
        const translatedLabel = t(`menu.languages.${lang}`);
        return { lang, label: translatedLabel, targetUrl };
    })
);
---

&lt;div class=&quot;language-picker&quot;&gt;
    &lt;label for=&quot;language&quot; class=&quot;sr-only&quot;&gt;
        {t(&quot;menu.languagesText.selectLanguage&quot;)}
    &lt;/label&gt;
    &lt;select
        name=&quot;language&quot;
        id=&quot;language&quot;
        aria-label={t(&quot;menu.languagesText.selectLanguage&quot;)}
        class=&quot;language-select&quot;
        onchange=&quot;window.location.href = this.value&quot;
    &gt;
        {
            languageUrls.map(({ lang, label, targetUrl }) =&gt; (
                &lt;option
                    value={targetUrl}
                    selected={lang === currentLang}
                    aria-selected={lang === currentLang}
                &gt;
                    {label}
                &lt;/option&gt;
            ))
        }
    &lt;/select&gt;
&lt;/div&gt;

&lt;style&gt;
    .language-picker {
        position: relative;
    }

    .language-select {
        padding: 0.5rem 1rem;
        border: 1px solid #d1d5db;
        border-radius: 0.375rem;
        background-color: white;
        cursor: pointer;
        font-size: 0.875rem;
    }

    .language-select:focus {
        outline: 2px solid #3b82f6;
        outline-offset: 2px;
    }

    .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }
&lt;/style&gt;
```

### 2. Header Component

Create `src/components/Header.astro`:

```astro
---
import {
    getLangFromUrl,
    useTranslations,
    useTranslatedPath,
} from &quot;../i18n/utils&quot;;
import navigationData from &quot;../data/navigationData&quot;;
import LanguagePicker from &quot;./LanguagePicker.astro&quot;;

// Get translations and path translator
const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
---

&lt;header class=&quot;header&quot;&gt;
    &lt;div class=&quot;container&quot;&gt;
        &lt;nav class=&quot;nav&quot; role=&quot;navigation&quot; aria-label=&quot;Main navigation&quot;&gt;
            &lt;!-- Logo/Home Link --&gt;
            &lt;a
                href={translatePath(&quot;/&quot;)}
                class=&quot;logo&quot;
                aria-label={t(&quot;menu.list.home&quot;)}
            &gt;
                Your Logo
            &lt;/a&gt;

            &lt;!-- Main Navigation --&gt;
            &lt;ul class=&quot;nav-list&quot; role=&quot;menubar&quot;&gt;
                {
                    navigationData.map((item) =&gt; (
                        &lt;li class=&quot;nav-item&quot; role=&quot;none&quot;&gt;
                            &lt;a
                                href={translatePath(item.href)}
                                class=&quot;nav-link&quot;
                                role=&quot;menuitem&quot;
                                aria-label={t(item.label)}
                            &gt;
                                {t(item.label)}
                            &lt;/a&gt;

                            {/* Dropdown menu for items with children */}
                            {item.children?.length &gt; 0 &amp;&amp; (
                                &lt;ul
                                    class=&quot;dropdown-menu&quot;
                                    role=&quot;menu&quot;
                                    aria-label={`${t(item.label)} submenu`}
                                &gt;
                                    {item.children.map((child) =&gt; (
                                        &lt;li role=&quot;none&quot;&gt;
                                            &lt;a
                                                href={translatePath(child.href)}
                                                class=&quot;dropdown-link&quot;
                                                role=&quot;menuitem&quot;
                                                aria-label={t(child.label)}
                                            &gt;
                                                {t(child.label)}
                                            &lt;/a&gt;
                                        &lt;/li&gt;
                                    ))}
                                &lt;/ul&gt;
                            )}
                        &lt;/li&gt;
                    ))
                }
            &lt;/ul&gt;

            &lt;!-- Language Picker --&gt;
            &lt;div class=&quot;nav-actions&quot;&gt;
                &lt;LanguagePicker /&gt;
            &lt;/div&gt;
        &lt;/nav&gt;
    &lt;/div&gt;
&lt;/header&gt;

&lt;style&gt;
    .header {
        background: white;
        border-bottom: 1px solid #e5e7eb;
        position: sticky;
        top: 0;
        z-index: 50;
    }

    .container {
        max-width: 1200px;
        margin: 0 auto;
        padding: 0 1rem;
    }

    .nav {
        display: flex;
        align-items: center;
        justify-content: space-between;
        padding: 1rem 0;
    }

    .logo {
        font-size: 1.5rem;
        font-weight: bold;
        text-decoration: none;
        color: #111827;
    }

    .nav-list {
        display: flex;
        list-style: none;
        margin: 0;
        padding: 0;
        gap: 2rem;
    }

    .nav-item {
        position: relative;
    }

    .nav-link {
        text-decoration: none;
        color: #374151;
        font-weight: 500;
        padding: 0.5rem 0;
        transition: color 0.2s;
    }

    .nav-link:hover {
        color: #3b82f6;
    }

    .dropdown-menu {
        position: absolute;
        top: 100%;
        left: 0;
        background: white;
        border: 1px solid #e5e7eb;
        border-radius: 0.375rem;
        box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
        list-style: none;
        margin: 0;
        padding: 0.5rem 0;
        min-width: 150px;
        opacity: 0;
        visibility: hidden;
        transform: translateY(-0.5rem);
        transition: all 0.2s;
    }

    .nav-item:hover .dropdown-menu {
        opacity: 1;
        visibility: visible;
        transform: translateY(0);
    }

    .dropdown-link {
        display: block;
        padding: 0.5rem 1rem;
        text-decoration: none;
        color: #374151;
        transition: background-color 0.2s;
    }

    .dropdown-link:hover {
        background-color: #f3f4f6;
    }

    .nav-actions {
        display: flex;
        align-items: center;
        gap: 1rem;
    }
&lt;/style&gt;
```

### 3. Footer Component

Create `src/components/Footer.astro`:

```astro
---
import { getLangFromUrl, useTranslations } from &quot;../i18n/utils&quot;;

const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
const currentYear = new Date().getFullYear();
---

&lt;footer class=&quot;footer&quot;&gt;
    &lt;div class=&quot;container&quot;&gt;
        &lt;div class=&quot;footer-content&quot;&gt;
            &lt;div class=&quot;footer-section&quot;&gt;
                &lt;h3&gt;{t(&quot;footer.name&quot;)}&lt;/h3&gt;
                &lt;p&gt;{t(&quot;footer.description&quot;)}&lt;/p&gt;
            &lt;/div&gt;

            &lt;div class=&quot;footer-section&quot;&gt;
                &lt;p&gt;{t(&quot;footer.made&quot;, { what: &quot;Astro&quot; })}&lt;/p&gt;
                &lt;p&gt;
                    {t(&quot;footer.copy&quot;)} © {currentYear} {t(&quot;footer.name&quot;)}.
                    {t(&quot;footer.allRightsReserved&quot;)}
                &lt;/p&gt;
            &lt;/div&gt;
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/footer&gt;

&lt;style&gt;
    .footer {
        background: #111827;
        color: white;
        margin-top: auto;
    }

    .container {
        max-width: 1200px;
        margin: 0 auto;
        padding: 0 1rem;
    }

    .footer-content {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
        gap: 2rem;
        padding: 3rem 0 2rem;
    }

    .footer-section h3 {
        margin-bottom: 1rem;
        color: #f9fafb;
    }

    .footer-section p {
        color: #d1d5db;
        line-height: 1.6;
    }
&lt;/style&gt;
```

## Blog System with Localization

### 1. Configure Content Collections

Create `src/content/config.ts`:

&gt; Note: This file must be named `config.ts` and placed in the `src/content/` directory for Astro to recognize it.

```typescript
import { defineCollection, z } from &quot;astro:content&quot;;

const blogCollection = defineCollection({
    type: &apos;content&apos;,
    schema: ({ image }) =&gt;
        z.object({
            title: z.string(),
            description: z.string(),
            author: z.string(),
            pubDate: z.date(),
            isDraft: z.boolean().default(false),
            linkedContent: z.string().optional(),
            image: image().optional(),
            imageAlt: z.string().optional(),
            keywords: z.string().optional(),
        }),
});

export const collections = {
    blog: blogCollection,
};
```

### 2. Create Blog Listing Page

Create `src/pages/[...blog].astro`:

```astro
---
import { getCollection } from &quot;astro:content&quot;;
import { useTranslations, useTranslatedPath } from &quot;../i18n/utils&quot;;
import Layout from &quot;../layouts/Layout.astro&quot;;

export function getStaticPaths() {
    return [
        // English route: /blog
        {
            params: { blog: &quot;/blog&quot; },
            props: { lang: &quot;en&quot; },
        },
        // Romanian route: /ro/blog
        {
            params: { blog: &quot;/ro/blog&quot; },
            props: { lang: &quot;ro&quot; },
        },
    ];
}

const { lang } = Astro.props;
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);

// Get blog posts filtered by language
const posts = await getCollection(&quot;blog&quot;, (entry) =&gt; {
    const [entryLang] = entry.id.split(&quot;/&quot;);
    const matches = entryLang === lang;
    return matches &amp;&amp; !entry.data.isDraft;
});

// Sort posts by publication date (newest first)
const sortedPosts = posts.sort(
    (a, b) =&gt;
        new Date(b.data.pubDate).getTime() - new Date(a.data.pubDate).getTime()
);
```

&gt; Note: Blog posts sort at build time, so there&apos;s no client-side performance impact.

```astro
&lt;Layout
    title={t(&quot;blog:head.title&quot;)}
    description={t(&quot;blog:head.description&quot;)}
    lang={lang}
&gt;
    &lt;main&gt;
        &lt;section class=&quot;blog-header&quot;&gt;
            &lt;h1&gt;{t(&quot;blog:title&quot;)}&lt;/h1&gt;
            &lt;p class=&quot;subtitle&quot;&gt;{t(&quot;blog:description&quot;)}&lt;/p&gt;
        &lt;/section&gt;

        &lt;section class=&quot;posts-grid&quot;&gt;
            {
                sortedPosts.length &gt; 0 ? (
                    sortedPosts.map((post) =&gt; (
                        &lt;article class=&quot;post-card&quot;&gt;
                            &lt;a href={`${translatePath(&quot;/blog&quot;)}/${post.slug}`}&gt;
                                {post.data.image &amp;&amp; (
                                    &lt;img
                                        src={post.data.image.src}
                                        alt={post.data.imageAlt || post.data.title}
                                        class=&quot;post-image&quot;
                                        loading=&quot;lazy&quot;
                                    /&gt;
                                )}

                                &lt;div class=&quot;post-content&quot;&gt;
                                    &lt;time class=&quot;post-date&quot;&gt;
                                        {post.data.pubDate.toLocaleDateString(lang)}
                                    &lt;/time&gt;
                                    &lt;h2 class=&quot;post-title&quot;&gt;{post.data.title}&lt;/h2&gt;
                                    &lt;p class=&quot;post-description&quot;&gt;{post.data.description}&lt;/p&gt;
                                    &lt;div class=&quot;read-more&quot;&gt;
                                        {t(&quot;blog:readMore&quot;)} →
                                    &lt;/div&gt;
                                &lt;/div&gt;
                            &lt;/a&gt;
                        &lt;/article&gt;
                    ))
                ) : (
                    &lt;p class=&quot;no-posts&quot;&gt;{t(&quot;blog:noPosts&quot;)}&lt;/p&gt;
                )
            }
        &lt;/section&gt;
    &lt;/main&gt;
&lt;/Layout&gt;

&lt;style&gt;
    .blog-header {
        text-align: center;
        padding: 4rem 2rem 2rem;
    }

    .subtitle {
        font-size: 1.25rem;
        color: #6b7280;
        margin-top: 1rem;
    }

    .posts-grid {
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
        gap: 2rem;
        padding: 2rem;
        max-width: 1200px;
        margin: 0 auto;
    }

    .post-card {
        border: 1px solid #e5e7eb;
        border-radius: 8px;
        overflow: hidden;
        transition: transform 0.2s, box-shadow 0.2s;
    }

    .post-card:hover {
        transform: translateY(-4px);
        box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
    }

    .post-card a {
        text-decoration: none;
        color: inherit;
        display: block;
    }

    .post-image {
        width: 100%;
        height: 200px;
        object-fit: cover;
    }

    .post-content {
        padding: 1.5rem;
    }

    .post-date {
        color: #6b7280;
        font-size: 0.875rem;
    }

    .post-title {
        margin: 0.5rem 0;
        font-size: 1.25rem;
        font-weight: 600;
        line-height: 1.4;
    }

    .post-description {
        color: #6b7280;
        margin-bottom: 1rem;
        line-height: 1.6;
    }

    .read-more {
        color: #3b82f6;
        font-weight: 500;
    }

    .no-posts {
        grid-column: 1 / -1;
        text-align: center;
        color: #6b7280;
        font-style: italic;
    }
&lt;/style&gt;
```

### 3. Create Blog Translation Files

Create `src/locales/en/blog.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Blog - Latest Articles&quot;,
        &quot;description&quot;: &quot;Read our latest articles about web development, technology trends, and digital innovation.&quot;
    },
    &quot;title&quot;: &quot;Our Blog&quot;,
    &quot;description&quot;: &quot;Insights, tutorials, and thoughts on modern web development&quot;,
    &quot;readMore&quot;: &quot;Read More&quot;,
    &quot;noPosts&quot;: &quot;No posts available yet. Check back soon!&quot;,
    &quot;publishedOn&quot;: &quot;Published on&quot;,
    &quot;author&quot;: &quot;Author&quot;,
    &quot;relatedPosts&quot;: &quot;Related Posts&quot;
}
```

Create `src/locales/ro/blog.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Blog - Ultimele Articole&quot;,
        &quot;description&quot;: &quot;Citește cele mai recente articole despre dezvoltarea web, tendințele tehnologice și inovația digitală.&quot;
    },
    &quot;title&quot;: &quot;Blogul Nostru&quot;,
    &quot;description&quot;: &quot;Perspective, tutoriale și gânduri despre dezvoltarea web modernă&quot;,
    &quot;readMore&quot;: &quot;Citește Mai Mult&quot;,
    &quot;noPosts&quot;: &quot;Nu sunt încă postări disponibile. Revino în curând!&quot;,
    &quot;publishedOn&quot;: &quot;Publicat pe&quot;,
    &quot;author&quot;: &quot;Autor&quot;,
    &quot;relatedPosts&quot;: &quot;Postări Corelate&quot;
}
```

### 4. Create Blog Post Detail Page

Create `src/pages/[blog]/[...slug].astro`:

```astro
---
import { getCollection } from &quot;astro:content&quot;;
import { useTranslations } from &quot;../i18n/utils&quot;;
import Layout from &quot;../layouts/Layout.astro&quot;;

export async function getStaticPaths() {
    const posts = await getCollection(&quot;blog&quot;, (entry) =&gt; !entry.data.isDraft);

    return posts.map((post) =&gt; {
        const [lang] = post.id.split(&quot;/&quot;);
        const isEnglish = lang === &quot;en&quot;;

        return {
            params: {
                blog: isEnglish ? &quot;blog&quot; : `${lang}/blog`,
                slug: post.slug
            },
            props: { post, lang }
        };
    });
}

const { post, lang } = Astro.props;
const { Content } = await post.render();
const t = useTranslations(lang);
---

&lt;Layout
    title={post.data.title}
    description={post.data.description}
    keywords={post.data.keywords}
    lang={lang}
&gt;
    &lt;article class=&quot;blog-post&quot;&gt;
        &lt;header class=&quot;post-header&quot;&gt;
            {post.data.image &amp;&amp; (
                &lt;img
                    src={post.data.image.src}
                    alt={post.data.imageAlt || post.data.title}
                    class=&quot;featured-image&quot;
                /&gt;
            )}

            &lt;div class=&quot;post-meta&quot;&gt;
                &lt;time class=&quot;post-date&quot;&gt;
                    {t(&quot;blog:publishedOn&quot;)} {post.data.pubDate.toLocaleDateString(lang)}
                &lt;/time&gt;
                &lt;div class=&quot;post-author&quot;&gt;
                    {t(&quot;blog:author&quot;)}: {post.data.author}
                &lt;/div&gt;
            &lt;/div&gt;

            &lt;h1 class=&quot;post-title&quot;&gt;{post.data.title}&lt;/h1&gt;
        &lt;/header&gt;

        &lt;div class=&quot;post-content&quot;&gt;
            &lt;Content /&gt;
        &lt;/div&gt;
    &lt;/article&gt;
&lt;/Layout&gt;

&lt;style&gt;
    .blog-post {
        max-width: 800px;
        margin: 0 auto;
        padding: 2rem;
    }

    .post-header {
        margin-bottom: 3rem;
    }

    .featured-image {
        width: 100%;
        height: 400px;
        object-fit: cover;
        border-radius: 8px;
        margin-bottom: 2rem;
    }

    .post-meta {
        display: flex;
        gap: 1rem;
        margin-bottom: 1rem;
        font-size: 0.875rem;
        color: #6b7280;
    }

    .post-title {
        font-size: 2.5rem;
        font-weight: 700;
        line-height: 1.2;
        margin: 0;
    }

    .post-content {
        line-height: 1.8;
        font-size: 1.125rem;
    }

    .post-content :global(h2) {
        margin-top: 3rem;
        margin-bottom: 1rem;
        font-size: 1.875rem;
        font-weight: 600;
    }

    .post-content :global(h3) {
        margin-top: 2rem;
        margin-bottom: 0.75rem;
        font-size: 1.5rem;
        font-weight: 600;
    }

    .post-content :global(p) {
        margin-bottom: 1.5rem;
    }

    .post-content :global(ul),
    .post-content :global(ol) {
        margin-bottom: 1.5rem;
        padding-left: 2rem;
    }

    .post-content :global(li) {
        margin-bottom: 0.5rem;
    }

    .post-content :global(blockquote) {
        border-left: 4px solid #3b82f6;
        padding-left: 1.5rem;
        margin: 2rem 0;
        font-style: italic;
        color: #6b7280;
    }

    .post-content :global(code) {
        background: #f3f4f6;
        padding: 0.2rem 0.4rem;
        border-radius: 4px;
        font-size: 0.875rem;
    }

    .post-content :global(pre) {
        background: #1f2937;
        color: #f9fafb;
        padding: 1.5rem;
        border-radius: 8px;
        overflow-x: auto;
        margin: 2rem 0;
    }

    .post-content :global(pre code) {
        background: none;
        padding: 0;
    }
&lt;/style&gt;
```

### Sample Blog Posts

Create the directory structure:

```bash
mkdir -p src/content/blog/en src/content/blog/ro
```

Create `src/content/blog/en/astro-performance-tips.md`:

```markdown
---
title: &quot;5 Essential Astro Performance Tips&quot;
description: &quot;Learn how to optimize your Astro website for lightning-fast performance with these proven techniques.&quot;
author: &quot;Web Developer&quot;
pubDate: 2025-01-15
linkedContent: &quot;astro-performance-tips&quot;
keywords: &quot;astro, performance, optimization, web development, static site generator&quot;
---

# 5 Essential Astro Performance Tips

The `linkedContent` field with the same value in both languages enables cross-language navigation.

Astro is fast by default, but these techniques can make sites even faster:

## 1. Optimize Images with Astro Assets

Always use Astro&apos;s built-in image optimization:

```astro
---
import { Image } from &apos;astro:assets&apos;;
import heroImage from &apos;../assets/hero.jpg&apos;;
---

&lt;Image
    src={heroImage}
    alt=&quot;Hero image&quot;
    width={800}
    height={400}
    loading=&quot;lazy&quot;
/&gt;
```

## 2. Use Component Islands Strategically

Only hydrate components that need interactivity:

```astro
&lt;!-- This loads JavaScript --&gt;
&lt;InteractiveComponent client:load /&gt;

&lt;!-- This doesn&apos;t load JavaScript --&gt;
&lt;StaticComponent /&gt;
```

## 3. Implement Proper Caching

Set up appropriate cache headers for your static assets and API responses.

## 4. Minimize Bundle Size

- Use tree shaking
- Import only what you need
- Consider using lighter alternatives to heavy libraries

## 5. Leverage Content Collections

Use Astro&apos;s Content Collections for better performance with large amounts of content.

For more optimization techniques, see the [Astro SSG Build Optimization](https://www.bitdoze.com/astro-ssg-build-optimization/) guide.

---

Learn more about building fast websites with [building a free Astro blog](https://www.bitdoze.com/build-astro-blog-free/) or [Astro and Convex for realtime apps](https://www.bitdoze.com/astro-convex-realtime-app/).


Create `src/content/blog/ro/sfaturi-performanta-astro.md`:

```markdown
---
title: &quot;5 Sfaturi Esențiale pentru Performanța Astro&quot;
description: &quot;Învață cum să optimizezi site-ul tău Astro pentru performanțe ultra-rapide cu aceste tehnici dovedite.&quot;
author: &quot;Dezvoltator Web&quot;
pubDate: 2025-01-15
linkedContent: &quot;astro-performance-tips&quot;
keywords: &quot;astro, performanță, optimizare, dezvoltare web, generator site static&quot;
---

# 5 Sfaturi Esențiale pentru Performanța Astro

Astro este deja rapid în mod implicit, dar există mai multe tehnici pe care le poți folosi pentru a-ți face site-urile și mai rapide. Iată primele noastre 5 sfaturi pentru optimizarea performanței.

## 1. Optimizează Imaginile cu Astro Assets

Folosește mereu optimizarea de imagini integrată în Astro:

```astro
---
import { Image } from &apos;astro:assets&apos;;
import heroImage from &apos;../assets/hero.jpg&apos;;
---

&lt;Image
    src={heroImage}
    alt=&quot;Imagine hero&quot;
    width={800}
    height={400}
    loading=&quot;lazy&quot;
/&gt;
```

## 2. Folosește Insulele de Componente Strategic

Hidratează doar componentele care au nevoie de interactivitate:

```astro
&lt;!-- Aceasta încarcă JavaScript --&gt;
&lt;InteractiveComponent client:load /&gt;

&lt;!-- Aceasta nu încarcă JavaScript --&gt;
&lt;StaticComponent /&gt;
```

## 3. Implementează Cache-uire Adecvată

Configurează header-uri de cache corespunzătoare pentru asset-urile statice și răspunsurile API.

## 4. Minimizează Dimensiunea Bundle-ului

- Folosește tree shaking
- Importă doar ce ai nevoie
- Consideră utilizarea unor alternative mai ușoare la bibliotecile grele

## 5. Valorifică Content Collections

Folosește Content Collections din Astro pentru performanță mai bună cu cantități mari de conținut.

Pentru tehnici de optimizare mai avansate, vezi ghidul [Optimizarea Build-ului Astro SSG](https://www.bitdoze.com/astro-ssg-build-optimization/).

---

Află mai multe despre construirea site-urilor rapide cu [construirea unui blog Astro gratuit](https://www.bitdoze.com/build-astro-blog-free/) sau [Astro și Convex pentru aplicații realtime](https://www.bitdoze.com/astro-convex-realtime-app/).


## Navigation and Components

### Advanced Navigation with Subpages

Create `src/pages/[pages]/[...index].astro`:

```astro
---
import Layout from &quot;../../layouts/Layout.astro&quot;;

export function getStaticPaths() {
    return [
        // English route: /pages
        { params: { pages: &quot;pages&quot;, index: undefined }, props: { lang: &quot;en&quot; } },
        // Romanian route: /ro/pagini
        { params: { pages: &quot;ro&quot;, index: &quot;pagini&quot; }, props: { lang: &quot;ro&quot; } },
    ];
}

const { lang } = Astro.props;
const { Content, frontmatter } = await import(`./_pages-${lang}.mdx`);
---

&lt;Layout
    title={frontmatter?.title}
    description={frontmatter?.description}
    lang={lang}
&gt;
    &lt;main&gt;
        &lt;Content /&gt;
    &lt;/main&gt;
&lt;/Layout&gt;
```

Create a subpage with nested routing at `src/pages/[pages]/[page1]/[...index].astro`:

```astro
---
import Layout from &quot;../../layouts/Layout.astro&quot;;

export function getStaticPaths() {
    return [
        // English: /pages/page-1
        {
            params: { pages: &quot;pages&quot;, page1: &quot;page-1&quot;, index: undefined },
            props: { lang: &quot;en&quot; }
        },
        // Romanian: /ro/pagini/pagina-1
        {
            params: { pages: &quot;ro&quot;, page1: &quot;pagini&quot;, index: &quot;pagina-1&quot; },
            props: { lang: &quot;ro&quot; }
        },
    ];
}

const { lang } = Astro.props;
const { Content, frontmatter } = await import(`./_page1-${lang}.mdx`);
---

&lt;Layout
    title={frontmatter?.title}
    description={frontmatter?.description}
    lang={lang}
&gt;
    &lt;main&gt;
        &lt;Content /&gt;
    &lt;/main&gt;
&lt;/Layout&gt;
```

Create page content files:

Create `src/pages/[pages]/_pages-en.mdx`:

```markdown
---
title: &quot;Pages - Examples &amp; Templates&quot;
description: &quot;Explore our collection of page examples and templates for building multilingual websites.&quot;
keywords: &quot;pages, templates, examples, multilingual, astro&quot;
---

# Pages

This section contains various page examples demonstrating different layouts and features.

## Available Pages

- [Page 1](/pages/page-1) - Basic content example
- [Page 2](/pages/page-2) - Advanced layout example

Each page demonstrates different aspects of our multilingual system.
```

Create `src/pages/[pages]/_pages-ro.mdx`:

```markdown
---
title: &quot;Pagini - Exemple și Șabloane&quot;
description: &quot;Explorează colecția noastră de exemple de pagini și șabloane pentru construirea site-urilor multilingve.&quot;
keywords: &quot;pagini, șabloane, exemple, multilingv, astro&quot;
---

# Pagini

Această secțiune conține diverse exemple de pagini care demonstrează diferite layout-uri și funcționalități.

## Pagini Disponibile

- [Pagina 1](/ro/pagini/pagina-1) - Exemplu de conținut de bază
- [Pagina 2](/ro/pagini/pagina-2) - Exemplu de layout avansat

Fiecare pagină demonstrează diferite aspecte ale sistemului nostru multilingv.
```

Create `src/pages/[pages]/[page1]/_page1-en.mdx`:

```markdown
---
title: &quot;Page 1 - Basic Example&quot;
description: &quot;A basic page example showing content structure and layout.&quot;
---

# Page 1 - Basic Example

This is a simple page demonstrating basic content structure and multilingual capabilities.

## Features

- Clean layout
- Responsive design
- Multilingual support
```

Create `src/pages/[pages]/[page1]/_page1-ro.mdx`:

```markdown
---
title: &quot;Pagina 1 - Exemplu de Bază&quot;
description: &quot;Un exemplu de pagină de bază care arată structura conținutului și layout-ul.&quot;
---

# Pagina 1 - Exemplu de Bază

Aceasta este o pagină simplă care demonstrează structura de bază a conținutului și capacitățile multilingve.

## Caracteristici

- Layout curat
- Design responsiv
- Suport multilingv
```

## SEO Optimization

### Enhanced Layout with Hreflang

Update `src/layouts/Layout.astro` to add SEO features:

```astro
---
import Header from &quot;../components/Header.astro&quot;;
import Footer from &quot;../components/Footer.astro&quot;;
import { getLangFromUrl, useTranslatedPath } from &quot;../i18n/utils&quot;;

interface Props {
    title: string;
    description: string;
    lang?: string;
    keywords?: string;
    ogImage?: string;
    canonicalUrl?: string;
}

const { title, description, lang, keywords, ogImage, canonicalUrl } = Astro.props;
const currentLang = lang || getLangFromUrl(Astro.url);
const translatePath = useTranslatedPath(currentLang);

// Generate alternate URLs for hreflang
const currentPath = Astro.url.pathname;
const baseUrl = Astro.site?.toString() || &apos;&apos;;

// Remove language prefix to get base path
let basePath = currentPath;
if (currentPath.startsWith(&apos;/ro/&apos;)) {
    basePath = currentPath.replace(&apos;/ro&apos;, &apos;&apos;) || &apos;/&apos;;
}

const alternateUrls = {
    en: baseUrl + (basePath === &apos;/&apos; ? &apos;&apos; : basePath),
    ro: baseUrl + translatePath(basePath, &apos;ro&apos;),
};
---

&lt;!doctype html&gt;
&lt;html lang={currentLang}&gt;
    &lt;head&gt;
        &lt;meta charset=&quot;UTF-8&quot; /&gt;
        &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;

        &lt;!-- Basic Meta Tags --&gt;
        &lt;title&gt;{title}&lt;/title&gt;
        &lt;meta name=&quot;description&quot; content={description} /&gt;
        {keywords &amp;&amp; &lt;meta name=&quot;keywords&quot; content={keywords} /&gt;}

        &lt;!-- Canonical URL --&gt;
        &lt;link rel=&quot;canonical&quot; href={canonicalUrl || Astro.url} /&gt;

        &lt;!-- Hreflang Tags --&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href={alternateUrls.en} /&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;ro&quot; href={alternateUrls.ro} /&gt;
        &lt;link rel=&quot;alternate&quot; hreflang=&quot;x-default&quot; href={alternateUrls.en} /&gt;

        &lt;!-- Open Graph Tags --&gt;
        &lt;meta property=&quot;og:title&quot; content={title} /&gt;
        &lt;meta property=&quot;og:description&quot; content={description} /&gt;
        &lt;meta property=&quot;og:url&quot; content={Astro.url} /&gt;
        &lt;meta property=&quot;og:site_name&quot; content=&quot;Your Site Name&quot; /&gt;
        &lt;meta property=&quot;og:locale&quot; content={currentLang === &apos;ro&apos; ? &apos;ro_RO&apos; : &apos;en_US&apos;} /&gt;
        &lt;meta property=&quot;og:type&quot; content=&quot;website&quot; /&gt;
        {ogImage &amp;&amp; &lt;meta property=&quot;og:image&quot; content={ogImage} /&gt;}

        &lt;!-- Twitter Cards --&gt;
        &lt;meta name=&quot;twitter:card&quot; content=&quot;summary_large_image&quot; /&gt;
        &lt;meta name=&quot;twitter:title&quot; content={title} /&gt;
        &lt;meta name=&quot;twitter:description&quot; content={description} /&gt;
        {ogImage &amp;&amp; &lt;meta name=&quot;twitter:image&quot; content={ogImage} /&gt;}

        &lt;!-- Favicon --&gt;
        &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/favicon.svg&quot; /&gt;
        &lt;meta name=&quot;generator&quot; content={Astro.generator} /&gt;

        &lt;!-- JSON-LD Structured Data --&gt;
        &lt;script type=&quot;application/ld+json&quot; set:html={JSON.stringify({
            &quot;@context&quot;: &quot;https://schema.org&quot;,
            &quot;@type&quot;: &quot;WebSite&quot;,
            &quot;name&quot;: &quot;Your Site Name&quot;,
            &quot;url&quot;: baseUrl,
            &quot;description&quot;: description,
            &quot;inLanguage&quot;: currentLang,
            &quot;potentialAction&quot;: {
                &quot;@type&quot;: &quot;SearchAction&quot;,
                &quot;target&quot;: `${baseUrl}/search?q={search_term_string}`,
                &quot;query-input&quot;: &quot;required name=search_term_string&quot;
            }
        })} /&gt;
    &lt;/head&gt;
    &lt;body&gt;
        &lt;Header /&gt;
        &lt;slot /&gt;
        &lt;Footer /&gt;
    &lt;/body&gt;
&lt;/html&gt;
```

### Sitemap with Localized URLs

Create `src/pages/sitemap.xml.ts`:

```typescript
import type { APIRoute } from &apos;astro&apos;;
import { getCollection } from &apos;astro:content&apos;;

export const GET: APIRoute = async ({ site }) =&gt; {
    const baseUrl = site?.toString() || &apos;https://yoursite.com&apos;;

    // Static pages
    const staticPages = [
        { url: &apos;&apos;, changefreq: &apos;monthly&apos;, priority: 1.0 },
        { url: &apos;about&apos;, changefreq: &apos;monthly&apos;, priority: 0.8 },
        { url: &apos;blog&apos;, changefreq: &apos;weekly&apos;, priority: 0.9 },
        { url: &apos;contact&apos;, changefreq: &apos;monthly&apos;, priority: 0.7 },
        { url: &apos;pages&apos;, changefreq: &apos;monthly&apos;, priority: 0.6 },
    ];

    // Get blog posts
    const posts = await getCollection(&apos;blog&apos;, (entry) =&gt; !entry.data.isDraft);

    const urls: string[] = [];

    // Add static pages for both languages
    staticPages.forEach(page =&gt; {
        // English URLs
        urls.push(`
            &lt;url&gt;
                &lt;loc&gt;${baseUrl}${page.url ? `/${page.url}` : &apos;&apos;}&lt;/loc&gt;
                &lt;changefreq&gt;${page.changefreq}&lt;/changefreq&gt;
                &lt;priority&gt;${page.priority}&lt;/priority&gt;
                &lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href=&quot;${baseUrl}${page.url ? `/${page.url}` : &apos;&apos;}&quot; /&gt;
                &lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;ro&quot; href=&quot;${baseUrl}/ro/${page.url || &apos;&apos;}&quot; /&gt;
            &lt;/url&gt;
        `);

        // Romanian URLs
        const roPath = getLocalizedPath(page.url, &apos;ro&apos;);
        urls.push(`
            &lt;url&gt;
                &lt;loc&gt;${baseUrl}/ro/${roPath}&lt;/loc&gt;
                &lt;changefreq&gt;${page.changefreq}&lt;/changefreq&gt;
                &lt;priority&gt;${page.priority}&lt;/priority&gt;
                &lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href=&quot;${baseUrl}${page.url ? `/${page.url}` : &apos;&apos;}&quot; /&gt;
                &lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;ro&quot; href=&quot;${baseUrl}/ro/${roPath}&quot; /&gt;
            &lt;/url&gt;
        `);
    });

    // Add blog posts
    posts.forEach(post =&gt; {
        const [lang, slug] = post.id.split(&apos;/&apos;);
        const isEnglish = lang === &apos;en&apos;;
        const postUrl = isEnglish
            ? `${baseUrl}/blog/${slug}`
            : `${baseUrl}/ro/blog/${slug}`;

        urls.push(`
            &lt;url&gt;
                &lt;loc&gt;${postUrl}&lt;/loc&gt;
                &lt;lastmod&gt;${post.data.pubDate.toISOString()}&lt;/lastmod&gt;
                &lt;changefreq&gt;monthly&lt;/changefreq&gt;
                &lt;priority&gt;0.8&lt;/priority&gt;
            &lt;/url&gt;
        `);
    });

    const sitemap = `&lt;?xml version=&quot;1.0&quot; encoding=&quot;UTF-8&quot;?&gt;
        &lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;
                xmlns:xhtml=&quot;http://www.w3.org/1999/xhtml&quot;&gt;
            ${urls.join(&apos;&apos;)}
        &lt;/urlset&gt;`;

    return new Response(sitemap, {
        headers: {
            &apos;Content-Type&apos;: &apos;application/xml&apos;
        }
    });
};

function getLocalizedPath(path: string, lang: &apos;ro&apos;): string {
    const routes = {
        &apos;about&apos;: &apos;despre&apos;,
        &apos;blog&apos;: &apos;blog&apos;,
        &apos;contact&apos;: &apos;contact&apos;,
        &apos;pages&apos;: &apos;pagini&apos;
    };

    return routes[path as keyof typeof routes] || path;
}
```

## Advanced Features

### 1. Contact Form with Localization

Create `src/pages/[...contact].astro`:

```astro
---
import Layout from &quot;../layouts/Layout.astro&quot;;
import { useTranslations } from &quot;../i18n/utils&quot;;

export function getStaticPaths() {
    return [
        // English route: /contact
        { params: { contact: &quot;/contact&quot; }, props: { lang: &quot;en&quot; } },
        // Romanian route: /ro/contact
        { params: { contact: &quot;/ro/contact&quot; }, props: { lang: &quot;ro&quot; } },
    ];
}

const { lang } = Astro.props;
const t = useTranslations(lang);
---

&lt;Layout
    title={t(&quot;contact:head.title&quot;)}
    description={t(&quot;contact:head.description&quot;)}
    lang={lang}
&gt;
    &lt;main class=&quot;contact-page&quot;&gt;
        &lt;section class=&quot;contact-header&quot;&gt;
            &lt;h1&gt;{t(&quot;contact:title&quot;)}&lt;/h1&gt;
            &lt;p&gt;{t(&quot;contact:description&quot;)}&lt;/p&gt;
        &lt;/section&gt;

        &lt;section class=&quot;contact-form-section&quot;&gt;
            &lt;form class=&quot;contact-form&quot; method=&quot;POST&quot; action=&quot;/api/contact&quot;&gt;
                &lt;input type=&quot;hidden&quot; name=&quot;lang&quot; value={lang} /&gt;

                &lt;div class=&quot;form-group&quot;&gt;
                    &lt;label for=&quot;name&quot;&gt;{t(&quot;contact:form.name&quot;)}&lt;/label&gt;
                    &lt;input
                        type=&quot;text&quot;
                        id=&quot;name&quot;
                        name=&quot;name&quot;
                        required
                        placeholder={t(&quot;contact:form.namePlaceholder&quot;)}
                    /&gt;
                &lt;/div&gt;

                &lt;div class=&quot;form-group&quot;&gt;
                    &lt;label for=&quot;email&quot;&gt;{t(&quot;contact:form.email&quot;)}&lt;/label&gt;
                    &lt;input
                        type=&quot;email&quot;
                        id=&quot;email&quot;
                        name=&quot;email&quot;
                        required
                        placeholder={t(&quot;contact:form.emailPlaceholder&quot;)}
                    /&gt;
                &lt;/div&gt;

                &lt;div class=&quot;form-group&quot;&gt;
                    &lt;label for=&quot;subject&quot;&gt;{t(&quot;contact:form.subject&quot;)}&lt;/label&gt;
                    &lt;input
                        type=&quot;text&quot;
                        id=&quot;subject&quot;
                        name=&quot;subject&quot;
                        required
                        placeholder={t(&quot;contact:form.subjectPlaceholder&quot;)}
                    /&gt;
                &lt;/div&gt;

                &lt;div class=&quot;form-group&quot;&gt;
                    &lt;label for=&quot;message&quot;&gt;{t(&quot;contact:form.message&quot;)}&lt;/label&gt;
                    &lt;textarea
                        id=&quot;message&quot;
                        name=&quot;message&quot;
                        required
                        rows=&quot;6&quot;
                        placeholder={t(&quot;contact:form.messagePlaceholder&quot;)}
                    &gt;&lt;/textarea&gt;
                &lt;/div&gt;

                &lt;button type=&quot;submit&quot; class=&quot;submit-button&quot;&gt;
                    {t(&quot;contact:form.submit&quot;)}
                &lt;/button&gt;
            &lt;/form&gt;
        &lt;/section&gt;
    &lt;/main&gt;
&lt;/Layout&gt;

&lt;style&gt;
    .contact-page {
        max-width: 800px;
        margin: 0 auto;
        padding: 2rem;
    }

    .contact-header {
        text-align: center;
        margin-bottom: 3rem;
    }

    .contact-header h1 {
        font-size: 2.5rem;
        margin-bottom: 1rem;
    }

    .contact-form {
        background: white;
        padding: 2rem;
        border-radius: 8px;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }

    .form-group {
        margin-bottom: 1.5rem;
    }

    .form-group label {
        display: block;
        margin-bottom: 0.5rem;
        font-weight: 500;
        color: #374151;
    }

    .form-group input,
    .form-group textarea {
        width: 100%;
        padding: 0.75rem;
        border: 1px solid #d1d5db;
        border-radius: 4px;
        font-size: 1rem;
        transition: border-color 0.2s;
    }

    .form-group input:focus,
    .form-group textarea:focus {
        outline: none;
        border-color: #3b82f6;
        box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
    }

    .submit-button {
        background: #3b82f6;
        color: white;
        padding: 0.75rem 2rem;
        border: none;
        border-radius: 4px;
        font-size: 1rem;
        font-weight: 500;
        cursor: pointer;
        transition: background-color 0.2s;
    }

    .submit-button:hover {
        background: #2563eb;
    }
&lt;/style&gt;
```

Create contact translation files `src/locales/en/contact.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Contact Us - Get in Touch&quot;,
        &quot;description&quot;: &quot;Get in touch with our team. We&apos;d love to hear from you and answer any questions you might have.&quot;
    },
    &quot;title&quot;: &quot;Contact Us&quot;,
    &quot;description&quot;: &quot;We&apos;d love to hear from you. Send us a message and we&apos;ll respond as soon as possible.&quot;,
    &quot;form&quot;: {
        &quot;name&quot;: &quot;Name&quot;,
        &quot;namePlaceholder&quot;: &quot;Your full name&quot;,
        &quot;email&quot;: &quot;Email&quot;,
        &quot;emailPlaceholder&quot;: &quot;your.email@example.com&quot;,
        &quot;subject&quot;: &quot;Subject&quot;,
        &quot;subjectPlaceholder&quot;: &quot;What is this about?&quot;,
        &quot;message&quot;: &quot;Message&quot;,
        &quot;messagePlaceholder&quot;: &quot;Tell us more about your inquiry...&quot;,
        &quot;submit&quot;: &quot;Send Message&quot;
    },
    &quot;success&quot;: &quot;Thank you! Your message has been sent successfully.&quot;,
    &quot;error&quot;: &quot;Sorry, there was an error sending your message. Please try again.&quot;
}
```

Create `src/locales/ro/contact.json`:

```json
{
    &quot;head&quot;: {
        &quot;title&quot;: &quot;Contactează-ne - Ia Legătura&quot;,
        &quot;description&quot;: &quot;Ia legătura cu echipa noastră. Ne-ar face plăcere să auzim de la tine și să răspundem la orice întrebări ai avea.&quot;
    },
    &quot;title&quot;: &quot;Contactează-ne&quot;,
    &quot;description&quot;: &quot;Ne-ar face plăcere să auzim de la tine. Trimite-ne un mesaj și vom răspunde cât mai curând posibil.&quot;,
    &quot;form&quot;: {
        &quot;name&quot;: &quot;Nume&quot;,
        &quot;namePlaceholder&quot;: &quot;Numele tău complet&quot;,
        &quot;email&quot;: &quot;Email&quot;,
        &quot;emailPlaceholder&quot;: &quot;email.tau@exemplu.com&quot;,
        &quot;subject&quot;: &quot;Subiect&quot;,
        &quot;subjectPlaceholder&quot;: &quot;Despre ce este vorba?&quot;,
        &quot;message&quot;: &quot;Mesaj&quot;,
        &quot;messagePlaceholder&quot;: &quot;Spune-ne mai multe despre întrebarea ta...&quot;,
        &quot;submit&quot;: &quot;Trimite Mesajul&quot;
    },
    &quot;success&quot;: &quot;Mulțumim! Mesajul tău a fost trimis cu succes.&quot;,
    &quot;error&quot;: &quot;Ne pare rău, a fost o eroare la trimiterea mesajului. Te rugăm să încerci din nou.&quot;
}
```

### 2. 404 Error Page with Localization

Create `src/pages/404.astro`:

```astro
---
import Layout from &quot;../layouts/Layout.astro&quot;;
import { useTranslations, useTranslatedPath } from &quot;../i18n/utils&quot;;

// Try to detect language from URL, fallback to default
const lang = Astro.url.pathname.startsWith(&apos;/ro/&apos;) ? &apos;ro&apos; : &apos;en&apos;;
const t = useTranslations(lang);
const translatePath = useTranslatedPath(lang);
---

&lt;Layout
    title={t(&quot;pageNotFound.head.title&quot;)}
    description={t(&quot;pageNotFound.head.description&quot;)}
    lang={lang}
&gt;
    &lt;main class=&quot;error-page&quot;&gt;
        &lt;div class=&quot;error-content&quot;&gt;
            &lt;h1 class=&quot;error-title&quot;&gt;{t(&quot;pageNotFound.title&quot;)}&lt;/h1&gt;
            &lt;p class=&quot;error-description&quot;&gt;{t(&quot;pageNotFound.head.description&quot;)}&lt;/p&gt;
            &lt;a href={translatePath(&quot;/&quot;)} class=&quot;back-home&quot;&gt;
                {t(&quot;pageNotFound.link&quot;)}
            &lt;/a&gt;
        &lt;/div&gt;
    &lt;/main&gt;
&lt;/Layout&gt;

&lt;style&gt;
    .error-page {
        min-height: 60vh;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        padding: 2rem;
    }

    .error-title {
        font-size: 4rem;
        margin-bottom: 1rem;
        color: #374151;
    }

    .error-description {
        font-size: 1.25rem;
        color: #6b7280;
        margin-bottom: 2rem;
    }

    .back-home {
        background: #3b82f6;
        color: white;
        padding: 0.75rem 2rem;
        border-radius: 4px;
        text-decoration: none;
        font-weight: 500;
        transition: background-color 0.2s;
    }

    .back-home:hover {
        background: #2563eb;
    }
&lt;/style&gt;
```

## Best Practices

&gt; **📋 Quick Reference**: These practices will save you hours of debugging and ensure your multilingual site works flawlessly.

### 1. Translation Management

| **Practice** | **Description** | **Example** |
|--------------|-----------------|-------------|
| **Namespace Organization** | Group related translations by feature | `common.json`, `blog.json`, `contact.json` |
| **Consistent Key Naming** | Use hierarchical dot notation | `menu.list.home`, `form.validation.required` |
| **Parameter Support** | Use placeholders for dynamic content | `&quot;welcome&quot;: &quot;Hello {{name}}!&quot;` |
| **Fallback Strategy** | Always provide English fallbacks | Check `defaultLang` in utils |

### 2. URL Structure Guidelines

| **Language** | **URL Pattern** | **Example** |
|--------------|-----------------|-------------|
| **English (Default)** | `/path` | `/about`, `/blog/post-slug` |
| **Romanian** | `/ro/translated-path` | `/ro/despre`, `/ro/blog/slug-tradus` |
| **Additional Languages** | `/lang/translated-path` | `/fr/a-propos`, `/de/uber-uns` |

### 3. Performance Optimizations

- **Static Generation**: All routes are pre-generated at build time
- **Code Splitting**: Each language loads only necessary translations
- **Image Optimization**: Use Astro&apos;s built-in image processing
- **SEO-Friendly**: Proper hreflang tags and structured data

&gt; **⚡ Performance**: This approach generates completely static files - no JavaScript required for basic navigation and content display.

### 4. Content Management

```
// Organize content by language folders
src/content/
├── blog/
│   ├── en/           # English posts
│   ├── ro/           # Romanian posts
│   └── images/       # Shared images
```

### 5. Testing Localization

Create `src/utils/test-i18n.ts`:

```typescript
import { ui, defaultLang } from &quot;../i18n/ui&quot;;
import { routes } from &quot;../i18n/routes&quot;;

export function validateTranslations() {
    const languages = Object.keys(ui);
    const issues: string[] = [];

    // Check if all languages have the same translation keys
    const defaultKeys = getNestedKeys(ui[defaultLang]);

    languages.forEach(lang =&gt; {
        if (lang === defaultLang) return;

        const langKeys = getNestedKeys(ui[lang]);
        const missingKeys = defaultKeys.filter(key =&gt; !langKeys.includes(key));
        const extraKeys = langKeys.filter(key =&gt; !defaultKeys.includes(key));

        if (missingKeys.length &gt; 0) {
            issues.push(`${lang} missing keys: ${missingKeys.join(&apos;, &apos;)}`);
        }

        if (extraKeys.length &gt; 0) {
            issues.push(`${lang} extra keys: ${extraKeys.join(&apos;, &apos;)}`);
        }
    });

    return issues;
}

function getNestedKeys(obj: any, prefix = &apos;&apos;): string[] {
    let keys: string[] = [];

    for (const key in obj) {
        const fullKey = prefix ? `${prefix}.${key}` : key;

        if (typeof obj[key] === &apos;object&apos; &amp;&amp; obj[key] !== null) {
            keys = keys.concat(getNestedKeys(obj[key], fullKey));
        } else {
            keys.push(fullKey);
        }
    }

    return keys;
}
```

## Troubleshooting

### Common Issues and Solutions

#### 1. Translation Not Found

**Problem**: Translation key returns the key itself instead of translated text
**Symptoms**: You see &quot;menu.list.home&quot; on your page instead of &quot;Home&quot; or &quot;Acasă&quot;

**Solutions**:
```typescript
// Check if key exists in translation files
const t = useTranslations(lang);
console.log(t(&apos;menu.list.home&apos;)); // Should return translated text, not the key

// Debug: Check if translations are loading
console.log(ui); // Should show nested object with languages
console.log(ui[lang]?.common?.menu?.list?.home); // Should show actual translation
```

**Common causes**:
- Typo in translation key
- Missing translation file
- JSON syntax error in translation file
- Wrong namespace (using `common:menu.list.home` when it should be just `menu.list.home`)

#### 2. Wrong Language URLs

**Problem**: Language switching creates incorrect or broken URLs
**Symptoms**: Clicking language switcher leads to 404 or wrong pages

**Solutions**:
```typescript
// 1. Verify route translations in routes.ts match your getStaticPaths
export const routes = {
    ro: {
        about: &quot;despre&quot;,  // Must match the URL you want: /ro/despre
        blog: &quot;blog&quot;,     // If same as English, you can omit this
        contact: &quot;contact&quot;, // Or use &quot;contacteaza&quot; for Romanian
    },
};

// 2. Check getStaticPaths parameters match routes.ts
export function getStaticPaths() {
    return [
        { params: { about: &quot;about&quot;, index: undefined }, props: { lang: &quot;en&quot; } },
        { params: { about: &quot;ro&quot;, index: &quot;despre&quot; }, props: { lang: &quot;ro&quot; } },
        //                                ^^^^^^^ Must match routes.ts
    ];
}
```

#### 3. Missing Hreflang Tags

**Problem**: Search engines can&apos;t understand language relationships
**Symptoms**: SEO issues, duplicate content penalties

**Solutions**:
```astro
&lt;!-- Add to your Layout.astro head section --&gt;
&lt;link rel=&quot;alternate&quot; hreflang=&quot;en&quot; href={alternateUrls.en} /&gt;
&lt;link rel=&quot;alternate&quot; hreflang=&quot;ro&quot; href={alternateUrls.ro} /&gt;
&lt;link rel=&quot;alternate&quot; hreflang=&quot;x-default&quot; href={alternateUrls.en} /&gt;
```

#### 4. Content Not Loading

**Problem**: Dynamic imports fail for MDX content files
**Symptoms**: &quot;Cannot resolve module&quot; or blank pages

**Solutions**:
```astro
---
// 1. Check file naming is exactly consistent
const { Content, frontmatter } = await import(`./_about-${lang}.mdx`);
//                                                       ^^^^^ Must match exactly

// 2. Verify files exist in correct locations
// src/pages/[about]/_about-en.mdx ✅
// src/pages/[about]/_about-ro.mdx ✅

// 3. Check file extensions match (.mdx vs .md)
// 4. Verify frontmatter is valid YAML
---
```

#### 5. Build Failures

**Problem**: Site builds locally but fails in production
**Symptoms**: &quot;getStaticPaths&quot; errors or missing routes

**Solutions**:
```typescript
// 1. Check all getStaticPaths return arrays
export function getStaticPaths() {
    return [  // Must be array
        // Your paths here
    ];
}

// 2. Verify all translation files are valid JSON
// Use JSON validator: https://jsonlint.com/

// 3. Check file case sensitivity (important for Linux servers)
// _About-en.mdx ❌  (capital A)
// _about-en.mdx ✅  (lowercase a)
```

#### 6. Language Switcher Not Working

**Problem**: Language dropdown appears but doesn&apos;t switch languages
**Symptoms**: Clicking dropdown options doesn&apos;t navigate to new URLs

**Solutions**:
```astro
&lt;!-- 1. Ensure onchange event is properly set --&gt;
&lt;select onchange=&quot;window.location.href = this.value&quot;&gt;

&lt;!-- 2. Verify URLs are being generated correctly --&gt;
{languageUrls.map(({ targetUrl }) =&gt; (
    &lt;option value={targetUrl}&gt;
        {/* Debug: Check if targetUrl looks correct */}
        {/* Should be &quot;/ro/despre&quot; not &quot;/undefined&quot; */}
    &lt;/option&gt;
))}

&lt;!-- 3. Check browser console for JavaScript errors --&gt;
&lt;!-- 4. Test with browser JavaScript enabled --&gt;
```

#### 7. Content Collections Errors

**Problem**: Blog posts not loading or collection schema errors
**Symptoms**: &quot;Collection does not exist&quot; or schema validation errors

**Solutions**:
```typescript
// 1. Verify config.ts location and name
// Must be: src/content/config.ts (not content.config.ts)

// 2. Check collection schema matches frontmatter
const blogCollection = defineCollection({
    type: &apos;content&apos;,
    schema: z.object({
        title: z.string(),
        pubDate: z.date(),    // Make sure dates are valid
        isDraft: z.boolean().default(false),
        // Add all fields you use in frontmatter
    }),
});

// 3. Verify file structure
// src/content/blog/en/post.md ✅
// src/content/blog/post.md ❌ (missing language folder)
```

### Performance Monitoring

Track your multilingual site performance:

```javascript
// Add to your analytics
gtag(&apos;config&apos;, &apos;GA_MEASUREMENT_ID&apos;, {
    custom_map: {
        custom_dimension_1: &apos;language&apos;
    }
});

// Track language switches
gtag(&apos;event&apos;, &apos;language_switch&apos;, {
    language: targetLanguage,
    page_location: window.location.href
});
```

## Deployment Considerations

### Environment Variables

```bash
# .env.production
PRODUCTION_DOMAIN=&quot;https://yourdomain.com&quot;

# Optional: Analytics tracking IDs per language
ANALYTICS_ID_EN=&quot;GA_MEASUREMENT_ID_EN&quot;
ANALYTICS_ID_RO=&quot;GA_MEASUREMENT_ID_RO&quot;
```

### Build Process

```bash
# Build for production
npm run build

# Preview the built site
npm run preview

# Deploy to your hosting platform
# (Vercel, Netlify, Cloudflare Pages, etc.)
```

### Server Configuration

For Apache servers, add to `.htaccess`:

```apache
# Language detection
RewriteEngine On
RewriteCond %{HTTP:Accept-Language} ^ro [NC]
RewriteRule ^$ /ro/ [R,L]
```

For Nginx:

```nginx
location / {
    if ($http_accept_language ~* ^ro) {
        return 301 /ro$uri;
    }
}
```

### 6. File and Folder Naming

- Use English names for files and folders in `src/pages/`
- Localize only URLs via `routes.ts`, not file names
- Keep content files organized by language (`_about-en.mdx`, `_about-ro.mdx`)
- Use consistent parameter naming in `getStaticPaths()`

### 7. Error Handling

Always provide fallbacks for missing translations:

```typescript
const translation =
    getNestedValue(ui[lang]?.[namespace], translationKey) ||
    getNestedValue(ui[defaultLang]?.[namespace], translationKey) ||
    key; // Returns the key itself if no translation found
```

### 8. Utility Functions

Add helpful utility functions to `src/utils/utils.ts`:

```typescript
/**
 * Format date according to locale
 */
export function formatDate(date: Date, locale: string = &apos;en&apos;): string {
    return new Intl.DateTimeFormat(locale, {
        year: &apos;numeric&apos;,
        month: &apos;long&apos;,
        day: &apos;numeric&apos;
    }).format(date);
}

/**
 * Generate slug from title
 */
export function generateSlug(title: string): string {
    return title
        .toLowerCase()
        .replace(/[^\w\s-]/g, &apos;&apos;) // Remove special characters
        .replace(/[\s_-]+/g, &apos;-&apos;) // Replace spaces and underscores with hyphens
        .replace(/^-+|-+$/g, &apos;&apos;); // Remove leading/trailing hyphens
}

/**
 * Get reading time estimate
 */
export function getReadingTime(content: string): number {
    const wordsPerMinute = 200;
    const words = content.trim().split(/\s+/).length;
    return Math.ceil(words / wordsPerMinute);
}
```

### 9. Project Structure Summary

Here&apos;s the complete project structure you&apos;ll have after following this guide:

```
your-astro-project/
├── src/
│   ├── components/
│   │   ├── Header.astro
│   │   ├── Footer.astro
│   │   └── LanguagePicker.astro
│   ├── content/
│   │   ├── blog/
│   │   │   ├── en/
│   │   │   │   ├── astro-performance-tips.md
│   │   │   │   └── ...
│   │   │   └── ro/
│   │   │       ├── sfaturi-performanta-astro.md
│   │   │       └── ...
│   │   └── content.config.ts
│   ├── data/
│   │   └── navigationData.ts
│   ├── i18n/
│   │   ├── routes.ts
│   │   ├── ui.ts
│   │   └── utils.ts
│   ├── layouts/
│   │   └── Layout.astro
│   ├── locales/
│   │   ├── en/
│   │   │   ├── common.json
│   │   │   ├── main.json
│   │   │   ├── blog.json
│   │   │   └── contact.json
│   │   └── ro/
│   │       ├── common.json
│   │       ├── main.json
│   │       ├── blog.json
│   │       └── contact.json
│   ├── pages/
│   │   ├── [about]/
│   │   │   ├── [...index].astro
│   │   │   ├── _about-en.mdx
│   │   │   └── _about-ro.mdx
│   │   ├── [blog]/
│   │   │   └── [...slug].astro
│   │   ├── [pages]/
│   │   │   ├── [...index].astro
│   │   │   ├── _pages-en.mdx
│   │   │   ├── _pages-ro.mdx
│   │   │   └── [page1]/
│   │   │       ├── [...index].astro
│   │   │       ├── _page1-en.mdx
│   │   │       └── _page1-ro.mdx
│   │   ├── [...blog].astro
│   │   ├── [...contact].astro
│   │   ├── [...index].astro
│   │   ├── 404.astro
│   │   └── sitemap.xml.ts
│   ├── styles/
│   │   └── global.css
│   └── utils/
│       └── utils.ts
├── astro.config.mjs
├── package.json
└── .env
```

## Implementation Checklist

Use this step-by-step checklist to implement localization in your Astro project:

### Phase 1: Setup
- [ ] Initialize Astro project and install dependencies (`@astrojs/mdx`, `@astrojs/sitemap`)
- [ ] Configure `astro.config.mjs` with sitemap integration
- [ ] Create environment file with `PRODUCTION_DOMAIN`
- [ ] Create directory structure: `src/i18n`, `src/locales/en`, `src/locales/ro`

### Phase 2: Core i18n System
- [ ] Create `src/i18n/ui.ts` with language configuration
- [ ] Create `src/i18n/routes.ts` with URL translations
- [ ] Create `src/i18n/utils.ts` with utility functions
- [ ] Test language detection: `getLangFromUrl()`
- [ ] Test translation function: `useTranslations()`
- [ ] Test path translation: `useTranslatedPath()`

### Phase 3: Translation Files
- [ ] Create `src/locales/en/common.json` with navigation and footer translations
- [ ] Create `src/locales/ro/common.json` with Romanian translations
- [ ] Create page-specific translation files (`main.json`, `blog.json`, `contact.json`)
- [ ] Verify all translation keys match between languages

### Phase 4: Basic Pages
- [ ] Create base layout `src/layouts/Layout.astro` with SEO tags
- [ ] Create home page `src/pages/[...index].astro`
- [ ] Create about page `src/pages/[about]/[...index].astro` with MDX content
- [ ] Test both English and Romanian versions of each page

### Phase 5: Navigation &amp; Components
- [ ] Create navigation data in `src/data/navigationData.ts`
- [ ] Create header component `src/components/Header.astro`
- [ ] Create footer component `src/components/Footer.astro`
- [ ] Create language picker `src/components/LanguagePicker.astro`
- [ ] Test language switching functionality

### Phase 6: Blog System
- [ ] Configure content collections in `src/content.config.ts`
- [ ] Create blog listing page `src/pages/[...blog].astro`
- [ ] Create blog post detail page `src/pages/[blog]/[...slug].astro`
- [ ] Create sample blog posts in both languages
- [ ] Test `linkedContent` for cross-language linking

### Phase 7: Advanced Features
- [ ] Create contact form `src/pages/[...contact].astro`
- [ ] Create 404 error page `src/pages/404.astro`
- [ ] Implement subpages with nested routing
- [ ] Test all forms and error handling

### Phase 8: SEO &amp; Production
- [ ] Add hreflang tags to layout
- [ ] Create sitemap `src/pages/sitemap.xml.ts`
- [ ] Add Open Graph and Twitter card meta tags
- [ ] Configure server redirects (Apache/Nginx)
- [ ] Test in production environment

### Phase 9: Testing &amp; Optimization
- [ ] Validate all translation keys with `validateTranslations()`
- [ ] Test language switching on all pages
- [ ] Verify SEO tags with browser dev tools
- [ ] Check performance with Lighthouse
- [ ] Test accessibility with screen readers

&gt; **✅ Pro Tip**: Use this checklist as you build. Don&apos;t wait until the end to test everything!

## Conclusion

You now have a complete internationalization system for your Astro project! This implementation provides:

✅ **SEO-optimized multilingual URLs** (`/about` → `/ro/despre`)
✅ **Static generation** for maximum performance
✅ **Flexible translation system** with namespace support
✅ **Smart language switching** with context preservation
✅ **Blog system** with cross-language content linking
✅ **Accessible components** with proper ARIA attributes
✅ **Production-ready** with comprehensive SEO features

The system is designed to scale with your needs. You can easily add new languages by:

1. Adding the language to `src/i18n/ui.ts`
2. Creating translation files in `src/locales/[lang]/`
3. Adding route mappings in `src/i18n/routes.ts`
4. Updating `getStaticPaths()` in your pages

For more advanced Astro techniques, check out these related articles:
- [Building a YouTube video integration for your Astro blog](https://www.bitdoze.com/add-youtube-videos-astro-blog/)
- [Creating realtime applications with Astro and Convex](https://www.bitdoze.com/astro-convex-realtime-app/)
- [Deploying Astro applications with Convex and Vercel](https://www.bitdoze.com/astro-convex-vercel-deployment/)

Happy building! 🚀</content:encoded><category>web-development</category><category>astro</category></item><item><title>Astro Build Speed Optimization: From 35 to 127 Pages/Second (Complete Beginner&apos;s Guide)</title><link>https://www.bitdoze.com/astro-ssg-build-optimization/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-ssg-build-optimization/</guid><description>Beginner&apos;s guide to speeding up Astro build times and why Static Site Generation (SSG) works better than SSR for large sites. Real optimization steps that improved build speed by 3.6x.</description><pubDate>Fri, 05 Sep 2025 00:00:00 GMT</pubDate><content:encoded>Slow Astro builds? People keep telling you to &quot;just switch to SSR&quot; for your large site? This guide shows how to speed up Astro builds while sticking with Static Site Generation (SSG), which makes more sense for most large sites.

The techniques here improved a large SSG site from 35 pages/second to 127 pages/second - a 3.6x speed improvement. These work for beginners and don&apos;t require switching to SSR.

This article comes from a Reddit case study: [Astro build speed optimization from 9642s to 2659s](https://www.reddit.com/r/astrojs/comments/1n8fntg/astro_build_speed_optimization_from_9642s_to/).


Before we dive in, here are some related Astro articles:
- [Build your Astro blog for free](https://www.bitdoze.com/build-astro-blog-free/)
- [Add YouTube videos to your Astro blog](https://www.bitdoze.com/add-youtube-videos-astro-blog/)
- [Build real-time apps with Astro and Convex](https://www.bitdoze.com/astro-convex-realtime-app/)
- [Deploy Astro and Convex to Vercel](https://www.bitdoze.com/astro-convex-vercel-deployment/)

## Understanding SSG vs SSR (The Basics)

Let&apos;s start with the basics. The difference between Static Site Generation (SSG) and Server-Side Rendering (SSR) matters when choosing an approach for your project.

### Static Site Generation (SSG)

**What it is:** Pages are pre-built at build time and served as static HTML files.

**How it works:**
1. During build, Astro processes your content and components
2. Generates static HTML files for each page
3. These files are served directly by a CDN or web server
4. No server processing needed for each request

**Pros:**
- ⚡ **Lightning fast delivery** - files served directly from CDN
- 💰 **Very cost-effective** - minimal server resources needed
- 🛡️ **Highly resilient** - can handle massive traffic spikes
- 🔒 **More secure** - no server-side vulnerabilities
- 📈 **Excellent SEO** - search engines love static content

**Cons:**
- ⏱️ **Build time grows** with more pages
- 🔄 **Data freshness** depends on rebuild frequency
- 🎯 **Limited personalization** without JavaScript

### Server-Side Rendering (SSR)

**What it is:** Pages are generated on each request (or cached with smart rules).

**How it works:**
1. User requests a page
2. Server processes the request in real-time
3. Generates HTML dynamically
4. Sends response to user

**Pros:**
- 🔥 **Always fresh data** - content is up-to-date on every request
- 👤 **Full personalization** - can customize per user/request
- ⚡ **Fast time-to-first-page** - no build step needed

**Cons:**
- 💰 **Higher costs** - requires server capacity for each request
- 🐌 **Slower under load** - server processing needed for every request
- 🕷️ **Vulnerable to crawler load** - bots can overwhelm your server

## Why SSG Often Beats SSR at Scale

The key insight from the case study: &quot;You don&apos;t ever fear a single item getting a million views in a day, you fear 100,000 items getting 10 views in a day.&quot;

### The Spider Problem

Modern websites face an unprecedented crawler load:
- Search engine bots (Google, Bing, etc.)
- AI training scrapers (ChatGPT, Claude, etc.)
- SEO tools and monitoring services
- Scraper bots

**Real numbers from our case study:**
- **2.3 million requests per day**
- **774,860 unique visitors**
- **710k unique URLs requested**
- **30:1 ratio of spider traffic to human traffic**

With SSG, each of these requests is a cheap file serve. With SSR, each request requires server processing power.

### What It Costs

**SSG Setup (from case study):**
- $29 web server + memcached + workers
- $29 database server
- $89 build server
- **Total: $147/month**

This setup handles **2.3M daily requests** easily, with average load under 2 on an 8-core system.

**Equivalent SSR Setup:**
- Would need multiple high-powered application servers
- Database connection pooling and caching layers
- Load balancers and auto-scaling
- **Estimated cost: $500-2000+/month**

## Real-World Performance Case Study
 
Let&apos;s look at the actual optimization journey that inspired this guide:

### Site Stats
- **349,734 total files**
- **346,236 HTML pages**
- **43GB total size**
- **API-powered build** (no local .md files)

### Performance Journey

| Stage | Pages Built | Build Time | Speed | Improvement |
|-------|-------------|------------|-------|-------------|
| **Initial** | 339,194 | 9,642s (2.7 hours) | ~35 pages/sec | Baseline |
| **Mid-optimization** | 339,251 | 3,583s (1 hour) | ~94 pages/sec | 2.7x faster |
| **Final optimized** | 339,340 | 2,659s (44 minutes) | ~127 pages/sec | **3.6x faster** |

Now let&apos;s break down exactly how they achieved this improvement.
 
## 8 Steps to Optimize Your Astro Builds

### Step 1: Upgrade Node.js and Astro

**Why this matters:** Newer versions include performance improvements, bug fixes, and optimizations.

**What to do:**
```bash
# Check current versions
node --version
npm list astro

# Upgrade Node.js to latest LTS (22+)
nvm install 22
nvm use 22

# Upgrade Astro to latest
npm update astro
```

**Expected improvement:** ~30% faster builds from version improvements alone.

### Step 2: Increase Node.js Memory Allocation

**Why this matters:** Large builds can hit memory limits, causing garbage collection pauses and slowdowns.

**What to do:**
```bash
# Method 1: Environment variable (recommended)
export NODE_OPTIONS=&quot;--max-old-space-size=8192&quot;

# Method 2: Direct command
node --max-old-space-size=8192 ./node_modules/.bin/astro build
```

**Memory allocation guide:**
- Small sites (&lt; 1k pages): 4GB (4096)
- Medium sites (1k-10k pages): 8GB (8192)
- Large sites (10k+ pages): 16GB+ (16384)

**Expected improvement:** Reduced build time and eliminated memory-related crashes.

### Step 3: Optimize Build Concurrency

**Why this matters:** Astro can process multiple pages simultaneously, but too much concurrency can cause resource contention.

**Finding your sweet spot:**
```js
// astro.config.mjs
export default defineConfig({
  build: {
    concurrency: 4, // Start here, then test 2, 6, 8
  },
});
```

**Testing methodology:**
1. Start with `concurrency: 2`
2. Run a build and time it
3. Increase to 4, then 6, then 8
4. Use the fastest setting

**Important:** More isn&apos;t always better! The case study found 4 was optimal on a 12-core system.

### Step 4: Configure Vite and Rollup for Speed

**Why this matters:** Vite handles bundling and optimization. Proper configuration can significantly impact build speed.

Here&apos;s the optimized configuration from our case study:

```js
// astro.config.mjs
import { defineConfig } from &quot;astro/config&quot;;
import { readFileSync } from &quot;fs&quot;;
import { cpus } from &quot;os&quot;;

const packageJson = JSON.parse(readFileSync(&quot;./package.json&quot;, &quot;utf8&quot;));
const CPU_COUNT = cpus().length;

export default defineConfig({
  build: {
    // Optimize concurrency for your CPU
    concurrency: 4,

    rollupOptions: {
      // Maximum parallel file operations
      maxParallelFileOps: CPU_COUNT * 3,
      output: {
        // Fewer, larger chunks = less overhead
        manualChunks: undefined,
        // Faster code generation
        generatedCode: {
          preset: &apos;es2022&apos;
        }
      }
    }
  },

  vite: {
    build: {
      // Allow larger chunks for speed
      chunkSizeWarningLimit: 10000,
      // Fastest minifier
      minify: &apos;esbuild&apos;,
      // Less transformation needed
      target: &apos;es2022&apos;,
      rollupOptions: {
        maxParallelFileOps: CPU_COUNT * 3
      }
    },

    esbuild: {
      target: &apos;es2022&apos;,
      // Fast minification settings
      minifyIdentifiers: false, // Skip for speed
      minifySyntax: true,
      minifyWhitespace: true,
    },

    // Aggressive caching for faster subsequent builds
    optimizeDeps: {
      force: false // Use cache when possible
    }
  },

  // Skip HTML compression for faster builds
  compressHTML: false,
});
```

**Key optimizations explained:**

- **`manualChunks: undefined`** - Reduces chunk fragmentation overhead
- **`target: &apos;es2022&apos;`** - Modern target means less transpilation
- **`minify: &apos;esbuild&apos;`** - Fastest minifier available
- **`compressHTML: false`** - Skip compression for speed (enable in production if needed)
- **`maxParallelFileOps`** - Utilize all CPU cores efficiently

### Step 5: Implement Smart Caching

**Why this matters:** If your site pulls data from APIs, caching eliminates redundant network requests.

Here&apos;s a robust caching implementation:

```js
// utils/fetchWithCache.js
import fs from &apos;fs&apos;;
import path from &apos;path&apos;;
import crypto from &apos;crypto&apos;;

export async function fetchWithCache(url, expirationSeconds = 600) {
  const start = Date.now();

  // Create unique cache filename
  const urlHash = crypto.createHash(&apos;md5&apos;).update(&quot;cache_v1_&quot; + url).digest(&apos;hex&apos;);
  const cacheDir = path.join(process.cwd(), &apos;.cache&apos;);
  const cacheFile = path.join(cacheDir, `${urlHash}.json`);

  // Ensure cache directory exists
  if (!fs.existsSync(cacheDir)) {
    fs.mkdirSync(cacheDir, { recursive: true });
  }

  // Check if cache file exists and is fresh
  if (fs.existsSync(cacheFile)) {
    const stats = fs.statSync(cacheFile);
    const ageInSeconds = (Date.now() - stats.mtime.getTime()) / 1000;

    if (ageInSeconds &lt; expirationSeconds) {
      const cachedData = JSON.parse(fs.readFileSync(cacheFile, &apos;utf8&apos;));
      console.log(`Cache hit: ${url} (${ageInSeconds.toFixed(1)}s old)`);
      return cachedData;
    }
  }

  // Fetch fresh data
  console.log(`Fetching: ${url}`);
  const response = await fetch(url, {
    headers: {
      &apos;User-Agent&apos;: &apos;Astro Build Bot&apos;,
    },
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${response.statusText}`);
  }

  const data = await response.json();

  // Save to cache
  fs.writeFileSync(cacheFile, JSON.stringify(data, null, 2));

  console.log(`Fresh fetch completed: ${((Date.now() - start) / 1000).toFixed(2)}s`);
  return data;
}
```

**Usage in your Astro pages:**

```js
// pages/[...slug].astro
---
import { fetchWithCache } from &apos;../utils/fetchWithCache.js&apos;;

export async function getStaticPaths() {
  // Use cached fetch instead of regular fetch
  const posts = await fetchWithCache(&apos;https://api.example.com/posts&apos;);

  return posts.map(post =&gt; ({
    params: { slug: post.slug },
    props: { post }
  }));
}
---
```

### Step 6: Cache Prewarming (Advanced)

**Why this matters:** For very large sites, you can prewarm your cache before the main build starts.

Here&apos;s a Node.js cache prewarming script:

```js
// scripts/prewarmCache.js
import { fetchWithCache } from &apos;../utils/fetchWithCache.js&apos;;

async function prewarmCache() {
  console.log(&apos;Starting cache prewarming...&apos;);

  // Define your API endpoints to prewarm
  const endpoints = [
    &apos;https://api.example.com/posts&apos;,
    &apos;https://api.example.com/categories&apos;,
    &apos;https://api.example.com/authors&apos;,
    // Add more endpoints as needed
  ];

  // Warm cache with limited concurrency
  const results = await Promise.allSettled(
    endpoints.map(url =&gt; fetchWithCache(url, 3600)) // 1 hour cache
  );

  const successful = results.filter(r =&gt; r.status === &apos;fulfilled&apos;).length;
  console.log(`Cache prewarming complete: ${successful}/${endpoints.length} successful`);
}

prewarmCache().catch(console.error);
```

**Run before your main build:**

```bash
# Package.json scripts
{
  &quot;scripts&quot;: {
    &quot;prewarm&quot;: &quot;node scripts/prewarmCache.js&quot;,
    &quot;build&quot;: &quot;npm run prewarm &amp;&amp; astro build&quot;
  }
}
```

### Step 7: Consider Ramdisk (Conditional)

**When it helps:** Only with slow storage (spinning disks, old SSDs).

**When it doesn&apos;t help:** Modern NVMe drives - improvement is typically smaller then 1%.

**How to set up (Linux/macOS):**

```bash
# Create 4GB ramdisk
sudo mount -t tmpfs -o size=4g tmpfs /tmp/astro-build

# Build in ramdisk
cd /tmp/astro-build
# ... run your build here ...
```

### Step 8: Hardware Upgrades

**When it&apos;s worth it:** If you&apos;re building multiple times per day, hardware ROI is real.

**What matters most:**
1. **CPU single-core performance** - Node.js loves fast cores
2. **CPU cache (L3/L4)** - More cache = faster builds
3. **Fast storage** - NVMe &gt; SATA SSD &gt; HDD
4. **Adequate RAM** - Avoid swapping at all costs

**Case study hardware impact:**
- Old: Intel Xeon E5-1650 v3 → 3,583s build time
- New: AMD Ryzen 9 5900X → 2,659s build time
- **25% improvement** from CPU upgrade alone

## Advanced Caching Strategies

### Cache Invalidation Strategy

Smart cache invalidation ensures fresh data when needed:

```js
// utils/smartCache.js
export async function fetchWithSmartCache(url, options = {}) {
  const {
    maxAge = 600,
    forceRefresh = false,
    invalidateOn = []
  } = options;

  if (forceRefresh) {
    return await fetchFresh(url);
  }

  // Check for invalidation conditions
  for (const condition of invalidateOn) {
    if (await condition()) {
      console.log(`Cache invalidated for ${url}`);
      return await fetchFresh(url);
    }
  }

  return await fetchWithCache(url, maxAge);
}

// Usage with invalidation
const posts = await fetchWithSmartCache(&apos;https://api.example.com/posts&apos;, {
  maxAge: 3600, // 1 hour
  invalidateOn: [
    () =&gt; process.env.FORCE_REFRESH === &apos;true&apos;,
    () =&gt; Date.now() - lastDeployTime &lt; 300000 // 5 minutes after deploy
  ]
});
```

### Batch Request Optimization

Minimize API calls by batching requests:

```js
// utils/batchFetch.js
export async function batchFetchWithCache(urls, batchSize = 10) {
  const results = [];

  for (let i = 0; i &lt; urls.length; i += batchSize) {
    const batch = urls.slice(i, i + batchSize);

    const batchResults = await Promise.allSettled(
      batch.map(url =&gt; fetchWithCache(url))
    );

    results.push(...batchResults);

    // Small delay to be nice to the API
    if (i + batchSize &lt; urls.length) {
      await new Promise(resolve =&gt; setTimeout(resolve, 100));
    }
  }

  return results;
}
```

## Hardware Considerations

### CPU Requirements

| Site Size | Recommended CPU | Cores | Cache |
|-----------|----------------|-------|--------|
| Small (&lt; 1k pages) | Any modern CPU | 4+ | 8MB+ |
| Medium (1k-10k pages) | Intel i7/AMD Ryzen 7 | 8+ | 16MB+ |
| Large (10k+ pages) | Intel i9/AMD Ryzen 9 | 12+ | 32MB+ |
| Huge (100k+ pages) | Server-grade CPU | 16+ | 64MB+ |

### Memory Requirements

**Base calculation:** ~2-4MB per page in memory during build.

| Site Size | Minimum RAM | Recommended |
|-----------|-------------|-------------|
| Small (&lt; 1k pages) | 8GB | 16GB |
| Medium (1k-10k pages) | 16GB | 32GB |
| Large (10k+ pages) | 32GB | 64GB |
| Huge (100k+ pages) | 64GB | 128GB+ |

### Storage Considerations

**Speed hierarchy:**
1. **NVMe Gen4** - Best for large builds
2. **NVMe Gen3** - Great for most uses
3. **SATA SSD** - Minimum recommended
4. **HDD** - Only with ramdisk

## When to Choose SSG vs SSR

### Decision Matrix

| Factor | SSG | SSR | Hybrid |
|--------|-----|-----|---------|
| **Content freshness** | Rebuild required | Always fresh | Mixed |
| **Personalization** | Limited | Full | Per-route |
| **Performance** | Excellent | Variable | Excellent |
| **Cost at scale** | Very low | High | Medium |
| **Crawler resilience** | Excellent | Poor | Good |
| **Development complexity** | Simple | Complex | Medium |

### Use Case Recommendations

**Choose SSG when:**
- ✅ Content doesn&apos;t change frequently (minutes/hours)
- ✅ Heavy anonymous/crawler traffic expected
- ✅ Budget constraints are important
- ✅ Maximum performance is priority
- ✅ Simple deployment preferred

**Choose SSR when:**
- ✅ Real-time data is essential
- ✅ Heavy personalization needed
- ✅ User-generated content is primary
- ✅ Small number of pages
- ✅ Server resources aren&apos;t constrained

**Choose Hybrid when:**
- ✅ Most content is static, some dynamic
- ✅ Need personalization on some routes
- ✅ Want to optimize costs and performance
- ✅ Can handle route-level complexity

### Hybrid Implementation Example

```js
// astro.config.mjs - Hybrid setup
export default defineConfig({
  output: &apos;hybrid&apos;,
  adapter: vercel(),

  integrations: [
    // Most pages are pre-rendered (SSG)
    // Specific routes can opt into SSR
  ],
});
```

```js
// pages/dashboard/[user].astro - SSR route
---
export const prerender = false; // This page uses SSR

const { user } = Astro.params;
const userData = await fetch(`/api/user/${user}`);
---

&lt;Layout title=&quot;Dashboard&quot;&gt;
  &lt;UserDashboard data={userData} /&gt;
&lt;/Layout&gt;
```

## Common Optimization Myths

### Myth 1: &quot;More concurrency is always better&quot;

**Reality:** Concurrency has diminishing returns and can cause resource contention.

**Test this:** Try concurrency values of 2, 4, 6, 8, 16. Most sites perform best between 2-6.

### Myth 2: &quot;Ramdisk always speeds up builds&quot;

**Reality:** Only helps with slow storage. NVMe drives make ramdisk nearly useless.

**Test this:** Time your build with and without ramdisk on your storage setup.

### Myth 3: &quot;You need SSR for large sites&quot;

**Reality:** SSG can handle hundreds of thousands of pages efficiently with proper optimization.

**Evidence:** Our case study site has 339k+ pages and builds in under 45 minutes.

### Myth 4: &quot;Build time doesn&apos;t matter in production&quot;

**Reality:** Faster builds mean:
- Quicker deployments
- More frequent updates
- Lower CI/CD costs
- Better developer experience

### Myth 5: &quot;HTML compression always saves significant space&quot;

**Reality:** Modern CDNs handle compression better, and build-time compression slows builds significantly.

**Recommendation:** Let your CDN handle compression for better performance.

## Monitoring and Measuring Success

### Build Performance Metrics

Track these metrics to measure optimization success:

```js
// build-metrics.js
const startTime = Date.now();

export function logBuildMetrics(pageCount) {
  const buildTime = (Date.now() - startTime) / 1000;
  const pagesPerSecond = pageCount / buildTime;

  console.log(`
📊 Build Metrics:
- Pages built: ${pageCount.toLocaleString()}
- Build time: ${buildTime.toFixed(1)}s
- Speed: ${pagesPerSecond.toFixed(1)} pages/sec
- Memory usage: ${process.memoryUsage().heapUsed / 1024 / 1024:.1f}MB
  `);
}
```

### CI/CD Integration

Track build performance over time:

```yaml
# .github/workflows/build-monitor.yml
name: Build Performance Monitor
on: [push, pull_request]

jobs:
  build-perf:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: &apos;22&apos;

      - run: npm ci

      - name: Build with timing
        run: |
          echo &quot;BUILD_START=$(date +%s)&quot; &gt;&gt; $GITHUB_ENV
          npm run build
          echo &quot;BUILD_END=$(date +%s)&quot; &gt;&gt; $GITHUB_ENV

      - name: Report performance
        run: |
          BUILD_TIME=$((BUILD_END - BUILD_START))
          echo &quot;Build completed in ${BUILD_TIME} seconds&quot;
          # Send to your analytics/monitoring system
```

## Troubleshooting Common Issues

### Out of Memory Errors

**Symptoms:**
```
FATAL ERROR: Ineffective mark-compacts near heap limit
JavaScript heap out of memory
```

**Solutions:**
1. Increase `--max-old-space-size`
2. Reduce build concurrency
3. Clear cache: `rm -rf .cache node_modules/.vite`
4. Check for memory leaks in your code

### Slow API Responses

**Symptoms:**
- Build hangs on certain pages
- Inconsistent build times
- Network timeout errors

**Solutions:**
1. Implement request timeout and retry logic
2. Use caching aggressively
3. Batch API requests when possible
4. Consider API rate limiting

```js
// Robust fetch with retries
async function fetchWithRetry(url, retries = 3) {
  for (let i = 0; i &lt; retries; i++) {
    try {
      const response = await fetch(url, {
        timeout: 10000 // 10 second timeout
      });

      if (response.ok) return response;

      if (i === retries - 1) throw new Error(`HTTP ${response.status}`);

      // Wait before retry
      await new Promise(resolve =&gt; setTimeout(resolve, 1000 * (i + 1)));

    } catch (error) {
      if (i === retries - 1) throw error;
      await new Promise(resolve =&gt; setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}
```

### Inconsistent Build Times

**Symptoms:**
- Build time varies significantly between runs
- Some builds much slower than others

**Solutions:**
1. Implement consistent caching strategy
2. Check for system resource contention
3. Monitor CPU/memory usage during builds
4. Use fixed versions for all dependencies

## Conclusion and Next Steps

Optimizing Astro builds for large SSG sites is entirely achievable with the right approach. The key takeaways:

### Quick Wins (Implement First)
- ✅ Upgrade Node.js and Astro
- ✅ Increase memory allocation
- ✅ Tune build concurrency (start with 4)
- ✅ Configure Vite for speed

### Medium Effort (High Impact)
- ✅ Implement smart caching for API calls
- ✅ Optimize your `astro.config.mjs`
- ✅ Monitor and measure build performance

### Advanced Optimizations
- ✅ Cache prewarming for very large sites
- ✅ Hardware upgrades if building frequently
- ✅ Custom fetch implementations with retry logic

### Remember the Core Principle

SSG isn&apos;t just about static content - it&apos;s about **economic efficiency at scale**. When crawlers and bots drive most of your traffic, serving pre-built files is far more cost-effective than processing every request server-side.

### Ready to Learn More?

If you&apos;re new to Astro or want to explore more advanced topics, check out:
- [Build your first Astro blog for free](https://www.bitdoze.com/build-astro-blog-free/)
- [Enhance your blog with YouTube videos](https://www.bitdoze.com/add-youtube-videos-astro-blog/)
- [Create real-time apps with Astro and Convex](https://www.bitdoze.com/astro-convex-realtime-app/)
- [Deploy Astro apps to Vercel with Convex](https://www.bitdoze.com/astro-convex-vercel-deployment/)

The full case study with detailed logs and configurations is available in the [original Reddit thread](https://www.reddit.com/r/astrojs/comments/1n8fntg/astro_build_speed_optimization_from_9642s_to/).

Have questions about optimizing your specific Astro setup? The techniques in this guide have been tested on real-world sites with hundreds of thousands of pages. Start with the quick wins, measure your improvements, and gradually implement the more advanced optimizations as needed.

Happy building! 🚀</content:encoded><category>web-development</category><category>astro</category></item><item><title>How to Add a Sidebar Menu to a Carrd Website</title><link>https://www.bitdoze.com/carrd-sidebar-menu/</link><guid isPermaLink="true">https://www.bitdoze.com/carrd-sidebar-menu/</guid><description>Learn how to add a customizable sidebar navigation menu to your carrd.co website with smooth animations and mobile optimization.</description><pubDate>Mon, 04 Aug 2025 00:00:00 GMT</pubDate><content:encoded>[Carrd.co](https://go.bitdoze.com/carrd) is an excellent platform for creating one-page websites, but navigation can become challenging when you have multiple sections. A sidebar menu provides an elegant solution by offering easy access to different parts of your site without taking up valuable screen real estate in your main content area.

A sidebar menu is particularly beneficial for Carrd sites because:

1. **It maximizes content space** by keeping navigation tucked away until needed, allowing your main content to shine.

2. **Provides better organization** for sites with multiple sections, making it easy for visitors to jump to specific areas.

3. **Offers excellent mobile experience** with smooth slide-in animations and touch-friendly interactions.

4. **Maintains visual hierarchy** by keeping the focus on your content while providing accessible navigation when needed.

5. **Customizable positioning** allows you to choose whether the sidebar opens from the left or right side to match your design preferences.

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

Some Carrd Tutorials:

- [Add Floating Menu Carrd](https://www.bitdoze.com/carrd-floating-menu/)
- [Add Stickey Header Carrd](https://www.bitdoze.com/add-stickey-header-carrd/)
- [Add Carrd Cookie Notice](https://www.bitdoze.com/add-cookie-notice-carrd/)
- [How To Add Pricing Table to Carrd.co](https://www.bitdoze.com/carrd-add-pricing-table/)
- [Carrd.co Review](https://www.bitdoze.com/carrd-review/)
- [How To Add Accordion FAQs Drop-Down to Carrd.co](https://www.bitdoze.com/add-accordion-carrd/)
- [Carrd.co Mobile Responsive Navbar](https://www.bitdoze.com/carrd-mobile-navbar/)

&gt; The complete list with Carrd plugins, themes and tutorials you can find on my **[carrdme.com](https://carrdme.com/)** website.

## How to Add the Carrd Sidebar Menu

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/UAyDbfnZWCs&quot;
  label=&quot;How to Add a Slide In Sidebar Menu to a Carrd Website&quot;
/&gt;

### 1. Add an embed element anywhere on the website

You just need to go on the `+` sign and add an Embed element anywhere on the website. Here&apos;s what you need to set:

- Type: Code
- Style: Hidden, Head

Just as in the picture below:
![carrd embed](../../assets/images/24/02/carrd-back-to-top-embed.png)


### 2. Use the HTML Code:

Below is the complete code you should use with detailed explanations:

```html
&lt;style&gt;
  :root {
    /* Sidebar Configuration */
    --sidebar-position: left; /* Options: &apos;left&apos; or &apos;right&apos; */
    --sidebar-width: 300px;
    --sidebar-bg-color: rgba(25, 25, 25, 0.95);
    --sidebar-text-color: #ffffff;
    --sidebar-border-color: #444;
    --sidebar-hover-color: rgba(255, 255, 255, 0.1);
    --sidebar-accent-color: #007bff;
    --contact-button-hover-color: #0056b3;

    /* Menu Button Configuration */
    --menu-button-bg: rgba(0, 123, 255, 0.9);
    --menu-button-color: #ffffff;
    --menu-button-size: 50px;
    --menu-button-floating: true; /* Options: true or false */
    --menu-button-float-position: top; /* Options: &apos;top&apos; or &apos;bottom&apos; (only when floating is true) */
    --menu-button-float-side: left; /* Options: &apos;left&apos; or &apos;right&apos; (only when floating is true) */
    --menu-button-position-top: 20px;
    --menu-button-position-side: 20px;

    /* Animation Settings */
    --sidebar-animation-speed: 0.3s;
    --button-animation-speed: 0.2s;

    /* Typography */
    --sidebar-font-family: inherit;
    --sidebar-font-size: 16px;
    --sidebar-heading-size: 20px;

    /* Spacing */
    --sidebar-padding: 20px;
    --menu-item-spacing: 15px;
  }

  /* Reset and Base Styles */
  * {
    box-sizing: border-box;
  }

  /* Menu Toggle Button */
  .sidebar-menu-toggle {
    background-color: var(--menu-button-bg);
    color: var(--menu-button-color);
    border: none;
    width: var(--menu-button-size);
    height: var(--menu-button-size);
    border-radius: 50%;
    cursor: pointer;
    z-index: 1001;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    transition: all var(--button-animation-speed) ease;
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
  }

  /* Floating button positioning */
  .sidebar-menu-toggle.floating {
    position: fixed;
  }

  .sidebar-menu-toggle.floating.float-top {
    top: var(--menu-button-position-top);
  }

  .sidebar-menu-toggle.floating.float-bottom {
    bottom: var(--menu-button-position-top);
  }

  .sidebar-menu-toggle.floating.float-left {
    left: var(--menu-button-position-side);
  }

  .sidebar-menu-toggle.floating.float-right {
    right: var(--menu-button-position-side);
  }

  /* Static button positioning (when not floating) */
  .sidebar-menu-toggle.static {
    position: relative;
    margin: 10px;
  }

  /* Position the menu button based on sidebar position (only for non-floating) */
  .sidebar-menu-toggle.static[data-position=&quot;left&quot;] {
    left: var(--menu-button-position-side);
  }

  .sidebar-menu-toggle.static[data-position=&quot;right&quot;] {
    right: var(--menu-button-position-side);
  }

  .sidebar-menu-toggle:hover {
    transform: scale(1.1);
    box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
  }

  /* Hamburger Icon */
  .hamburger-icon {
    width: 20px;
    height: 2px;
    background-color: var(--menu-button-color);
    margin: 2px 0;
    transition: all var(--button-animation-speed) ease;
    transform-origin: center;
  }

  /* Hamburger Animation */
  .sidebar-menu-toggle.active .hamburger-icon:nth-child(1) {
    transform: rotate(45deg) translate(4px, 4px);
  }

  .sidebar-menu-toggle.active .hamburger-icon:nth-child(2) {
    opacity: 0;
  }

  .sidebar-menu-toggle.active .hamburger-icon:nth-child(3) {
    transform: rotate(-45deg) translate(4px, -4px);
  }

  /* Sidebar Overlay */
  .sidebar-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    background-color: rgba(0, 0, 0, 0.5);
    z-index: 999;
    opacity: 0;
    visibility: hidden;
    transition: all var(--sidebar-animation-speed) ease;
  }

  .sidebar-overlay.active {
    opacity: 1;
    visibility: visible;
  }

  /* Sidebar Menu */
  .sidebar-menu {
    position: fixed;
    top: 0;
    width: var(--sidebar-width);
    height: 100%;
    background-color: var(--sidebar-bg-color);
    color: var(--sidebar-text-color);
    z-index: 1000;
    padding: var(--sidebar-padding);
    font-family: var(--sidebar-font-family);
    font-size: var(--sidebar-font-size);
    transition: transform var(--sidebar-animation-speed) ease;
    overflow-y: auto;
    box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
  }

  /* Sidebar positioning based on CSS variable */
  .sidebar-menu[data-position=&quot;left&quot;] {
    left: 0;
    transform: translateX(-100%);
    border-right: 1px solid var(--sidebar-border-color);
  }

  .sidebar-menu[data-position=&quot;right&quot;] {
    right: 0;
    transform: translateX(100%);
    border-left: 1px solid var(--sidebar-border-color);
  }

  .sidebar-menu.active {
    transform: translateX(0);
  }

  /* Close Button */
  .sidebar-close {
    position: absolute;
    top: 15px;
    background: none;
    border: none;
    color: var(--sidebar-text-color);
    font-size: 24px;
    cursor: pointer;
    width: 30px;
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    transition: background-color var(--button-animation-speed) ease;
  }

  .sidebar-close[data-position=&quot;left&quot;] {
    right: 15px;
  }

  .sidebar-close[data-position=&quot;right&quot;] {
    left: 15px;
  }

  .sidebar-close:hover {
    background-color: var(--sidebar-hover-color);
  }

  /* Sidebar Header */
  .sidebar-header {
    margin-top: 50px;
    margin-bottom: 30px;
    padding-bottom: 20px;
    border-bottom: 1px solid var(--sidebar-border-color);
  }

  .sidebar-title {
    font-size: var(--sidebar-heading-size);
    font-weight: bold;
    margin: 0;
    color: var(--sidebar-accent-color);
  }

  /* Navigation Menu */
  .sidebar-nav {
    list-style: none;
    padding: 0;
    margin: 0;
  }

  .sidebar-nav li {
    margin-bottom: var(--menu-item-spacing);
  }

  .sidebar-nav a {
    color: var(--sidebar-text-color);
    text-decoration: none;
    display: block;
    padding: 12px 15px;
    border-radius: 8px;
    transition: all var(--button-animation-speed) ease;
    border-left: 3px solid transparent;
  }

  .sidebar-nav a:hover {
    background-color: var(--sidebar-hover-color);
    border-left-color: var(--sidebar-accent-color);
    transform: translateX(5px);
  }

  /* Contact Button */
  .sidebar-contact-btn {
    margin-top: 30px;
    padding-top: 20px;
    border-top: 1px solid var(--sidebar-border-color);
  }

  .contact-button {
    display: block;
    width: 100%;
    padding: 15px;
    background-color: var(--sidebar-accent-color);
    color: white;
    text-decoration: none;
    text-align: center;
    border-radius: 8px;
    font-weight: bold;
    transition: all var(--button-animation-speed) ease;
    border: none;
    cursor: pointer;
    font-size: var(--sidebar-font-size);
  }

  .contact-button:hover {
    background-color: var(--contact-button-hover-color);
    transform: translateY(-2px);
    box-shadow: 0 4px 10px rgba(0, 123, 255, 0.3);
  }

  /* Mobile Responsiveness */
  @media (max-width: 768px) {
    :root {
      --sidebar-width: 280px;
      --sidebar-font-size: 15px;
      --sidebar-heading-size: 18px;
      --menu-button-size: 45px;
    }
  }

  @media (max-width: 480px) {
    :root {
      --sidebar-width: 250px;
      --sidebar-font-size: 14px;
      --sidebar-heading-size: 16px;
      --menu-button-size: 40px;
      --menu-button-position-top: 15px;
      --menu-button-position-side: 15px;
    }
  }

  /* Prevent body scroll when sidebar is open */
  body.sidebar-open {
    overflow: hidden;
  }
&lt;/style&gt;

&lt;!-- Menu Toggle Button --&gt;
&lt;button class=&quot;sidebar-menu-toggle&quot; id=&quot;sidebarToggle&quot;&gt;
  &lt;span class=&quot;hamburger-icon&quot;&gt;&lt;/span&gt;
  &lt;span class=&quot;hamburger-icon&quot;&gt;&lt;/span&gt;
  &lt;span class=&quot;hamburger-icon&quot;&gt;&lt;/span&gt;
&lt;/button&gt;

&lt;!-- Sidebar Overlay --&gt;
&lt;div class=&quot;sidebar-overlay&quot; id=&quot;sidebarOverlay&quot;&gt;&lt;/div&gt;

&lt;!-- Sidebar Menu --&gt;
&lt;nav class=&quot;sidebar-menu&quot; id=&quot;sidebarMenu&quot;&gt;
  &lt;button class=&quot;sidebar-close&quot; id=&quot;sidebarClose&quot;&gt;&amp;times;&lt;/button&gt;

  &lt;div class=&quot;sidebar-header&quot;&gt;
    &lt;h3 class=&quot;sidebar-title&quot;&gt;Navigation&lt;/h3&gt;
  &lt;/div&gt;

  &lt;ul class=&quot;sidebar-nav&quot;&gt;
    &lt;li&gt;&lt;a href=&quot;#home&quot;&gt;Home&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#about&quot;&gt;About&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#services&quot;&gt;Services&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#portfolio&quot;&gt;Portfolio&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#testimonials&quot;&gt;Testimonials&lt;/a&gt;&lt;/li&gt;
    &lt;li&gt;&lt;a href=&quot;#blog&quot;&gt;Blog&lt;/a&gt;&lt;/li&gt;
  &lt;/ul&gt;

  &lt;div class=&quot;sidebar-contact-btn&quot;&gt;
    &lt;a href=&quot;#contact&quot; class=&quot;contact-button&quot;&gt;Contact Us&lt;/a&gt;
  &lt;/div&gt;
&lt;/nav&gt;

&lt;script&gt;
document.addEventListener(&apos;DOMContentLoaded&apos;, function() {
  // Get elements
  const toggleBtn = document.getElementById(&apos;sidebarToggle&apos;);
  const closeBtn = document.getElementById(&apos;sidebarClose&apos;);
  const overlay = document.getElementById(&apos;sidebarOverlay&apos;);
  const sidebar = document.getElementById(&apos;sidebarMenu&apos;);
  const body = document.body;

  // Get CSS variables
  const sidebarPosition = getComputedStyle(document.documentElement)
    .getPropertyValue(&apos;--sidebar-position&apos;).trim();
  const isFloating = getComputedStyle(document.documentElement)
    .getPropertyValue(&apos;--menu-button-floating&apos;).trim() === &apos;true&apos;;
  const floatPosition = getComputedStyle(document.documentElement)
    .getPropertyValue(&apos;--menu-button-float-position&apos;).trim();
  const floatSide = getComputedStyle(document.documentElement)
    .getPropertyValue(&apos;--menu-button-float-side&apos;).trim();

  // Set button positioning classes
  if (isFloating) {
    toggleBtn.classList.add(&apos;floating&apos;);
    if (floatPosition === &apos;bottom&apos;) {
      toggleBtn.classList.add(&apos;float-bottom&apos;);
    } else {
      toggleBtn.classList.add(&apos;float-top&apos;);
    }
    if (floatSide === &apos;right&apos;) {
      toggleBtn.classList.add(&apos;float-right&apos;);
    } else {
      toggleBtn.classList.add(&apos;float-left&apos;);
    }
  } else {
    toggleBtn.classList.add(&apos;static&apos;);
  }

  // Set data attributes for positioning
  toggleBtn.setAttribute(&apos;data-position&apos;, sidebarPosition);
  closeBtn.setAttribute(&apos;data-position&apos;, sidebarPosition);
  sidebar.setAttribute(&apos;data-position&apos;, sidebarPosition);

  // Open sidebar function
  function openSidebar() {
    sidebar.classList.add(&apos;active&apos;);
    overlay.classList.add(&apos;active&apos;);
    toggleBtn.classList.add(&apos;active&apos;);
    body.classList.add(&apos;sidebar-open&apos;);
  }

  // Close sidebar function
  function closeSidebar() {
    sidebar.classList.remove(&apos;active&apos;);
    overlay.classList.remove(&apos;active&apos;);
    toggleBtn.classList.remove(&apos;active&apos;);
    body.classList.remove(&apos;sidebar-open&apos;);
  }

  // Event listeners
  toggleBtn.addEventListener(&apos;click&apos;, function(e) {
    e.stopPropagation();
    if (sidebar.classList.contains(&apos;active&apos;)) {
      closeSidebar();
    } else {
      openSidebar();
    }
  });

  closeBtn.addEventListener(&apos;click&apos;, closeSidebar);
  overlay.addEventListener(&apos;click&apos;, closeSidebar);

  // Close sidebar when clicking on navigation links
  const navLinks = document.querySelectorAll(&apos;.sidebar-nav a, .contact-button&apos;);
  navLinks.forEach(link =&gt; {
    link.addEventListener(&apos;click&apos;, function() {
      closeSidebar();
    });
  });

  // Close sidebar on escape key
  document.addEventListener(&apos;keydown&apos;, function(e) {
    if (e.key === &apos;Escape&apos; &amp;&amp; sidebar.classList.contains(&apos;active&apos;)) {
      closeSidebar();
    }
  });

  // Handle window resize
  window.addEventListener(&apos;resize&apos;, function() {
    if (window.innerWidth &gt; 768 &amp;&amp; sidebar.classList.contains(&apos;active&apos;)) {
      closeSidebar();
    }
  });
});
&lt;/script&gt;
```

Below are the key customization variables you can modify:

### Sidebar Configuration Variables:

1. **`--sidebar-position: left;`**
   - Options: `left` or `right`
   - Determines which side the sidebar opens from

2. **`--menu-button-floating: true;`**
   - Options: `true` or `false`
   - When `true`, the menu button floats on the page; when `false`, it&apos;s positioned relative to where the code is inserted

3. **`--menu-button-float-position: top;`**
   - Options: `top` or `bottom`
   - Only applies when `--menu-button-floating` is `true`
   - Determines whether the floating button appears at the top or bottom of the screen

4. **`--menu-button-float-side: left;`**
   - Options: `left` or `right`
   - Only applies when `--menu-button-floating` is `true`
   - Determines whether the floating button appears on the left or right side of the screen

5. **`--sidebar-width: 300px;`**
   - Controls the width of the sidebar menu

6. **`--sidebar-bg-color: rgba(25, 25, 25, 0.95);`**
   - Background color of the sidebar (supports transparency)

7. **`--sidebar-text-color: #ffffff;`**
   - Color of text inside the sidebar

8. **`--sidebar-accent-color: #007bff;`**
   - Accent color used for highlights and the contact button

9. **`--menu-button-bg: rgba(0, 123, 255, 0.9);`**
   - Background color of the hamburger menu button

10. **`--contact-button-hover-color: #0056b3;`**
   - Background color of the contact button when hovered

11. **`--sidebar-animation-speed: 0.3s;`**
    - Speed of sidebar open/close animations

You can easily customize colors using [https://rgbacolorpicker.com/](https://rgbacolorpicker.com/) for colors with transparency support.

### Button Positioning Options:

- **Floating Mode** (`--menu-button-floating: true`): The menu button will float on the screen at the specified position (top/bottom and left/right)
- **Static Mode** (`--menu-button-floating: false`): The menu button will be positioned relative to where you insert the embed code in your Carrd site

&lt;Button link=&quot;https://go.bitdoze.com/carrd&quot; text=&quot;Carrd.co&quot; /&gt;

### 3. Customize the Menu Items

```html
&lt;ul class=&quot;sidebar-nav&quot;&gt;
  &lt;li&gt;&lt;a href=&quot;#home&quot;&gt;Home&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#about&quot;&gt;About&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#services&quot;&gt;Services&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#portfolio&quot;&gt;Portfolio&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#testimonials&quot;&gt;Testimonials&lt;/a&gt;&lt;/li&gt;
  &lt;li&gt;&lt;a href=&quot;#blog&quot;&gt;Blog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
```

This section contains your navigation menu items. Simply add or remove `&lt;li&gt;&lt;a href=&quot;#section&quot;&gt;Section Name&lt;/a&gt;&lt;/li&gt;` entries to match your Carrd site&apos;s sections.

### 4. Customize the Contact Button

The contact button at the bottom of the sidebar can be customized by changing:

```html
&lt;a href=&quot;#contact&quot; class=&quot;contact-button&quot;&gt;Contact Us&lt;/a&gt;
```

You can change the link destination and button text to match your needs.

## Key Features

### Mobile Optimization
The sidebar is fully responsive and automatically adjusts its size and positioning for mobile devices. It includes:
- Touch-friendly button sizes
- Optimized spacing for mobile screens
- Prevents body scrolling when sidebar is open
- Smooth animations optimized for mobile performance

### Smooth Animations
The sidebar includes several animation effects:
- Slide-in/slide-out transitions
- Hamburger icon morphing to X when active
- Hover effects on menu items
- Button scaling and shadow effects

### Accessibility Features
- Keyboard support (Escape key closes the sidebar)
- Focus management
- Screen reader friendly markup
- High contrast color options

### Cross-Browser Compatibility
The code uses modern CSS features with fallbacks and is compatible with all major browsers.

## Conclusion

This sidebar menu provides a professional and user-friendly navigation solution for your Carrd website. The customizable nature allows you to match your site&apos;s branding perfectly, while the responsive design ensures it works beautifully on all devices.

The sidebar menu strikes the perfect balance between functionality and aesthetics, giving your visitors easy access to all sections of your site without compromising your content&apos;s visual impact. Whether you choose left or right positioning, the smooth animations and intuitive interactions will enhance your site&apos;s user experience significantly.

By implementing this sidebar menu, you&apos;re adding a professional touch that sets your Carrd site apart while maintaining the platform&apos;s core benefits of simplicity and speed.</content:encoded><category>web-development</category><category>carrd</category></item><item><title>How to Self-Host n8n: Complete Guide to Workflow Automation</title><link>https://www.bitdoze.com/n8n-self-host-workflow-automation/</link><guid isPermaLink="true">https://www.bitdoze.com/n8n-self-host-workflow-automation/</guid><description>Guide to self-hosting n8n, an open-source workflow automation platform. Includes Docker, Traefik, and Dokploy setup options.</description><pubDate>Sat, 02 Aug 2025 00:00:00 GMT</pubDate><content:encoded>Managing workflows across multiple applications gets complicated fast. Whether you&apos;re automating document processing, syncing data between platforms, or building multi-step business processes, an automation tool helps.

**n8n** is an open-source, self-hosted workflow automation platform. It competes with tools like Zapier or Microsoft Power Automate, but you host it yourself and customize it however you want.

## What is n8n?

n8n (pronounced &quot;nodemation&quot;) connects different services and automates repetitive tasks through a visual interface. The platform uses nodes—components that perform specific functions—connected together to form automation sequences.

### Key Benefits of n8n

&lt;ListCheck&gt;

- **Visual Workflow Builder**: Create automations using a drag-and-drop interface

- **400+ Built-in Integrations**: Connect to Google Workspace, Microsoft 365, Slack, GitHub, databases, and APIs

- **Custom JavaScript Nodes**: Write custom code when pre-built nodes don&apos;t do what you need

- **Webhook Support**: Trigger workflows from external events

- **Advanced Flow Control**: Add conditional logic, loops, error handling, and parallel processing

- **Queue Mode**: Process high-volume operations with queuing and retry mechanisms

- **Credential Management**: Securely store and manage API keys, tokens, and authentication details with encryption

- **Version Control Integration**: Track workflow changes with Git

- **Self-Hosted Privacy**: Keep your data on your own servers

- **Extensive API**: Manage workflows and configurations through REST APIs

- **Multi-Environment Support**: Deploy across development, staging, and production environments

- **Active Community**: Get regular updates and community-contributed nodes

&lt;/ListCheck&gt;

The project maintains an open-source approach with additional enterprise features available for organizations requiring advanced capabilities. You can explore the codebase and contribute at the [official GitHub repository](https://github.com/n8n-io/n8n) or visit their [website](https://n8n.io) for comprehensive documentation.

### How n8n Works

n8n uses nodes. Each node represents a function or service integration. You create workflows by connecting nodes in sequence, and data flows from one node to the next.

| Component Type | Purpose | Use Cases |
|---------------|---------|-----------|
| **Trigger Nodes** | Initiate workflow execution | Webhook requests, scheduled tasks, file changes, email arrivals |
| **Regular Nodes** | Perform actions | API calls, data processing, file operations |
| **Control Nodes** | Manage workflow logic | Conditional branching, loops, error handling, data merging |
| **Code Nodes** | Run custom scripts | Complex calculations, data transformations |
| **Sub-workflow Nodes** | Reference other workflows | Reusable components, modular design |

This modular approach lets you build simple data sync tasks or complex multi-step business processes.

&lt;Notice type=&quot;info&quot; title=&quot;System Requirements&quot;&gt;

n8n is lightweight, but complex workflows with multiple executions need adequate resources.

&lt;/Notice&gt;

**Essential Requirements:**

- **Server Infrastructure**: A VPS or dedicated server. [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) works well, or use a [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/)
- **Operating System**: Linux-based system (Ubuntu 20.04+ recommended) with Docker support
- **Domain Name**: A registered domain for accessing your n8n instance (e.g., n8n.yourdomain.com)
- **SSL Certificate**: For secure HTTPS access to your workflows and webhook endpoints

**Technical Prerequisites:**

- **Reverse Proxy Setup**: Traefik configured with Docker:
  - [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)
  - [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/)
- **Container Management**: Docker and Docker Compose installed on your server
- **Optional Management UI**: Dockge for simplified container management - see [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/)

**Recommended Specifications:**

| Component | Minimum | Recommended | Enterprise |
|-----------|---------|-------------|------------|
| **CPU** | 2 cores | 4 cores | 8+ cores |
| **RAM** | 2GB | 4GB | 8GB+ |
| **Storage** | 20GB SSD | 50GB SSD | 100GB+ NVMe |
| **Network** | 100 Mbps | 1 Gbps | 10 Gbps |

&lt;Notice type=&quot;warning&quot; title=&quot;Performance Considerations&quot;&gt;

Complex workflows with frequent API calls or large data processing may need higher specs. Monitor usage and scale as needed.

&lt;/Notice&gt;

## Setup Option 1: Docker &amp; Docker Compose (Standalone)

This approach works well for getting started or development environments where you want direct access to n8n.

### Step 1: Create Project Directory

Create a directory for your n8n installation:

```bash
mkdir -p ~/n8n-automation &amp;&amp; cd ~/n8n-automation
```

This creates a clean workspace where all n8n-related files will be organized and easily manageable.

### Step 2: Create Docker Compose Configuration

Create a comprehensive Docker Compose file that includes both n8n and Redis for optimal performance:

```yaml
version: &apos;3.8&apos;

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    container_name: n8n-app
    restart: unless-stopped
    ports:
      - &apos;5678:5678&apos;
    environment:
      - VUE_APP_URL_BASE_API=http://localhost:5678
      - N8N_EDITOR_BASE_URL=http://localhost:5678
      - WEBHOOK_URL=http://localhost:5678
      - GENERIC_TIMEZONE=UTC
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - DB_TYPE=sqlite
      - N8N_LOG_LEVEL=info
      - N8N_LOG_OUTPUT=console
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=168
    volumes:
      - ./data:/home/node/.n8n
      - ./files:/files
      - ./custom-nodes:/opt/custom-nodes
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
      - CHOWN
      - DAC_OVERRIDE
    depends_on:
      redis:
        condition: service_healthy
    healthcheck:
      test: [&apos;CMD&apos;, &apos;wget&apos;, &apos;--no-verbose&apos;, &apos;--tries=1&apos;, &apos;--spider&apos;, &apos;http://localhost:5678/healthz&apos;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s

  redis:
    container_name: n8n-redis
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    restart: unless-stopped
    volumes:
      - redis-data:/data
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
      - CHOWN
      - DAC_OVERRIDE
    healthcheck:
      test: [&apos;CMD&apos;, &apos;redis-cli&apos;, &apos;ping&apos;]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 30s

volumes:
  redis-data:
    driver: local
```

### Step 3: Initialize Configuration Directory

Set up the necessary directories and generate a secure encryption key:

```bash
# Create required directories
mkdir -p {data,files,custom-nodes}

# Generate a secure encryption key
echo &quot;N8N_ENCRYPTION_KEY=$(openssl rand -base64 32)&quot; &gt; .env

# Set proper permissions
chmod 700 data
chmod 755 files custom-nodes
```

&lt;Notice type=&quot;info&quot; title=&quot;Encryption Key Security&quot;&gt;

The encryption key is crucial for securing credentials and sensitive data. Store it safely and use the same key when migrating or backing up your n8n instance.

&lt;/Notice&gt;

### Step 4: Launch n8n

Start your n8n instance and verify the deployment:

```bash
# Launch the containers
docker compose up -d

# Verify containers are running
docker compose ps

# Check logs for any issues
docker compose logs -f n8n
```

### Step 5: Access Your Automation Platform

Navigate to `http://localhost:5678` in your web browser. You&apos;ll be greeted with the n8n setup wizard where you can:

- Create your administrator account
- Configure basic settings
- Start building your first workflow

&lt;Notice type=&quot;success&quot; title=&quot;Installation Complete&quot;&gt;

Your n8n instance is now operational! You can begin creating powerful automation workflows immediately.

&lt;/Notice&gt;

## Setup Option 2: Traefik &amp; Dockge Integration

This professional setup provides automatic HTTPS certificates, domain routing, and simplified management through Dockge&apos;s intuitive interface—ideal for production environments.

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisites Check&quot;&gt;

Ensure you have Traefik configured with wildcard certificates using our [comprehensive guide](https://www.bitdoze.com/traefik-wildcard-certificate/). This setup assumes you&apos;re using CloudFlare as your DNS provider.

&lt;/Notice&gt;

### Step 1: Prepare Traefik Network

Establish the external network that Traefik uses for service discovery:

```bash
# Create the Traefik network if it doesn&apos;t exist
docker network create traefik-net 2&gt;/dev/null || echo &quot;Network already exists&quot;

# Verify network creation
docker network ls | grep traefik-net
```

### Step 2: Enhanced Docker Compose with Traefik Labels

Create a production-ready configuration with automatic SSL and domain routing:

```yaml
version: &apos;3.8&apos;

networks:
  traefik-net:
    external: true

services:
  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    container_name: n8n-production
    environment:
      - VUE_APP_URL_BASE_API=https://n8n.yourdomain.com
      - N8N_EDITOR_BASE_URL=https://n8n.yourdomain.com
      - WEBHOOK_URL=https://n8n.yourdomain.com
      - GENERIC_TIMEZONE=UTC
      - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}
      - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
      - DB_TYPE=sqlite
      - N8N_LOG_LEVEL=warn
      - N8N_LOG_OUTPUT=file
      - EXECUTIONS_DATA_PRUNE=true
      - EXECUTIONS_DATA_MAX_AGE=336
      - N8N_METRICS=true
      - QUEUE_BULL_REDIS_HOST=redis
      - QUEUE_BULL_REDIS_PORT=6379
    volumes:
      - ./data:/home/node/.n8n
      - ./files:/files
      - ./logs:/var/log/n8n
    networks:
      - traefik-net
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.n8n.rule=Host(`n8n.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.n8n.entrypoints=https&quot;
      - &quot;traefik.http.routers.n8n.tls=true&quot;
      - &quot;traefik.http.routers.n8n.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.n8n.loadbalancer.server.port=5678&quot;
      - &quot;traefik.http.routers.n8n.middlewares=security-headers@file&quot;

  redis:
    container_name: n8n-redis-prod
    image: redis:7-alpine
    command: redis-server --appendonly yes --maxmemory 1gb --maxmemory-policy allkeys-lru --save 900 1
    restart: unless-stopped
    networks:
      - traefik-net
    volumes:
      - redis-data:/data
      - ./redis-config:/usr/local/etc/redis
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID
      - CHOWN
      - DAC_OVERRIDE
    healthcheck:
      test: [&apos;CMD&apos;, &apos;redis-cli&apos;, &apos;ping&apos;]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 30s

volumes:
  redis-data:
    driver: local
```

### Step 3: Deploy Through Dockge

If you&apos;re using Dockge for container management:

1. **Access Dockge Interface**: Navigate to your Dockge installation
2. **Create New Stack**: Click &quot;Create Stack&quot; and name it &quot;n8n-production&quot;
3. **Paste Configuration**: Copy the Docker Compose content above
4. **Configure Environment**: Add your encryption key and domain settings
5. **Deploy Stack**: Click deploy and monitor the deployment logs

&lt;Button text=&quot;Deploy via Dockge&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

### Step 4: Configure Domain DNS

Update your DNS records to point to your server:

| Record Type | Name | Value | TTL |
|-------------|------|-------|-----|
| A | n8n | YOUR_SERVER_IP | 300 |
| AAAA | n8n | YOUR_IPv6_ADDRESS | 300 |

&lt;Notice type=&quot;info&quot; title=&quot;DNS Propagation&quot;&gt;

DNS changes may take up to 24 hours to propagate globally. Use tools like `nslookup` or online DNS checkers to verify propagation.

&lt;/Notice&gt;

## Setup Option 3: Dokploy Easy Deployment

Dokploy offers the simplest deployment method with a user-friendly interface that handles most configuration automatically—perfect for users who prefer GUI-based management.

### Step 1: Install Dokploy

Follow our comprehensive [Dokploy installation guide](https://www.bitdoze.com/dokploy-install/) to set up this modern deployment platform on your server.

### Step 2: Create n8n Application

1. **Access Dokploy Dashboard**: Log into your Dokploy instance
2. **Create New Project**: Click &quot;New Project&quot; and name it &quot;n8n-automation&quot;
3. **Select Template**: Choose &quot;n8n&quot; from the available templates or use Docker Compose mode
4. **Configure Basic Settings**: Set your application name and description

![Dokploy Service](../../assets/images/25/07/dokploy-createservice.png)
![Dokploy n8n](../../assets/images/25/07/dokploy-SearXNG.png)

### Step 3: Configure Environment Variables

Set up essential environment variables for optimal n8n operation:

| Variable Name | Value | Description |
|---------------|-------|-------------|
| `VUE_APP_URL_BASE_API` | `https://n8n.yourdomain.com` | Frontend API endpoint |
| `N8N_EDITOR_BASE_URL` | `https://n8n.yourdomain.com` | Editor interface URL |
| `WEBHOOK_URL` | `https://n8n.yourdomain.com` | Webhook endpoint base |
| `GENERIC_TIMEZONE` | `UTC` | Server timezone |
| `N8N_ENCRYPTION_KEY` | `your_generated_key` | Encryption key for credentials |
| `N8N_LOG_LEVEL` | `info` | Logging verbosity |
| `EXECUTIONS_DATA_PRUNE` | `true` | Enable execution cleanup |
| `EXECUTIONS_DATA_MAX_AGE` | `168` | Keep executions for 7 days |

&lt;Notice type=&quot;warning&quot; title=&quot;Environment Security&quot;&gt;

Ensure your encryption key is strong and unique. Never reuse keys across different n8n instances or environments.

&lt;/Notice&gt;




### Step 4: Domain Configuration

1. **Add Domain**: In Dokploy, navigate to your n8n application settings
2. **Configure SSL**: Enable automatic SSL certificate generation
3. **Set Domain**: Enter your chosen subdomain (e.g., n8n.yourdomain.com)
4. **Verify DNS**: Ensure your domain points to the Dokploy server

![Dokploy n8n domain](../../assets/images/25/07/dokploy-domain.png)


### Step 5: Deploy Application

1. **Review Configuration**: Double-check all settings and environment variables
2. **Deploy**: Click the deploy button and monitor the deployment process
3. **Access Application**: Once deployed, access your n8n instance via the configured domain

![Dokploy n8n deploy](../../assets/images/25/07/dokploy-deploy.png)

&lt;Button text=&quot;Launch n8n Deployment&quot; size=&quot;xl&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;


## Monitoring and Maintenance

Establish robust monitoring and maintenance practices to ensure your n8n instance operates reliably and efficiently over time.

### Health Monitoring

Implement comprehensive health checks to monitor your n8n deployment:

```yaml
healthcheck:
  test: [&apos;CMD&apos;, &apos;wget&apos;, &apos;--no-verbose&apos;, &apos;--tries=1&apos;, &apos;--spider&apos;, &apos;http://localhost:5678/healthz&apos;]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 60s
```

**Key Metrics to Monitor:**

| Metric | Description | Alert Threshold |
|--------|-------------|-----------------|
| **Response Time** | API endpoint response times | &gt; 5 seconds |
| **Memory Usage** | Container memory consumption | &gt; 80% |
| **CPU Usage** | Processing load | &gt; 85% sustained |
| **Disk Space** | Storage utilization | &gt; 90% |
| **Failed Executions** | Workflow failure rate | &gt; 10% |
| **Queue Length** | Pending workflow executions | &gt; 100 items |

### Regular Maintenance Tasks

Establish a maintenance schedule to keep your n8n instance running smoothly:

**Weekly Tasks:**
- Review execution logs for errors
- Check storage usage and clean old executions
- Verify backup integrity
- Update workflow documentation

**Monthly Tasks:**
- Update n8n to the latest stable version
- Review and optimize workflow performance
- Audit user access and permissions
- Analyze usage patterns and resource requirements

**Quarterly Tasks:**
- Comprehensive security audit
- Disaster recovery testing
- Performance benchmarking
- Infrastructure capacity planning

### Backup Strategies

Implement a comprehensive backup strategy to protect your automation workflows and data:

```bash
#!/bin/bash
# n8n Backup Script

BACKUP_DIR=&quot;/backups/n8n&quot;
DATE=$(date +%Y%m%d_%H%M%S)
N8N_DATA_DIR=&quot;./data&quot;

# Create backup directory
mkdir -p $BACKUP_DIR

# Backup n8n data
tar -czf $BACKUP_DIR/n8n_data_$DATE.tar.gz $N8N_DATA_DIR

# Backup Docker Compose configuration
cp docker-compose.yml $BACKUP_DIR/docker-compose_$DATE.yml
cp .env $BACKUP_DIR/env_$DATE.backup

# Clean old backups (keep last 30 days)
find $BACKUP_DIR -name &quot;*.tar.gz&quot; -mtime +30 -delete

echo &quot;Backup completed: n8n_data_$DATE.tar.gz&quot;
```


## Conclusion

Self-hosting n8n transforms your approach to workflow automation by providing unprecedented control, privacy, and customization capabilities. Throughout this comprehensive guide, we&apos;ve explored three distinct setup methods—from simple Docker deployments to sophisticated Traefik integrations and user-friendly Dokploy implementations—ensuring you can choose the approach that best matches your technical expertise and infrastructure requirements.

**Key Advantages of Self-Hosting n8n:**

&lt;ListCheck&gt;

- **Complete Data Control**: Your sensitive workflows and data remain entirely under your jurisdiction
- **Unlimited Scalability**: Scale resources and capabilities based on your specific needs without artificial limitations
- **Cost Effectiveness**: Eliminate recurring subscription fees while gaining enterprise-grade functionality
- **Customization Freedom**: Modify, extend, and integrate n8n to perfectly match your unique requirements
- **Privacy Assurance**: No third-party access to your automation logic or processed data
- **Integration Flexibility**: Connect any service or system without external restrictions

&lt;/ListCheck&gt;

Whether you&apos;re automating document processing workflows, synchronizing data between business systems, or creating complex multi-step processes, n8n provides the foundation for building robust, reliable automation solutions that grow with your needs.

**Next Steps:**

1. **Start Small**: Begin with simple workflows to familiarize yourself with n8n&apos;s capabilities
2. **Explore Integrations**: Test connections to your most frequently used services
3. **Build Complexity**: Gradually create more sophisticated workflows as your confidence grows
4. **Share Knowledge**: Contribute to the n8n community and learn from other users&apos; experiences
5. **Scale Thoughtfully**: Monitor performance and scale your infrastructure as workflow complexity increases

&lt;Button text=&quot;Begin Your Automation Journey&quot; size=&quot;xl&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

The power of workflow automation awaits—start building your digital efficiency engine today with n8n and transform how you handle repetitive tasks, data integration, and business process automation. Your future self will thank you for the time and effort saved through intelligent automation.

For additional guidance on Docker containerization and related technologies, explore our extensive collection of [self-hosting guides](https://www.bitdoze.com/best-self-hosted-panels/) and [Docker tutorials](https://www.bitdoze.com/docker-containers-home-server/). n8n also appears in the [top AI GitHub repos](/top-ai-github-repos/) catalog next to Dify, Langflow, and agent frameworks.</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category></item><item><title>Deploy Your Astro + Convex App to Vercel: The Simplest Production Setup</title><link>https://www.bitdoze.com/astro-convex-vercel-deployment/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-convex-vercel-deployment/</guid><description>Deploy your real-time Astro and Convex application to Vercel in minutes with zero configuration - the easiest way to go from development to production</description><pubDate>Thu, 31 Jul 2025 07:00:00 GMT</pubDate><content:encoded>Now that you&apos;ve built a real-time chat app with [Astro and Convex](/astro-convex-realtime-app/), it&apos;s time to deploy it. In this guide, I&apos;ll show you how to deploy your Astro + Convex application to Vercel without complex configuration.

## Why Vercel + Convex Works Well

### Production Deployment Options

**Vercel** provides:
- Git-based deployments
- Automatic CI/CD
- A free tier for projects
- Edge network
- Analytics and performance monitoring
- Astro support

**Convex Cloud** handles:
- Real-time database sync
- Automatic scaling
- Security features
- Environment-based deployments
- Performance monitoring

The Vercel integration with Convex handles configuration automatically.

## What We&apos;ll Deploy

We&apos;ll take your existing Astro + Convex chat application and:
- Deploy to Vercel without code changes
- Set up production deployments
- Configure environment variables
- Set up preview deployments for pull requests
- Add custom domain (optional)
- Implement production monitoring

No adapters or complex config files needed.

## Prerequisites

Before starting, you need:
- An existing Astro + Convex project ([follow our previous guide](/astro-convex-realtime-app/))
- A [Vercel account](https://vercel.com)
- A [GitHub](https://github.com) repository with your code



## Step 1: Prepare Convex Production

### Create Production Deployment

Create a production deployment in Convex:

```sh
cd your-project-directory
npx convex deploy
```

This command creates a new production deployment, deploys your functions and schema, and generates a production deployment URL.

### Get Your Production Deploy Key

1. Visit the [Convex Dashboard](https://dashboard.convex.dev)
2. Select your project
3. Go to **Settings** → **Deploy Keys**
4. Click **Generate Production Deploy Key**
5. Copy the key - you&apos;ll need it for Vercel

```sh
# The key will look like this:
CONVEX_DEPLOY_KEY=your-production-deploy-key-here
```

### Add Vercel Adapter to Astro

Add the Vercel adapter to make your project work on Vercel:

```sh
npx astro add vercel
```

## Step 2: Push to GitHub

Add your repository to GitHub and push your code:

```sh
git remote add origin git@github.com:bitdoze/test-repo.git
git branch -M main
git add -A
git commit -m &quot;changes to code&quot;
git push -u origin main
```

## Step 3: Deploy to Vercel

### Connect Your Repository

1. **Go to Vercel Dashboard**
   - Visit [vercel.com/new](https://vercel.com/new)
   - Sign in with GitHub

2. **Import Your Project**
   - Click **Import** next to your repository
   - Vercel detects it&apos;s an Astro project

### Configure Build Settings

**Framework Preset**: Astro

**Build Command**:
```
npx convex deploy --cmd &apos;npm run build&apos;
```

**Output Directory**: `dist`

**Install Command**: `npm install`

### Set Environment Variables

Click **Environment Variables** and add:

| Variable Name | Value | Environment |
|---------------|-------|-------------|
| `CONVEX_DEPLOY_KEY` | `your-production-deploy-key` | Production |

### Deploy Your Site

1. **Review Settings**
   - Check your build command
   - Verify the environment variable

2. **Click Deploy**
   - Vercel starts building
   - Watch the build process
   - Build completes in 1-2 minutes

3. **Done**
   - Your site is live at `https://your-project.vercel.app`
   - Convex functions are deployed automatically



## Step 4: Custom Domain Setup (Optional)

### Add Custom Domain in Vercel

1. **Access Domain Settings**
   - Go to your project dashboard
   - Navigate to **Settings** → **Domains**
   - Click **Add Domain**

2. **Configure Domain**
   - Enter your domain (e.g., `myapp.example.com`)
   - Vercel provides DNS instructions
   - Add the CNAME record to your DNS provider

3. **SSL Setup**
   - Vercel provisions SSL certificates
   - HTTPS is enabled
   - Automatic HTTP to HTTPS redirects

### Domain Verification

- When DNS propagates, the status shows &quot;Valid&quot; (usually 5-10 minutes)
- Test your domain to confirm it works
- SSL certificates are issued and renewed automatically

## Step 5: Monitoring &amp; Analytics

### Vercel Analytics

**Real User Monitoring** (Free):
1. Go to your project dashboard
2. Click the **Analytics** tab
3. View performance data
4. Monitor Core Web Vitals

**Speed Insights** (Free):
- Lighthouse scores
- Performance recommendations
- Real user data

### Vercel Pro Analytics

**Web Analytics** ($20/month):
- Visitor analytics
- Page view tracking
- Referrer analysis
- Geographic data

**Audience** (Pro feature):
- User behavior data
- Conversion tracking
- A/B testing

### Monitor Convex Performance

In your [Convex Dashboard](https://dashboard.convex.dev):

1. **Function Logs**: Monitor function execution
2. **Performance Metrics**: Track function response times
3. **Error Tracking**: Get notified of function errors
4. **Usage Analytics**: Monitor database operations



## Conclusion

You&apos;ve deployed your Astro + Convex application to Vercel. This setup gives you:

### What You&apos;ve Accomplished

- Simple deployment without adapters or complex configuration
- Automatic deployments on every push
- Fast loading times through the edge network
- Built-in monitoring and performance insights
- Security features included by default

### Why This Stack Works

- Deploy quickly without hours of configuration
- Free tiers available for both platforms
- Real-time capabilities for interactive applications
- Automatic scaling for traffic spikes
- No manual security configuration needed
- Fast performance worldwide

### Development Workflow

1. Develop locally with `npm run dev` and `npx convex dev`
2. Push to GitHub when ready
3. Automatic deployment to Vercel
4. Preview URLs for pull requests
5. Production deployment when merged to main
6. Built-in monitoring

The Astro + Convex + Vercel stack works well for building real-time applications like chat apps, collaborative tools, and interactive dashboards.</content:encoded><category>web-development</category><category>astro</category><category>convex</category></item><item><title>Build a Real-Time App with Astro and Convex</title><link>https://www.bitdoze.com/astro-convex-realtime-app/</link><guid isPermaLink="true">https://www.bitdoze.com/astro-convex-realtime-app/</guid><description>Learn how to build real-time applications using Astro&apos;s static site generation with Convex&apos;s backend-as-a-service. Tutorial with working code examples and deployment guide.</description><pubDate>Thu, 31 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Let&apos;s build applications that are fast and real-time. We&apos;ll combine **Astro** for static sites and **Convex** for backend functionality. By the end of this tutorial, you&apos;ll have a chat application that loads quickly and updates in real-time across connected users.

You get both speed and interactivity.

## 🤔 What Makes This Stack So Special?

Let&apos;s understand why Astro + Convex works well:

### Why Astro is Perfect for Modern Apps

**Astro** ships HTML with JavaScript only where you need it, unlike frameworks that send megabytes of JavaScript.

&lt;ListCheck&gt;
- **Zero JavaScript by default** - Pages load with pure HTML/CSS
- **Islands Architecture** - Interactive components are hydrated independently
- **Framework agnostic** - Use React, Vue, Svelte, or plain JavaScript components
- **Built-in optimizations** - Image optimization, CSS bundling, and more out of the box
&lt;/ListCheck&gt;

### Why Convex is a Backend Game-Changer

**Convex** handles backend development:

&lt;ListCheck&gt;

- **Real-time by default** - Queries become live subscriptions

- **Strong consistency** - No more race conditions or data inconsistencies
- **TypeScript everywhere** - End-to-end type safety from database to frontend
- **Serverless and scalable** - Zero infrastructure management required
- **ACID transactions** - Your data stays consistent even under heavy load

&lt;/ListCheck&gt;

Convex provides real-time capabilities with consistency guarantees.

---

## 🎯 What We&apos;re Building Today

We&apos;re building a **real-time chat application**:

- **Fast initial page load** with Astro
- **Real-time message updates** across all connected users
- **Type-safe API** from database to frontend
- **Production-ready deployment** on modern hosting platforms
- **Beautiful, responsive UI** with Tailwind CSS

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ZZWOj6kwWxc&quot;
  label=&quot;Astro + Convex Real-Time Chat Demo&quot;
/&gt;

---

## 🛠️ Prerequisites &amp; Setup

Before starting, make sure you have:

&lt;ListCheck&gt;

- **Node.js 18+** installed on your machine
- **Basic TypeScript knowledge** (we&apos;ll explain the Convex-specific parts)
- **A GitHub account** (for Convex authentication)
- **About 45 minutes** of focused coding time

&lt;/ListCheck&gt;

&lt;Notice type=&quot;info&quot; title=&quot;New to TypeScript?&quot;&gt;

The TypeScript here is straightforward, and Convex&apos;s type safety helps development.

&lt;/Notice&gt;

---

## 🚀 Step 1: Create Your Astro Project

Create a new Astro project:

```bash
# Create a new Astro project
npm create astro@latest astro-convex-chat

# When prompted, choose:
# - &quot;Empty&quot; template
# - Yes to TypeScript
# - Yes to install dependencies
# - Yes to initialize git repository

# Navigate to your project
cd astro-convex-chat
```

Now let&apos;s add React integration for our interactive components:

```bash
# Add React support to Astro
npx astro add react

# Add Tailwind CSS for styling
npx astro add tailwind

# Install additional utilities we&apos;ll need
npm install npm-run-all clsx
```

&lt;Notice type=&quot;success&quot; title=&quot;Pro Tip&quot;&gt;

The `npx astro add` commands configure TypeScript types and build settings automatically.

&lt;/Notice&gt;

---

## 🔧 Step 2: Install and Configure Convex

Add Convex to the project:

```bash
# Install Convex
npm install convex

# Initialize Convex (this will prompt you to sign in with GitHub)
npx convex dev
```

During the Convex setup process:

1. **Sign in with GitHub** - Convex uses GitHub for authentication
2. **Create a new project** - Name it something like &quot;astro-chat-app&quot;
3. **Accept the default configuration** - Convex will create a `convex/` folder

This creates several important files:
- `convex/` folder - Where your backend functions live
- `.env.local` - Contains your Convex deployment URL
- `convex/_generated/` - Auto-generated TypeScript types

&lt;Notice type=&quot;warning&quot; title=&quot;Keep convex dev Running&quot;&gt;

Make sure to keep the `npx convex dev` command running throughout development. It watches your backend functions and keeps everything in sync.

&lt;/Notice&gt;

---

## 📊 Step 3: Design Your Database Schema

Convex uses a schema-first approach for type safety. Define the chat app&apos;s data structure in `convex/schema.ts`:

```typescript
import { defineSchema, defineTable } from &quot;convex/server&quot;;
import { v } from &quot;convex/values&quot;;

export default defineSchema({
  // Users table to store user information
  users: defineTable({
    name: v.string(),
    email: v.optional(v.string()),
    avatar: v.optional(v.string()),
  }).index(&quot;by_email&quot;, [&quot;email&quot;]),

  // Messages table for chat messages
  messages: defineTable({
    author: v.string(),
    body: v.string(),
    timestamp: v.number(),
  }).index(&quot;by_timestamp&quot;, [&quot;timestamp&quot;]),

  // Rooms table for different chat rooms (future enhancement)
  rooms: defineTable({
    name: v.string(),
    description: v.optional(v.string()),
    isPrivate: v.boolean(),
  }),
});
```

**What&apos;s happening here?**

&lt;ListCheck&gt;

- **defineSchema** creates our database schema with type safety
- **defineTable** defines individual tables with their fields
- **v.string(), v.number()** are Convex&apos;s type validators
- **v.optional()** makes fields optional
- **.index()** creates database indexes for efficient queries

&lt;/ListCheck&gt;

The indexes help performance. `by_timestamp` lets you query messages in chronological order.

---

## 🔨 Step 4: Create Backend Functions

Create backend functions for the chat app. In Convex, functions run on the server and are exposed as APIs.

### Message Functions

Create `convex/messages.ts`:

```typescript
import { v } from &quot;convex/values&quot;;
import { mutation, query } from &quot;./_generated/server&quot;;

// Query to get all messages (with real-time updates!)
export const getMessages = query({
  args: {},
  handler: async (ctx) =&gt; {
    // Get the last 50 messages, ordered by timestamp
    const messages = await ctx.db
      .query(&quot;messages&quot;)
      .withIndex(&quot;by_timestamp&quot;)
      .order(&quot;desc&quot;)
      .take(50);

    // Return them in chronological order (oldest first)
    return messages.reverse();
  },
});

// Mutation to send a new message
export const sendMessage = mutation({
  args: {
    author: v.string(),
    body: v.string()
  },
  handler: async (ctx, args) =&gt; {
    // Validate input
    if (!args.author.trim()) {
      throw new Error(&quot;Author name is required&quot;);
    }

    if (!args.body.trim()) {
      throw new Error(&quot;Message cannot be empty&quot;);
    }

    // Insert the message with current timestamp
    await ctx.db.insert(&quot;messages&quot;, {
      author: args.author.trim(),
      body: args.body.trim(),
      timestamp: Date.now(),
    });
  },
});

// Query to get message count (for stats)
export const getMessageCount = query({
  args: {},
  handler: async (ctx) =&gt; {
    const messages = await ctx.db.query(&quot;messages&quot;).collect();
    return messages.length;
  },
});
```

**Key Concepts Explained:**

&lt;ListCheck&gt;

- **query** functions can only read data and automatically provide real-time updates
- **mutation** functions can modify data and run as atomic transactions
- **ctx.db** gives you access to your database with full type safety
- **withIndex()** uses our predefined indexes for efficient queries
- **Error handling** is built-in - thrown errors are automatically sent to the client

&lt;/ListCheck&gt;

### User Functions

Create `convex/users.ts` for user management:

```typescript
import { v } from &quot;convex/values&quot;;
import { mutation, query } from &quot;./_generated/server&quot;;

// Get or create a user
export const getOrCreateUser = mutation({
  args: {
    name: v.string(),
    email: v.optional(v.string()),
  },
  handler: async (ctx, args) =&gt; {
    // Check if user already exists
    let user = null;
    if (args.email) {
      user = await ctx.db
        .query(&quot;users&quot;)
        .withIndex(&quot;by_email&quot;, (q) =&gt; q.eq(&quot;email&quot;, args.email))
        .first();
    }

    // Create new user if not found
    if (!user) {
      const userId = await ctx.db.insert(&quot;users&quot;, {
        name: args.name,
        email: args.email,
      });
      user = await ctx.db.get(userId);
    }

    return user;
  },
});

// Get online users count
export const getActiveUsersCount = query({
  args: {},
  handler: async (ctx) =&gt; {
    const users = await ctx.db.query(&quot;users&quot;).collect();
    return users.length;
  },
});
```

---

## ⚛️ Step 5: Create the Convex Provider for Astro

Astro&apos;s component islands need a way to connect to Convex. Let&apos;s create a provider wrapper in `src/lib/convex.tsx`:

```typescript
import { CONVEX_URL } from &quot;astro:env/client&quot;;
import { ConvexProvider, ConvexReactClient } from &quot;convex/react&quot;;
import { type FunctionComponent, type JSX } from &quot;react&quot;;

const client = new ConvexReactClient(CONVEX_URL);

// Astro context providers don&apos;t work when used in .astro files.
// See this and other related issues: https://github.com/withastro/astro/issues/2016#issuecomment-981833594
//
// This exists to conveniently wrap any component that uses Convex.
export function withConvexProvider&lt;Props extends JSX.IntrinsicAttributes&gt;(
  Component: FunctionComponent&lt;Props&gt;,
) {
  return function WithConvexProvider(props: Props) {
    return (
      &lt;ConvexProvider client={client}&gt;
        &lt;Component {...props} /&gt;
      &lt;/ConvexProvider&gt;
    );
  };
}

```

---

## 🎨 Step 6: Build the Chat Interface Components

Now let&apos;s create our React components for the chat interface. These will be used as Astro islands.

### Message List Component

Create `src/components/MessageList.tsx`:

```typescript
import { useQuery } from &quot;convex/react&quot;;
import { api } from &quot;../../convex/_generated/api&quot;;
import { withConvexProvider } from &quot;../lib/convex&quot;;
import { clsx } from &quot;clsx&quot;;

function MessageListComponent() {
  const messages = useQuery(api.messages.getMessages);
  const messageCount = useQuery(api.messages.getMessageCount);

  if (messages === undefined) {
    return (
      &lt;div className=&quot;flex-1 flex items-center justify-center&quot;&gt;
        &lt;div className=&quot;text-center&quot;&gt;
          &lt;div className=&quot;animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2&quot;&gt;&lt;/div&gt;
          &lt;p className=&quot;text-gray-500&quot;&gt;Loading messages...&lt;/p&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    );
  }

  return (
    &lt;div className=&quot;flex-1 overflow-y-auto p-4 space-y-4&quot;&gt;
      {/* Chat header with stats */}
      &lt;div className=&quot;text-center text-sm text-gray-500 mb-6&quot;&gt;
        {messageCount} messages in this chat
      &lt;/div&gt;

      {messages.length === 0 ? (
        &lt;div className=&quot;text-center py-12&quot;&gt;
          &lt;div className=&quot;text-6xl mb-4&quot;&gt;💬&lt;/div&gt;
          &lt;h3 className=&quot;text-lg font-medium text-gray-900 mb-2&quot;&gt;
            No messages yet
          &lt;/h3&gt;
          &lt;p className=&quot;text-gray-500&quot;&gt;
            Be the first to start the conversation!
          &lt;/p&gt;
        &lt;/div&gt;
      ) : (
        &lt;div className=&quot;space-y-3&quot;&gt;
          {messages.map((message) =&gt; (
            &lt;div
              key={message._id}
              className={clsx(
                &quot;max-w-xs lg:max-w-md px-4 py-2 rounded-2xl&quot;,
                &quot;bg-blue-500 text-white ml-auto&quot;
              )}
            &gt;
              &lt;div className=&quot;flex items-center justify-between mb-1&quot;&gt;
                &lt;span className=&quot;text-xs font-medium opacity-90&quot;&gt;
                  {message.author}
                &lt;/span&gt;
                &lt;span className=&quot;text-xs opacity-75&quot;&gt;
                  {new Date(message.timestamp).toLocaleTimeString([], {
                    hour: &apos;2-digit&apos;,
                    minute: &apos;2-digit&apos;
                  })}
                &lt;/span&gt;
              &lt;/div&gt;
              &lt;p className=&quot;text-sm&quot;&gt;{message.body}&lt;/p&gt;
            &lt;/div&gt;
          ))}
        &lt;/div&gt;
      )}
    &lt;/div&gt;
  );
}

// Export the wrapped component as default
const MessageList = withConvexProvider(MessageListComponent);
export default MessageList;
```

### Message Input Component

Create `src/components/MessageInput.tsx`:

```typescript
import { useMutation } from &quot;convex/react&quot;;
import { useState, useRef, useEffect } from &quot;react&quot;;
import { api } from &quot;../../convex/_generated/api&quot;;
import { withConvexProvider } from &quot;../lib/convex&quot;;
import { clsx } from &quot;clsx&quot;;

function MessageInputComponent() {
  const sendMessage = useMutation(api.messages.sendMessage);
  const [author, setAuthor] = useState(&quot;&quot;);
  const [body, setBody] = useState(&quot;&quot;);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState&lt;string | null&gt;(null);
  const messageInputRef = useRef&lt;HTMLInputElement&gt;(null);

  // Load author name from localStorage
  useEffect(() =&gt; {
    const savedAuthor = localStorage.getItem(&quot;chat-author-name&quot;);
    if (savedAuthor) {
      setAuthor(savedAuthor);
    }
  }, []);

  // Save author name to localStorage when it changes
  useEffect(() =&gt; {
    if (author) {
      localStorage.setItem(&quot;chat-author-name&quot;, author);
    }
  }, [author]);

  const handleSubmit = async (e: React.FormEvent) =&gt; {
    e.preventDefault();
    setError(null);

    if (!author.trim() || !body.trim()) {
      setError(&quot;Please enter both your name and a message&quot;);
      return;
    }

    setIsLoading(true);
    try {
      await sendMessage({
        author: author.trim(),
        body: body.trim()
      });

      // Clear message input and focus it
      setBody(&quot;&quot;);
      messageInputRef.current?.focus();

    } catch (err) {
      console.error(&quot;Failed to send message:&quot;, err);
      setError(err instanceof Error ? err.message : &quot;Failed to send message&quot;);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    &lt;div className=&quot;border-t bg-white p-4&quot;&gt;
      {error &amp;&amp; (
        &lt;div className=&quot;mb-3 p-2 bg-red-50 border border-red-200 rounded-md text-red-700 text-sm&quot;&gt;
          {error}
        &lt;/div&gt;
      )}

      &lt;form onSubmit={handleSubmit} className=&quot;space-y-3&quot;&gt;
        {/* Author name input */}
        &lt;div&gt;
          &lt;input
            type=&quot;text&quot;
            placeholder=&quot;Your name&quot;
            value={author}
            onChange={(e) =&gt; setAuthor(e.target.value)}
            className=&quot;w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent&quot;
            disabled={isLoading}
          /&gt;
        &lt;/div&gt;

        {/* Message input */}
        &lt;div className=&quot;flex gap-2&quot;&gt;
          &lt;input
            ref={messageInputRef}
            type=&quot;text&quot;
            placeholder=&quot;Type your message...&quot;
            value={body}
            onChange={(e) =&gt; setBody(e.target.value)}
            className=&quot;flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent&quot;
            disabled={isLoading}
          /&gt;
          &lt;button
            type=&quot;submit&quot;
            disabled={isLoading || !author.trim() || !body.trim()}
            className={clsx(
              &quot;px-6 py-2 rounded-lg font-medium transition-colors&quot;,
              &quot;focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2&quot;,
              isLoading || !author.trim() || !body.trim()
                ? &quot;bg-gray-300 text-gray-500 cursor-not-allowed&quot;
                : &quot;bg-blue-500 text-white hover:bg-blue-600&quot;
            )}
          &gt;
            {isLoading ? (
              &lt;div className=&quot;flex items-center gap-2&quot;&gt;
                &lt;div className=&quot;w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin&quot;&gt;&lt;/div&gt;
                Sending...
              &lt;/div&gt;
            ) : (
              &quot;Send&quot;
            )}
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
}

// Export the wrapped component as default
const MessageInput = withConvexProvider(MessageInputComponent);
export default MessageInput;
```

---

## 🏗️ Step 7: Create the Main Layout

Create `src/layouts/ChatLayout.astro`:

```astro
---
import &apos;../styles/global.css&apos;
export interface Props {
  title: string;
}

const { title } = Astro.props;
---

&lt;!DOCTYPE html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;UTF-8&quot; /&gt;
    &lt;meta name=&quot;description&quot; content=&quot;Real-time chat built with Astro and Convex&quot; /&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&gt;
    &lt;link rel=&quot;icon&quot; type=&quot;image/svg+xml&quot; href=&quot;/favicon.svg&quot; /&gt;
    &lt;title&gt;{title}&lt;/title&gt;
  &lt;/head&gt;
  &lt;body class=&quot;bg-gray-50 min-h-screen&quot;&gt;
    &lt;slot /&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

---

## 📱 Step 8: Build the Main Chat Page

Update `src/pages/index.astro`:

```astro
---
import ChatLayout from &apos;../layouts/ChatLayout.astro&apos;;
import MessageList from &apos;../components/MessageList&apos;;
import MessageInput from &apos;../components/MessageInput&apos;;
---

&lt;ChatLayout title=&quot;Astro + Convex Chat&quot;&gt;
  &lt;div class=&quot;min-h-screen flex flex-col&quot;&gt;
    &lt;!-- Header --&gt;
    &lt;header class=&quot;bg-white shadow-sm border-b&quot;&gt;
      &lt;div class=&quot;max-w-4xl mx-auto px-4 py-4&quot;&gt;
        &lt;div class=&quot;flex items-center justify-between&quot;&gt;
          &lt;div&gt;
            &lt;h1 class=&quot;text-2xl font-bold text-gray-900&quot;&gt;
              ⚡ Astro + Convex Chat
            &lt;/h1&gt;
            &lt;p class=&quot;text-sm text-gray-600&quot;&gt;
              Lightning-fast real-time messaging
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;div class=&quot;flex items-center gap-2 text-sm text-gray-500&quot;&gt;
            &lt;div class=&quot;w-2 h-2 bg-green-500 rounded-full animate-pulse&quot;&gt;&lt;/div&gt;
            &lt;span&gt;Live&lt;/span&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/header&gt;

    &lt;!-- Chat Container --&gt;
    &lt;main class=&quot;flex-1 max-w-4xl mx-auto w-full bg-white shadow-lg flex flex-col&quot;&gt;
      &lt;MessageList client:load /&gt;
      &lt;MessageInput client:load /&gt;
    &lt;/main&gt;

    &lt;!-- Footer --&gt;
    &lt;footer class=&quot;bg-gray-100 border-t&quot;&gt;
      &lt;div class=&quot;max-w-4xl mx-auto px-4 py-3&quot;&gt;
        &lt;p class=&quot;text-center text-sm text-gray-600&quot;&gt;
          Built with
          &lt;a href=&quot;https://astro.build&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;Astro&lt;/a&gt;
          and
          &lt;a href=&quot;https://convex.dev&quot; class=&quot;text-blue-600 hover:underline&quot;&gt;Convex&lt;/a&gt;
        &lt;/p&gt;
      &lt;/div&gt;
    &lt;/footer&gt;
  &lt;/div&gt;
&lt;/ChatLayout&gt;
```

&lt;Notice type=&quot;info&quot; title=&quot;The client:load Directive&quot;&gt;

The `client:load` directive tells Astro to hydrate these React components on the client side. This gives us the interactivity we need while keeping the initial page load fast.

&lt;/Notice&gt;

---

## ⚙️ Step 9: Configure Environment Variables

Update your `astro.config.mjs` to handle environment variables properly:

```javascript
// @ts-check
import react from &quot;@astrojs/react&quot;;
import tailwindcss from &quot;@tailwindcss/vite&quot;;
import { defineConfig, envField } from &quot;astro/config&quot;;

// https://astro.build/config
export default defineConfig({
  integrations: [react()],
  env: {
    schema: {
      CONVEX_URL: envField.string({
        access: &quot;public&quot;,
        context: &quot;client&quot;,
      }),
    },
  },
  vite: {
    plugins: [tailwindcss()],
  },
});

```

Create or update `.env.local` to ensure your Convex URL is accessible:

```ini
# Your Convex deployment URL (auto-generated by convex dev)
CONVEX_URL=https://your-deployment.convex.cloud
PUBLIC_CONVEX_URL=https://your-deployment.convex.cloud
```

---

## 🚀 Step 10: Run Your Application

Now let&apos;s see your creation in action! Update your `package.json` scripts:

```json
{
  &quot;scripts&quot;: {
    &quot;dev&quot;: &quot;run-p dev:*&quot;,
    &quot;dev:astro&quot;: &quot;astro dev&quot;,
    &quot;dev:convex&quot;: &quot;convex dev&quot;,
    &quot;build&quot;: &quot;astro build&quot;,
    &quot;preview&quot;: &quot;astro preview&quot;,
    &quot;convex&quot;: &quot;convex&quot;
  }
}
```

Start your development environment:

```bash
# This runs both Astro and Convex in parallel
npm run dev
```

Open your browser to `http://localhost:4321` and you should see your chat app! 🎉

&lt;Notice type=&quot;success&quot; title=&quot;Testing Real-Time Updates&quot;&gt;

Open multiple browser tabs or windows to see the real-time magic in action. Messages sent from one tab will instantly appear in all other tabs!

&lt;/Notice&gt;

---



## 🔧 Advanced Patterns &amp; Best Practices

### Error Handling

Implement robust error handling in your components:

```typescript
// In your components
const [error, setError] = useState&lt;string | null&gt;(null);

try {
  await sendMessage({ author, body });
} catch (err) {
  if (err instanceof ConvexError) {
    setError(err.data);
  } else {
    setError(&quot;Something went wrong. Please try again.&quot;);
  }
}
```

### Performance Optimization

&lt;ListCheck&gt;

- **Use Astro&apos;s partial hydration** - Only hydrate interactive components
- **Implement pagination** for large message lists
- **Add debouncing** for real-time features like typing indicators
- **Use Convex&apos;s built-in caching** - Queries are automatically cached and invalidated

&lt;/ListCheck&gt;

### Security Best Practices

&lt;ListCheck&gt;

- **Input validation** - Always validate data in your Convex functions
- **Rate limiting** - Implement rate limiting for message sending
- **Content moderation** - Add filters for inappropriate content
- **Authentication** - Implement proper user authentication for production apps

&lt;/ListCheck&gt;

---

## 🎓 What You&apos;ve Learned

Congratulations! You&apos;ve just built a production-ready real-time application using cutting-edge technologies. Here&apos;s what you&apos;ve mastered:

&lt;ListCheck&gt;

- **Astro&apos;s Islands Architecture** - Fast loading with selective interactivity
- **Convex&apos;s Real-Time Database** - Automatic synchronization across clients
- **Type-Safe Development** - End-to-end TypeScript with auto-generated types
- **Modern React Patterns** - Hooks, error handling, and performance optimization
- **Deployment Strategies** - Static site hosting with serverless backend

&lt;/ListCheck&gt;

## 🚀 Next Steps &amp; Enhancements

Ready to take your app to the next level? Here are some exciting features to add:

&lt;Button text=&quot;Message Threading&quot; size=&quot;sm&quot; color=&quot;blue&quot; variant=&quot;outline&quot; /&gt;
&lt;Button text=&quot;File Uploads&quot; size=&quot;sm&quot; color=&quot;green&quot; variant=&quot;outline&quot; /&gt;
&lt;Button text=&quot;User Authentication&quot; size=&quot;sm&quot; color=&quot;purple&quot; variant=&quot;outline&quot; /&gt;
&lt;Button text=&quot;Push Notifications&quot; size=&quot;sm&quot; color=&quot;red&quot; variant=&quot;outline&quot; /&gt;

### Advanced Features to Implement:

&lt;ListCheck&gt;

- **User Authentication** with Convex Auth for secure login
- **Multiple Chat Rooms** with real-time room switching
- **File Sharing** using Convex&apos;s built-in file storage
- **Message Search** with full-text search capabilities
- **Typing Indicators** showing when users are typing
- **Message Reactions** with emoji support
- **Push Notifications** for mobile users
- **Message Threading** for organized conversations

&lt;/ListCheck&gt;

### Production Considerations:

&lt;ListCheck&gt;

- **Rate Limiting** to prevent spam and abuse
- **Content Moderation** with automated filtering
- **Analytics Integration** for user insights
- **Error Monitoring** with tools like Sentry
- **Performance Monitoring** to track real-world usage
- **Backup Strategies** for critical data
- **Load Testing** to ensure scalability

&lt;/ListCheck&gt;

---

## 💡 Why This Stack is Perfect for Modern Apps

The Astro + Convex combination gives you superpowers that were previously impossible:

**Astro Benefits:**
- **Instant page loads** with minimal JavaScript
- **SEO-friendly** static site generation
- **Framework flexibility** - use any UI library
- **Automatic optimizations** for images, CSS, and more

**Convex Benefits:**
- **Real-time everything** without complex WebSocket management
- **Strong consistency** prevents data corruption and race conditions
- **Serverless scalability** from prototype to millions of users
- **TypeScript integration** catches bugs before they reach production

**Together, they provide:**
- **Best-in-class performance** - Fast initial loads AND real-time updates
- **Developer experience** that makes building complex apps feel simple
- **Production readiness** with built-in scalability and reliability
- **Cost effectiveness** - pay only for what you use

&lt;Notice type=&quot;info&quot; title=&quot;Perfect for Startups&quot;&gt;

This stack is ideal for startups and side projects because you can build and deploy rapidly without worrying about infrastructure, scaling, or complex backend management.

&lt;/Notice&gt;

---

## 🏆 Conclusion

You&apos;ve just built something pretty amazing! A real-time chat application that:
&lt;ListCheck&gt;
- **Loads instantly** thanks to Astro&apos;s static generation
- **Updates in real-time** across all connected users
- **Scales automatically** with Convex&apos;s serverless architecture
- **Maintains data consistency** even under heavy load
- **Provides excellent developer experience** with end-to-end type safety
&lt;/ListCheck&gt;
This is just the beginning. The patterns and concepts you&apos;ve learned here apply to countless other applications:

- **Collaborative tools** (think Google Docs)
- **Live dashboards** with real-time metrics
- **Gaming applications** with live leaderboards
- **E-commerce sites** with live inventory updates
- **Social platforms** with instant notifications

The web is moving towards real-time, interactive experiences, and you now have the tools to build them efficiently and reliably.

&lt;Button text=&quot;Star This Tutorial on GitHub&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; link=&quot;https://github.com/your-repo/astro-convex-tutorial&quot; external={true} icon=&quot;star&quot; iconPosition=&quot;left&quot; /&gt;

---

**What&apos;s next?** Try building your own real-time application! Whether it&apos;s a collaborative todo app, a live polling system, or a multiplayer game, you now have the foundation to create amazing user experiences.

Happy coding!</content:encoded><category>web-development</category><category>astro</category><category>convex</category></item><item><title>Display YouTube Videos on Your Astro Blog (SSG + SSR)</title><link>https://www.bitdoze.com/add-youtube-videos-astro-blog/</link><guid isPermaLink="true">https://www.bitdoze.com/add-youtube-videos-astro-blog/</guid><description>Learn how to automatically display your latest YouTube videos on your Astro blog using both Static Site Generation (SSG) and Server-Side Rendering (SSR) approaches. Complete with code examples and explanations for beginners.</description><pubDate>Wed, 30 Jul 2025 03:00:00 GMT</pubDate><content:encoded>Want to display your latest YouTube videos on your blog without manually updating them? This guide shows how to automatically display YouTube videos on your Astro blog.

Whether you&apos;re using the [free Astro blog theme](https://www.bitdoze.com/build-astro-blog-free/) or your own setup, this guide covers two approaches: **Static Site Generation (SSG)** for performance, and **Server-Side Rendering (SSR)** for real-time updates.

## 🎯 What You&apos;ll Learn

&lt;ListCheck&gt;

- **SSG Approach**: Fetch YouTube videos at build time
- **SSR Approach**: Dynamic video fetching with server-side rendering
- **RSS Feed Parsing**: How to work with YouTube&apos;s RSS feeds
- **Error Handling**: Handle failures gracefully
- **Performance Optimization**: Best practices for both approaches

&lt;/ListCheck&gt;

## 🛠️ Prerequisites

Before starting, make sure you have:

- An Astro blog (check out our [free Astro blog guide](https://www.bitdoze.com/build-astro-blog-free/) if you need one!)
- Basic knowledge of JavaScript/TypeScript
- A YouTube channel ID (we&apos;ll show you how to find it)
- Node.js installed on your machine

&lt;Notice type=&quot;info&quot; title=&quot;Channel ID vs Username&quot;&gt;

YouTube Channel IDs look like this: `UCGsUtKhXsRrMvYAWm8q0bCg`. You can find yours by going to your YouTube channel and looking at the URL, or using tools like YouTube Channel ID finder.

&lt;/Notice&gt;



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/x6pjyeQ_6V0&quot;
  label=&quot;Display Latest YouTube Videos on Your Astro Blog (SSG + SSR)&quot;
/&gt;


## 🏗️ Method 1: SSG Approach (Build-Time Fetching)

The SSG approach fetches YouTube videos during build, creating static HTML that loads quickly. This works for most blogs where real-time updates aren&apos;t critical.

### Step 1: Create the SSG Component

Create a component that fetches YouTube videos at build time:

```astro
---
// src/components/YouTubeVideosSSG.astro
import { Icon } from &quot;astro-icon/components&quot;;
import { Image } from &quot;astro:assets&quot;;

// Your YouTube Channel ID
const CHANNEL_ID = &quot;UCGsUtKhXsRrMvYAWm8q0bCg&quot;; // Replace with your channel ID
const MAX_VIDEOS = 6; // Number of videos to display

// Fetch YouTube RSS feed at build time
let videos = [];
let error = null;

try {
  const response = await fetch(
    `https://www.youtube.com/feeds/videos.xml?channel_id=${CHANNEL_ID}`
  );

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const xmlText = await response.text();

  // Parse XML to extract video data
  const videoRegex =
    /&lt;entry&gt;.*?&lt;yt:videoId&gt;(.*?)&lt;\/yt:videoId&gt;.*?&lt;title&gt;(.*?)&lt;\/title&gt;.*?&lt;published&gt;(.*?)&lt;\/published&gt;.*?&lt;\/entry&gt;/gs;

  let match;
  while ((match = videoRegex.exec(xmlText)) !== null &amp;&amp; videos.length &lt; MAX_VIDEOS) {
    const [, videoId, title, published] = match;
    videos.push({
      id: videoId,
      title: title
        .replace(/&amp;amp;/g, &quot;&amp;&quot;)
        .replace(/&amp;lt;/g, &quot;&lt;&quot;)
        .replace(/&amp;gt;/g, &quot;&gt;&quot;)
        .replace(/&amp;quot;/g, &apos;&quot;&apos;)
        .replace(/&amp;#39;/g, &quot;&apos;&quot;),
      published: new Date(published),
      thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
      url: `https://www.youtube.com/watch?v=${videoId}`,
    });
  }
} catch (err) {
  console.error(&quot;Error fetching YouTube videos:&quot;, err);
  error = err.message;
}
---

&lt;section class=&quot;py-8 bg-white dark:bg-gray-900 rounded-lg&quot;&gt;
  &lt;div class=&quot;max-w-5xl mx-auto px-4 sm:px-6&quot;&gt;
    &lt;div class=&quot;text-center mb-8&quot;&gt;
      &lt;h2 class=&quot;text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-4&quot;&gt;
        Latest YouTube Videos
      &lt;/h2&gt;
      &lt;p class=&quot;text-lg text-gray-600 dark:text-gray-300&quot;&gt;
        Check out our latest content on YouTube
      &lt;/p&gt;
    &lt;/div&gt;

    {error ? (
      &lt;div class=&quot;text-center py-8&quot;&gt;
        &lt;p class=&quot;text-red-600 dark:text-red-400&quot;&gt;
          Unable to load YouTube videos. Please try again later.
        &lt;/p&gt;
      &lt;/div&gt;
    ) : videos.length === 0 ? (
      &lt;div class=&quot;text-center py-8&quot;&gt;
        &lt;p class=&quot;text-gray-600 dark:text-gray-400&quot;&gt;
          No videos found.
        &lt;/p&gt;
      &lt;/div&gt;
    ) : (
      &lt;div class=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6&quot;&gt;
        {videos.map((video) =&gt; (
          &lt;div class=&quot;bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 border border-gray-200 dark:border-gray-700&quot;&gt;
            &lt;div class=&quot;relative&quot;&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;Image
                  src={video.thumbnail}
                  alt={video.title}
                  width={320}
                  height={180}
                  class=&quot;w-full h-48 object-cover rounded-t-lg&quot;
                  loading=&quot;lazy&quot;
                  format=&quot;webp&quot;
                /&gt;
              &lt;/a&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;Icon
                  name=&quot;mdi:play-circle&quot;
                  class=&quot;absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white w-16 h-16 drop-shadow-lg opacity-80 hover:opacity-100 transition-opacity&quot;
                /&gt;
              &lt;/a&gt;
            &lt;/div&gt;
            &lt;div class=&quot;p-4&quot;&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;h3 class=&quot;text-lg font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors&quot;&gt;
                  {video.title}
                &lt;/h3&gt;
              &lt;/a&gt;
              &lt;p class=&quot;text-sm text-gray-500 dark:text-gray-400&quot;&gt;
                {video.published.toLocaleDateString(&quot;en-US&quot;, {
                  year: &quot;numeric&quot;,
                  month: &quot;short&quot;,
                  day: &quot;numeric&quot;,
                })}
              &lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
    )}

    &lt;div class=&quot;text-center mt-8&quot;&gt;
      &lt;a
        href={`https://www.youtube.com/channel/${CHANNEL_ID}`}
        target=&quot;_blank&quot;
        rel=&quot;noopener noreferrer&quot;
        class=&quot;inline-flex items-center px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors duration-300&quot;
      &gt;
        &lt;Icon name=&quot;mdi:youtube&quot; class=&quot;w-5 h-5 mr-2&quot; /&gt;
        View All Videos
      &lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/section&gt;

&lt;style&gt;
  .line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
&lt;/style&gt;
```

### Understanding the SSG Code

Here&apos;s what the code does:

1. **Build-Time Execution**: The `---` section runs during build time, not in the browser
2. **RSS Feed Fetching**: We use YouTube&apos;s RSS feed (`/feeds/videos.xml`) which is free and doesn&apos;t require API keys
3. **XML Parsing**: We use regex to extract video data from the XML response
4. **Error Handling**: Try-catch handles network failures
5. **HTML Entity Decoding**: Converting `&amp;amp;` back to `&amp;`, etc.

&lt;Notice type=&quot;success&quot; title=&quot;Why RSS Over API?&quot;&gt;

YouTube&apos;s RSS feed works well here. It&apos;s free, doesn&apos;t need authentication, and provides the data we need.

&lt;/Notice&gt;

### Step 2: Using the SSG Component

Add the component to any page or layout:

```astro
---
// src/pages/index.astro (or wherever you want it)
import YouTubeVideosSSG from &apos;../components/YouTubeVideosSSG.astro&apos;;
---

&lt;html&gt;
  &lt;body&gt;
    &lt;!-- Your other content --&gt;
    &lt;YouTubeVideosSSG /&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

## 🚀 Method 2: SSR Approach with server:defer

The SSR approach with `server:defer` provides real-time updates with performance. The `server:defer` directive creates a server island that renders on demand.

### Step 1: Configure Astro for SSR

First, install the Node.js adapter :

```bash
npx astro add node

```
This would make the `astro.config.mjs` to look like below:

```javascript
// astro.config.mjs
import { defineConfig } from &apos;astro/config&apos;;
import node from &apos;@astrojs/node&apos;;

export default defineConfig({
  output: &apos;server&apos;, // Enable SSR
  adapter: node({
    mode: &apos;standalone&apos;
  }),
});
```

### Step 2: Create the SSR Component with server:defer

```astro
---
// src/components/YouTubeVideosSSR.astro
import { Icon } from &quot;astro-icon/components&quot;;
import { Image } from &quot;astro:assets&quot;;

export interface Props {
  channelId: string;
  maxVideos?: number;
}

const { channelId, maxVideos = 6 } = Astro.props;

// Fetch YouTube videos - this runs on the server when the island is rendered
let videos = [];
let error = null;

try {
  const response = await fetch(
    `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`
  );

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const xmlText = await response.text();

  // Parse XML to extract video data
  const videoRegex =
    /&lt;entry&gt;.*?&lt;yt:videoId&gt;(.*?)&lt;\/yt:videoId&gt;.*?&lt;title&gt;(.*?)&lt;\/title&gt;.*?&lt;published&gt;(.*?)&lt;\/published&gt;.*?&lt;\/entry&gt;/gs;

  let match;
  while ((match = videoRegex.exec(xmlText)) !== null &amp;&amp; videos.length &lt; maxVideos) {
    const [, videoId, title, published] = match;
    videos.push({
      id: videoId,
      title: title
        .replace(/&amp;amp;/g, &quot;&amp;&quot;)
        .replace(/&amp;lt;/g, &quot;&lt;&quot;)
        .replace(/&amp;gt;/g, &quot;&gt;&quot;)
        .replace(/&amp;quot;/g, &apos;&quot;&apos;)
        .replace(/&amp;#39;/g, &quot;&apos;&quot;),
      published: new Date(published),
      thumbnail: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
      url: `https://www.youtube.com/watch?v=${videoId}`,
    });
  }
} catch (err) {
  console.error(&quot;Error fetching YouTube videos:&quot;, err);
  error = err.message;
}
---

&lt;section class=&quot;py-8 bg-white dark:bg-gray-900 rounded-lg&quot;&gt;
  &lt;div class=&quot;max-w-5xl mx-auto px-4 sm:px-6&quot;&gt;
    &lt;div class=&quot;text-center mb-8&quot;&gt;
      &lt;h2 class=&quot;text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-4&quot;&gt;
        Latest YouTube Videos
      &lt;/h2&gt;
      &lt;p class=&quot;text-lg text-gray-600 dark:text-gray-300&quot;&gt;
        Check out our latest content on YouTube
      &lt;/p&gt;
    &lt;/div&gt;

    {error ? (
      &lt;div class=&quot;text-center py-8&quot;&gt;
        &lt;p class=&quot;text-red-600 dark:text-red-400&quot;&gt;
          Unable to load YouTube videos. Please try again later.
        &lt;/p&gt;
      &lt;/div&gt;
    ) : videos.length === 0 ? (
      &lt;div class=&quot;text-center py-8&quot;&gt;
        &lt;p class=&quot;text-gray-600 dark:text-gray-400&quot;&gt;
          No videos found.
        &lt;/p&gt;
      &lt;/div&gt;
    ) : (
      &lt;div class=&quot;grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6&quot;&gt;
        {videos.map((video) =&gt; (
          &lt;div class=&quot;bg-white dark:bg-gray-800 rounded-lg shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1 border border-gray-200 dark:border-gray-700&quot;&gt;
            &lt;div class=&quot;relative&quot;&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;Image
                  src={video.thumbnail}
                  alt={video.title}
                  width={320}
                  height={180}
                  class=&quot;w-full h-48 object-cover rounded-t-lg&quot;
                  loading=&quot;lazy&quot;
                  format=&quot;webp&quot;
                /&gt;
              &lt;/a&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;Icon
                  name=&quot;mdi:play-circle&quot;
                  class=&quot;absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-white w-16 h-16 drop-shadow-lg opacity-80 hover:opacity-100 transition-opacity&quot;
                /&gt;
              &lt;/a&gt;
            &lt;/div&gt;
            &lt;div class=&quot;p-4&quot;&gt;
              &lt;a href={video.url} target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;
                &lt;h3 class=&quot;text-lg font-semibold text-gray-900 dark:text-white line-clamp-2 mb-2 hover:text-blue-600 dark:hover:text-blue-400 transition-colors&quot;&gt;
                  {video.title}
                &lt;/h3&gt;
              &lt;/a&gt;
              &lt;p class=&quot;text-sm text-gray-500 dark:text-gray-400&quot;&gt;
                {video.published.toLocaleDateString(&quot;en-US&quot;, {
                  year: &quot;numeric&quot;,
                  month: &quot;short&quot;,
                  day: &quot;numeric&quot;,
                })}
              &lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        ))}
      &lt;/div&gt;
    )}

    &lt;div class=&quot;text-center mt-8&quot;&gt;
      &lt;a
        href={`https://www.youtube.com/channel/${channelId}`}
        target=&quot;_blank&quot;
        rel=&quot;noopener noreferrer&quot;
        class=&quot;inline-flex items-center px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors duration-300&quot;
      &gt;
        &lt;Icon name=&quot;mdi:youtube&quot; class=&quot;w-5 h-5 mr-2&quot; /&gt;
        View All Videos
      &lt;/a&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/section&gt;

&lt;style&gt;
  .line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
&lt;/style&gt;
```

### Step 3: Using the SSR Component with server:defer

```astro
---
// src/pages/index.astro
import YouTubeVideosSSR from &apos;../components/YouTubeVideosSSR.astro&apos;;
---

&lt;html&gt;
  &lt;body&gt;
    &lt;!-- Your other content --&gt;

    &lt;!-- The server:defer directive makes this a server island --&gt;
    &lt;YouTubeVideosSSR
      server:defer
      channelId=&quot;UCGsUtKhXsRrMvYAWm8q0bCg&quot;
      maxVideos={6}
    /&gt;
  &lt;/body&gt;
&lt;/html&gt;
```

### Understanding server:defer

The `server:defer` directive is the magic that makes this approach so powerful:

1. **Page loads instantly** - The main page renders without waiting for YouTube data
2. **Component renders on demand** - The YouTube component becomes a &quot;server island&quot; that renders separately
3. **No client-side JavaScript needed** - Everything is handled server-side
4. **Automatic error handling** - If the YouTube fetch fails, only this component is affected
5. **Better user experience** - Users see the page immediately, videos load progressively

&lt;Notice type=&quot;success&quot; title=&quot;Why server:defer is Amazing&quot;&gt;

With `server:defer`, you get the performance benefits of static generation for your main content, while having dynamic, fresh data for specific components. It&apos;s the perfect hybrid approach!

&lt;/Notice&gt;

## 🌟 Enhanced Version for Cloudflare Pages

If you&apos;re using Cloudflare Pages (like in our [free Astro blog guide](https://www.bitdoze.com/build-astro-blog-free/)), you&apos;ll need to use the Cloudflare adapter:

```bash
npx astro add cloudflare

```


## 🎯 When to Use Each Approach

### Use SSG When:

&lt;ListCheck&gt;

- **Performance is critical** - SSG sites load instantly

- **You don&apos;t need real-time updates** - Videos update only when you rebuild

- **You have limited server resources** - No server-side processing needed

- **SEO is important** - Static content is easily crawlable

- **You want lower costs** - No server runtime costs

&lt;/ListCheck&gt;

### Use SSR When:

&lt;ListCheck&gt;

- **Real-time updates are important** - Videos appear immediately after publishing

- **You have dynamic content needs** - Different videos for different users

- **You want server-side caching** - Better control over cache strategies

- **You need user-specific content** - Personalized video recommendations

- **You have server infrastructure** - Can handle server-side processing

&lt;/ListCheck&gt;

## 🔧 Customization Options

### Styling the Components

Both components use Tailwind CSS classes. You can customize the appearance by modifying the classes:

```astro
&lt;!-- Change the grid layout --&gt;
&lt;div class=&quot;grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4&quot;&gt;

&lt;!-- Change the card styling --&gt;
&lt;div class=&quot;bg-gradient-to-r from-blue-500 to-purple-600 rounded-xl shadow-xl&quot;&gt;

&lt;!-- Change the hover effects --&gt;
&lt;div class=&quot;transform hover:scale-105 transition-transform duration-300&quot;&gt;
```

### Adding More Video Data

You can extract additional data from the RSS feed:

```javascript
// Add description extraction
const descriptionRegex = /&lt;media:description&gt;(.*?)&lt;\/media:description&gt;/s;
const descMatch = descriptionRegex.exec(entry);
const description = descMatch ? descMatch[1] : &apos;&apos;;

// Add view count (requires YouTube API)
// Add duration (requires YouTube API)
```

### Error Handling Improvements

Add retry logic and better error states:

```javascript
async function fetchWithRetry(url, maxRetries = 3) {
  for (let i = 0; i &lt; maxRetries; i++) {
    try {
      const response = await fetch(url);
      if (response.ok) return response;
    } catch (error) {
      if (i === maxRetries - 1) throw error;
      await new Promise(resolve =&gt; setTimeout(resolve, 1000 * (i + 1)));
    }
  }
}
```

## 🚀 Performance Optimization Tips

### 1. Image Optimization

Use Astro&apos;s Image component for automatic optimization:

```astro
&lt;Image
  src={video.thumbnail}
  alt={video.title}
  width={320}
  height={180}
  format=&quot;webp&quot;
  quality={80}
  loading=&quot;lazy&quot;
/&gt;
```

### 2. Caching Strategies

For SSR, implement proper caching:

```javascript
// Cache in memory for development
const cache = new Map();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes

function getCachedVideos(channelId) {
  const cacheKey = `videos_${channelId}`;
  const cached = cache.get(cacheKey);

  if (cached &amp;&amp; Date.now() - cached.timestamp &lt; CACHE_DURATION) {
    return cached.data;
  }

  return null;
}
```

### 3. Lazy Loading

Implement intersection observer for better performance:

```javascript
// Only load videos when component is visible
const observer = new IntersectionObserver((entries) =&gt; {
  entries.forEach(entry =&gt; {
    if (entry.isIntersecting) {
      loadYouTubeVideos();
      observer.disconnect();
    }
  });
});

observer.observe(document.getElementById(&apos;youtube-videos-container&apos;));
```

## 🔍 Troubleshooting Common Issues

### RSS Feed Not Loading

&lt;Notice type=&quot;warning&quot; title=&quot;CORS Issues&quot;&gt;

If you&apos;re getting CORS errors in development, YouTube&apos;s RSS feed should work fine in production. For local development, you might need to use a CORS proxy or test the production build.

&lt;/Notice&gt;

### Videos Not Displaying

1. **Check Channel ID**: Make sure your channel ID is correct
2. **Check Network**: Verify the RSS feed URL in your browser
3. **Check Console**: Look for JavaScript errors in browser dev tools
4. **Check Build Logs**: For SSG, check build-time errors

### Performance Issues

1. **Reduce Video Count**: Display fewer videos initially
2. **Implement Pagination**: Load more videos on demand
3. **Optimize Images**: Use smaller thumbnail sizes
4. **Add Loading States**: Improve perceived performance

## 🎉 Conclusion

You now have two powerful ways to display YouTube videos on your Astro blog! The SSG approach gives you blazing-fast performance with build-time generation, while the SSR approach provides real-time updates with server-side rendering.

Choose the method that best fits your needs:
- **Go with SSG** if you want maximum performance and don&apos;t mind rebuilding to show new videos
- **Choose SSR** if you need real-time updates and have server infrastructure

Both approaches work perfectly with the [free Astro blog theme](https://www.bitdoze.com/build-astro-blog-free/) we covered earlier, so you can enhance your blog with dynamic YouTube content right away!

&lt;Notice type=&quot;success&quot; title=&quot;Pro Tip&quot;&gt;

You can even combine both approaches - use SSG for your main video showcase and SSR for a &quot;Latest Video&quot; widget that updates in real-time!

&lt;/Notice&gt;

Happy coding, and may your YouTube videos get all the views they deserve! 🎬✨

---

*Want to take your Astro blog even further? Check out our other guides on [building a free Astro blog](https://www.bitdoze.com/build-astro-blog-free/) and advanced Astro techniques!*</content:encoded><category>web-development</category><category>astro</category></item><item><title>Google Opal: The NEW AI App Builder That Turns Ideas Into Reality</title><link>https://www.bitdoze.com/google-opal-ai-app-builder/</link><guid isPermaLink="true">https://www.bitdoze.com/google-opal-ai-app-builder/</guid><description>Discover Google Opal, the groundbreaking no-code AI platform that transforms simple prompts into powerful mini-apps, plus explore Jules and Gemini CLI in Google&apos;s new AI development ecosystem.</description><pubDate>Wed, 30 Jul 2025 02:00:00 GMT</pubDate><content:encoded>The world of app development is experiencing a seismic shift. What once required months of coding, debugging, and testing can now be accomplished in minutes through simple conversations with AI. Google has just unveiled **[Opal](https://opal.withgoogle.com/)**, a revolutionary experimental tool that&apos;s set to democratize app creation like never before. Combined with their recently launched **[Jules](https://jules.google)** coding agent and **[Gemini CLI](https://github.com/google-gemini/gemini-cli)**, Google is building a comprehensive AI-powered development ecosystem that&apos;s reshaping how we think about software creation.

In this deep dive, we&apos;ll explore Google Opal&apos;s game-changing capabilities, examine how it compares to existing no-code solutions, and discover how it fits into Google&apos;s broader AI development strategy alongside Jules and Gemini CLI.

&lt;Notice type=&quot;info&quot; title=&quot;Public Beta Access&quot;&gt;

Google Opal is currently available in US-only public beta through Google Labs. No waitlist required - you can start building AI mini-apps today at opal.google.com.

&lt;/Notice&gt;

## What Is Google Opal?

Google Opal represents a paradigm shift in application development. Unlike traditional no-code platforms that require you to learn their specific interfaces and limitations, Opal allows you to build sophisticated AI mini-apps using nothing but natural language descriptions and visual editing tools.

![Google Opal AI App Builder Overview](../../assets/images/25/07/opal1.webp)

At its core, Opal is designed to bridge the gap between having an idea and seeing it come to life as a functional application. It harnesses Google&apos;s most advanced AI models - including **Gemini 2.5**, **Veo 3**, and **Imagen 4** - to create comprehensive workflows that chain together prompts, model calls, and various tools.

### The Magic Behind Opal

What makes Opal truly revolutionary is its approach to app creation:

&lt;ListCheck&gt;

- **Natural Language Input**: Describe your app idea in plain English
- **Visual Workflow Generation**: Opal automatically creates illustrated workflows showing each step
- **Multi-Model Integration**: Seamlessly combines text, image, and video generation capabilities
- **Real-Time Editing**: Modify workflows using conversational commands or visual editors
- **Instant Sharing**: Deploy and share apps immediately with anyone who has a Google account

&lt;/ListCheck&gt;

## Core Features That Set Opal Apart

### 1. Conversational App Building

Traditional app development requires learning programming languages, frameworks, and deployment processes. Opal eliminates these barriers entirely. You simply describe what you want your app to do, and Opal translates your instructions into a functional workflow.

**Example Interaction:**

![Google Opal AI Conversational App Building](../../assets/images/25/07/opal-describe-project.webp)

```
User: &quot;Create an app that generates personalized workout plans based on user fitness goals and available equipment&quot;

Opal: *Creates workflow with input collection, goal analysis, equipment matching, and personalized plan generation*
```

### 2. Visual Workflow Editor

Every app in Opal is represented as a visual workflow with three main components:

- **Inputs**: What information your app needs from users
- **Generation Steps**: The AI processes and transformations applied
- **Outputs**: The final results delivered to users



This visual approach makes complex AI workflows understandable and editable by anyone, regardless of technical background.

### 3. Multi-Modal AI Integration

Opal&apos;s integration with Google&apos;s AI model suite enables unprecedented creative possibilities:

| AI Model | Capability | Use Cases |
|----------|------------|-----------|
| **Gemini 2.5** | Advanced text generation and reasoning | Blog posts, analysis, conversational interfaces |
| **Veo 3** | Video generation with audio | Marketing videos, educational content, presentations |
| **Imagen 4** | High-quality image generation | Visual assets, illustrations, product mockups |

### 4. Template Gallery and Remix Culture

Opal launches with a curated gallery of starter templates, each designed for specific use cases:

&lt;ListCheck&gt;

- **Immersive Virtual Games**: Interactive entertainment experiences
- **Video Ad Generators**: Targeted marketing content creation
- **Educational Tools**: Learning and training applications
- **Productivity Apps**: Workflow automation and organization tools
- **Creative Projects**: Art generation and design assistance

&lt;/ListCheck&gt;

![Google Opal AI Templates](../../assets/images/25/07/opal-templates.webp)

Users can start with these templates and remix them to fit their exact needs, fostering a culture of collaborative innovation.

### 5. Seamless Sharing and Deployment

Once your app is ready, Opal makes sharing effortless. With the click of a button, you can:
- Generate a shareable URL
- Toggle between private and public access
- Deploy instantly without server setup or hosting concerns
- Allow others to use your app with their Google accounts

## Google&apos;s AI Development Ecosystem: The Complete Picture

Opal doesn&apos;t exist in isolation. It&apos;s part of Google&apos;s comprehensive strategy to revolutionize software development through AI. Let&apos;s explore how it fits with Jules and Gemini CLI.

### Jules: The Autonomous Coding Agent


**Jules** represents Google&apos;s vision of autonomous coding assistance. Unlike traditional code completion tools, Jules is a true coding agent that:

&lt;ListCheck&gt;

- **Operates Asynchronously**: Works in the background while you focus on other tasks
- **Understands Full Context**: Analyzes entire codebases, not just individual files
- **Performs Complex Tasks**: Writes tests, builds features, fixes bugs, and updates dependencies
- **Integrates with GitHub**: Works directly within your existing development workflow
- **Provides Audio Summaries**: Converts commit history into contextual audio changelogs

&lt;/ListCheck&gt;

#### Jules Key Capabilities

| Feature | Description | Benefit |
|---------|-------------|---------|
| **Real Codebase Analysis** | Works with actual projects, not sandboxed environments | Accurate, context-aware solutions |
| **Parallel Execution** | Handles multiple tasks simultaneously in cloud VMs | Faster development cycles |
| **Visible Workflow** | Shows planning and reasoning before making changes | Transparent, controllable development |
| **User Steerability** | Allows modification of plans during execution | Maintains developer control |

Jules is currently in public beta worldwide, available wherever Gemini models are accessible, with free usage during the beta period.

### Gemini CLI: Command-Line AI Workflows

The **Gemini CLI** completes Google&apos;s AI development triangle by bringing powerful AI capabilities directly to your terminal. This open-source tool excels at:

&lt;ListCheck&gt;

- **Large Codebase Analysis**: Query and edit codebases beyond Gemini&apos;s 1M token context window
- **Multimodal App Generation**: Create applications from PDFs, sketches, or other visual inputs
- **Operational Automation**: Handle complex tasks like pull request analysis and code rebases
- **Tool Integration**: Connect with MCP servers and external tools for extended capabilities
- **Media Generation**: Access Imagen, Veo, and Lyria for creative content creation

&lt;/ListCheck&gt;

#### Popular Gemini CLI Use Cases

**Codebase Exploration:**
```bash
gemini
&gt; Describe the main pieces of this system&apos;s architecture
&gt; What security mechanisms are in place?
&gt; Generate a README section for the authentication module
```

**Workflow Automation:**
```bash
&gt; Make me a slide deck showing git history from the last 7 days
&gt; Create a wall display app for our most active GitHub issues
&gt; Convert all images in this directory to PNG with EXIF date naming
```

## How Opal Compares to Existing Solutions

The no-code/low-code market is crowded with solutions like Bubble, Webflow, and Microsoft Power Apps. Here&apos;s how Opal differentiates itself:

### Comparison Matrix


| Feature | Google Opal | Traditional No-Code | AI Coding Tools |
|---------|-------------|-------------------|-----------------|
| **Learning Curve** | Minimal (natural language) | Moderate (platform-specific) | High (coding required) |
| **AI Integration** | Native multi-modal AI | Limited or external | Code-focused only |
| **Workflow Visualization** | Automatic generation | Manual drag-and-drop | No visual representation |
| **Deployment Speed** | Instant | Minutes to hours | Hours to days |
| **Customization Depth** | AI-driven flexibility | Template constraints | Unlimited but complex |
| **Collaboration** | Built-in sharing | Platform-dependent | Developer-only |

### Unique Advantages of Opal

&lt;Notice type=&quot;success&quot; title=&quot;Opal&apos;s Competitive Edge&quot;&gt;

Unlike traditional no-code platforms that force you to think in terms of their components and limitations, Opal lets you think in terms of your actual goals and desired outcomes.

&lt;/Notice&gt;

1. **True Natural Language Interface**: No need to learn platform-specific terminology or workflows
2. **AI-First Architecture**: Built around AI capabilities rather than retrofitting AI into existing frameworks
3. **Instant Iteration**: Modify apps through conversation rather than navigating complex interfaces
4. **Multi-Modal by Default**: Seamlessly incorporates text, images, and video without additional setup

## Real-World Applications and Use Cases

### Business and Marketing

**Scenario**: A small business owner wants to create personalized video advertisements for different customer segments.

**Traditional Approach**: Hire a video production team, create multiple versions manually, weeks of production time.

**Opal Approach**:
```
&quot;Create an app that generates personalized video ads based on customer demographics and product interests&quot;
```
Result: Automated video generation system ready in minutes.

### Education and Training

**Scenario**: A teacher needs interactive learning modules for different learning styles.

**Opal Solution**:
- Input: Learning objectives and student preferences
- Process: Generate visual explanations, audio summaries, and interactive quizzes
- Output: Personalized learning experiences for each student

### Content Creation

**Scenario**: A content creator wants to maintain consistent publishing across multiple platforms.

**Opal Workflow**:
1. Input article topic and target audience
2. Generate blog post, social media variants, and accompanying visuals
3. Output optimized content for each platform

### Internal Tools and Automation

**Scenario**: A startup needs custom tools for project management and reporting.

**Opal Advantage**: Create specialized mini-apps for specific workflows without hiring developers or purchasing expensive software licenses.

## Getting Started with Google Opal

### Prerequisites and Setup

&lt;ListCheck&gt;

- **Location**: Currently US-only (expanding soon)
- **Account**: Google account required
- **Access**: No waitlist - immediate beta access
- **Cost**: Free during public beta phase
- **Browser**: Modern web browser with JavaScript enabled

&lt;/ListCheck&gt;

### Step-by-Step Quick Start

1. **Visit Opal**: Navigate to opal.google.com
2. **Sign In**: Use your Google account credentials
3. **Explore Templates**: Browse the demo gallery for inspiration
4. **Create Your First App**: Click &quot;Create New&quot; and describe your idea
5. **Customize Workflow**: Use the visual editor to refine your app
6. **Test and Share**: Deploy your app and share the URL


## The Future of AI-Powered Development

### Current Limitations and Challenges

While Opal represents a significant advancement, it&apos;s important to understand its current limitations:

**Technical Constraints:**
- US-only availability during beta
- Dependent on Google&apos;s AI model capabilities and limitations
- Limited to mini-app scope (not full enterprise applications)
- Requires internet connectivity for all operations

**Design Considerations:**
- Apps are constrained by Opal&apos;s workflow paradigm
- Complex business logic may require multiple connected apps
- Integration with external systems is limited to available tools

### What&apos;s Coming Next

Based on Google&apos;s roadmap and industry trends, we can expect:

&lt;ListCheck&gt;

- **Global Expansion**: Availability in more countries and languages
- **Enhanced Model Integration**: Access to newer, more capable AI models
- **Advanced Tool Connectivity**: Better integration with enterprise systems
- **Collaborative Features**: Team-based app development and sharing
- **Performance Optimizations**: Faster execution and better reliability

&lt;/ListCheck&gt;

### The Broader Impact on Software Development

Opal, Jules, and Gemini CLI represent more than just individual tools - they signal a fundamental shift in how software gets built:

- **Democratization of Development**: Non-technical users can create sophisticated applications
- **Speed of Innovation**: Ideas can be prototyped and tested in minutes rather than months
- **Reduced Development Costs**: Fewer resources needed for custom software solutions
- **Enhanced Creativity**: Focus shifts from technical implementation to creative problem-solving



## Conclusion: The Dawn of Conversational Development

Google Opal represents more than just another no-code platform - it&apos;s the beginning of a new era where the barrier between having an idea and creating a functional application has virtually disappeared. Combined with Jules for serious development work and Gemini CLI for operational automation, Google has created the most comprehensive AI-powered development ecosystem available today.

### Why Opal Matters

**For Individual Creators:**
- Transform ideas into reality without technical barriers
- Rapid prototyping and iteration capabilities
- Access to enterprise-grade AI models without infrastructure costs

**For Businesses:**
- Dramatically reduced time-to-market for new solutions
- Lower development costs for custom applications
- Enhanced ability to test and validate concepts quickly

**For Developers:**
- Focus on high-level problem solving rather than implementation details
- Enhanced productivity through AI assistance
- New opportunities in AI-powered application development

### The Road Ahead

As Opal moves beyond its beta phase and expands globally, we can expect to see:
- More sophisticated AI models integrated into the platform
- Enhanced collaboration features for team development
- Better integration with existing business systems
- A thriving ecosystem of shared templates and components

The convergence of natural language processing, visual workflow design, and multi-modal AI capabilities in Opal creates unprecedented opportunities for innovation. Whether you&apos;re a solo entrepreneur, a startup team, or an enterprise looking to accelerate digital transformation, Google&apos;s AI development ecosystem offers tools that can dramatically change how you approach software creation.

**Ready to start building?** Visit Opal today and experience the future of application development firsthand.

&lt;Button text=&quot;Try Google Opal&quot; url=&quot;https://opal.google.com&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

&lt;Button text=&quot;Explore Jules&quot; url=&quot;https://jules.google&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

&lt;Button text=&quot;Get Gemini CLI&quot; url=&quot;https://github.com/google-gemini/gemini-cli&quot; size=&quot;lg&quot; color=&quot;purple&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Stay Updated&quot;&gt;

Google&apos;s AI development tools are rapidly evolving. Follow Google Labs and the Gemini developer community for the latest updates, new features, and expanded availability.

&lt;/Notice&gt;</content:encoded><category>ai</category><category>no-code</category></item><item><title>Self-Host SearXNG Privacy Search Engine</title><link>https://www.bitdoze.com/searxng-self-host-privacy-search/</link><guid isPermaLink="true">https://www.bitdoze.com/searxng-self-host-privacy-search/</guid><description>Learn how to self-host SearXNG, a powerful privacy-focused metasearch engine that aggregates results from multiple search engines while keeping your searches private. Complete guide with Docker, Traefik, and Dokploy setup options.</description><pubDate>Wed, 30 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Search engines track your queries to build profiles about your interests. Google, Bing, and Yahoo collect personal data, which raises privacy concerns.

**SearXNG** is a self-hosted, privacy-focused metasearch engine. It aggregates results from multiple search engines while maintaining anonymity.

## What is SearXNG and How Can It Transform Your Search Experience?


**SearXNG** is an open-source metasearch engine. Unlike search engines that track users, SearXNG queries multiple search engines simultaneously while preserving anonymity.

### Key Benefits of SearXNG

&lt;ListCheck&gt;

- **Complete Privacy Protection**: No tracking, cookies, or user profiling
- **Aggregated Results**: Combines results from 230+ search engines

- **Customizable Experience**: Choose which search engines to include and configure preferences
- **Open Source Transparency**: Fully auditable code with active community development
- **Multi-Language Support**: Available in numerous languages with localized results

&lt;/ListCheck&gt;

### How SearXNG Works

SearXNG uses metasearch architecture:

| Component | Function | Benefit |
|-----------|----------|---------|
| **Query Distribution** | Sends your search to multiple engines | Comprehensive results |
| **Result Aggregation** | Combines and deduplicates responses | Unified experience |
| **Privacy Layer** | Acts as intermediary proxy | Anonymous searching |
| **Customization Engine** | Filters based on preferences | Personalized results |

SearXNG is a fork of the original Searx project with additional features and improvements. Check the [GitHub repository](https://github.com/searxng/searxng) and [documentation](https://docs.searxng.org/).

&gt; For a comprehensive list of useful applications, check out our guide on [Docker containers for home servers](https://www.bitdoze.com/docker-containers-home-server/).

## Prerequisites

Before starting, make sure you have:

&lt;Notice type=&quot;info&quot; title=&quot;Hardware Requirements&quot;&gt;

SearXNG is lightweight, but handling multiple search engine queries needs adequate resources.

&lt;/Notice&gt;


- **VPS or Dedicated Server**: A reliable hosting platform where you can install SearXNG. We recommend [Hetzner](https://go.bitdoze.com/hetzner), [Hostinger](https://go.bitdoze.com/hostinger-vps) for excellent performance and pricing. For optimal experience, consider:
  - **Minimum**: 2 CPU cores, 2GB RAM, 20GB storage
  - **Recommended**: 4+ CPU cores, 4GB+ RAM, 40GB+ storage
  - **Alternative**: [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/) for local hosting

- **Reverse Proxy Setup** (for HTTPS access):
  - **Option 1**: Traefik with Docker - follow: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)
  - **Option 2**: Traefik with Let&apos;s Encrypt wildcard certificates - see: [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/)

- **Container Management**: Docker and container orchestration tools:
  - **Docker Engine**: Latest stable version
  - **Docker Compose**: For multi-container orchestration
  - **Dockge** (optional): Simplified Docker management - tutorial: [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/)

- **Domain Name**: A domain or subdomain pointing to your server (e.g., `search.yourdomain.com`)


## Setup Option 1: Docker &amp; Docker Compose (Standalone)

This method installs SearXNG using Docker containers. Works for users who want a simple, self-contained instance.

### Step 1: Create Project Directory

Create a directory for SearXNG:

```bash
mkdir -p ~/searxng &amp;&amp; cd ~/searxng
```

### Step 2: Create Docker Compose Configuration

Create a comprehensive `docker-compose.yml` file that includes SearXNG with Redis caching for optimal performance:

```yaml
version: &apos;3.8&apos;

services:
  searxng:
    image: &quot;docker.io/searxng/searxng:latest&quot;
    container_name: &quot;searxng&quot;
    volumes:
      - &quot;./config:/etc/searxng:rw&quot;
    ports:
      - &quot;8080:8080&quot;
    environment:
      - PGID=1000
      - PUID=1000
      - SEARXNG_BASE_URL=http://localhost:8080
      - SEARXNG_REDIS_URL=redis://redis:6379/0
      - UWSGI_WORKERS=4
      - UWSGI_THREADS=4
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped

  redis:
    container_name: searxng-redis
    image: docker.io/valkey/valkey:8-alpine
    command: valkey-server --save 30 1 --loglevel warning
    restart: unless-stopped
    volumes:
      - &quot;redis-data:/data&quot;
    cap_drop:
      - ALL
    cap_add:
      - SETGID
      - SETUID
      - DAC_OVERRIDE
      - CHOWN
    healthcheck:
      test: [&quot;CMD&quot;, &quot;valkey-server&quot;, &quot;--version&quot;]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  redis-data:
```

### Step 3: Initialize Configuration Directory

Create the necessary directory structure for SearXNG configuration files:

```bash
mkdir -p config
```

### Step 4: Launch SearXNG

Deploy your SearXNG instance using Docker Compose:

```bash
docker compose up -d
```

### Step 5: Access Your Search Engine

Once the containers are running, access SearXNG through your web browser:

- **Local Access**: `http://localhost:8080`
- **Network Access**: `http://your-server-ip:8080`

&lt;Notice type=&quot;success&quot; title=&quot;Installation Complete&quot;&gt;

Your SearXNG instance is now operational! You can begin searching immediately while enjoying complete privacy.

&lt;/Notice&gt;

## Setup Option 2: Traefik &amp; Dockge Integration

This advanced setup integrates SearXNG with Traefik reverse proxy and Dockge container management, providing HTTPS encryption, automatic SSL certificates, and simplified management through a web interface.

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisites for This Method&quot;&gt;

Ensure you have Traefik and Dockge properly configured by following our [Traefik Wildcard Certificate guide](https://www.bitdoze.com/traefik-wildcard-certificate/).

&lt;/Notice&gt;

### Step 1: Prepare Traefik Network

Verify your Traefik network exists and create if necessary:

```bash
docker network create traefik-net
```

### Step 2: Enhanced Docker Compose with Traefik Labels

Create a production-ready `docker-compose.yml` with Traefik integration:

```yaml
version: &apos;3.8&apos;

networks:
  traefik-net:
    external: true

services:
  searxng:
    image: &quot;docker.io/searxng/searxng:latest&quot;
    container_name: &quot;searxng&quot;
    volumes:
      - &quot;./config:/etc/searxng:rw&quot;
    environment:
      - PGID=1000
      - PUID=1000
      - SEARXNG_BASE_URL=https://search.yourdomain.com
      - SEARXNG_REDIS_URL=redis://redis:6379/0
      - UWSGI_WORKERS=4
      - UWSGI_THREADS=4
    cap_drop:
      - ALL
    cap_add:
      - CHOWN
      - SETGID
      - SETUID
    networks:
      - traefik-net
    depends_on:
      redis:
        condition: service_healthy
    restart: unless-stopped
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.searxng.rule=Host(`search.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.searxng.entrypoints=https&quot;
      - &quot;traefik.http.routers.searxng.tls=true&quot;
      - &quot;traefik.http.routers.searxng.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.searxng.loadbalancer.server.port=8080&quot;

  redis:
    container_name: searxng-redis
    image: docker.io/valkey/valkey:8-alpine
    command: valkey-server --save 30 1 --loglevel warning
    restart: unless-stopped
    networks:
      - traefik-net
    volumes:
      - &quot;redis-data:/data&quot;
    cap_drop:
      - ALL
    cap_add:
      - SETGID
      - SETUID
      - DAC_OVERRIDE
      - CHOWN
    healthcheck:
      test: [&quot;CMD&quot;, &quot;valkey-server&quot;, &quot;--version&quot;]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes:
  redis-data:
```

### Step 3: Deploy Through Dockge

1. Access your Dockge interface (typically `https://dockge.yourdomain.com`)
2. Create a new stack named &quot;searxng&quot;
3. Paste the Docker Compose configuration
4. Customize the domain name in the Traefik labels
5. Deploy the stack

### Step 4: Configure Domain DNS

Point your chosen subdomain to your server&apos;s IP address:

| Record Type | Name | Value | TTL |
|-------------|------|-------|-----|
| A | search | your-server-ip | 300 |

&lt;Notice type=&quot;warning&quot; title=&quot;DNS Propagation&quot;&gt;

Allow 5-15 minutes for DNS changes to propagate before accessing your SearXNG instance.

&lt;/Notice&gt;

## Setup Option 3: Dokploy Easy Deployment

Dokploy offers the most streamlined deployment experience with built-in templates and automated configuration. This method is ideal for users who prefer GUI-based management with minimal command-line interaction.

### Step 1: Install Dokploy

If you haven&apos;t already, set up Dokploy on your server following our comprehensive guide: [Dokploy Installation Tutorial](https://www.bitdoze.com/dokploy-install/).

### Step 2: Create SearXNG Application

1. **Access Dokploy Dashboard**: Navigate to your Dokploy interface
2. **Create New Project**: Click &quot;New Project&quot; and name it &quot;searxng&quot;
3. **Select Template**: Choose &quot;SearXNG&quot; from the available templates or create a custom compose application

![Dokploy Service](../../assets/images/25/07/dokploy-createservice.png)
![Dokploy SearXNG](../../assets/images/25/07/dokploy-SearXNG.png)

### Step 3: Configure Environment Variables

Set up the following environment variables in Dokploy:

| Variable | Value | Description |
|----------|-------|-------------|
| `SEARXNG_BASE_URL` | `https://search.yourdomain.com` | Your public URL |
| `UWSGI_WORKERS` | `4` | Number of worker processes |
| `UWSGI_THREADS` | `4` | Threads per worker |
| `PGID` | `1000` | Group ID for file permissions |
| `PUID` | `1000` | User ID for file permissions |

![Dokploy SearXNG deploy](../../assets/images/25/07/dokploy-deploy.png)


### Step 4: Domain Configuration

1. Navigate to the &quot;Domains&quot; section in your Dokploy project
2. Add your domain: `search.yourdomain.com`
3. Set Container Port (8080 for SearXNG)
4. Enable SSL/TLS certificate generation in Certificate Provider

![Dokploy SearXNG domain](../../assets/images/25/07/dokploy-domain.png)

### Step 5: Deploy Application

Click the &quot;Deploy&quot; button and monitor the deployment logs. Dokploy will automatically:

- Pull required Docker images
- Set up networking
- Generate SSL certificates
- Configure reverse proxy rules

&lt;Button text=&quot;Deploy SearXNG&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

## Advanced Configuration and Customization

Once your SearXNG instance is operational, you can enhance its functionality through various configuration options.

### Search Engine Selection

Navigate to **Preferences → Engines** to customize your search sources:

&lt;ListCheck&gt;

- **General Search**: Google, Bing, DuckDuckGo, Startpage, Yandex
- **News Sources**: BBC News, Reuters, Associated Press, Wikinews
- **Academic**: Google Scholar, Microsoft Academic, Semantic Scholar
- **Media**: YouTube, Vimeo, Flickr, Unsplash
- **Shopping**: Amazon, eBay, AliExpress
- **Social**: Reddit, Twitter, Stack Overflow

&lt;/ListCheck&gt;

![SearXNG search engines](../../assets/images/25/07/searxng-ui.png)

### Privacy and Security Settings

Configure these essential privacy options:

| Setting | Recommended Value | Purpose |
|---------|------------------|---------|
| **Safe Search** | Moderate | Filter inappropriate content |
| **Image Proxy** | Enabled | Hide IP from image sources |
| **Method** | POST | Prevent query leaks in referrers |
| **Autocomplete** | Disabled | Avoid external service calls |

### Performance Optimization

Fine-tune SearXNG performance based on your server specifications:

```yaml
environment:
  - UWSGI_WORKERS=8        # 2x CPU cores
  - UWSGI_THREADS=4        # Adjust based on RAM
  - SEARXNG_REDIS_URL=redis://redis:6379/0
```

### Custom Themes and Appearance

SearXNG supports multiple themes and customization options:

- **Default Theme**: Clean, modern interface
- **Simple Theme**: Minimal design with fast loading
- **Oscar Theme**: Feature-rich with advanced filters
- **Pix-art Theme**: Artistic, image-focused layout

## Browser Integration

### Setting SearXNG as Default Search Engine

**For Firefox/LibreWolf:**
1. Navigate to `about:preferences#search`
2. Click &quot;Add Search Engine&quot;
3. Enter details:
   - **Name**: SearXNG Privacy Search
   - **URL**: `https://search.yourdomain.com/search?q=%s`
4. Set as default search engine

**For Chrome/Chromium:**
1. Go to Settings → Search engine → Manage search engines
2. Click &quot;Add&quot; next to &quot;Other search engines&quot;
3. Fill in:
   - **Search engine**: SearXNG
   - **Keyword**: searxng
   - **URL**: `https://search.yourdomain.com/search?q=%s`

### Browser Extension Benefits

&lt;Notice type=&quot;info&quot; title=&quot;Privacy Enhancement&quot;&gt;

Installing SearXNG as your default search enhances privacy by routing all searches through your self-hosted instance, eliminating tracking from commercial search engines.

&lt;/Notice&gt;

## Monitoring and Maintenance

### Health Monitoring

Implement monitoring to ensure optimal SearXNG performance:

```yaml
# Add to your docker-compose.yml
healthcheck:
  test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8080/search?q=test&amp;format=json&quot;]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 40s
```

### Regular Maintenance Tasks

&lt;ListCheck&gt;

- **Weekly**: Review search engine performance and disable problematic sources
- **Monthly**: Update Docker images for security patches
- **Quarterly**: Analyze search patterns and optimize engine selection
- **Annually**: Review and update SSL certificates (if not automated)

&lt;/ListCheck&gt;

### Backup Strategies

Protect your SearXNG configuration with regular backups:

```bash
# Backup configuration
tar -czf searxng-backup-$(date +%Y%m%d).tar.gz config/

# Backup Redis data (optional)
docker exec searxng-redis redis-cli BGSAVE
```

## Troubleshooting Common Issues

### Search Results Not Appearing

**Symptoms**: Empty search results or specific engines not working

**Solutions**:
1. Check engine status in preferences
2. Verify network connectivity from container
3. Review rate limiting settings
4. Update engine configurations

### Performance Issues

**Symptoms**: Slow search responses or timeouts

**Solutions**:
1. Increase UWSGI workers and threads
2. Optimize Redis memory allocation
3. Disable problematic search engines
4. Implement result caching

### SSL Certificate Problems

**Symptoms**: HTTPS errors or certificate warnings

**Solutions**:
1. Verify domain DNS configuration
2. Check Traefik certificate generation logs
3. Restart Traefik container
4. Validate certificate resolver settings

&lt;Notice type=&quot;warning&quot; title=&quot;Rate Limiting&quot;&gt;

Some search engines implement aggressive rate limiting. If you experience blocked requests, consider reducing the number of simultaneous engines or implementing request delays.

&lt;/Notice&gt;

## Security Best Practices

### Network Security

&lt;ListCheck&gt;

- **Firewall Configuration**: Block unnecessary ports, allow only HTTP/HTTPS traffic
- **VPN Access**: Consider restricting access through VPN for enhanced privacy
- **Regular Updates**: Keep Docker images and host system updated
- **Access Logs**: Monitor and analyze access patterns for anomalies

&lt;/ListCheck&gt;

### Data Protection

| Component | Security Measure | Implementation |
|-----------|-----------------|----------------|
| **Search Queries** | No logging | Default SearXNG behavior |
| **User Sessions** | No cookies | Disabled in preferences |
| **IP Addresses** | Proxy protection | Redis session storage |
| **SSL/TLS** | Strong encryption | Let&apos;s Encrypt certificates |

## Conclusion

Self-hosting SearXNG represents a significant step toward digital privacy and search independence. By implementing your own metasearch engine, you gain:

&lt;ListCheck&gt;

- **Complete Privacy Control**: No tracking, profiling, or data collection
- **Search Result Diversity**: Access to multiple search engines simultaneously
- **Customization Freedom**: Tailor the search experience to your preferences
- **Infrastructure Ownership**: Full control over your search infrastructure
- **Cost Effectiveness**: Minimal hosting costs for unlimited private searching

&lt;/ListCheck&gt;

Whether you choose the straightforward Docker approach, the robust Traefik integration, or the streamlined Dokploy deployment, SearXNG provides a powerful foundation for private, efficient web searching. The investment in self-hosting pays dividends in privacy protection and search quality enhancement.

As digital privacy becomes increasingly important, tools like SearXNG demonstrate that you don&apos;t need to compromise functionality for privacy. Take control of your search experience today and enjoy the freedom of truly private web searching.

&lt;Button text=&quot;Start Your SearXNG Journey&quot; size=&quot;xl&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

**Ready to explore more self-hosting opportunities?** Check out our comprehensive guides on [Docker container management](https://www.bitdoze.com/dockge-install/), [Traefik reverse proxy setup](https://www.bitdoze.com/traefik-proxy-docker/), and [Dokploy platform deployment](https://www.bitdoze.com/dokploy-install/) to expand your self-hosted infrastructure.</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category></item><item><title>How to Self-Host Stirling PDF</title><link>https://www.bitdoze.com/stirling-pdf-self-host-manipulation/</link><guid isPermaLink="true">https://www.bitdoze.com/stirling-pdf-self-host-manipulation/</guid><description>Learn how to self-host Stirling PDF, a PDF manipulation tool for merging, splitting, converting, and processing PDFs. Complete guide with Docker, Traefik, and Dokploy setup options.</description><pubDate>Wed, 30 Jul 2025 00:00:00 GMT</pubDate><content:encoded>PDF documents are commonly used for professional communication, legal documentation, and information sharing. Manipulating these files often requires expensive software licenses or online services that may compromise document privacy and security.

**Stirling PDF** is a self-hosted application that processes, edits, and manages PDF documents. This solution provides PDF manipulation without software subscriptions while keeping documents on your own infrastructure.

## What is Stirling PDF?


**Stirling PDF** is an open-source web application for PDF manipulation tools. It provides an alternative to commercial software and online services.

### Key Capabilities of Stirling PDF

&lt;ListCheck&gt;

- Document Management: Merge, split, rotate, and reorganize PDF pages
- Format Conversion: Convert PDFs to/from images, Word documents, Excel files, and more
- Security Operations: Add or remove passwords, digital signatures, and encryption
- Compression &amp; Optimization: Reduce file sizes while maintaining quality
- OCR Integration: Extract text from scanned documents and images
- Metadata Management: Clean, edit, or remove document metadata
- Batch Processing: Handle multiple documents simultaneously
- API Access: Integrate with automation workflows and other applications

&lt;/ListCheck&gt;

### How Stirling PDF Works

| Component | Function | Benefit |
|-----------|----------|---------|
| Web Interface | Browser-based GUI | Access from any device |
| Processing Engine | Java-based PDF manipulation core | Reliable operations |
| API Layer | RESTful endpoints for automation | Integration with workflows |
| Storage System | Temporary file handling | Document processing |

Stirling PDF is an open-source project. View documentation and contribute at their [GitHub repository](https://github.com/Stirling-Tools/Stirling-PDF) and [project website](https://stirlingpdf.io/).

&gt; For a comprehensive list of useful applications, check out our guide on [Docker containers for home servers](https://www.bitdoze.com/docker-containers-home-server/).

## Prerequisites

Before deploying your Stirling PDF instance, ensure you have the necessary infrastructure components configured:

&lt;Notice type=&quot;info&quot; title=&quot;Resource Requirements&quot;&gt;

Stirling PDF performs intensive document processing operations. Adequate system resources ensure optimal performance, especially when handling large files or batch operations.

&lt;/Notice&gt;


- **Server Infrastructure**: A reliable hosting platform for your Stirling PDF deployment:
  - **Minimum Specifications**: 2 CPU cores, 4GB RAM, 50GB storage
  - **Recommended Configuration**: 4+ CPU cores, 8GB+ RAM, 100GB+ SSD storage
  - **Cloud Provider**: [Hetzner VPS](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) offers excellent performance-to-price ratio
  - **Local Alternative**: [Mini PC as Home Server](https://www.bitdoze.com/best-mini-pc-home-server/) for on-premise deployment

- **Reverse Proxy Configuration** (for secure HTTPS access):
  - **Traefik with Docker**: Follow our comprehensive guide: [How to Use Traefik as A Reverse Proxy in Docker](https://www.bitdoze.com/traefik-proxy-docker/)
  - **Advanced Setup**: Implement SSL with our tutorial: [Traefik FREE Let&apos;s Encrypt Wildcard Certificate With CloudFlare Provider](https://www.bitdoze.com/traefik-wildcard-certificate/)

- **Container Orchestration Tools**:
  - **Docker Engine**: Latest stable release with container runtime
  - **Docker Compose**: Multi-container application management
  - **Dockge** (recommended): Streamlined container management interface - see: [Dockge - Portainer Alternative for Docker Management](https://www.bitdoze.com/dockge-install/)

- **Network Configuration**:
  - **Domain Access**: Subdomain pointing to your server (e.g., `pdf.yourdomain.com`)
  - **Firewall Rules**: Appropriate port access and security configurations



## Setup Option 1: Docker &amp; Docker Compose (Standalone)

This approach provides a straightforward deployment using Docker containers, ideal for users seeking a simple, self-contained Stirling PDF installation without external dependencies.

### Step 1: Create Project Structure

Establish a dedicated directory hierarchy for your Stirling PDF deployment:

```bash
mkdir -p ~/stirling-pdf &amp;&amp; cd ~/stirling-pdf
```

### Step 2: Configure Docker Compose

Create a `docker-compose.yml` file:

```yaml
version: &apos;3.8&apos;

services:
  stirling-pdf:
    image: stirlingtools/stirling-pdf:latest
    container_name: stirling-pdf
    ports:
      - &quot;8080:8080&quot;
    volumes:
      - ./tessdata:/usr/share/tessdata    # OCR language data
      - ./configs:/configs                # Configuration files
      - ./logs:/logs                      # Application logs
      - ./custom-files:/customFiles       # Custom templates/files
    environment:
      - DOCKER_ENABLE_SECURITY=false     # Disable for standalone use
      - INSTALL_BOOK_AND_ADVANCED_HTML_OPS=true
      - LANGS=en_US                      # Set your preferred language
    restart: unless-stopped
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8080/api/v1/info/status&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 40s
```

### Step 3: Initialize Directory Structure

Create the necessary directories for Stirling PDF operation:

```bash
mkdir -p tessdata configs logs custom-files
```

### Step 4: Configure OCR Support (Optional)

Download language packs for OCR:

```bash
# Download common language data for Tesseract OCR
wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/eng.traineddata
wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/fra.traineddata
wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/deu.traineddata
```

### Step 5: Deploy Stirling PDF

Launch your Stirling PDF instance using Docker Compose:

```bash
docker compose up -d
```

### Step 6: Verify Installation

Access your Stirling PDF instance and verify functionality:

- **Local Access**: Navigate to `http://localhost:8080`
- **Network Access**: Visit `http://your-server-ip:8080`
- **API Documentation**: Check `http://localhost:8080/swagger-ui/index.html`

&lt;Notice type=&quot;success&quot; title=&quot;Installation Complete&quot;&gt;

Your Stirling PDF instance is operational. You can process PDF documents.

&lt;/Notice&gt;

## Setup Option 2: Traefik &amp; Dockge Integration

This setup integrates Stirling PDF with Traefik reverse proxy and Dockge management interface for HTTPS encryption and SSL certificates.

&lt;Notice type=&quot;info&quot; title=&quot;Prerequisites&quot;&gt;

This configuration requires a properly configured Traefik and Dockge environment. Follow our [Traefik Wildcard Certificate guide](https://www.bitdoze.com/traefik-wildcard-certificate/) for setup instructions.

&lt;/Notice&gt;

### Step 1: Verify Network Configuration

Ensure your Traefik network is properly configured:

```bash
# Verify existing network
docker network ls | grep traefik-net

# Create if necessary
docker network create traefik-net
```

### Step 2: Docker Compose Configuration

Create a `docker-compose.yml` with Traefik integration:

```yaml
version: &apos;3.8&apos;

networks:
  traefik-net:
    external: true

services:
  stirling-pdf:
    image: stirlingtools/stirling-pdf:latest
    container_name: stirling-pdf
    volumes:
      - ./tessdata:/usr/share/tessdata
      - ./configs:/configs
      - ./logs:/logs
      - ./custom-files:/customFiles
    environment:
      - DOCKER_ENABLE_SECURITY=true      # Enhanced security for public access
      - INSTALL_BOOK_AND_ADVANCED_HTML_OPS=true
      - LANGS=en_US,fr_FR,de_DE          # Multiple language support
      - SYSTEM_ROOTURIPATH=/              # Root path configuration
      - UI_APPNAME=Stirling PDF           # Custom application name
      - UI_HOMEDESCRIPTION=Your Private PDF Manipulation Suite
      - SECURITY_ENABLELOGIN=false       # Disable if using external auth
      - SYSTEM_MAXFILESIZE=2000          # Max file size in MB
    networks:
      - traefik-net
    restart: unless-stopped
    labels:
      - &quot;traefik.enable=true&quot;
      - &quot;traefik.http.routers.stirling-pdf.rule=Host(`pdf.yourdomain.com`)&quot;
      - &quot;traefik.http.routers.stirling-pdf.entrypoints=https&quot;
      - &quot;traefik.http.routers.stirling-pdf.tls=true&quot;
      - &quot;traefik.http.routers.stirling-pdf.tls.certresolver=letsencrypt&quot;
      - &quot;traefik.http.services.stirling-pdf.loadbalancer.server.port=8080&quot;
      # Security headers
      - &quot;traefik.http.routers.stirling-pdf.middlewares=stirling-pdf-headers&quot;
      - &quot;traefik.http.middlewares.stirling-pdf-headers.headers.accesscontrolallowmethods=GET,OPTIONS,PUT,POST,DELETE,PATCH&quot;
      - &quot;traefik.http.middlewares.stirling-pdf-headers.headers.accesscontrolmaxage=100&quot;
      - &quot;traefik.http.middlewares.stirling-pdf-headers.headers.addvaryheader=true&quot;
    healthcheck:
      test: [&quot;CMD&quot;, &quot;curl&quot;, &quot;-f&quot;, &quot;http://localhost:8080/api/v1/info/status&quot;]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 60s
```

### Step 3: Deploy via Dockge

1. **Access Dockge Interface**: Navigate to your Dockge dashboard (typically `https://dockge.yourdomain.com`)
2. **Create New Stack**: Click &quot;New Stack&quot; and name it &quot;stirling-pdf&quot;
3. **Configure Composition**: Paste the Docker Compose configuration
4. **Customize Settings**: Update the domain name in Traefik labels to match your setup
5. **Deploy Stack**: Click deploy and monitor the deployment logs

### Step 4: DNS Configuration

Configure your domain&apos;s DNS records to point to your server:

| Record Type | Name | Value | TTL |
|-------------|------|-------|-----|
| A | pdf | your-server-ip | 300 |
| CNAME | pdf | yourdomain.com | 300 |

### Step 5: Verify HTTPS Access

After DNS propagation (typically 5-15 minutes), verify secure access:

- **HTTPS Access**: `https://pdf.yourdomain.com`
- **SSL Certificate**: Verify the Let&apos;s Encrypt certificate is properly installed
- **API Documentation**: `https://pdf.yourdomain.com/swagger-ui/index.html`

&lt;Notice type=&quot;warning&quot; title=&quot;DNS Propagation Time&quot;&gt;

DNS changes may take 5-15 minutes to propagate globally.

&lt;/Notice&gt;

## Setup Option 3: Dokploy Deployment

Dokploy provides a graphical interface with pre-configured templates and automated SSL certificate management. This option works well for users who prefer graphical interfaces over command-line operations.

### Step 1: Dokploy Platform Setup

If not already installed, set up Dokploy on your server using our comprehensive guide: [Dokploy Installation and Configuration Tutorial](https://www.bitdoze.com/dokploy-install/).

### Step 2: Create Stirling PDF Project

1. **Access Dokploy Dashboard**: Navigate to your Dokploy interface
2. **Create New Project**: Click &quot;New Project&quot; and name it &quot;stirling-pdf&quot;
3. **Select Template**: Choose &quot;StarlingPDF&quot; from the available templates or create a custom compose application


![Dokploy Service](../../assets/images/25/07/dokploy-createservice.png)
![Dokploy SearXNG](../../assets/images/25/07/dokploy-SearXNG.png)

### Step 3: Environment Variables Configuration

Configure the environment variables in Dokploy:

| Variable | Value | Description |
|----------|-------|-------------|
| `DOCKER_ENABLE_SECURITY` | `true` | Enable security features |
| `INSTALL_BOOK_AND_ADVANCED_HTML_OPS` | `true` | Install additional PDF operations |
| `LANGS` | `en_US,fr_FR,de_DE` | OCR and UI language support |
| `SYSTEM_MAXFILESIZE` | `2000` | Maximum file size in MB |
| `UI_APPNAME` | `Stirling PDF` | Application name |
| `UI_HOMEDESCRIPTION` | `Private PDF Suite` | Homepage description |
| `SECURITY_ENABLELOGIN` | `false` | Disable for external authentication |



Use this in Dokploy configuration:

```yaml
environment:
  - DOCKER_ENABLE_SECURITY=${DOCKER_ENABLE_SECURITY}
  - INSTALL_BOOK_AND_ADVANCED_HTML_OPS=${INSTALL_BOOK_AND_ADVANCED_HTML_OPS}
  - LANGS=${LANGS}
  - SYSTEM_MAXFILESIZE=${SYSTEM_MAXFILESIZE}
  - UI_APPNAME=${UI_APPNAME}
  - UI_HOMEDESCRIPTION=${UI_HOMEDESCRIPTION}
  - SECURITY_ENABLELOGIN=${SECURITY_ENABLELOGIN}
```

### Step 4: Domain and SSL Configuration

1. **Navigate to Domains**: Go to the &quot;Domains&quot; section in your Dokploy project
2. **Add Domain**: Enter your desired domain: `pdf.yourdomain.com`
3. **Enable SSL**: Toggle SSL/TLS certificate generation
4. **Configure Auto-Renewal**: Enable automatic certificate renewal
5. **Set Redirects**: Configure HTTP to HTTPS redirection

![Dokploy SearXNG domain](../../assets/images/25/07/dokploy-domain.png)

### Step 5: Deploy and Monitor

1. **Deploy Application**: Click the &quot;Deploy&quot; button in Dokploy
2. **Monitor Logs**: Watch the deployment process
3. **Verify Status**: Check application health
4. **Test Functionality**: Access your instance and test PDF operations

![Dokploy SearXNG deploy](../../assets/images/25/07/dokploy-deploy.png)

&lt;Button text=&quot;Deploy with Dokploy&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

## Advanced Configuration and Feature Optimization

Once Stirling PDF is operational, you can enhance its capabilities through configuration options.

### Document Processing Configuration

Customize Stirling PDF through environment variables:

&lt;ListCheck&gt;

- File Size Limits: Configure maximum upload sizes
- OCR Languages: Install Tesseract language packs for multi-language support
- Security Settings: Enable authentication, rate limiting, and access controls
- UI Customization: Customize logos and descriptions
- Feature Toggles: Enable or disable specific functionality
- Performance Tuning: Adjust memory allocation and processing parameters

&lt;/ListCheck&gt;

### OCR and Language Support

Enhance OCR capabilities by installing language data:

| Language | Code | Download Command |
|----------|------|------------------|
| **English** | eng | `wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/eng.traineddata` |
| **Spanish** | spa | `wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/spa.traineddata` |
| **French** | fra | `wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/fra.traineddata` |
| **German** | deu | `wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/deu.traineddata` |
| **Chinese Simplified** | chi_sim | `wget -P tessdata/ https://github.com/tesseract-ocr/tessdata/raw/main/chi_sim.traineddata` |

### Security and Access Control

Configure security measures for production deployments:

```yaml
environment:
  - DOCKER_ENABLE_SECURITY=true
  - SECURITY_ENABLELOGIN=true
  - SECURITY_LOGINATTEMPTSLIMIT=5
  - SECURITY_LOGINRESETTIMEDURATION=120  # minutes
  - SYSTEM_ROOTURIPATH=/
  - SECURITY_OAUTH2_AUTOCREATEUSER=true
```

### API Integration and Automation

Stirling PDF provides a comprehensive REST API for automation and integration:

**Common API Endpoints:**
- **Document Conversion**: `/api/v1/convert/pdf-to-img`
- **Security Operations**: `/api/v1/security/add-password`
- **Merge Operations**: `/api/v1/general/merge-pdfs`
- **Compression**: `/api/v1/general/compress-pdf`

**Example API Usage:**
```bash
# Convert PDF to images
curl -X POST &quot;https://pdf.yourdomain.com/api/v1/convert/pdf-to-img&quot; \
  -H &quot;Content-Type: multipart/form-data&quot; \
  -F &quot;fileInput=@document.pdf&quot; \
  -F &quot;imageFormat=PNG&quot; \
  -F &quot;singleOrMultiple=multiple&quot;
```

## Performance Optimization and Resource Management

### System Resource Configuration

Optimize Stirling PDF performance based on server specifications:

| Server Specs | Recommended Settings | Use Case |
|---------------|---------------------|----------|
| **2 CPU / 4GB RAM** | `SYSTEM_MAXFILESIZE=500` | Light personal use |
| **4 CPU / 8GB RAM** | `SYSTEM_MAXFILESIZE=1000` | Small team/office |
| **8+ CPU / 16GB+ RAM** | `SYSTEM_MAXFILESIZE=2000` | Heavy processing/enterprise |

### Docker Resource Limits

Configure container resource limits:

```yaml
services:
  stirling-pdf:
    # ... other configuration
    deploy:
      resources:
        limits:
          cpus: &apos;2.0&apos;
          memory: 4G
        reservations:
          cpus: &apos;1.0&apos;
          memory: 2G
```

### Monitoring and Health Checks

Implement monitoring for production environments:

```yaml
healthcheck:
  test: [&quot;CMD-SHELL&quot;, &quot;curl -f http://localhost:8080/api/v1/info/status || exit 1&quot;]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 60s
```

## Integration with Workflow Automation

### n8n Integration

Connect Stirling PDF with n8n for document processing workflows:

**Example Workflow:**
1. **Trigger**: Email attachment received
2. **Process**: Remove password protection via Stirling PDF API
3. **Convert**: Transform to searchable PDF with OCR
4. **Store**: Save to document management system

### API-First Architecture Benefits

&lt;ListCheck&gt;

- Automated Processing: Integrate with CI/CD pipelines for document automation
- Batch Operations: Process multiple documents programmatically
- Custom Applications: Build tools using Stirling PDF as backend
- Workflow Integration: Connect with tools like n8n, Zapier, or custom scripts
- Monitoring Integration: Track processing metrics

&lt;/ListCheck&gt;

## Security Best Practices and Data Protection

### Access Control Implementation

&lt;Notice type=&quot;warning&quot; title=&quot;Security Considerations&quot;&gt;

When exposing Stirling PDF to the internet, implement security measures to protect documents.

&lt;/Notice&gt;

Implement security for production deployments:

| Security Layer | Implementation | Purpose |
|----------------|----------------|---------|
| Authentication | Built-in login system | User access control |
| Authorization | Role-based permissions | Feature-specific access |
| Network Security | Reverse proxy + SSL | Encrypted communication |
| Rate Limiting | Request throttling | Prevent abuse |
| File Validation | Content type checking | Malicious file protection |

### Data Privacy Measures

&lt;ListCheck&gt;

- Temporary File Cleanup: Automatic deletion of processed files after operations
- Memory Management: Secure handling of document content in memory
- Log Sanitization: Removal of sensitive information from application logs
- Network Isolation: Container networking restrictions for security
- Backup Encryption: Encrypted storage of configuration and temporary files

&lt;/ListCheck&gt;

### Production Security Configuration

```yaml
environment:
  - DOCKER_ENABLE_SECURITY=true
  - SECURITY_ENABLELOGIN=true
  - SECURITY_LOGINATTEMPTSLIMIT=5
  - SECURITY_LOGINRESETTIMEDURATION=120
  - SYSTEM_MAXFILESIZE=1000
  - SYSTEM_MAXREQUESTSIZE=1000
  - UI_HOMEDESCRIPTION=Secure PDF Processing Suite
```

## Troubleshooting Common Issues

### Performance and Memory Issues

**Symptoms**: Slow processing, out-of-memory errors, or container crashes

**Solutions**:
1. **Increase Container Memory**: Adjust Docker memory limits
2. **Optimize File Sizes**: Reduce maximum file size limits
3. **Monitor Resource Usage**: Use `docker stats` to analyze consumption
4. **Process Queue Management**: Implement batch processing for large files

### OCR Processing Problems

**Symptoms**: OCR operations failing or producing poor results

**Solutions**:
1. **Verify Language Data**: Ensure proper Tesseract language files are installed
2. **Check Image Quality**: Higher resolution images produce better OCR results
3. **Language Configuration**: Verify correct language codes in environment variables
4. **Memory Allocation**: OCR operations require adequate RAM for processing

### API Integration Difficulties

**Symptoms**: API calls failing or returning unexpected results

**Solutions**:
1. **Authentication Check**: Verify API authentication if enabled
2. **Content-Type Headers**: Ensure correct multipart/form-data headers
3. **File Size Limits**: Check file size restrictions in API calls
4. **Error Response Analysis**: Review detailed error messages from API responses

### SSL Certificate Issues

**Symptoms**: HTTPS errors, certificate warnings, or connection failures

**Solutions**:
1. **DNS Verification**: Confirm domain points to correct server IP
2. **Certificate Renewal**: Check automatic certificate renewal configuration
3. **Traefik Logs**: Review Traefik logs for certificate generation errors
4. **Network Connectivity**: Verify Let&apos;s Encrypt can reach your server

&lt;Notice type=&quot;info&quot; title=&quot;Debugging&quot;&gt;

Enable debug logging by setting `SYSTEM_LOGFILE=/logs/stirling.log` to capture detailed application behavior.

&lt;/Notice&gt;

## Maintenance and Updates

### Regular Maintenance Tasks

&lt;ListCheck&gt;

- **Weekly**: Monitor disk usage and clean temporary files
- **Monthly**: Update Docker images for security patches and new features
- **Quarterly**: Review and optimize system performance settings
- **Annually**: Audit security configurations and access permissions

&lt;/ListCheck&gt;

### Update Procedures

Keep your Stirling PDF instance current with regular updates:

```bash
# Update Docker images
cd ~/stirling-pdf
docker compose pull
docker compose up -d

# Clean up old images
docker image prune -f
```

### Backup Strategies

Protect your Stirling PDF configuration and data:

```bash
# Backup configuration and data
tar -czf stirling-pdf-backup-$(date +%Y%m%d).tar.gz \
  configs/ tessdata/ custom-files/ docker-compose.yml

# Automated backup script
#!/bin/bash
BACKUP_DIR=&quot;/backups/stirling-pdf&quot;
mkdir -p $BACKUP_DIR
tar -czf &quot;$BACKUP_DIR/stirling-pdf-$(date +%Y%m%d-%H%M%S).tar.gz&quot; \
  configs/ tessdata/ custom-files/ docker-compose.yml
```

## Conclusion

Self-hosting Stirling PDF gives you control over document processing. By implementing your own PDF manipulation suite, you achieve:

&lt;ListCheck&gt;

- Complete Data Control: Your sensitive documents never leave your infrastructure
- Cost-Effective Solution: Eliminate expensive software licenses and subscription fees
- Unlimited Processing: No file size restrictions or usage limits imposed by third parties
- Custom Integration: API-first architecture enables workflow automation
- Enhanced Security: Multi-layered security controls protect your document processing
- Scalable Performance: Adjust resources based on your processing needs

&lt;/ListCheck&gt;

Whether you choose the Docker deployment, the Traefik integration, or the Dokploy approach, Stirling PDF provides a foundation for private document processing. Self-hosting delivers benefits in privacy protection, cost savings, and processing flexibility.

As document security becomes increasingly important in professional environments, tools like Stirling PDF demonstrate that you can maintain full control without sacrificing functionality. Transform your document workflow and experience private PDF processing.

&lt;Button text=&quot;Start Your Stirling PDF Journey&quot; size=&quot;xl&quot; color=&quot;red&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

Explore our guides on [Docker container orchestration](https://www.bitdoze.com/dockge-install/), [Traefik reverse proxy configuration](https://www.bitdoze.com/traefik-proxy-docker/), and [Dokploy platform management](https://www.bitdoze.com/dokploy-install/).</content:encoded><category>self-hosting</category><category>self-hosted</category><category>docker</category></item><item><title>Disk Imaging and Cloning with Linux dd Command</title><link>https://www.bitdoze.com/linux-dd-command-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/linux-dd-command-guide/</guid><description>Learn how to use the powerful dd command in Linux for disk imaging, cloning, and data backup. Essential guide for system administrators and home server users.</description><pubDate>Tue, 29 Jul 2025 00:00:00 GMT</pubDate><content:encoded>The `dd` command is a powerful but dangerous tool in Linux. Called &quot;disk duplicator&quot; or &quot;data destroyer&quot; because it can overwrite data, `dd` handles disk imaging, cloning, and low-level data operations.

I use `dd` on my home server for creating system backups, cloning drives before upgrades, and preparing bootable media. Whether managing a [home server](https://www.bitdoze.com/why-need-home-server/) or enterprise systems, understanding `dd` helps with data management and disaster recovery.

This guide covers using `dd` safely.

## Understanding the dd Command

&lt;Notice type=&quot;info&quot; title=&quot;What is dd?&quot;&gt;
The `dd` command (disk duplicator) is a low-level utility that copies and converts files at the byte level. It can copy entire disks, partitions, or create files with specific patterns.

### Why Use dd?

&lt;ListCheck&gt;

- **Bit-perfect copies**: Creates exact replicas

- **Sector-level operations**: Works at the lowest level, copying everything including boot sectors
- **Versatile functionality**: Can create images, clone disks, wipe data, and generate test files
- **Bootable media creation**: Perfect for creating bootable USB drives and installation media
- **Forensic applications**: Preserves disk state for analysis

&lt;/ListCheck&gt;
&lt;/Notice&gt;

The fundamental syntax of `dd` is straightforward:

```bash
dd if=input_file of=output_file [options]
```

#### Essential Parameters

| Parameter | Description | Example |
|-----------|-------------|---------|
| `if=` | Input file (source) | `if=/dev/sda` |
| `of=` | Output file (destination) | `of=/home/backup.img` |
| `bs=` | Block size | `bs=4M` (4 megabytes) |
| `count=` | Number of blocks to copy | `count=1000` |
| `skip=` | Skip blocks at start of input | `skip=100` |
| `seek=` | Skip blocks at start of output | `seek=50` |
| `conv=` | Conversion options | `conv=sync,noerror` |
| `status=` | Progress display | `status=progress` |
&lt;Notice type=&quot;error&quot; title=&quot;Danger: Data Destruction Risk&quot;&gt;

The dd command can permanently destroy data if used incorrectly. Always double-check your `if=` (input) and `of=` (output) parameters. There is no &quot;undo&quot; operation.

&lt;/Notice&gt;

**Safety checklist:**

&lt;ListCheck&gt;

- **Verify device names** with `lsblk` or `fdisk -l`
- **Unmount target devices** before operations
- **Have recent backups** of important data
- **Test commands** on non-critical systems first
- **Use `--dry-run` when available** (though not supported by dd itself)
&lt;/ListCheck&gt;

## Disk Imaging Operations

### Creating Complete Disk Images

Disk imaging creates a file with an exact copy:

```bash
# Create image of entire disk
sudo dd if=/dev/sda of=/backup/disk-image.img bs=4M status=progress

# Create image of specific partition
sudo dd if=/dev/sda1 of=/backup/partition-image.img bs=4M status=progress

# Compress image during creation (saves space)
sudo dd if=/dev/sda bs=4M status=progress | gzip &gt; /backup/disk-image.img.gz
```

### Advanced Imaging with Error Handling

For disks with bad sectors:

```bash
# Copy with error resilience
sudo dd if=/dev/sda of=/backup/disk-image.img bs=4M conv=sync,noerror status=progress

# Skip bad sectors and continue
sudo ddrescue /dev/sda /backup/disk-image.img /backup/rescue.log
```

### Restoring from Images

```bash
# Restore complete disk from image
sudo dd if=/backup/disk-image.img of=/dev/sda bs=4M status=progress

# Restore from compressed image
gunzip -c /backup/disk-image.img.gz | sudo dd of=/dev/sda bs=4M status=progress

# Restore specific partition
sudo dd if=/backup/partition-image.img of=/dev/sda1 bs=4M status=progress
```

## Disk Cloning Operations

### Direct Disk-to-Disk Cloning

Clone disks directly without creating intermediate files:

```bash
# Clone entire disk
sudo dd if=/dev/sda of=/dev/sdb bs=4M status=progress

# Clone with better performance (larger block size)
sudo dd if=/dev/sda of=/dev/sdb bs=16M status=progress

# Clone with verification
sudo dd if=/dev/sda of=/dev/sdb bs=4M status=progress &amp;&amp; sync
```

### Partition-Level Cloning

```bash
# Clone specific partitions
sudo dd if=/dev/sda1 of=/dev/sdb1 bs=4M status=progress

# Clone partition table only
sudo dd if=/dev/sda of=/dev/sdb bs=512 count=1
```

### Performance Optimization for Cloning

![dd Performance](../../assets/images/25/07/dd-perf.svg)


Optimal block sizes for different scenarios:

| Use Case | Recommended Block Size | Reasoning |
|----------|----------------------|-----------|
| **SSD to SSD** | `bs=16M` | Takes advantage of high sequential speeds |
| **HDD to HDD** | `bs=4M` | Balances speed with system responsiveness |
| **Network storage** | `bs=1M` | Reduces network overhead |
| **USB drives** | `bs=4M` | Good balance for varying USB speeds |
| **System partition** | `bs=4M` | Safe choice for critical data |

## Specialized dd Operations

### Master Boot Record (MBR) Management

```bash
# Backup MBR (first 512 bytes containing partition table)
sudo dd if=/dev/sda of=/backup/mbr-backup.img bs=512 count=1

# Restore MBR
sudo dd if=/backup/mbr-backup.img of=/dev/sda bs=512 count=1

# Backup extended boot record (first 1024 bytes)
sudo dd if=/dev/sda of=/backup/boot-backup.img bs=1024 count=1
```

### Creating Bootable Media

```bash
# Create bootable USB from ISO
sudo dd if=/path/to/linux.iso of=/dev/sdX bs=4M status=progress &amp;&amp; sync

# Create Windows installation USB (requires additional tools)
sudo dd if=/path/to/windows.iso of=/dev/sdX bs=4M status=progress

# Verify bootable media creation
sudo dd if=/dev/sdX bs=4M count=1 | md5sum
sudo dd if=/path/to/linux.iso bs=4M count=1 | md5sum
```

&lt;Notice type=&quot;warning&quot; title=&quot;Data Destruction Warning&quot;&gt;

These operations destroy data permanently. Have backups and target the correct device.

&lt;/Notice&gt;

```bash
# Simple zero-fill wipe
sudo dd if=/dev/zero of=/dev/sda bs=4M status=progress

# Random data wipe (more secure)
sudo dd if=/dev/urandom of=/dev/sda bs=4M status=progress

# DoD 5220.22-M compliant wipe (3-pass)
sudo dd if=/dev/zero of=/dev/sda bs=4M status=progress     # Pass 1: zeros
sudo dd if=/dev/urandom of=/dev/sda bs=4M status=progress  # Pass 2: random
sudo dd if=/dev/zero of=/dev/sda bs=4M status=progress     # Pass 3: zeros

# Wipe specific number of blocks
sudo dd if=/dev/zero of=/dev/sda bs=4M count=1000 status=progress
```

### Creating Test Files and Benchmarking

```bash
# Create file with random data for testing
dd if=/dev/urandom of=/tmp/test-1gb.dat bs=1M count=1024

# Create file with zeros (faster, compressible)
dd if=/dev/zero of=/tmp/test-1gb-zeros.dat bs=1M count=1024

# Create sparse file (doesn&apos;t use actual disk space)
dd if=/dev/zero of=/tmp/sparse.dat bs=1M count=1024 seek=1024

# Benchmark disk write speed
dd if=/dev/zero of=/tmp/benchmark bs=1M count=1024 oflag=direct
```

## Advanced dd Techniques

### Using dd with Pipes and Compression

```bash
# Create compressed image on-the-fly
sudo dd if=/dev/sda bs=4M status=progress | gzip -c &gt; /backup/compressed-image.img.gz

# Decompress and restore simultaneously
gunzip -c /backup/compressed-image.img.gz | sudo dd of=/dev/sda bs=4M status=progress

# Create image with progress and compression
sudo dd if=/dev/sda bs=4M status=progress | pv | gzip &gt; /backup/image.img.gz

# Network transfer with compression
sudo dd if=/dev/sda bs=4M status=progress | gzip | ssh user@remote &apos;cat &gt; /backup/remote-image.img.gz&apos;
```

### Error Recovery and Resilient Copying

```bash
# Continue copying despite read errors
sudo dd if=/dev/sda of=/backup/image.img bs=4M conv=sync,noerror status=progress

# Skip to different position and continue
sudo dd if=/dev/sda of=/backup/image.img bs=4M skip=1000 seek=1000 status=progress

# Use ddrescue for better error handling
sudo ddrescue -d -r3 /dev/sda /backup/image.img /backup/rescue.log
```

### Integration with Home Server Workflows

I use `dd` in automated backup routines on my home server:

```bash
#!/bin/bash
# automated-disk-backup.sh
# Integration with home server backup strategy

DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=&quot;/srv/backups/disk-images&quot;
LOG_FILE=&quot;/var/log/disk-backup.log&quot;

# Create backup directory
mkdir -p &quot;$BACKUP_DIR&quot;

# Backup system drive
echo &quot;$(date): Starting system drive backup&quot; &gt;&gt; &quot;$LOG_FILE&quot;
sudo dd if=/dev/sda of=&quot;$BACKUP_DIR/system-backup-$DATE.img&quot; bs=4M status=progress 2&gt;&gt; &quot;$LOG_FILE&quot;

# Verify backup integrity
if [ $? -eq 0 ]; then
    echo &quot;$(date): Backup completed successfully&quot; &gt;&gt; &quot;$LOG_FILE&quot;

    # Compress older backups
    find &quot;$BACKUP_DIR&quot; -name &quot;*.img&quot; -mtime +1 -exec gzip {} \;

    # Clean up old backups (keep 7 days)
    find &quot;$BACKUP_DIR&quot; -name &quot;*.img.gz&quot; -mtime +7 -delete
else
    echo &quot;$(date): Backup failed!&quot; &gt;&gt; &quot;$LOG_FILE&quot;
    exit 1
fi
```

This integrates well with [LVM setups](https://www.bitdoze.com/add-new-drive-lvm/) and complements other backup strategies.

## Monitoring and Progress Display

### Built-in Progress Monitoring

```bash
# Basic progress display
sudo dd if=/dev/sda of=/backup/image.img bs=4M status=progress

# Send progress signals manually
sudo dd if=/dev/sda of=/backup/image.img bs=4M &amp;
# In another terminal, send USR1 signal for progress
sudo kill -USR1 $(pidof dd)
```

### Enhanced Monitoring with External Tools

```bash
# Using pv (pipe viewer) for better progress display
sudo dd if=/dev/sda bs=4M | pv -s $(sudo blockdev --getsize64 /dev/sda) | dd of=/backup/image.img bs=4M

# Monitor with iostat during operation
iostat -x 1 /dev/sda

# Watch dd process in real-time
watch -n 1 &apos;sudo kill -USR1 $(pidof dd) 2&gt;/dev/null&apos;
```

## Troubleshooting Common Issues

### Input/Output Errors

&lt;Notice type=&quot;error&quot; title=&quot;Common Error: Input/output error&quot;&gt;
This typically indicates bad sectors on the source drive or hardware issues.
&lt;/Notice&gt;

**Solutions:**

&lt;ListCheck&gt;
- **Use conv=noerror,sync**: Continues copying despite errors
- **Check drive health**: Run `smartctl -a /dev/sda` to check SMART status
- **Try ddrescue**: Better error recovery than standard dd
- **Reduce block size**: Smaller blocks may skip over bad sectors
&lt;/ListCheck&gt;

```bash
# Check drive health
sudo smartctl -a /dev/sda

# Use error-tolerant copying
sudo dd if=/dev/sda of=/backup/image.img bs=4M conv=noerror,sync status=progress

# Alternative with ddrescue
sudo ddrescue --force --no-scrape /dev/sda /backup/image.img /backup/rescue.log
```

### Permission and Access Issues

```bash
# Ensure proper permissions
sudo chmod +r /dev/sda  # Read access
sudo chmod +w /dev/sdb  # Write access

# Check if device is mounted
lsblk | grep sda
mount | grep sda

# Unmount if necessary
sudo umount /dev/sda1
```

### Performance Problems

```bash
# Optimize for your hardware
# For SSDs
sudo dd if=/dev/sda of=/dev/sdb bs=16M oflag=direct status=progress

# For HDDs
sudo dd if=/dev/sda of=/dev/sdb bs=4M status=progress

# Check system load during operation
iostat -x 1
```

### Verification and Integrity Checking

```bash
# Verify copied data integrity
sudo md5sum /dev/sda &gt; source.md5
sudo md5sum /backup/image.img &gt; backup.md5
diff source.md5 backup.md5

# Compare disks directly
sudo cmp /dev/sda /dev/sdb

# Verify specific partitions
sudo dd if=/dev/sda1 bs=4M | md5sum
sudo dd if=/backup/partition.img bs=4M | md5sum
```

## Best Practices and Safety Guidelines

### Pre-Operation Checklist

&lt;ListCheck&gt;
- **Identify devices correctly** using `lsblk`, `fdisk -l`, or `blkid`
- **Unmount all partitions** on target devices
- **Check available disk space** for image files
- **Test on non-critical data** first
- **Have backups** of important data
- **Document the procedure** for repeatability
&lt;/ListCheck&gt;

### Performance Best Practices

| Scenario | Recommended Settings | Notes |
|----------|---------------------|-------|
| **Large disk cloning** | `bs=16M`, `oflag=direct` | Bypasses system cache |
| **Network operations** | `bs=1M`, use compression | Reduces bandwidth usage |
| **System backups** | `bs=4M`, `status=progress` | Good balance of speed/safety |
| **Forensic imaging** | `bs=512`, `conv=noerror,sync` | Preserves exact structure |
| **Bootable media** | `bs=4M`, verify with `sync` | Ensures complete write |

### Security Considerations

```bash
# Secure deletion patterns
# Single pass (fast)
sudo dd if=/dev/zero of=/dev/sda bs=4M status=progress

# Three-pass DoD standard
for pass in zero random zero; do
    case $pass in
        zero) source=&quot;/dev/zero&quot; ;;
        random) source=&quot;/dev/urandom&quot; ;;
    esac
    sudo dd if=$source of=/dev/sda bs=4M status=progress
done

# Verify secure deletion
sudo hexdump -C /dev/sda | head -20
```

## Real-World Use Cases

### Home Server Maintenance

For home server administrators managing systems like my N100 setup:

```bash
# Pre-upgrade system backup
sudo dd if=/dev/sda of=/backup/pre-upgrade-$(date +%Y%m%d).img bs=4M status=progress

# Create bootable recovery media
sudo dd if=/path/to/rescue.iso of=/dev/sdX bs=4M status=progress

# Clone drive before hardware migration
sudo dd if=/dev/sda of=/dev/sdb bs=4M status=progress
```

### Development and Testing

```bash
# Create identical test environments
sudo dd if=/dev/source-system of=/dev/test-system bs=4M status=progress

# Generate test data files
dd if=/dev/urandom of=/tmp/test-data.bin bs=1M count=100

# Create disk image for virtual machines
sudo dd if=/dev/sda of=/vm-images/template.img bs=4M status=progress
```

### Disaster Recovery

```bash
# Emergency system recovery
sudo dd if=/backup/system-backup.img of=/dev/sda bs=4M status=progress

# Restore from network backup
ssh backup-server &apos;cat /backup/system.img&apos; | sudo dd of=/dev/sda bs=4M status=progress

# Quick MBR repair
sudo dd if=/backup/mbr-backup.img of=/dev/sda bs=512 count=1
```

## Alternative Tools and When to Use Them

While `dd` is powerful, sometimes other tools are more appropriate:

| Tool | Best For | Advantages |
|------|----------|------------|
| **rsync** | File-level synchronization | Incremental, network-aware |
| **cp** | Simple file copying | Preserves permissions, faster for files |
| **tar** | Archive creation | Compression, selective restore |
| **ddrescue** | Damaged drives | Superior error recovery |
| **clonezilla** | GUI disk cloning | User-friendly, compression |
| **partclone** | Partition-aware cloning | Only copies used sectors |

## Conclusion

The `dd` command is an indispensable tool for Linux system administrators and home server enthusiasts. Its ability to create bit-perfect copies, handle low-level disk operations, and integrate into automated workflows makes it essential for data backup, system migration, and disaster recovery.

While its power comes with responsibility—the potential for data destruction is real—following proper safety procedures and understanding its capabilities will make you a more effective system administrator. Whether you&apos;re managing a [home server](https://www.bitdoze.com/why-need-home-server/) setup or enterprise infrastructure, mastering `dd` is a valuable skill that will serve you well.

Remember: with great power comes great responsibility. Always double-check your commands, test on non-critical data first, and maintain current backups of important information.

&lt;Button text=&quot;Master Your dd Skills&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;</content:encoded><category>linux</category><category>dd</category><category>backup</category></item><item><title>How to Setup Shareable Drive with NFS in Linux - Complete Guide</title><link>https://www.bitdoze.com/setup-nfs-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/setup-nfs-linux/</guid><description>Learn how to configure Network File System (NFS) on Linux to create shareable drives across your network. Perfect for home servers and file sharing.</description><pubDate>Tue, 29 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Network File System (NFS) is one of the most efficient ways to share files across Linux machines on your network. I run it on my N100 mini PC that hosts Jellyfin and handles backups for everything on my network. It lets me access media files, backups, and documents from any Linux machine.

This guide covers the basics through to advanced mounting options.

## What is NFS and Why Use It?

&lt;Notice type=&quot;info&quot; title=&quot;Understanding NFS&quot;&gt;
Network File System (NFS) is a protocol that lets you access files over a network the same way you access local storage. It works well in Linux environments.
&lt;/Notice&gt;

### Key Benefits of NFS

&lt;ListCheck&gt;
- **Native Linux Support**: Built into all major distributions
- **High Performance**: Optimized for Unix-like systems with minimal overhead
- **Scalability**: Handles multiple concurrent connections
- **Flexibility**: Supports various mount options and security configurations
- **Cost-Effective**: Open source with no licensing fees
&lt;/ListCheck&gt;

### NFS vs Other Protocols

| Protocol | Best For | Performance | Security | Complexity |
|----------|----------|-------------|----------|------------|
| NFS | Linux-only environments | High | Moderate | Low |
| SMB/CIFS | Mixed Windows/Linux | Moderate | High | Moderate |
| FTP | File transfers | Low | Variable | Low |
| SSH/SFTP | Secure transfers | Moderate | High | High |

## Prerequisites and Planning

Before starting, make sure you have:

&lt;ListCheck&gt;
- **Two or more Linux machines** (server and clients)
- **Root or sudo access** on all machines
- **Network connectivity** between devices
- **Static IP addresses** (this saves headaches later)
- **Firewall configuration** knowledge
&lt;/ListCheck&gt;

### Network Architecture Overview

![NFS Network](../../assets/images/25/07/nfs-network.svg)


## Step 1: Setting Up the NFS Server

### Install NFS Server Package

Update your system and install the NFS kernel server:

```bash
sudo apt update &amp;&amp; sudo apt install nfs-kernel-server -y
```

For CentOS/RHEL systems:

```bash
sudo yum install nfs-utils -y
# or for newer versions
sudo dnf install nfs-utils -y
```

### Create the Shared Directory

Set up a directory structure for your NFS shares. I organize by purpose:

```bash
# Create main NFS directory
sudo mkdir -p /srv/nfs

# Create specific share directories
sudo mkdir -p /srv/nfs/media
sudo mkdir -p /srv/nfs/backups
sudo mkdir -p /srv/nfs/documents
```

&lt;Notice type=&quot;warning&quot; title=&quot;Directory Location&quot;&gt;
Using `/srv/nfs` follows Linux filesystem standards. Don&apos;t use `/media` or `/mnt` for NFS exports since these are reserved for temporary mounts.
&lt;/Notice&gt;

### Configure Permissions

Set ownership and permissions:

```bash
# Set ownership to nobody:nogroup for security
sudo chown -R nobody:nogroup /srv/nfs

# Set permissions (755 for directories, 644 for files)
sudo chmod -R 755 /srv/nfs
```

### Configure NFS Exports

The `/etc/exports` file defines which directories are shared and with what permissions:

```bash
sudo nano /etc/exports
```

Add your export configurations:

```bash
# Basic configuration for local network
/srv/nfs/media    192.168.1.0/24(rw,sync,no_subtree_check,no_root_squash)
/srv/nfs/backups  192.168.1.0/24(rw,sync,no_subtree_check,root_squash)
/srv/nfs/documents 192.168.1.101(rw,sync,no_subtree_check) 192.168.1.102(ro,sync,no_subtree_check)
```

### Export Options Explained

| Option | Description | Use Case |
|--------|-------------|----------|
| `rw` | Read-write access | Full access for trusted clients |
| `ro` | Read-only access | Sharing read-only content |
| `sync` | Synchronous writes | Data integrity (recommended) |
| `async` | Asynchronous writes | Better performance, less safe |
| `no_subtree_check` | Disable subtree checking | Improved reliability |
| `root_squash` | Map root to anonymous user | Security (default) |
| `no_root_squash` | Allow root access | Administrative access |
| `all_squash` | Map all users to anonymous | Maximum security |

### Apply Export Configuration

After configuring exports, apply the changes:

```bash
# Export the file systems
sudo exportfs -a

# Restart NFS services
sudo systemctl restart nfs-kernel-server
sudo systemctl enable nfs-kernel-server
```

### Configure Firewall

Allow NFS traffic through the firewall:

```bash
# For UFW (Ubuntu)
sudo ufw allow from 192.168.1.0/24 to any port nfs

# For firewalld (CentOS/RHEL)
sudo firewall-cmd --permanent --add-service=nfs
sudo firewall-cmd --reload
```

## Step 2: Setting Up NFS Clients

### Install NFS Client Package

On client machines, install the NFS utilities:

```bash
sudo apt update &amp;&amp; sudo apt install nfs-common -y
```

### Create Mount Points

Create directories where NFS shares will be mounted:

```bash
# Create mount points
sudo mkdir -p /mnt/server-media
sudo mkdir -p /mnt/server-backups
sudo mkdir -p /mnt/server-docs
```

### Test Manual Mounting

Before setting up automatic mounting, test the connection:

```bash
# Mount the media share
sudo mount -t nfs 192.168.1.100:/srv/nfs/media /mnt/server-media

# Verify the mount
df -h | grep nfs
ls -la /mnt/server-media
```

&lt;Notice type=&quot;success&quot; title=&quot;Verification&quot;&gt;
If the NFS mount appears in `df -h` output and you can access files in the mounted directory, the basic setup works.
&lt;/Notice&gt;

### Configure Permanent Mounting

Edit `/etc/fstab` for automatic mounting on boot:

```bash
sudo nano /etc/fstab
```

Add entries for your NFS shares:

```bash
# NFS mounts
192.168.1.100:/srv/nfs/media    /mnt/server-media    nfs    defaults,_netdev    0    0
192.168.1.100:/srv/nfs/backups  /mnt/server-backups  nfs    defaults,_netdev    0    0
192.168.1.100:/srv/nfs/documents /mnt/server-docs    nfs    defaults,_netdev,ro 0    0
```

### Mount Options for fstab

| Option | Description | Benefit |
|--------|-------------|---------|
| `_netdev` | Network device | Waits for network before mounting |
| `soft` | Soft mount | Returns error if server unavailable |
| `hard` | Hard mount | Retries indefinitely (default) |
| `intr` | Interruptible | Allows process interruption |
| `rsize=8192` | Read buffer size | Optimizes read performance |
| `wsize=8192` | Write buffer size | Optimizes write performance |

Test the fstab configuration:

```bash
# Test mounting all fstab entries
sudo mount -a

# Verify mounts
mount | grep nfs
```

## Step 3: Advanced Configuration Options

### Using AutoFS for On-Demand Mounting

AutoFS handles automatic mounting and unmounting, which helps when the NFS server isn&apos;t always available.

Install AutoFS:

```bash
sudo apt install autofs -y
```

Configure the master map:

```bash
sudo nano /etc/auto.master
```

Add this line:

```bash
/mnt/auto /etc/auto.nfs --timeout=60 --ghost
```

Create the AutoFS map file:

```bash
sudo nano /etc/auto.nfs
```

Configure your auto-mounts:

```bash
media     -fstype=nfs4,rw,soft,intr  192.168.1.100:/srv/nfs/media
backups   -fstype=nfs4,rw,soft,intr  192.168.1.100:/srv/nfs/backups
documents -fstype=nfs4,ro,soft,intr  192.168.1.100:/srv/nfs/documents
```

Start and enable AutoFS:

```bash
sudo systemctl restart autofs
sudo systemctl enable autofs
```

### Performance Optimization

For better performance with large files or heavy traffic:

```bash
# Add to /etc/fstab for optimized performance
192.168.1.100:/srv/nfs/media /mnt/server-media nfs rsize=32768,wsize=32768,hard,intr,_netdev 0 0
```

### Security Enhancements

#### Using NFSv4 with Kerberos

For better security in production environments:

```bash
# On server: Install Kerberos
sudo apt install krb5-kdc krb5-admin-server -y

# Configure NFSv4 security
echo &quot;Domain = your-domain.com&quot; | sudo tee -a /etc/idmapd.conf
```

#### Restricting Access by IP Range

Modify `/etc/exports` for tighter security:

```bash
# More restrictive access
/srv/nfs/media 192.168.1.100/32(rw,sync,no_subtree_check) 192.168.1.101/32(ro,sync,no_subtree_check)
```

## Step 4: Monitoring and Maintenance

### Checking NFS Status

Monitor your NFS setup:

```bash
# Check NFS server status
sudo systemctl status nfs-kernel-server

# View active exports
sudo exportfs -v

# Show NFS statistics
nfsstat -s  # Server stats
nfsstat -c  # Client stats

# Monitor active connections
sudo ss -tuln | grep :2049
```

### Log Analysis

NFS logs are typically found in:

```bash
# Check NFS logs
sudo journalctl -u nfs-kernel-server
sudo tail -f /var/log/syslog | grep nfs
```

### Performance Monitoring

Create a simple monitoring script:

```bash
#!/bin/bash
# nfs-monitor.sh

echo &quot;=== NFS Performance Monitor ===&quot;
echo &quot;Date: $(date)&quot;
echo

echo &quot;Active NFS Mounts:&quot;
df -h | grep nfs
echo

echo &quot;NFS Server Statistics:&quot;
nfsstat -s | head -10
echo

echo &quot;Network Connections:&quot;
sudo ss -tuln | grep :2049
```

## Troubleshooting Common Issues

### Connection Problems

&lt;Notice type=&quot;error&quot; title=&quot;Common Error: Connection Refused&quot;&gt;
This usually means firewall issues or NFS services aren&apos;t running.
&lt;/Notice&gt;

**Solution checklist:**

&lt;ListCheck&gt;
- **Verify NFS services are running**: `sudo systemctl status nfs-kernel-server`
- **Check firewall rules**: ports 2049, 111, and related ports should be open
- **Test network connectivity**: `ping` between server and client
- **Verify exports**: `sudo exportfs -v` on server
&lt;/ListCheck&gt;

### Permission Issues

```bash
# Fix ownership issues
sudo chown -R nobody:nogroup /srv/nfs

# Check export permissions
sudo exportfs -v | grep your-share
```

### Performance Issues

For slow NFS performance:

1. **Adjust buffer sizes**:
   ```bash
   # In /etc/fstab
   rsize=32768,wsize=32768
   ```

2. **Use async for non-critical data**:
   ```bash
   # In /etc/exports
   /srv/nfs/temp *(rw,async,no_subtree_check)
   ```

3. **Monitor network utilization**:
   ```bash
   iftop -i eth0
   ```

## Integration with Home Server Setup

NFS works well with other home server technologies. Here&apos;s how I use it:

### Media Server Integration

For Jellyfin and other media servers:

```bash
# Media directory structure
/srv/nfs/media/
├── movies/
├── tv-shows/
├── music/
└── photos/
```

This structure lets the N100 mini PC serve media files efficiently across the network.

### Backup Strategy

Combined with the [LVM setup](https://www.bitdoze.com/add-new-drive-lvm/) from my previous article:

```bash
# Backup scripts can now target NFS shares
rsync -av /home/user/documents/ /mnt/server-backups/user-docs/
```

### Container Integration

When running [Docker containers](https://www.bitdoze.com/docker-containers-home-server/):

```yaml
# docker-compose.yml
version: &apos;3&apos;
services:
  jellyfin:
    image: jellyfin/jellyfin
    volumes:
      - /mnt/server-media:/media:ro
      - ./config:/config
```

## Best Practices and Security Considerations

### Security Best Practices

&lt;ListCheck&gt;
- **Use NFSv4** when possible for better security
- **Set firewall rules** to restrict access to trusted networks
- **Keep both server and clients updated**
- **Check access logs** for suspicious activity
- **Use root_squash** by default unless you need otherwise
&lt;/ListCheck&gt;

### Backup Considerations

&lt;Notice type=&quot;warning&quot; title=&quot;Backup Strategy&quot;&gt;
NFS shares should be part of your backup strategy. Back up the shared data on the server side to prevent data loss.
&lt;/Notice&gt;

### Network Considerations

- **Use wired connections** when possible for better performance
- **Set up network monitoring** as discussed in [server monitoring](https://www.bitdoze.com/sever-monitoring/)
- **Consider network segmentation** for security

## Conclusion

NFS on Linux gives you solid file sharing across your network. I run it on my N100 mini PC alongside Jellyfin, and it handles everything I throw at it.

Start with a basic setup. Get that working first, then add AutoFS and performance tuning as you need them.

For more on home servers and Linux, see my articles on [best mini PCs for home servers](https://www.bitdoze.com/best-mini-pc-home-server/) and [Docker containers for home servers](https://www.bitdoze.com/docker-containers-home-server/).

&lt;Button text=&quot;Start Building Your NFS Setup&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;</content:encoded><category>linux</category><category>home-server</category><category>homelab</category></item><item><title>How to Setup Shareable Drive with Samba in Linux - Complete Guide</title><link>https://www.bitdoze.com/setup-samba-linux/</link><guid isPermaLink="true">https://www.bitdoze.com/setup-samba-linux/</guid><description>Learn how to configure Samba (SMB/CIFS) on Linux to create cross-platform file shares. Perfect for mixed Windows/Linux environments and home servers.</description><pubDate>Tue, 29 Jul 2025 00:00:00 GMT</pubDate><content:encoded>While [NFS works well for Linux-to-Linux file sharing](https://www.bitdoze.com/setup-nfs-linux/), many home servers have a mix of operating systems. When you need to share files between Linux servers and Windows machines, or want broader compatibility, Samba handles it.

I run Samba alongside NFS on my N100 mini PC that hosts Jellyfin and handles backups. This lets all family devices - Windows laptops, Android phones, and Linux machines - access shared media and documents. NFS gives me performance between Linux systems, and Samba covers everything else.

This guide covers setting up Samba on Linux, from basic configuration through advanced security and performance tuning.

## Understanding SMB, CIFS, and Samba

&lt;Notice type=&quot;info&quot; title=&quot;Protocol Evolution&quot;&gt;
SMB (Server Message Block) is the original protocol developed by Microsoft. CIFS (Common Internet File System) was Microsoft&apos;s enhanced version. Modern implementations use SMB2/SMB3 protocols, but the terms are often used interchangeably.
&lt;/Notice&gt;

### What is Samba?

Samba is an open-source implementation of the SMB/CIFS protocol that lets Linux and Unix systems communicate with Windows systems using native Windows networking protocols. It turns your Linux machine into a Windows-compatible file server.

### Key Advantages of Samba

&lt;ListCheck&gt;
- **Cross-Platform Compatibility**: Works with Windows, macOS, Linux, and mobile devices
- **Native Integration**: Shows up as a standard network drive in Windows Explorer
- **Advanced Authentication**: Supports Active Directory and complex user management
- **Printer Sharing**: Can share printers across the network
- **Wide Device Support**: Compatible with smart TVs, media players, and IoT devices
&lt;/ListCheck&gt;

### Samba vs NFS Comparison

| Feature | Samba (SMB/CIFS) | NFS |
|---------|------------------|-----|
| **Cross-Platform** | Excellent (Windows native) | Limited (Linux/Unix focus) |
| **Performance on Linux** | Good | Excellent |
| **Security Options** | Advanced (AD integration) | Basic to moderate |
| **Configuration Complexity** | Moderate | Simple |
| **Mobile Device Support** | Excellent | Limited |
| **Windows Integration** | Native | Requires third-party tools |

## Prerequisites and Planning

Before setting up Samba, make sure you have:

&lt;ListCheck&gt;
- **Linux server** with enough storage space
- **Root or sudo access** on the server
- **Network connectivity** between devices
- **Static IP address** for the server (recommended)
- **Firewall configuration** knowledge
- **User accounts** planned for access control
&lt;/ListCheck&gt;

### Network Architecture for Mixed Environment

![smb Network](../../assets/images/25/07/smb-network.svg)


## Step 1: Installing and Configuring Samba Server

### Install Samba Package

Install Samba on your Linux server:

```bash
# For Ubuntu/Debian
sudo apt update &amp;&amp; sudo apt install samba samba-common-bin -y

# For CentOS/RHEL/Fedora
sudo dnf install samba samba-client samba-common -y
# or for older versions
sudo yum install samba samba-client samba-common -y
```

### Create Samba Users

Samba maintains its own user database separate from system users. Create users for file sharing:

```bash
# Add system user (if doesn&apos;t exist)
sudo useradd -m -s /bin/bash mediauser

# Add user to Samba database
sudo smbpasswd -a mediauser
```

&lt;Notice type=&quot;warning&quot; title=&quot;User Management&quot;&gt;
Samba users must exist as system users first. The `smbpasswd` command sets a separate password for SMB authentication, which can be different from the system password.
&lt;/Notice&gt;

### Create Shared Directories

Organize your shared directories:

```bash
# Create main Samba directory
sudo mkdir -p /srv/samba

# Create specific share directories
sudo mkdir -p /srv/samba/media
sudo mkdir -p /srv/samba/documents
sudo mkdir -p /srv/samba/backups
sudo mkdir -p /srv/samba/public

# Set ownership
sudo chown -R mediauser:mediauser /srv/samba/media
sudo chown -R mediauser:mediauser /srv/samba/documents
sudo chown -R nobody:nogroup /srv/samba/public

# Set permissions
sudo chmod -R 755 /srv/samba
sudo chmod -R 777 /srv/samba/public  # Public share with full access
```

### Configure Samba Settings

The main configuration file is `/etc/samba/smb.conf`. First, backup the original:

```bash
sudo cp /etc/samba/smb.conf /etc/samba/smb.conf.backup
```

Edit the configuration file:

```bash
sudo nano /etc/samba/smb.conf
```

Here&apos;s a solid configuration:

```ini
[global]
# Server identification
workgroup = WORKGROUP
server string = BitDoze Home Server
netbios name = HOMESERVER

# Network settings
interfaces = lo eth0
bind interfaces only = yes
server role = standalone server

# Security settings
security = user
encrypt passwords = yes
map to guest = bad user
guest account = nobody

# Performance optimization
socket options = TCP_NODELAY IPTOS_LOWDELAY SO_RCVBUF=131072 SO_SNDBUF=131072
read raw = yes
write raw = yes
max xmit = 65535
dead time = 15

# Logging
log file = /var/log/samba/%m.log
max log size = 50
log level = 1

# Character set
unix charset = UTF-8
dos charset = CP932

#======================= Share Definitions =======================

[media]
comment = Media Files (Movies, Music, Photos)
path = /srv/samba/media
valid users = mediauser
read only = no
browsable = yes
create mask = 0755
directory mask = 0755
force user = mediauser
force group = mediauser

[documents]
comment = Personal Documents
path = /srv/samba/documents
valid users = mediauser
read only = no
browsable = yes
create mask = 0644
directory mask = 0755

[backups]
comment = Backup Storage
path = /srv/samba/backups
valid users = mediauser
read only = no
browsable = no
create mask = 0600
directory mask = 0700
hide unreadable = yes

[public]
comment = Public Share (Guest Access)
path = /srv/samba/public
public = yes
guest ok = yes
read only = no
browsable = yes
create mask = 0666
directory mask = 0777
force user = nobody
force group = nogroup
```

### Key Configuration Options Explained

| Section | Option | Purpose |
|---------|---------|---------|
| **Global** | `workgroup` | Windows workgroup name |
| **Global** | `security = user` | Require authentication |
| **Global** | `encrypt passwords` | Use encrypted authentication |
| **Share** | `valid users` | Restrict access to specific users |
| **Share** | `create mask` | Default permissions for new files |
| **Share** | `directory mask` | Default permissions for new directories |
| **Share** | `force user/group` | Override file ownership |

### Test Configuration

Verify your Samba configuration:

```bash
# Test configuration syntax
sudo testparm

# Test with detailed output
sudo testparm -v
```

### Start and Enable Samba Services

```bash
# Start Samba services
sudo systemctl start smbd nmbd

# Enable auto-start on boot
sudo systemctl enable smbd nmbd

# Check service status
sudo systemctl status smbd nmbd
```

### Configure Firewall

Allow Samba traffic through the firewall:

```bash
# For UFW (Ubuntu)
sudo ufw allow samba

# For firewalld (CentOS/RHEL)
sudo firewall-cmd --permanent --add-service=samba
sudo firewall-cmd --reload

# Manual port configuration if needed
sudo ufw allow 137/udp  # NetBIOS Name Service
sudo ufw allow 138/udp  # NetBIOS Datagram Service
sudo ufw allow 139/tcp  # NetBIOS Session Service
sudo ufw allow 445/tcp  # SMB over TCP
```

## Step 2: Connecting from Different Operating Systems

### Windows Clients

#### Method 1: Using File Explorer

1. Open File Explorer
2. In the address bar, type: `\\192.168.1.100` (replace with your server IP)
3. Enter your Samba username and password
4. Browse available shares

#### Method 2: Map Network Drive

```powershell
# Using Command Prompt
net use Z: \\192.168.1.100\media /user:mediauser

# Using PowerShell
New-PSDrive -Name &quot;Z&quot; -PSProvider FileSystem -Root &quot;\\192.168.1.100\media&quot; -Credential (Get-Credential)
```

### Linux Clients

#### Install CIFS Utilities

```bash
# Ubuntu/Debian
sudo apt install cifs-utils -y

# CentOS/RHEL
sudo dnf install cifs-utils -y
```

#### Create Credentials File

For security, store credentials in a protected file:

```bash
# Create credentials file
sudo nano /etc/samba/credentials

# Add content:
username=mediauser
password=your_password
domain=WORKGROUP

# Secure the file
sudo chmod 600 /etc/samba/credentials
```

#### Mount Samba Shares

```bash
# Create mount points
sudo mkdir -p /mnt/samba-media
sudo mkdir -p /mnt/samba-docs

# Mount shares
sudo mount -t cifs //192.168.1.100/media /mnt/samba-media -o credentials=/etc/samba/credentials,uid=1000,gid=1000,iocharset=utf8

# Verify mount
df -h | grep cifs
```

#### Permanent Mounting via fstab

Add entries to `/etc/fstab` for automatic mounting:

```bash
sudo nano /etc/fstab
```

Add these lines:

```bash
# Samba shares
//192.168.1.100/media /mnt/samba-media cifs credentials=/etc/samba/credentials,uid=1000,gid=1000,iocharset=utf8,_netdev 0 0
//192.168.1.100/documents /mnt/samba-docs cifs credentials=/etc/samba/credentials,uid=1000,gid=1000,iocharset=utf8,_netdev 0 0
```

### macOS Clients

1. Open Finder
2. Press `Cmd + K` to open &quot;Connect to Server&quot;
3. Enter: `smb://192.168.1.100`
4. Authenticate with your Samba credentials
5. Select shares to mount

### Mobile Devices

For Android and iOS devices, use apps like:
- **ES File Explorer** (Android)
- **FE File Explorer** (iOS/Android)
- **VLC Media Player** (for media streaming)

Configuration typically requires:
- Server IP: `192.168.1.100`
- Share name: `media`
- Username/Password: Your Samba credentials

## Step 3: Advanced Configuration and Security

### User and Group Management

#### Create Multiple Users

```bash
# Add additional users
sudo useradd -m john
sudo smbpasswd -a john

sudo useradd -m mary
sudo smbpasswd -a mary

# Create groups
sudo groupadd sambausers
sudo usermod -a -G sambausers john
sudo usermod -a -G sambausers mary
```

#### Group-Based Shares

Add group-based access to `smb.conf`:

```ini
[family-photos]
comment = Family Photo Collection
path = /srv/samba/photos
valid users = @sambausers
read only = no
browsable = yes
create mask = 0664
directory mask = 0775
force group = sambausers
```

### Enhanced Security Configuration

#### Restrict SMB Protocol Versions

For better security, disable older SMB versions:

```ini
[global]
# Disable SMB1 (security risk)
server min protocol = SMB2
client min protocol = SMB2

# Prefer SMB3 for encryption
server max protocol = SMB3
```

#### Enable SMB Encryption

```ini
[global]
# Global encryption
smb encrypt = required

# Or per-share encryption
[secure-docs]
path = /srv/samba/secure
smb encrypt = required
valid users = mediauser
```

#### Access Control Lists

```bash
# Install ACL support
sudo apt install acl -y

# Set detailed permissions
sudo setfacl -R -m u:john:rwx /srv/samba/media
sudo setfacl -R -m u:mary:r-- /srv/samba/media
sudo setfacl -R -m g:sambausers:rw- /srv/samba/documents
```

### Performance Optimization

#### Tuning for Media Serving

For large file transfers and media streaming:

```ini
[global]
# Buffer sizes
socket options = TCP_NODELAY IPTOS_LOWDELAY SO_RCVBUF=262144 SO_SNDBUF=262144

# Async I/O
aio read size = 16384
aio write size = 16384

# Connection optimization
max connections = 0
deadtime = 15
keepalive = 300

[media]
# Media-specific optimizations
read raw = yes
write raw = yes
strict locking = no
oplocks = yes
level2 oplocks = yes
```

#### Disk I/O Optimization

```bash
# For the media directory, consider mounting with optimized options
sudo mount -o remount,noatime,nobarrier /srv/samba/media
```

## Step 4: Integration with Home Server Setup

### Jellyfin Integration

For media server integration, as I use in my N100 setup:

```bash
# Create dedicated media structure
sudo mkdir -p /srv/samba/media/{movies,tvshows,music,photos}

# Set ownership for Jellyfin user
sudo chown -R jellyfin:jellyfin /srv/samba/media

# Update Samba configuration
```

```ini
[jellyfin-media]
comment = Jellyfin Media Library
path = /srv/samba/media
valid users = mediauser, jellyfin
read only = no
browsable = yes
force user = jellyfin
force group = jellyfin
create mask = 0664
directory mask = 0775
```

### Backup Integration

Combine with the [LVM setup](https://www.bitdoze.com/add-new-drive-lvm/) for comprehensive backup:

```bash
# Backup script using Samba shares
#!/bin/bash
BACKUP_DATE=$(date +%Y%m%d)
rsync -av --delete /home/ /srv/samba/backups/home-backup-$BACKUP_DATE/
```

### Docker Container Access

For [Docker containers](https://www.bitdoze.com/docker-containers-home-server/):

```yaml
# docker-compose.yml
version: &apos;3.8&apos;
services:
  filemanager:
    image: filebrowser/filebrowser
    volumes:
      - /srv/samba:/srv
    ports:
      - &quot;8080:80&quot;
```

## Step 5: Monitoring and Maintenance

### Monitoring Active Connections

```bash
# View active Samba connections
sudo smbstatus

# Detailed connection info
sudo smbstatus -v

# Monitor in real-time
watch -n 2 &apos;sudo smbstatus&apos;
```

### Log Analysis

```bash
# View Samba logs
sudo tail -f /var/log/samba/log.smbd

# Client-specific logs
sudo tail -f /var/log/samba/192.168.1.101.log

# Search for errors
sudo grep -i error /var/log/samba/*.log
```

### Performance Monitoring Script

```bash
#!/bin/bash
# samba-monitor.sh

echo &quot;=== Samba Performance Monitor ===&quot;
echo &quot;Date: $(date)&quot;
echo

echo &quot;Active Connections:&quot;
sudo smbstatus -b
echo

echo &quot;Share Access:&quot;
sudo smbstatus -S
echo

echo &quot;Locked Files:&quot;
sudo smbstatus -L
echo

echo &quot;Disk Usage:&quot;
df -h /srv/samba/*
```

## Troubleshooting Common Issues

### Connection Problems

&lt;Notice type=&quot;error&quot; title=&quot;Common Error: Access Denied&quot;&gt;
This often happens due to user authentication issues or incorrect permissions.
&lt;/Notice&gt;

**Diagnostic steps:**

&lt;ListCheck&gt;
- **Verify user exists in Samba**: `sudo pdbedit -L -v`
- **Check share permissions**: `ls -la /srv/samba/`
- **Test from server**: `smbclient -L localhost -U mediauser`
- **Verify firewall**: `sudo ufw status` or `sudo firewall-cmd --list-all`
&lt;/ListCheck&gt;

```bash
# Reset user password
sudo smbpasswd -x mediauser  # Remove user
sudo smbpasswd -a mediauser  # Re-add user

# Test local connection
smbclient //localhost/media -U mediauser
```

### Performance Issues

#### Slow Transfer Speeds

1. **Check network connectivity**:
   ```bash
   iperf3 -s  # On server
   iperf3 -c 192.168.1.100  # On client
   ```

2. **Optimize buffer sizes**:
   ```ini
   [global]
   socket options = TCP_NODELAY SO_RCVBUF=524288 SO_SNDBUF=524288
   ```

3. **Disable unnecessary features**:
   ```ini
   [media]
   strict locking = no
   oplocks = no
   ```

### Windows 10/11 Compatibility

For modern Windows versions that block SMB1:

```bash
# Ensure SMB2/3 is properly configured
sudo nano /etc/samba/smb.conf
```

```ini
[global]
server min protocol = SMB2
server max protocol = SMB3
```

### Permission Debugging

```bash
# Check effective permissions
sudo smbcacls //localhost/media /path/to/file -U mediauser

# Reset permissions
sudo chmod -R 755 /srv/samba/
sudo chown -R mediauser:mediauser /srv/samba/media
```

## Security Best Practices

### Network Security

&lt;ListCheck&gt;
- **Use strong passwords** for all Samba users
- **Limit access by IP range** using `hosts allow` in share definitions
- **Disable guest access** unless specifically needed
- **Use SMB3 encryption** for sensitive data
- **Keep security updates** current on the server
&lt;/ListCheck&gt;

```ini
[secure-share]
path = /srv/samba/secure
hosts allow = 192.168.1.0/24
hosts deny = ALL
encrypt passwords = yes
smb encrypt = required
```

### Backup and Recovery

&lt;Notice type=&quot;warning&quot; title=&quot;Configuration Backup&quot;&gt;
Always backup your Samba configuration before making changes. The configuration and user database are critical for maintaining access.
&lt;/Notice&gt;

```bash
# Backup Samba configuration
sudo cp /etc/samba/smb.conf /backup/smb.conf.$(date +%Y%m%d)

# Backup user database
sudo cp -r /var/lib/samba /backup/samba-users.$(date +%Y%m%d)

# Create restoration script
echo &apos;#!/bin/bash
sudo cp /backup/smb.conf.YYYYMMDD /etc/samba/smb.conf
sudo cp -r /backup/samba-users.YYYYMMDD/* /var/lib/samba/
sudo systemctl restart smbd nmbd&apos; &gt; restore-samba.sh
```

## Conclusion

Samba on Linux gives you cross-platform file sharing that works well in mixed environments. It requires more configuration than [NFS](https://www.bitdoze.com/setup-nfs-linux/), but the universal compatibility makes it worth it for networks with Windows, macOS, and mobile devices.

On my N100 mini PC, running both NFS and Samba works best - NFS handles Linux-to-Linux transfers with speed, while Samba covers everything else.

For more on home servers, see my articles on [best mini PCs for home servers](https://www.bitdoze.com/best-mini-pc-home-server/) and [server monitoring](https://www.bitdoze.com/sever-monitoring/).

&lt;Button text=&quot;Start Your Samba Setup&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;</content:encoded><category>linux</category><category>home-server</category><category>homelab</category></item><item><title>How to Setup WebDAV Server with Nginx on Linux - Complete Guide</title><link>https://www.bitdoze.com/setup-webdav-nginx/</link><guid isPermaLink="true">https://www.bitdoze.com/setup-webdav-nginx/</guid><description>Learn how to configure WebDAV with Nginx on Linux for secure, web-based file sharing. Perfect for remote access and cross-platform file management.</description><pubDate>Tue, 29 Jul 2025 00:00:00 GMT</pubDate><content:encoded>While [NFS](https://www.bitdoze.com/setup-nfs-linux/) works well for Linux-to-Linux file sharing and [Samba](https://www.bitdoze.com/setup-samba-linux/) handles cross-platform compatibility, sometimes you need web-based file access that works through firewalls and NAT. That&apos;s where WebDAV (Web Distributed Authoring and Versioning) comes in.

I run WebDAV alongside NFS and Samba on my N100 mini PC to get secure remote access to files when I&apos;m away from home. Unlike traditional file sharing protocols, WebDAV works over standard HTTP/HTTPS ports, so you can access your home server files from anywhere with just a web browser or WebDAV client.

This guide covers setting up a WebDAV server using Nginx on Linux, with SSL encryption and security features.

## Understanding WebDAV

&lt;Notice type=&quot;info&quot; title=&quot;What is WebDAV?&quot;&gt;
WebDAV (Web Distributed Authoring and Versioning) is an extension of HTTP that allows clients to perform remote web content authoring operations. It enables users to collaboratively edit and manage files on remote web servers.
&lt;/Notice&gt;

### Key Advantages of WebDAV

&lt;ListCheck&gt;
- **Firewall Friendly**: Uses standard HTTP(S) ports (80/443), works through most firewalls
- **Secure by Default**: Built-in SSL/TLS encryption support
- **Universal Access**: Accessible via web browsers and dedicated clients
- **Cross-Platform**: Supported by Windows, macOS, Linux, and mobile platforms
- **Version Control**: Built-in file locking and versioning support
- **Cloud-Like Experience**: Provides Dropbox-style functionality on your own server
&lt;/ListCheck&gt;

### WebDAV vs Other File Sharing Protocols

| Feature | WebDAV | NFS | Samba/SMB | FTP |
|---------|---------|-----|-----------|-----|
| **Remote Access** | Excellent | Poor | Poor | Good |
| **Security** | Excellent (HTTPS) | Moderate | Good | Poor |
| **Firewall Compatibility** | Excellent | Poor | Poor | Moderate |
| **Web Browser Access** | Yes | No | No | Limited |
| **Mobile Support** | Excellent | Poor | Good | Good |
| **Performance (LAN)** | Moderate | Excellent | Very Good | Good |
| **Setup Complexity** | Moderate | Simple | Moderate | Simple |

## Prerequisites and Planning

Before setting up WebDAV with Nginx, make sure you have:

&lt;ListCheck&gt;
- **Linux server** with root or sudo access
- **Nginx web server** (we&apos;ll install if needed)
- **SSL certificate** (Let&apos;s Encrypt recommended)
- **Domain name** or dynamic DNS (for remote access)
- **Basic understanding** of Nginx configuration
- **Firewall access** to ports 80 and 443
&lt;/ListCheck&gt;

### WebDAV Architecture Overview

![WebDAV Arhitecture](../../assets/images/25/07/webdav-arhitecture.svg)

## Step 1: Installing and Configuring Nginx

### Install Nginx with WebDAV Module

Check if Nginx is already installed and whether it includes the WebDAV module:

```bash
# Check if Nginx is installed
nginx -v

# Check for WebDAV module
nginx -V 2&gt;&amp;1 | grep -o with-http_dav_module
```

If Nginx isn&apos;t installed or lacks the WebDAV module, install it:

```bash
# Ubuntu/Debian - Install Nginx with extra modules
sudo apt update
sudo apt install nginx nginx-extras -y

# CentOS/RHEL - Enable EPEL repository first
sudo dnf install epel-release -y
sudo dnf install nginx nginx-mod-http-dav-ext -y

# Alternative: Compile Nginx with WebDAV support
# (Only if package doesn&apos;t include WebDAV module)
```

&lt;Notice type=&quot;warning&quot; title=&quot;Module Availability&quot;&gt;
The standard Nginx package may not include the WebDAV module. The `nginx-extras` package on Ubuntu/Debian or `nginx-mod-http-dav-ext` on CentOS/RHEL includes this module.
&lt;/Notice&gt;

### Create WebDAV Directory Structure

Set up the directory structure for your WebDAV shares:

```bash
# Create WebDAV root directory
sudo mkdir -p /var/www/webdav

# Create subdirectories for different purposes
sudo mkdir -p /var/www/webdav/documents
sudo mkdir -p /var/www/webdav/media
sudo mkdir -p /var/www/webdav/projects
sudo mkdir -p /var/www/webdav/shared

# Set ownership to www-data (Nginx user)
sudo chown -R www-data:www-data /var/www/webdav

# Set appropriate permissions
sudo chmod -R 755 /var/www/webdav
```

### Configure SSL Certificate

For secure remote access, set up SSL using Let&apos;s Encrypt:

```bash
# Install Certbot
sudo apt install certbot python3-certbot-nginx -y

# Obtain SSL certificate (replace with your domain)
sudo certbot --nginx -d webdav.yourdomain.com

# Verify certificate renewal
sudo certbot renew --dry-run
```
&lt;Notice type=&quot;warning&quot; title=&quot;Domain DNS&quot;&gt;
    Make sure the domain you&apos;re using points to the WebDAV server with an A record.
&lt;/Notice&gt;



## Step 2: Configuring Nginx for WebDAV

### Create WebDAV Configuration

Create a dedicated Nginx configuration file for WebDAV:

```bash
sudo nano /etc/nginx/sites-available/webdav
```

Add the following configuration:

```nginx
server {
    listen 80;
    server_name webdav.yourdomain.com;

    # Redirect HTTP to HTTPS
    return 301 https://$server_name$request_uri;
}

server {
    listen 443 ssl http2;
    server_name webdav.yourdomain.com;

    # SSL Configuration
    ssl_certificate /etc/letsencrypt/live/webdav.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/webdav.yourdomain.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    # Security Headers
    add_header Strict-Transport-Security &quot;max-age=31536000; includeSubDomains&quot; always;
    add_header X-Content-Type-Options nosniff;
    add_header X-Frame-Options DENY;
    add_header X-XSS-Protection &quot;1; mode=block&quot;;

    # WebDAV Configuration
    location / {
        root /var/www/webdav;

        # Enable WebDAV methods
        dav_methods PUT DELETE MKCOL COPY MOVE;
        dav_ext_methods PROPFIND PROPPATCH LOCK UNLOCK;
        dav_access user:rw group:rw all:r;

        # Create directories automatically
        create_full_put_path on;

        # Client body settings for large file uploads
        client_body_temp_path /tmp/nginx_client_temp;
        client_max_body_size 10G;
        client_body_timeout 300s;

        # Authentication
        auth_basic &quot;WebDAV Access&quot;;
        auth_basic_user_file /etc/nginx/.htpasswd;

        # Additional WebDAV headers
        add_header DAV &quot;1, 2&quot; always;

        # Handle WebDAV PROPFIND method
        if ($request_method = PROPFIND) {
            add_header Content-Type &quot;application/xml; charset=utf-8&quot;;
        }

        # Logging
        access_log /var/log/nginx/webdav_access.log;
        error_log /var/log/nginx/webdav_error.log;
    }

    # Disable access to hidden files
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Handle large file uploads
    location /upload {
        root /var/www/webdav;
        dav_methods PUT;
        create_full_put_path on;
        client_max_body_size 50G;
        client_body_timeout 600s;

        auth_basic &quot;WebDAV Upload&quot;;
        auth_basic_user_file /etc/nginx/.htpasswd;
    }
}
```

### Create WebDAV Users

Set up authentication for WebDAV access:

```bash
# Install apache2-utils for htpasswd
sudo apt install apache2-utils -y

# Create password file and first user
sudo htpasswd -c /etc/nginx/.htpasswd webdavuser

# Add additional users
sudo htpasswd /etc/nginx/.htpasswd john
sudo htpasswd /etc/nginx/.htpasswd mary

# Secure the password file
sudo chmod 640 /etc/nginx/.htpasswd
sudo chown root:www-data /etc/nginx/.htpasswd
```

### Advanced User Management

For more sophisticated user management, create different access levels:

```bash
sudo nano /etc/nginx/sites-available/webdav-advanced
```

```nginx
# Advanced WebDAV configuration with multiple user levels
server {
    listen 443 ssl http2;
    server_name webdav.yourdomain.com;

    # ... SSL configuration (same as above) ...

    # Admin access (full WebDAV methods)
    location /admin {
        alias /var/www/webdav/admin;
        dav_methods PUT DELETE MKCOL COPY MOVE;
        dav_ext_methods PROPFIND PROPPATCH LOCK UNLOCK;
        dav_access user:rw group:rw all:r;
        create_full_put_path on;

        auth_basic &quot;Admin WebDAV&quot;;
        auth_basic_user_file /etc/nginx/.htpasswd-admin;
    }

    # User access (limited methods)
    location /users {
        alias /var/www/webdav/users;
        dav_methods PUT DELETE MKCOL;
        dav_ext_methods PROPFIND PROPPATCH;
        dav_access user:rw group:rw all:r;
        create_full_put_path on;

        auth_basic &quot;User WebDAV&quot;;
        auth_basic_user_file /etc/nginx/.htpasswd-users;
    }

    # Public read-only access
    location /public {
        alias /var/www/webdav/public;
        dav_methods off;
        autoindex on;
        autoindex_exact_size off;
        autoindex_localtime on;
    }
}
```

### Enable and Test Configuration

```bash
# Test Nginx configuration
sudo nginx -t

# Create symbolic link to enable site
sudo ln -s /etc/nginx/sites-available/webdav /etc/nginx/sites-enabled/

# Remove default site if needed
sudo rm -f /etc/nginx/sites-enabled/default

# Restart Nginx
sudo systemctl restart nginx
sudo systemctl enable nginx

# Check status
sudo systemctl status nginx
```

## Step 3: Client Configuration for Different Platforms

### Windows Clients

#### Method 1: Native Windows WebDAV

```powershell
# Map WebDAV as network drive
net use W: https://webdav.yourdomain.com /user:webdavuser

# Or using File Explorer:
# 1. Open File Explorer
# 2. Right-click &quot;This PC&quot; → &quot;Map network drive&quot;
# 3. Enter: https://webdav.yourdomain.com
# 4. Check &quot;Connect using different credentials&quot;
# 5. Enter username and password
```

#### Method 2: Third-Party Clients

Popular Windows WebDAV clients:

| Client | Type | Features |
|--------|------|----------|
| **WinSCP** | GUI | SFTP, WebDAV, file sync |
| **NetDrive** | Drive mapping | Multiple protocols |
| **WebDrive** | Commercial | Advanced caching |
| **BitKinex** | GUI | Multi-protocol support |

### Linux Clients

#### Command Line Access

```bash
# Install davfs2 for WebDAV mounting
sudo apt install davfs2 -y

# Create mount point
sudo mkdir /mnt/webdav

# Mount WebDAV share
sudo mount -t davfs https://webdav.yourdomain.com /mnt/webdav

# Create credentials file for automatic mounting
sudo nano /etc/davfs2/secrets

# Add line:
https://webdav.yourdomain.com webdavuser your_password

# Secure credentials file
sudo chmod 600 /etc/davfs2/secrets
```

#### Permanent Mounting

Add to `/etc/fstab` for automatic mounting:

```bash
sudo nano /etc/fstab

# Add line:
https://webdav.yourdomain.com /mnt/webdav davfs _netdev,user,uid=1000,gid=1000 0 0
```

#### GUI Clients

```bash
# Install Nautilus (GNOME)
sudo apt install nautilus -y

# In Nautilus: Other Locations → Connect to Server
# Enter: davs://webdav.yourdomain.com

# Install Dolphin (KDE)
sudo apt install dolphin -y

# In Dolphin: Network → Add Network Folder → WebDAV
```

### macOS Clients

#### Native macOS Support

1. Open **Finder**
2. Press `Cmd + K` (Connect to Server)
3. Enter: `https://webdav.yourdomain.com`
4. Enter credentials when prompted

#### Command Line (macOS)

```bash
# Mount WebDAV share
mkdir ~/webdav
mount_webdav https://webdav.yourdomain.com ~/webdav

# Unmount
umount ~/webdav
```

### Mobile Clients

#### iOS Applications

| App | Features | Price |
|-----|----------|-------|
| **WebDAV Nav+** | Full WebDAV client | Paid |
| **FE File Explorer** | Multi-protocol support | Freemium |
| **Documents by Readdle** | Document management + WebDAV | Free |

#### Android Applications

| App | Features | Price |
|-----|----------|-------|
| **Solid Explorer** | Dual-pane file manager | Paid |
| **Total Commander** | With WebDAV plugin | Free |
| **FX File Explorer** | WebDAV support | Freemium |

## Step 4: Advanced Configuration and Security

### Enhanced Security Configuration

#### IP-Based Access Control

```nginx
# Restrict access by IP range
location / {
    allow 192.168.1.0/24;
    allow 10.0.0.0/8;
    deny all;

    # ... rest of WebDAV configuration ...
}
```

#### Rate Limiting

```nginx
# Add to http block in nginx.conf
http {
    limit_req_zone $binary_remote_addr zone=webdav:10m rate=10r/m;

    # Apply in server block
    location / {
        limit_req zone=webdav burst=5 nodelay;
        # ... WebDAV configuration ...
    }
}
```

#### Two-Factor Authentication Integration

For enhanced security, integrate with external authentication:

```nginx
# Example with auth_request module
location /auth {
    internal;
    proxy_pass http://your-auth-service;
    proxy_pass_request_body off;
    proxy_set_header Content-Length &quot;&quot;;
    proxy_set_header X-Original-URI $request_uri;
}

location / {
    auth_request /auth;
    # ... WebDAV configuration ...
}
```

### Performance Optimization

#### Caching Configuration

```nginx
# Add caching for static content
location ~* \.(jpg|jpeg|png|gif|ico|css|js|pdf)$ {
    expires 1y;
    add_header Cache-Control &quot;public, immutable&quot;;
    access_log off;
}

# Enable gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
```

#### Large File Handling

```nginx
# Optimize for large file uploads
client_max_body_size 50G;
client_body_buffer_size 128k;
client_body_timeout 300s;
client_header_timeout 300s;
keepalive_timeout 300s;
send_timeout 300s;

# Use sendfile for large file downloads
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on;
tcp_nodelay on;
```

### Integration with Home Server Setup

#### Media Server Integration

For integration with your media server setup:

```bash
# Create symbolic links to existing media directories
sudo ln -s /srv/samba/media /var/www/webdav/media
sudo ln -s /srv/nfs/documents /var/www/webdav/documents

# Ensure proper permissions
sudo chown -h www-data:www-data /var/www/webdav/media
sudo chown -h www-data:www-data /var/www/webdav/documents
```

#### Backup Integration

Combine with your [backup strategy](https://www.bitdoze.com/add-new-drive-lvm/):

```bash
#!/bin/bash
# webdav-backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=&quot;/var/www/webdav/backups&quot;

# Create timestamped backup directory
mkdir -p &quot;$BACKUP_DIR/$DATE&quot;

# Backup important configurations
cp /etc/nginx/sites-available/webdav &quot;$BACKUP_DIR/$DATE/&quot;
cp /etc/nginx/.htpasswd &quot;$BACKUP_DIR/$DATE/&quot;

# Backup WebDAV content
rsync -av /var/www/webdav/documents/ &quot;$BACKUP_DIR/$DATE/documents/&quot;
```

#### Docker Integration

For [Docker container](https://www.bitdoze.com/docker-containers-home-server/) access:

```yaml
# docker-compose.yml
version: &apos;3.8&apos;
services:
  file-manager:
    image: filebrowser/filebrowser
    ports:
      - &quot;8080:80&quot;
    volumes:
      - /var/www/webdav:/srv
    environment:
      - FB_BASEURL=/files

  webdav-nginx:
    image: nginx:alpine
    ports:
      - &quot;443:443&quot;
    volumes:
      - /var/www/webdav:/var/www/webdav
      - /etc/nginx/sites-available/webdav:/etc/nginx/conf.d/default.conf
      - /etc/letsencrypt:/etc/letsencrypt
```

## Step 5: Monitoring and Maintenance

### Log Analysis and Monitoring

```bash
# Monitor WebDAV access logs
sudo tail -f /var/log/nginx/webdav_access.log

# Check for errors
sudo tail -f /var/log/nginx/webdav_error.log

# Analyze WebDAV usage
sudo awk &apos;{print $1}&apos; /var/log/nginx/webdav_access.log | sort | uniq -c | sort -nr

# Monitor file upload/download activity
sudo grep -E &quot;(PUT|GET)&quot; /var/log/nginx/webdav_access.log | tail -20
```

### Performance Monitoring Script

```bash
#!/bin/bash
# webdav-monitor.sh

echo &quot;=== WebDAV Server Monitor ===&quot;
echo &quot;Date: $(date)&quot;
echo

echo &quot;Nginx Status:&quot;
sudo systemctl status nginx --no-pager -l
echo

echo &quot;SSL Certificate Status:&quot;
sudo certbot certificates
echo

echo &quot;Active Connections:&quot;
sudo netstat -an | grep :443 | grep ESTABLISHED | wc -l
echo

echo &quot;Disk Usage:&quot;
df -h /var/www/webdav
echo

echo &quot;Recent Access (Last 10 entries):&quot;
sudo tail -10 /var/log/nginx/webdav_access.log
```

### Automated Maintenance

Create a maintenance script for regular tasks:

```bash
#!/bin/bash
# webdav-maintenance.sh

# Rotate logs
sudo logrotate /etc/logrotate.d/nginx

# Clean temporary files
sudo find /tmp/nginx_client_temp -type f -mtime +1 -delete 2&gt;/dev/null

# Check SSL certificate expiration
DAYS_UNTIL_EXPIRY=$(sudo certbot certificates 2&gt;/dev/null | grep &quot;VALID&quot; | head -1 | grep -oP &apos;\d+(?= days)&apos;)
if [ &quot;$DAYS_UNTIL_EXPIRY&quot; -lt 30 ]; then
    echo &quot;SSL certificate expires in $DAYS_UNTIL_EXPIRY days. Consider renewal.&quot;
fi

# Update file permissions
sudo chown -R www-data:www-data /var/www/webdav
sudo find /var/www/webdav -type d -exec chmod 755 {} \;
sudo find /var/www/webdav -type f -exec chmod 644 {} \;
```

## Troubleshooting Common Issues

### WebDAV-Specific Problems

&lt;Notice type=&quot;error&quot; title=&quot;Common Error: 405 Method Not Allowed&quot;&gt;
This usually means the WebDAV module isn&apos;t properly loaded or configured in Nginx.
&lt;/Notice&gt;

**Diagnostic steps:**

&lt;ListCheck&gt;
- **Verify WebDAV module**: `nginx -V 2&gt;&amp;1 | grep dav`
- **Check configuration syntax**: `sudo nginx -t`
- **Review error logs**: `sudo tail -f /var/log/nginx/error.log`
- **Test WebDAV methods**: `curl -X PROPFIND https://webdav.yourdomain.com/`
&lt;/ListCheck&gt;

```bash
# Test WebDAV connectivity
curl -X OPTIONS https://webdav.yourdomain.com/ -u webdavuser:password -v

# Test PROPFIND method
curl -X PROPFIND https://webdav.yourdomain.com/ -u webdavuser:password -H &quot;Depth: 1&quot; -v

# Test file upload
curl -X PUT https://webdav.yourdomain.com/test.txt -u webdavuser:password -d &quot;test content&quot;
```

### Authentication Issues

```bash
# Check password file
sudo cat /etc/nginx/.htpasswd

# Test authentication
curl -X GET https://webdav.yourdomain.com/ -u webdavuser:password -v

# Reset user password
sudo htpasswd /etc/nginx/.htpasswd webdavuser
```

### SSL/TLS Problems

```bash
# Test SSL configuration
openssl s_client -connect webdav.yourdomain.com:443 -servername webdav.yourdomain.com

# Check certificate validity
sudo certbot certificates

# Renew certificate if needed
sudo certbot renew --force-renewal -d webdav.yourdomain.com
```

### Performance Issues

#### Slow Upload/Download Speeds

1. **Increase buffer sizes**:
```nginx
client_body_buffer_size 256k;
large_client_header_buffers 4 256k;
```

2. **Optimize worker processes**:
```nginx
worker_processes auto;
worker_connections 1024;
```

3. **Enable HTTP/2**:
```nginx
listen 443 ssl http2;
```

### File Permission Issues

```bash
# Fix ownership issues
sudo chown -R www-data:www-data /var/www/webdav

# Check SELinux context (CentOS/RHEL)
sudo setsebool -P httpd_can_network_connect 1
sudo semanage fcontext -a -t httpd_exec_t &quot;/var/www/webdav(/.*)?&quot;
sudo restorecon -R /var/www/webdav
```

## Security Best Practices

### Access Control Best Practices

&lt;ListCheck&gt;
- **Use strong passwords** and consider password policies
- **Implement IP whitelisting** for administrative access
- **Enable HTTPS only** - never use plain HTTP for WebDAV
- **Keep security updates** current on Nginx and system packages
- **Monitor access logs** for suspicious activity
- **Use fail2ban** to prevent brute force attacks
&lt;/ListCheck&gt;

```bash
# Install and configure fail2ban
sudo apt install fail2ban -y

# Create WebDAV jail configuration
sudo nano /etc/fail2ban/jail.local
```

```ini
[webdav]
enabled = true
port = 443
filter = webdav
logpath = /var/log/nginx/webdav_error.log
maxretry = 5
bantime = 3600
findtime = 600
```

```bash
# Create WebDAV filter
sudo nano /etc/fail2ban/filter.d/webdav.conf
```

```ini
[Definition]
failregex = ^&lt;HOST&gt; -.*&quot;(GET|POST|PUT|DELETE|PROPFIND|PROPPATCH|MKCOL|COPY|MOVE|LOCK|UNLOCK)&quot; .* (401|403) .*$
ignoreregex =
```

### Backup and Recovery

&lt;Notice type=&quot;warning&quot; title=&quot;Configuration Backup&quot;&gt;
Always backup your WebDAV configuration and user data. SSL certificates and authentication files are critical for maintaining access.
&lt;/Notice&gt;

```bash
#!/bin/bash
# webdav-backup-config.sh

BACKUP_DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_ROOT=&quot;/backup/webdav-config&quot;

mkdir -p &quot;$BACKUP_ROOT/$BACKUP_DATE&quot;

# Backup Nginx configuration
cp /etc/nginx/sites-available/webdav &quot;$BACKUP_ROOT/$BACKUP_DATE/&quot;

# Backup authentication files
cp /etc/nginx/.htpasswd* &quot;$BACKUP_ROOT/$BACKUP_DATE/&quot;

# Backup SSL certificates
cp -r /etc/letsencrypt &quot;$BACKUP_ROOT/$BACKUP_DATE/&quot;

# Create restoration script
cat &gt; &quot;$BACKUP_ROOT/$BACKUP_DATE/restore.sh&quot; &lt;&lt; &apos;EOF&apos;
#!/bin/bash
sudo cp webdav /etc/nginx/sites-available/
sudo cp .htpasswd* /etc/nginx/
sudo cp -r letsencrypt /etc/
sudo systemctl restart nginx
echo &quot;WebDAV configuration restored&quot;
EOF

chmod +x &quot;$BACKUP_ROOT/$BACKUP_DATE/restore.sh&quot;
echo &quot;Backup completed: $BACKUP_ROOT/$BACKUP_DATE&quot;
```

## Use Cases and Integration Examples

### Remote Work Setup

For remote access to your home server files:

```nginx
# Dedicated remote work location
location /work {
    alias /var/www/webdav/work;

    # Enhanced security for work files
    auth_basic &quot;Work Files Access&quot;;
    auth_basic_user_file /etc/nginx/.htpasswd-work;

    # Restrict to specific hours (9 AM to 6 PM UTC)
    access_by_lua_block {
        local hour = tonumber(os.date(&quot;%H&quot;))
        if hour &lt; 9 or hour &gt; 18 then
            ngx.status = 403
            ngx.say(&quot;Access restricted to business hours&quot;)
            ngx.exit(403)
        end
    }

    dav_methods PUT DELETE MKCOL COPY MOVE;
    dav_ext_methods PROPFIND PROPPATCH LOCK UNLOCK;
    create_full_put_path on;
}
```

### Photo Backup from Mobile

Configure automatic photo backup from mobile devices:

```nginx
location /photos {
    alias /var/www/webdav/photos;

    # Allow large image uploads
    client_max_body_size 100M;

    # Organize by date
    try_files $uri $uri/ @create_date_folder;

    dav_methods PUT MKCOL;
    create_full_put_path on;

    auth_basic &quot;Photo Backup&quot;;
    auth_basic_user_file /etc/nginx/.htpasswd-photos;
}

location @create_date_folder {
    # Auto-create date-based folders
    access_by_lua_block {
        local date = os.date(&quot;%Y/%m/%d&quot;)
        ngx.var.uri = &quot;/photos/&quot; .. date .. ngx.var.uri
    }
}
```

### Integration with Home Server Ecosystem

Combine WebDAV with your existing [home server setup](https://www.bitdoze.com/why-need-home-server/):

```bash
# Create unified access point
sudo mkdir -p /var/www/webdav/unified
sudo ln -s /srv/nfs/media /var/www/webdav/unified/media-nfs
sudo ln -s /srv/samba/documents /var/www/webdav/unified/docs-samba
sudo ln -s /var/lib/docker/volumes /var/www/webdav/unified/container-data

# Set permissions
sudo chown -h www-data:www-data /var/www/webdav/unified/*
```

This creates a single WebDAV endpoint that provides access to files from your [NFS](https://www.bitdoze.com/setup-nfs-linux/), [Samba](https://www.bitdoze.com/setup-samba-linux/), and [Docker container](https://www.bitdoze.com/docker-containers-home-server/) setups.

## Conclusion

WebDAV with Nginx gives you secure remote file access that complements your existing file sharing infrastructure. Unlike [NFS](https://www.bitdoze.com/setup-nfs-linux/) which works best on local networks, or [Samba](https://www.bitdoze.com/setup-samba-linux/) for cross-platform local sharing, WebDAV handles remote access through firewalls and NAT.

On my N100 mini PC, WebDAV serves as the bridge for accessing files remotely while keeping local protocols for internal network access. Running all three protocols covers everything from local media streaming to secure remote document access.

Start with basic functionality. Get SSL and authentication working first, then add rate limiting, fail2ban, and custom access controls as you need them.

For more on building a complete home server, see my guides on [server monitoring](https://www.bitdoze.com/sever-monitoring/) and [best mini PCs for home servers](https://www.bitdoze.com/best-mini-pc-home-server/).

&lt;Button text=&quot;Deploy Your WebDAV Server&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;</content:encoded><category>linux</category><category>home-server</category><category>homelab</category></item><item><title>Why You Need a Home Server in 2026: Your Gateway to Digital Independence</title><link>https://www.bitdoze.com/why-need-home-server/</link><guid isPermaLink="true">https://www.bitdoze.com/why-need-home-server/</guid><description>Discover the compelling reasons to set up a home server in 2026. From AI workloads to data privacy, explore benefits, uses, and whether it&apos;s worth running your own server at home.</description><pubDate>Mon, 28 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&quot;Do I need a home server?&quot; It&apos;s a question I hear more often lately, and honestly, I get why. Between privacy headaches, cloud subscriptions that keep creeping up in price, and smart home devices everywhere, running your own server starts to make real sense. This guide covers what you can actually do with a home server and whether it&apos;s worth the effort for your situation.

Here&apos;s the thing: data privacy isn&apos;t getting any better, cloud costs keep rising, and AI tools are now practical to run locally. People keep asking me, &quot;Can I really run a server from home?&quot; For most households with even basic tech comfort, the answer is yes.

Home servers aren&apos;t just for hardcore techies anymore. You can run AI apps, stream media to any device, and keep your data on hardware you actually own.

## What is a Home Server and Why Run a Server at Home?

A home server is a dedicated computer system that runs continuously in your home network, providing various services to connected devices. Unlike traditional computers used for daily tasks, home servers operate 24/7, offering centralized storage, media streaming, automation, and countless other functions.

&lt;Notice type=&quot;info&quot; title=&quot;Personal Experience&quot;&gt;
I&apos;ve been running an Intel N100 mini PC as my home server for over a year now. It handles my Jellyfin media server for family movie nights and automatically backs up all our important files. The peace of mind knowing our memories are safely stored at home, plus the convenience of accessing our media library anywhere in the house, has been absolutely transformative.
&lt;/Notice&gt;

It&apos;s essentially your own cloud. You stop relying on Google, Dropbox, or Netflix for basic services and build something that works for you.

### What Goes Into a Home Server

&lt;ListCheck&gt;
- **CPU**: The processor handling all the work
- **RAM**: Memory for running applications
- **Storage**: HDDs or SSDs where your data lives
- **Network**: Ethernet or Wi-Fi to connect devices
- **Operating System**: Usually Linux, managing everything
&lt;/ListCheck&gt;

Running a server at home used to be a niche hobby. Now it&apos;s practical and accessible. The hardware is cheap, the software is polished, and you don&apos;t need to be a sysadmin to get started.

## Do I Need a Home Server in 2026?

Whether you need a home server comes down to what you do online and how much you care about controlling your own data. If you stream a lot, handle sensitive files, or just want to stop paying for ten different cloud subscriptions, a home server moves from &quot;nice to have&quot; to &quot;probably should get one.&quot;

### Signs You Should Get a Home Server

&lt;Accordion label=&quot;Digital Storage Needs&quot; group=&quot;server-needs&quot; expanded=&quot;true&quot;&gt;
Tired of &quot;storage full&quot; notifications or paying monthly for iCloud, Google Drive, and Dropbox? A home server gives you as much storage as you&apos;re willing to buy in hard drives, and you pay once.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Privacy and Security Concerns&quot; group=&quot;server-needs&quot;&gt;
If you don&apos;t love the idea of Google scanning your documents or photos being used to train AI models, keeping everything local starts looking pretty good.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Smart Home Integration&quot; group=&quot;server-needs&quot;&gt;
Got a bunch of smart bulbs, thermostats, and cameras that barely talk to each other? A home server running Home Assistant can actually make them work together.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Media Consumption Habits&quot; group=&quot;server-needs&quot;&gt;
If you&apos;ve got a media collection and a house full of devices that could play it, a home server turns that collection into your personal Netflix.
&lt;/Accordion&gt;

Here&apos;s who benefits most:

&lt;ListCheck&gt;
- **Families with gadgets everywhere**: Multiple laptops, tablets, phones, and TVs
- **Remote workers**: Need secure file access and backups that actually work
- **Photographers and video people**: Storage space fills up fast
- **Privacy-minded folks**: Done with Big Tech&apos;s data harvesting
- **Smart home people**: Want devices to work locally, not phone home constantly
- **Gamers**: Want private servers for friends
&lt;/ListCheck&gt;

### Do You Actually Need One?

| Scenario | What You Get | Priority |
|---|---|---|
| Multiple cloud subscriptions | One bill instead of many | High |
| Care about privacy | Your data stays yours | High |
| Smart home setup | Local control hub | High |
| Big media collection | Personal streaming | Medium |
| Work from home | Secure file access | Medium |
| Want to learn tech | Real skills practice | Medium |
| Just basic file sharing | Simple network storage | Low |

## Benefits of Home Server: Why It&apos;s Worth It

Home servers do more than store files. They replace multiple paid services and give you control you can&apos;t get from the cloud.

### Privacy and Security

The best reason to run a server at home: your data stays on your hardware. Not Google&apos;s. Not Dropbox&apos;s. Yours.

&lt;Notice type=&quot;success&quot; title=&quot;Privacy Protection&quot;&gt;
With a home server, you&apos;re the only one with access to your data. No company scanning your files, no algorithms analyzing your photos, no third-party access period.
&lt;/Notice&gt;

What you get:

- **No third-party access**: Your files aren&apos;t being scanned or monetized
- **Compliance**: You control how data is handled for GDPR, HIPAA, or other requirements
- **Encryption**: Full-disk encryption keeps data safe even if someone steals the hardware
- **Network isolation**: Sensitive stuff never leaves your house

### The Money You&apos;ll Save

Cloud subscriptions add up fast. Hardware is a one-time purchase.

Cloud storage costs keep going up while mini PCs get cheaper and more capable.

| Service | Annual Cloud Cost | Home Server Option | 5-Year Savings |
|---|---|---|---|
| 2TB storage | $240 | $300 one-time | $900 |
| Streaming (3 services) | $540 | Self-hosted Jellyfin | $2,400 |
| VPN | $120 | Self-hosted WireGuard | $500 |
| Photo backup | $180 | Local Immich | $600 |
| **Total** | **$1,080** | **$300** | **$4,400** |

&lt;ListCheck&gt;
- **Ditch subscriptions**: One hardware purchase replaces multiple monthly bills
- **Cut streaming costs**: Host your own media library
- **Save bandwidth**: Local content doesn&apos;t use your internet cap
- **Low power**: Modern mini PCs use less energy than a light bulb
- **Lasts years**: Good hardware runs for 5+ years
&lt;/ListCheck&gt;

### Speed and Reliability

&lt;Accordion label=&quot;Local Network Speed&quot; group=&quot;performance&quot; expanded=&quot;true&quot;&gt;
Local networks run at gigabit speeds (1000+ Mbps), way faster than most internet connections. 4K video streams instantly. Large files transfer in seconds.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;You Control Uptime&quot; group=&quot;performance&quot;&gt;
Your server stays up as long as your power and internet do. No waiting for Netflix or Google to fix their outages.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Configure It Your Way&quot; group=&quot;performance&quot;&gt;
Want to run weird software? Optimize for specific tasks? Change any setting? On your own server, you can. No vendor restrictions.
&lt;/Accordion&gt;

## Home Server Uses: What to Actually Do With It

Home servers can run all kinds of software. Here&apos;s what people actually use them for.

### Media and Entertainment

&lt;Accordion label=&quot;Personal Media Streaming&quot; group=&quot;media-uses&quot; expanded=&quot;true&quot;&gt;
Jellyfin turns your movie and TV collection into a private Netflix. It organizes everything, downloads cover art and descriptions, and plays on any device. You can even stream remotely when you&apos;re not home.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Music Streaming&quot; group=&quot;media-uses&quot;&gt;
Navidrome is basically self-hosted Spotify. Upload your music, stream it anywhere, keep your files.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Photo Backup&quot; group=&quot;media-uses&quot;&gt;
Immich auto-backs up photos from everyone&apos;s phones, organizes them by face, and creates shared albums. Google Photos without the privacy concerns.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Game Servers&quot; group=&quot;media-uses&quot;&gt;
Host Minecraft, Counter-Strike, or other multiplayer servers for you and your friends. Low latency, full control.
&lt;/Accordion&gt;

### Backups

- **Scheduled backups**: Copy files from all devices automatically
- **Version history**: Recover old versions of documents
- **Remote sync**: Copy critical stuff to another location
- **Ransomware protection**: Air-gapped backups malware can&apos;t touch

### Smart Home Control

&lt;ListCheck&gt;
- **Home Assistant**: One app for all your smart lights, thermostats, cameras
- **Local control**: Commands stay on your network, no internet needed
- **Automations**: &quot;Goodnight mode&quot; turns off lights, locks doors, sets temperature
- **Privacy**: Devices stop phoning home to manufacturer servers
&lt;/ListCheck&gt;

### AI Applications

Running AI at home is actually practical now. You can host:

&lt;ListCheck&gt;
- **LLMs**: ChatGPT-style chatbots via Ollama or LM Studio, completely private
- **Image generation**: Stable Diffusion for AI art, no API fees
- **Voice transcription**: Whisper for converting audio to text locally
- **Security analysis**: Object detection on your camera feeds
- **Smart assistants**: AI-powered Home Assistant integrations
&lt;/ListCheck&gt;

### Work and Productivity

| What You Need | Software | What It Does |
|---|---|---|
| File sync | Nextcloud, Syncthing | Your own Dropbox |
| Documents | OnlyOffice, Collabora | Edit together in real time |
| Projects | Kanboard, Wekan | Task management |
| Passwords | Bitwarden, Vaultwarden | Secure password storage |
| Notes | Joplin Server, TriliumNext | Knowledge base |
| Calendar | Radicale, Baikal | Contact and calendar sync |

### Development Work

**Testing environments**: Separate containers or VMs for each project. No more clutter on your main machine.

**CI/CD pipelines**: Auto-run tests and deployments with GitLab CI or Jenkins.

**Local databases**: PostgreSQL, MySQL, MongoDB running locally for faster dev work.

**Learning**: Safe place to experiment with Docker and Kubernetes before production.

&lt;Notice type=&quot;info&quot; title=&quot;Skill Building&quot;&gt;
Running a server teaches you Linux, networking, Docker, and system admin. These are marketable skills.
&lt;/Notice&gt;

## Can I Run a Server from Home? Technical Requirements

Yes. The technology is there now. Mini PCs are cheap, software is user-friendly, and most internet connections can handle it.

### What Internet You Need

Requirements are lower than you&apos;d think.

| Server Type | Upload Speed | Notes |
|---|---|---|
| File sharing | 10+ Mbps | More users need more bandwidth |
| Media streaming | 25+ Mbps | 4K needs the higher end |
| Web apps | 5+ Mbps | Not much bandwidth needed |
| Game servers | 10+ Mbps | Latency matters more than speed |
| Backups | Flexible | Schedule them for off-hours |

### Hardware Options

Mini PCs have made home servers accessible. No need for rackmount gear or server rooms.

&lt;Accordion label=&quot;Budget Setup ($200-400)&quot; group=&quot;hardware-needs&quot; expanded=&quot;true&quot;&gt;
An Intel N100 mini PC handles files, media, and backups for most households. Best bang for your buck.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Mid-Range ($400-800)&quot; group=&quot;hardware-needs&quot;&gt;
AMD Ryzen systems handle multiple users, AI workloads, and heavier tasks. Still very power-efficient.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Professional ($800+)&quot; group=&quot;hardware-needs&quot;&gt;
Redundant storage, better networking, faster processors. For businesses or serious power users.
&lt;/Accordion&gt;

### Power and Running Costs

Mini PCs use almost no power:

- **Intel N100**: 6-15 watts (less than an LED bulb)
- **AMD Ryzen**: 15-35 watts (laptop charger territory)
- **Yearly electricity**: $15-50 depending on your rates

### Network Setup

You&apos;ll need to configure a few things, but modern routers and software make it manageable.

&lt;ListCheck&gt;
- **Static IP**: Give your server a fixed address on your network
- **Port forwarding**: Access services from outside your home
- **Dynamic DNS**: Connect reliably even when your IP changes
- **VPN**: Secure way to access your network remotely
- **Firewall**: Block unwanted access attempts
&lt;/ListCheck&gt;

## Is a Home Server Worth It? Let&apos;s Run the Numbers

Look at upfront cost versus ongoing savings. With cloud prices climbing, the math keeps getting better for home servers.

### Two Real Examples

**The Family Setup**

Four people, bunch of devices. Currently paying:
- $15/month iCloud
- $45/month for Netflix, Disney+, etc.
- $10/month VPN
- **Total: $70/month**

$800 spent on a home server replaces all of that:
- Local storage instead of cloud
- Jellyfin for media
- Self-hosted VPN
- Automated backups

Break-even in 11 months. After that, you&apos;re saving $840 per year.

**The Remote Worker**

Graphic designer with client files to protect. Needs:
- Reliable backups
- Fast access to big design files
- Secure sharing

Home server delivers:
- Nightly automated backups
- Gigabit speeds for large files locally
- No third-party services for sensitive client data
- Redundant drives so failures don&apos;t mean data loss

The value here isn&apos;t just money. It&apos;s not losing client work.

### Beyond the Money

You&apos;ll break even in 1-2 years typically. But there are other benefits:

- **Skills**: Learn Linux, networking, Docker. Valuable stuff.
- **Family**: Everyone can access photos and videos easily
- **Flexibility**: Add new services whenever you want
- **Independence**: Stop relying on companies that can change terms or prices

### The Downsides

Be honest about the risks:

&lt;Accordion label=&quot;Hardware Can Fail&quot; group=&quot;risks&quot; expanded=&quot;true&quot;&gt;
Any computer can die. Keep backups, consider RAID, maybe have spare parts ready.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;There&apos;s a Learning Curve&quot; group=&quot;risks&quot;&gt;
It&apos;s gotten easier, but you still need to learn some things. Good news: documentation and communities are excellent.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;You Need Power and Internet&quot; group=&quot;risks&quot;&gt;
No power or internet means no access when away from home. A UPS helps with short outages.
&lt;/Accordion&gt;

## Getting Started

### First Containers to Install

Once your hardware is ready, start with these:

&lt;ListCheck&gt;
- **[Jellyfin](https://www.bitdoze.com/docker-containers-home-server/)**: Your Netflix replacement
- **File Browser**: Web file manager
- **Duplicati**: Automated backups
- **Portainer**: Manage your Docker containers
- **Homepage**: Dashboard for all your services
&lt;/ListCheck&gt;

Check out our full guide on [Docker containers for home servers](https://www.bitdoze.com/docker-containers-home-server/) for more options.

### What Hardware to Buy

Our [best mini PC for home server](https://www.bitdoze.com/best-mini-pc-home-server/) guide covers Intel N100 budget options up to high-performance AMD Ryzen systems.

### Keeping It Running

Monitoring matters. Our [server monitoring guide](https://www.bitdoze.com/sever-monitoring/) shows you the tools and techniques.

&lt;Notice type=&quot;info&quot; title=&quot;Monitoring&quot;&gt;
Catch problems before they break things. Monitor disk space, CPU, and memory at minimum.
&lt;/Notice&gt;

### Skills You&apos;ll Build

| What You Learn | How You Use It | Why It Matters |
|---|---|---|
| Linux | Daily server tasks | IT jobs pay well |
| Networking | VPNs, routers | Essential for security work |
| Docker/Kubernetes | Container management | Every company uses this now |
| Automation | Scripts, CI/CD | DevOps careers |
| Security | Firewalls, certs | Relevant everywhere |

## Future-Proofing Your Setup

### What&apos;s Coming

&lt;Accordion label=&quot;Edge Computing&quot; group=&quot;future-tech&quot; expanded=&quot;true&quot;&gt;
Home servers will process more data locally, only sending what&apos;s necessary to the cloud. Less latency, better privacy.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Better AI Hardware&quot; group=&quot;future-tech&quot;&gt;
New chips specifically for AI are coming. Faster voice recognition, image analysis, and automation on your local machine.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Security Improvements&quot; group=&quot;future-tech&quot;&gt;
Hardware security features, better encryption, automatic threat detection built into newer systems.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Decentralized Tech&quot; group=&quot;future-tech&quot;&gt;
Run blockchain nodes, crypto validators, or decentralized apps from home.
&lt;/Accordion&gt;

### Plan for Growth

&lt;ListCheck&gt;
- **Expandable storage**: Can you add more drives later?
- **Network capacity**: Will your router handle more traffic?
- **Power**: Room for more hardware?
- **Cooling**: Heat management if you add components
- **Software**: Docker makes moving services easy
&lt;/ListCheck&gt;

## Legal and ISP Stuff

### Check Your ISP

Most providers don&apos;t mind personal servers but check your agreement. Commercial use or heavy traffic might violate terms.

### Legal Basics

Personal servers are legal. Just remember:
- Business use might need permits depending on where you live
- Handling other people&apos;s data has regulations
- Don&apos;t pirate content on your media server
- Encryption software has export restrictions in some places

### Real Limitations

**Upload speed**: Most home internet has slow uploads. Affects remote access.

**Changing IP**: Use dynamic DNS to connect reliably when your IP changes.

**Power outages**: UPS keeps you running through short ones.

## Security Basics

### Lock Down Your Network

**Firewall**: Use UFW or iptables. Only open ports you actually need.

**VPN for remote access**: Don&apos;t expose admin interfaces to the internet. WireGuard or OpenVPN.

**Updates**: Keep everything current. Security patches matter.

### Protect Your Data

**Backup rule**: 3 copies, 2 different media types, 1 offsite.

**Encryption**: Full-disk encryption (LUKS) for the server. Encrypt sensitive files.

**Access**: SSH keys instead of passwords. Multi-factor auth where possible.

## Conclusion

The real question isn&apos;t whether you can afford a home server. It&apos;s whether you can keep paying rising cloud prices and tolerate companies mining your data.

A home server pays for itself within a year or two. After that, you save money every month. More importantly, you own your data. Your photos, documents, and media aren&apos;t being scanned, analyzed, or monetized by anyone but you.

Start simple. A $200 mini PC running Jellyfin and file backups gets you started. Add services as you learn. The self-hosting community is active and helpful.

Your data deserves better than being someone else&apos;s product.

&lt;Button text=&quot;Start Your Home Server Journey&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

---

*Check out our [mini PC recommendations](https://www.bitdoze.com/best-mini-pc-home-server/) and [Docker container guide](https://www.bitdoze.com/docker-containers-home-server/) to get started.*</content:encoded><category>self-hosting</category><category>home-server</category><category>homelab</category></item><item><title>Podman vs Docker - Which Container Tool Should You Choose in 2026-2027?</title><link>https://www.bitdoze.com/podman-vs-docker/</link><guid isPermaLink="true">https://www.bitdoze.com/podman-vs-docker/</guid><description>Complete comparison of Podman and Docker container engines. Learn about security, performance, and which tool fits your development needs best.</description><pubDate>Sun, 27 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Containers changed how we ship software. You package everything an app needs, and it runs the same on your laptop, a server, or in the cloud. Docker and Podman are the two main tools for this. I&apos;ve used both extensively, and honestly, each has its place.

This guide compares them straight up. No fluff, just what matters.

## What Are Containers?

Containers package your app with everything it needs: code, runtime, libraries, config. It all travels together and runs the same wherever you deploy it.

&lt;Accordion label=&quot;Why Use Containers?&quot; group=&quot;container-basics&quot; expanded=&quot;true&quot;&gt;
- **Consistent**: Same behavior on your laptop and production
- **Fast**: Start in seconds
- **Lightweight**: Share the host OS, no full VM needed
- **Isolated**: Apps don&apos;t step on each other
&lt;/Accordion&gt;

&lt;Accordion label=&quot;How They Work&quot; group=&quot;container-basics&quot;&gt;
Containers use the host OS kernel but keep everything else separate. You get isolation without the overhead of running a full operating system for each app.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Containers vs VMs&quot; group=&quot;container-basics&quot;&gt;
- **VMs**: Full OS per instance, heavy, slow to boot
- **Containers**: Shared OS, lightweight, fast startup
&lt;/Accordion&gt;

## Docker: What It Is

Docker came out in 2013 and made containers mainstream. It wasn&apos;t the first container tech, but it was the one that caught on.

### How Docker Works

Docker runs as a client-server setup:

&lt;ListCheck&gt;
- **Client**: The `docker` commands you type
- **Daemon**: Background service that actually manages containers
- **Registry**: Where images live (Docker Hub)
&lt;/ListCheck&gt;

The daemon runs as root. When you run a command, the client asks the daemon to do the work.

### What Docker Offers

&lt;Tabs&gt;
  &lt;Tab name=&quot;Easy to Learn&quot;&gt;
    Commands are simple and logical. Most people pick up the basics in a day. Docker Desktop gives you a GUI if you prefer that.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Huge Ecosystem&quot;&gt;
    Docker Hub has millions of images. Databases, web servers, dev tools. Pretty much everything is there.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Good Docs&quot;&gt;
    Documentation is solid. Tons of tutorials, Stack Overflow answers, blog posts. When you hit a problem, someone else has already solved it.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Podman: A Different Approach

Red Hat built Podman in 2018 to fix Docker&apos;s security model. The name means &quot;Pod Manager&quot; because it can group containers like Kubernetes does.

### How Podman Works

No daemon. When you run a command, it executes directly and exits when done. More like traditional Unix tools.

&lt;Notice type=&quot;info&quot; title=&quot;Key Difference&quot;&gt;
Docker keeps a background service running constantly. Podman doesn&apos;t. Each command is its own process.
&lt;/Notice&gt;

### Why People Choose Podman

&lt;ListCheck&gt;
- **No root needed**: Run containers as a regular user
- **More secure**: Nothing running in the background to exploit
- **Pod support**: Group containers like in Kubernetes
- **Familiar commands**: `podman` works like `docker`
&lt;/ListCheck&gt;

## Architecture: Daemon vs No Daemon

This is the fundamental difference.

&lt;Accordion label=&quot;Docker: Client-Server&quot; group=&quot;architecture&quot; expanded=&quot;true&quot;&gt;
- **Client**: You type `docker` commands
- **Server**: Daemon runs in background as root
- **How it works**: Client asks daemon to do everything

**Good**: Centralized, handles multiple clients
**Bad**: Always consumes resources, needs root, single point of failure
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Podman: Direct Execution&quot; group=&quot;architecture&quot;&gt;
- **No daemon**: Commands run directly
- **Fork-exec**: Traditional Unix model
- **Result**: Nothing running when you&apos;re not using it

**Good**: Zero idle resources, more secure, no single point of failure
**Bad**: Some Docker features work differently
&lt;/Accordion&gt;

### Resource Usage

| When | Docker | Podman |
|----------|--------|--------|
| Idle | 50-100 MB | 0 MB |
| Running | Daemon + containers | Just containers |
| CPU | Always some usage | Only when active |

## Security

Podman wins here. Here&apos;s why.

### Root Access

&lt;Tabs&gt;
  &lt;Tab name=&quot;Docker&quot;&gt;
    **Default setup**:
    - Daemon runs as root
    - Compromise the daemon, compromise the system

    **Rootless mode exists**:
    - Not the default
    - Extra setup required
    - Some features broken in rootless
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Podman&quot;&gt;
    **Rootless by default**:
    - Run containers as normal user
    - No persistent root process

    **User namespaces**:
    - Container root maps to regular user
    - Works out of the box
  &lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Security&quot;&gt;
Podman is safer by design. No daemon running as root means less attack surface.
&lt;/Notice&gt;

### Real Example

CVE-2019-5736 was a nasty container escape bug. With Docker, you needed root to exploit it. Podman&apos;s rootless containers made the attack much harder to pull off.

## Performance

Both are fast enough for real work.

### Startup Time

&lt;ListCheck&gt;
- **Docker**: Daemon caches info, repeat starts are quick
- **Podman**: No daemon overhead, first start might be slightly slower
- **Bottom line**: Difference is under a second for most apps
&lt;/ListCheck&gt;

### Memory

**Docker**:
- Always using memory for the daemon
- Good for servers with many containers

**Podman**:
- Zero memory when idle
- Better for laptops and edge devices

### Building Images

| Feature | Docker (BuildKit) | Podman (Buildah) |
|---------|-------------------|------------------|
| Speed | Fast | Similar |
| Caching | Great | Good |
| Multi-stage | Yes | Yes |
| Rootless builds | Limited | Full |

## Developer Experience

### Commands

Podman copied Docker&apos;s interface:

```bash
# Same commands
docker run nginx        podman run nginx
docker build -t myapp . podman build -t myapp .
docker ps               podman ps
```

You can alias them: `alias docker=podman`

### Tool Support

&lt;Accordion label=&quot;Docker Tools&quot; group=&quot;tools&quot; expanded=&quot;true&quot;&gt;
Everything supports Docker:
- VS Code extension
- JetBrains integration
- GitHub Actions, GitLab CI
- Every cloud provider

Docker Compose is the standard for multi-container apps. Docker Desktop works on Windows, Mac, and Linux.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Podman Tools&quot; group=&quot;tools&quot;&gt;
Support is growing:
- VS Code extensions exist
- Native OpenShift integration
- Generates Kubernetes YAML
- systemd integration

Podman Compose exists but isn&apos;t as polished as Docker Compose.
&lt;/Accordion&gt;

&lt;Notice type=&quot;warning&quot; title=&quot;Compose&quot;&gt;
Podman Compose works for basic setups. Complex Docker Compose files might need tweaking.
&lt;/Notice&gt;

## Pick Docker If...

&lt;Button text=&quot;Choose Docker When:&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; /&gt;

&lt;ListCheck&gt;
- **Learning containers**: Better docs, more tutorials, easier to start
- **On Windows**: Docker Desktop works well
- **Need the ecosystem**: Tools, integrations, cloud support
- **Team knows it**: Already using Docker everywhere
- **Docker Swarm**: If you&apos;re using Swarm for orchestration
&lt;/ListCheck&gt;

**Companies using Docker**: Netflix, Spotify, Uber - all running massive container workloads.

## Pick Podman If...

&lt;Button text=&quot;Choose Podman When:&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; /&gt;

&lt;ListCheck&gt;
- **Security matters**: Rootless by default, no daemon
- **Linux shop**: Works great on Linux
- **Going to Kubernetes**: Podman pods map to Kubernetes pods
- **Resource conscious**: Zero memory when idle
- **Red Hat stack**: RHEL, OpenShift environments
&lt;/ListCheck&gt;

**Companies using Podman**: CERN, Red Hat, and government agencies evaluating it for security.

## Switching Between Them

### Docker to Podman

Usually easy:
1. Install Podman
2. `alias docker=podman`
3. Test your containers
4. Fix any issues

### Watch Out For

&lt;Notice type=&quot;warning&quot; title=&quot;Migration&quot;&gt;
- Compose files might need tweaks
- Networking differences
- Root vs rootless permissions
- Some tools expect the Docker daemon
&lt;/Notice&gt;

## Benchmarks (2024)

| Test | Docker | Podman | Winner |
|------|--------|--------|--------|
| Start time | 0.8s | 0.7s | Podman |
| Build | 45s | 47s | Tie |
| Idle memory | 95MB | 0MB | Podman |
| CPU overhead | 2% | 0.5% | Podman |

&lt;Notice type=&quot;info&quot; title=&quot;Reality Check&quot;&gt;
Performance differences don&apos;t matter for most apps. Pick based on security and features.
&lt;/Notice&gt;

## What&apos;s Next

&lt;Accordion label=&quot;Kubernetes&quot; group=&quot;future&quot; expanded=&quot;true&quot;&gt;
Both are improving Kubernetes support. Podman can generate Kubernetes YAML directly. The industry is standardizing on Kubernetes anyway.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Security&quot; group=&quot;future&quot;&gt;
Rootless containers, better scanning, supply chain security. This keeps getting more important.
&lt;/Accordion&gt;

&lt;Accordion label=&quot;Edge/IoT&quot; group=&quot;future&quot;&gt;
Containers on smaller devices. Efficiency matters more there.
&lt;/Accordion&gt;

### AI Workloads

Both support GPU containers now. ML training, model serving, all doable.

## My Recommendation

&lt;Tabs&gt;
  &lt;Tab name=&quot;New to Containers&quot;&gt;
    **Start with Docker**. Better learning materials, more help available, Docker Desktop is convenient.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Security Focus&quot;&gt;
    **Use Podman**. Rootless by default is a real advantage. Security teams appreciate it.
  &lt;/Tab&gt;

  &lt;Tab name=&quot;Mixed Environments&quot;&gt;
    **Use both**. Docker for dev on Windows/Mac. Podman for production on Linux. OCI format means containers work everywhere.
  &lt;/Tab&gt;
&lt;/Tabs&gt;

## Common Commands

```bash
# Docker / Podman - same commands
docker run -d nginx              # Background container
docker build -t myapp .          # Build image
docker ps                        # List containers
docker images                    # List images
docker exec -it container bash   # Shell in container
```

## Final Word

Both tools work. Both are actively maintained. Both run OCI containers.

**Docker**: Better for learning, better Windows support, bigger ecosystem.

**Podman**: Better security model, no daemon, rootless by default.

I use Docker on my Mac for development because Docker Desktop is convenient. I use Podman on Linux servers because I don&apos;t want a root daemon running.

Pick what fits your situation. You can always switch later.

&gt; Looking for containerized apps? Check out [toolhunt.net&apos;s self-hosted section](https://toolhunt.net/sh/).</content:encoded><category>self-hosting</category><category>docker</category><category>podman</category><category>containers</category></item><item><title>Bulk URL Checker with uv: Validate Website Accessibility in Python</title><link>https://www.bitdoze.com/uv-url-checker-script/</link><guid isPermaLink="true">https://www.bitdoze.com/uv-url-checker-script/</guid><description>Learn how to build a powerful URL checker script using uv that validates multiple websites concurrently, detects broken links, and generates detailed reports.</description><pubDate>Thu, 24 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Broken links suck. They annoy users, hurt your SEO, and make you look unprofessional. I wrote this script to check hundreds of URLs at once because manually clicking through links is a waste of time.

This tool checks URLs in parallel, categorizes what went wrong, and saves the broken ones to a file. I use it for auditing sites, checking external links, and monitoring APIs. It&apos;s simple but gets the job done.

&lt;Notice type=&quot;info&quot; title=&quot;New to uv?&quot;&gt;
  If you&apos;re new to uv or want to learn how to set up full Python projects, start
  with our comprehensive guide [Getting Started with uv: Setting Up Your Python
  Project in 2025](https://www.bitdoze.com/uv-get-start/) before diving into
  this advanced script.
&lt;/Notice&gt;

## What This Script Does

- **Checks multiple URLs at once**: Uses ThreadPoolExecutor to run requests in parallel
- **Fixes URLs without protocols**: Automatically adds HTTPS if missing
- **Catches different error types**: Timeouts, connection errors, HTTP errors
- **Shows response times**: See how fast each URL responds
- **Reads from files**: Load URLs from a text file
- **Saves broken links**: Writes problematic URLs to a file for review
- **Shows progress**: Real-time counter while checking

## The Script

Save this as `url_checker.py`:

```python
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
#     &quot;requests&quot;,
# ]
# ///

import requests
from urllib.parse import urlparse
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import sys

def check_url(url, timeout=10):
    &quot;&quot;&quot;
    Check if a URL is accessible and return status information.

    Args:
        url (str): The URL to check
        timeout (int): Timeout in seconds (default: 10)

    Returns:
        dict: Contains url, status, error_type, and response_time
    &quot;&quot;&quot;
    # Add http:// if no scheme is provided
    if not url.startswith((&apos;http://&apos;, &apos;https://&apos;)):
        url = &apos;https://&apos; + url

    start_time = time.time()

    try:
        response = requests.get(url, timeout=timeout, allow_redirects=True)
        response_time = time.time() - start_time

        return {
            &apos;url&apos;: url,
            &apos;status&apos;: &apos;OK&apos;,
            &apos;status_code&apos;: response.status_code,
            &apos;error_type&apos;: None,
            &apos;response_time&apos;: round(response_time, 2)
        }

    except requests.exceptions.Timeout:
        return {
            &apos;url&apos;: url,
            &apos;status&apos;: &apos;TIMEOUT&apos;,
            &apos;status_code&apos;: None,
            &apos;error_type&apos;: &apos;Connection timeout&apos;,
            &apos;response_time&apos;: timeout
        }

    except requests.exceptions.ConnectionError as e:
        return {
            &apos;url&apos;: url,
            &apos;status&apos;: &apos;CONNECTION_ERROR&apos;,
            &apos;status_code&apos;: None,
            &apos;error_type&apos;: f&apos;Connection error: {str(e)[:100]}...&apos;,
            &apos;response_time&apos;: time.time() - start_time
        }

    except requests.exceptions.RequestException as e:
        return {
            &apos;url&apos;: url,
            &apos;status&apos;: &apos;ERROR&apos;,
            &apos;status_code&apos;: None,
            &apos;error_type&apos;: f&apos;Request error: {str(e)[:100]}...&apos;,
            &apos;response_time&apos;: time.time() - start_time
        }

def read_urls_from_file(filename):
    &quot;&quot;&quot;Read URLs from a text file, one per line.&quot;&quot;&quot;
    urls = []
    try:
        with open(filename, &apos;r&apos;, encoding=&apos;utf-8&apos;) as file:
            for line in file:
                url = line.strip()
                if url and not url.startswith(&apos;#&apos;):  # Skip empty lines and comments
                    urls.append(url)
        return urls
    except FileNotFoundError:
        print(f&quot;Error: File &apos;{filename}&apos; not found.&quot;)
        return []
    except Exception as e:
        print(f&quot;Error reading file: {e}&quot;)
        return []

def check_urls_batch(urls, timeout=10, max_workers=10):
    &quot;&quot;&quot;
    Check multiple URLs concurrently.

    Args:
        urls (list): List of URLs to check
        timeout (int): Timeout per request in seconds
        max_workers (int): Maximum number of concurrent threads

    Returns:
        list: List of results for each URL
    &quot;&quot;&quot;
    results = []

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        # Submit all tasks
        future_to_url = {executor.submit(check_url, url, timeout): url for url in urls}

        # Process completed tasks
        for i, future in enumerate(as_completed(future_to_url), 1):
            result = future.result()
            results.append(result)

            # Progress indicator
            print(f&quot;Checked {i}/{len(urls)} URLs: {result[&apos;url&apos;]} - {result[&apos;status&apos;]}&quot;)

    return results

def main():
    # Configuration
    filename = input(&quot;Enter the filename containing URLs (or press Enter for &apos;urls.txt&apos;): &quot;).strip()
    if not filename:
        filename = &apos;urls.txt&apos;

    timeout = input(&quot;Enter timeout in seconds (or press Enter for 10): &quot;).strip()
    timeout = int(timeout) if timeout.isdigit() else 10

    print(f&quot;\nReading URLs from &apos;{filename}&apos;...&quot;)
    urls = read_urls_from_file(filename)

    if not urls:
        print(&quot;No URLs found to check.&quot;)
        return

    print(f&quot;Found {len(urls)} URLs to check.&quot;)
    print(f&quot;Using timeout: {timeout} seconds&quot;)
    print(&quot;-&quot; * 50)

    # Check all URLs
    results = check_urls_batch(urls, timeout=timeout)

    # Separate problematic URLs
    problematic_urls = [r for r in results if r[&apos;status&apos;] != &apos;OK&apos;]
    working_urls = [r for r in results if r[&apos;status&apos;] == &apos;OK&apos;]

    print(&quot;\n&quot; + &quot;=&quot; * 50)
    print(&quot;SUMMARY&quot;)
    print(&quot;=&quot; * 50)
    print(f&quot;Total URLs checked: {len(results)}&quot;)
    print(f&quot;Working URLs: {len(working_urls)}&quot;)
    print(f&quot;Problematic URLs: {len(problematic_urls)}&quot;)

    if problematic_urls:
        print(&quot;\n&quot; + &quot;=&quot; * 50)
        print(&quot;PROBLEMATIC URLs&quot;)
        print(&quot;=&quot; * 50)

        # Group by error type
        timeout_urls = [r for r in problematic_urls if r[&apos;status&apos;] == &apos;TIMEOUT&apos;]
        connection_error_urls = [r for r in problematic_urls if r[&apos;status&apos;] == &apos;CONNECTION_ERROR&apos;]
        other_error_urls = [r for r in problematic_urls if r[&apos;status&apos;] == &apos;ERROR&apos;]

        if timeout_urls:
            print(f&quot;\nTIMEOUT ERRORS ({len(timeout_urls)}):&quot;)
            for result in timeout_urls:
                print(f&quot;  - {result[&apos;url&apos;]}&quot;)

        if connection_error_urls:
            print(f&quot;\nCONNECTION ERRORS ({len(connection_error_urls)}):&quot;)
            for result in connection_error_urls:
                print(f&quot;  - {result[&apos;url&apos;]}&quot;)
                print(f&quot;    Error: {result[&apos;error_type&apos;]}&quot;)

        if other_error_urls:
            print(f&quot;\nOTHER ERRORS ({len(other_error_urls)}):&quot;)
            for result in other_error_urls:
                print(f&quot;  - {result[&apos;url&apos;]}&quot;)
                print(f&quot;    Error: {result[&apos;error_type&apos;]}&quot;)

        # Save problematic URLs to file
        with open(&apos;problematic_urls.txt&apos;, &apos;w&apos;, encoding=&apos;utf-8&apos;) as f:
            f.write(&quot;# Problematic URLs found during check\n&quot;)
            f.write(f&quot;# Checked on: {time.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)}\n\n&quot;)

            if timeout_urls:
                f.write(&quot;# TIMEOUT ERRORS\n&quot;)
                for result in timeout_urls:
                    f.write(f&quot;{result[&apos;url&apos;]}\n&quot;)
                f.write(&quot;\n&quot;)

            if connection_error_urls:
                f.write(&quot;# CONNECTION ERRORS\n&quot;)
                for result in connection_error_urls:
                    f.write(f&quot;{result[&apos;url&apos;]}\n&quot;)
                f.write(&quot;\n&quot;)

            if other_error_urls:
                f.write(&quot;# OTHER ERRORS\n&quot;)
                for result in other_error_urls:
                    f.write(f&quot;{result[&apos;url&apos;]}\n&quot;)

        print(f&quot;\nProblematic URLs saved to &apos;problematic_urls.txt&apos;&quot;)

    if working_urls:
        print(f&quot;\nWORKING URLs ({len(working_urls)}):&quot;)
        for result in working_urls:
            print(f&quot;  ✓ {result[&apos;url&apos;]} (Status: {result[&apos;status_code&apos;]}, Time: {result[&apos;response_time&apos;]}s)&quot;)

if __name__ == &quot;__main__&quot;:
    print(&quot;URL Connection Checker&quot;)
    print(&quot;=&quot; * 30)
    main()
```

## How It Works

| Function | What It Does |
|----------|-------------|
| `check_url()` | Checks one URL, handles errors, times the response |
| `read_urls_from_file()` | Loads URLs from a text file, skips comments and empty lines |
| `check_urls_batch()` | Runs multiple checks in parallel using threads |
| `main()` | Handles user input, runs the checks, prints results |

### Error Types

- **OK**: URL works fine
- **TIMEOUT**: Took too long to respond
- **CONNECTION_ERROR**: DNS or connection issues
- **ERROR**: Other request failures

## Running It

With `uv`, you just run the file. No setup needed.

### Quick Start

1. **Create a URL file** (`urls.txt`):
```text
# URLs to check
https://www.google.com
https://www.github.com
https://nonexistent-website-12345.com
bitdoze.com
example.com
```

2. **Run it**:
```bash
uv run url_checker.py
```

3. **Follow the prompts**:
   - Press Enter for `urls.txt` or type a different filename
   - Press Enter for 10 second timeout or enter your own

### Sample Output

```
URL Connection Checker
==============================
Enter the filename containing URLs (or press Enter for &apos;urls.txt&apos;):
Enter timeout in seconds (or press Enter for 10):

Reading URLs from &apos;urls.txt&apos;...
Found 8 URLs to check.
Using timeout: 10 seconds
--------------------------------------------------
Checked 1/8 URLs: https://www.google.com - OK
Checked 2/8 URLs: https://www.github.com - OK
Checked 3/8 URLs: https://www.stackoverflow.com - OK
Checked 4/8 URLs: https://nonexistent-website-12345.com - CONNECTION_ERROR
Checked 5/8 URLs: https://httpstat.us/500 - OK
Checked 6/8 URLs: https://httpstat.us/404 - OK
Checked 7/8 URLs: https://bitdoze.com - OK
Checked 8/8 URLs: https://example.com - OK

==================================================
SUMMARY
==================================================
Total URLs checked: 8
Working URLs: 7
Problematic URLs: 1

==================================================
PROBLEMATIC URLs
==================================================

CONNECTION ERRORS (1):
  - https://nonexistent-website-12345.com
    Error: Connection error: HTTPSConnectionPool(host=&apos;nonexistent-website-12345.com&apos;, port=443)...

Problematic URLs saved to &apos;problematic_urls.txt&apos;

WORKING URLs (7):
  ✓ https://www.google.com (Status: 200, Time: 0.15s)
  ✓ https://www.github.com (Status: 200, Time: 0.23s)
  ✓ https://www.stackoverflow.com (Status: 200, Time: 0.18s)
  ✓ https://httpstat.us/500 (Status: 500, Time: 1.02s)
  ✓ https://httpstat.us/404 (Status: 404, Time: 0.98s)
  ✓ https://bitdoze.com (Status: 200, Time: 0.45s)
  ✓ https://example.com (Status: 200, Time: 0.32s)
```

## Tips and Tricks

### Custom Settings

Run with your own file and timeout:
```bash
uv run url_checker.py
# Enter: my_links.txt
# Enter: 5
```

### Organize URL Files

Use separate files for different checks:

**APIs** (`apis.txt`):
```text
https://api.github.com
https://httpbin.org/get
```

**Social** (`social.txt`):
```text
https://twitter.com/myhandle
https://linkedin.com/in/me
```

### Speed It Up

For lots of URLs, increase workers:
```python
results = check_urls_batch(urls, timeout=timeout, max_workers=20)
```

| URLs | Workers | Approx Time |
|------|---------|-------------|
| 1-50 | 5-10 | 10-30 sec |
| 51-200 | 10-15 | 30-60 sec |
| 200+ | 15-25 | 1-3 min |

## Reading the Output

| Code | Meaning | What To Do |
|------|---------|------------|
| 200 | Works fine | Nothing |
| 301/302 | Redirect | Update if permanent |
| 404 | Not found | Fix or remove link |
| 500 | Server error | Contact site owner |
| Timeout | Too slow | Check connection or increase timeout |
| Connection Error | DNS/network | Check URL spelling |

### Output File

`problematic_urls.txt` gets created with broken links organized by error type.

## Use Cases

- **Site audits**: Check your external links
- **SEO**: Validate backlinks
- **API monitoring**: Check endpoint health
- **Competitor tracking**: Monitor if competitor sites are down

## Customizations

### Add User-Agent

Some sites block scripts without a user agent:
```python
headers = {&apos;User-Agent&apos;: &apos;Mozilla/5.0 ...&apos;}
response = requests.get(url, headers=headers, ...)
```

### Export to CSV

```python
import csv

def save_to_csv(results, filename=&apos;results.csv&apos;):
    with open(filename, &apos;w&apos;, newline=&apos;&apos;) as f:
        writer = csv.DictWriter(f, fieldnames=[&apos;url&apos;, &apos;status&apos;, ...])
        writer.writeheader()
        writer.writerows(results)
```

### CI/CD Integration

```yaml
# GitHub Actions - check URLs weekly
name: URL Check
on:
  schedule:
    - cron: &quot;0 9 * * 1&quot;

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - run: curl -LsSf https://astral.sh/uv/install.sh | sh
      - run: uv run url_checker.py
```

## Wrap Up

That&apos;s it. A simple script that checks URLs fast and tells you what&apos;s broken. No virtual environments to manage, no dependencies to install manually. Just `uv run` and go.

I use this regularly to keep sites clean. Works for me, should work for you too.</content:encoded><category>tools</category><category>uv</category><category>python</category></item><item><title>Best Mini PC For Home Server 2026: Build A Modern Home Lab At Low Costs</title><link>https://www.bitdoze.com/best-mini-pc-home-server/</link><guid isPermaLink="true">https://www.bitdoze.com/best-mini-pc-home-server/</guid><description>Discover the latest mini PCs for home servers in 2026. Compare Intel N150, AMD Ryzen AI, and Apple M4 options for streaming, NAS, AI workloads, and more.</description><pubDate>Wed, 23 Jul 2025 00:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;
import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Mini PCs have gotten ridiculously capable. I run several at home - from cheap N100 boxes to high-end AMD AI systems. They handle media streaming, file storage, AI models, and smart home control. All while using less power than a light bulb and fitting on a shelf.

The 2026 lineup is impressive. You get AI acceleration built-in, 8K video support, and fast networking. Whether you want a basic file server or a machine for running local LLMs, there&apos;s a mini PC that fits.

## What to Do With a Home Server

A home server handles a bunch of useful stuff:

1. **AI stuff**: Run local LLMs, image recognition, smart automation
2. **Media streaming**: Jellyfin, Plex, 4K/8K transcoding
3. **Home automation**: Home Assistant, local IoT control
4. **Private cloud**: Your own Dropbox alternative
5. **Dev/testing**: Docker, VMs, CI/CD pipelines
6. **Network security**: VPN, ad-blocking, monitoring
7. **Backups**: Automated, versioned file backups

&gt; You can check [Best 100+ Docker Containers for Home Server](https://www.bitdoze.com/docker-containers-home-server/) to see what apps can be hosted with the help of docker.

## Why Mini PCs Work Great as Servers

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/0bLrnG0S4mg&quot;
  label=&quot;Best Mini PC Home Server&quot;
/&gt;

Mini PCs hit a sweet spot for home servers:

1. **Low power**: 10-65W vs 200W+ for full desktops
2. **Quiet**: Many are silent or near-silent
3. **Small**: Fit on a shelf, in a cabinet, or behind a monitor
4. **Cheap to run**: Low electricity bills even running 24/7
5. **Capable**: Handle multiple 4K streams, AI workloads, VMs
6. **Cheap to buy**: $200-1000 covers most needs
7. **Fast networking**: 2.5GbE+ and WiFi 7 on newer models

If you&apos;re interested in exploring free open source self-hosted applications, check out [toolhunt.net self hosted section](https://toolhunt.net/sh/).

## Factors to Consider

When selecting a mini PC for your 2026 home server, these factors are crucial:

### CPU Architecture and AI Capabilities

- **AI Processing Units (NPUs)**: Look for integrated AI acceleration with 40+ TOPS for local AI workloads
- **Core Count**: Modern processors offer 12-32 threads for excellent multitasking
- **Architecture**:
  - **x86**: Maximum compatibility with enterprise software and virtualization
  - **ARM**: Superior efficiency for always-on services and specific workloads
- **Boost Frequencies**: 5.0GHz+ boost clocks for demanding single-threaded applications

### Memory and Storage Performance

- **DDR5 Support**: Essential for bandwidth-intensive applications and AI workloads
- **Memory Capacity**: 32GB+ recommended for modern virtualization and AI applications
- **Storage**: PCIe 4.0 NVMe SSDs with 7000MB/s+ speeds for responsive performance
- **Expandability**: Multiple M.2 slots and support for large capacity drives

### Advanced Connectivity

- **Network**: 2.5GbE minimum, with 10GbE options for high-bandwidth applications
- **USB4/Thunderbolt**: Essential for high-speed external storage and eGPU connectivity
- **WiFi 7**: Latest wireless standard for maximum wireless performance
- **Display**: 8K support for future-proofing and advanced monitoring setups

### Thermal Management and Reliability

- **Cooling Systems**: Advanced thermal solutions for sustained performance
- **24/7 Operation**: Components rated for continuous operation
- **Power Efficiency**: Lower operating costs and reduced heat generation
- **Build Quality**: Robust construction for long-term reliability

### Future-Proofing and Ecosystem

- **Software Support**: Long-term OS and driver support commitments
- **Upgrade Path**: Modular designs allowing component upgrades
- **Ecosystem Integration**: Compatibility with existing infrastructure
- **Professional Features**: Enterprise-grade security and management capabilities

## Recommended Mini PCs for Home Server

| Product | CPU | RAM | Storage | Key Features | Ideal For | Power | Price Range |
|---------|-----|-----|---------|--------------|-----------|-------|-------------|
| [GMKtec N150 Mini PC](https://amzn.to/4o2IU3D) | Intel N150 (4C/4T) | 16GB DDR4 | 512GB SSD | 2.5GbE, WiFi 6, Dual 4K | Basic server, Office | 10-25W | $ |
| [Beelink SER9 Pro](https://amzn.to/4o2mGin) | Ryzen AI 9 365 (10C/20T) | 32GB LPDDR5X | 1TB PCIe4.0 | 73 TOPS NPU, Triple 4K@240Hz | AI tasks, Gaming server | 25-65W | $$$ |
| [MINISFORUM AI X1 Pro](https://amzn.to/4kT9rh9) | Ryzen AI 9 HX370 (12C/24T) | 96GB DDR5 | 2TB SSD | 80 TOPS NPU, Quad 8K, OCuLink | Professional AI, VMs | 30-65W | $$$$ |
| [GMKtec EVO-X2](https://amzn.to/3UrrKiE) | Ryzen AI Max+ 395 (16C/32T) | 64GB LPDDR5X | 1TB SSD | 50+ TOPS NPU, Quad 8K, RGB | Extreme performance | 35-75W | $$$$ |
| [GMKtec EVO-X1](https://amzn.to/4m9Uvwd) | Ryzen AI 9 HX-370 (12C/24T) | 64GB DDR5 | 1TB SSD | 50 TOPS NPU, Triple 8K, OCuLink | High-end gaming/AI | 30-65W | $$$ |
| [ASUS NUC 15 Pro Tall](https://amzn.to/450xy7L) | Core Ultra 7 255H (16C/22T) | Up to 96GB DDR5 | Dual storage | WiFi 7, Thunderbolt 4, 4K quad | Enterprise features | 25-65W | $$$$ |
| [ASUS NUC 15 Pro+](https://amzn.to/4lIAxZL) | Core Ultra 5 225H (14C/18T) | Up to 96GB DDR5 | Dual storage | WiFi 7, Ultra-quiet design | Professional use | 20-55W | $$$ |
| [MINISFORUM MS-A2](https://amzn.to/450QYJM) | Ryzen 9 9955HX (16C/32T) | 64GB DDR5 | 1TB SSD | PCIe×16, Dual 10G SFP+, 8K | Workstation/server | 40-120W | $$$$ |
| [Apple Mac Mini M4](https://amzn.to/4562Iuw) | M4 (10C CPU/10C GPU) | 24GB Unified | 256GB SSD | Apple Intelligence, TB4 | macOS ecosystem | 10-35W | $$$ |

Price Range Key:
$ = Budget-friendly ($200-500)
$$ = Mid-range ($500-1000)
$$$ = High-end ($1000-2000)
$$$$ = Premium ($2000+)

### ARM vs X86 Architecture

The choice between ARM and x86 has become more nuanced in 2026:

**ARM Advantages:**
- **Exceptional Efficiency**: 2-3x better performance per watt
- **AI Integration**: Native AI acceleration in modern ARM chips
- **Silent Operation**: Lower heat generation enables fanless designs
- **Long-term Reliability**: Fewer moving parts and lower operating temperatures
- **Ecosystem Growth**: Improved software compatibility, especially for server applications

**x86 Advantages:**
- **Universal Compatibility**: Runs virtually all server software and legacy applications
- **Raw Performance**: Higher peak performance for demanding computational tasks
- **Virtualization**: Superior support for running multiple x86 virtual machines
- **Professional Software**: Full compatibility with enterprise and professional applications
- **Upgrade Flexibility**: More options for memory and storage expansion

**2026 Recommendation**: For most home server users, modern x86 systems with AI acceleration offer the best balance of compatibility and performance. ARM systems like the Apple M4 are excellent for specific use cases where efficiency and ecosystem integration are priorities.

### Entry-Level: Intel N150 Mini PC

#### [GMKtec N150 Mini PC](https://amzn.to/4o2IU3D)

![GMKtec N150 Mini PC](../../assets/images/25/07/GMKtec-N150-Mini-PC.jpg)

The GMKtec N150 Mini PC represents the latest evolution of budget-friendly home servers. Built around Intel&apos;s newest Twin Lake N150 processor, this system offers a 6-10% performance improvement over the popular N100, making it perfect for 2026&apos;s basic server needs. With 16GB DDR4 RAM and a 512GB PCIe SSD, it provides excellent value for essential home server tasks like file sharing, basic media streaming, and home automation hubs.

**Key Features:**
- Intel Twin Lake N150 CPU (4C/4T, up to 3.6GHz) - Latest 2026 upgrade over N100
- 16GB DDR4 RAM for smooth multitasking
- 512GB PCIe 3.0 NVMe SSD with quick boot times
- Intel i226v 2.5GbE Ethernet for high-speed networking
- WiFi 6 and Bluetooth 5.2 for modern wireless connectivity
- Dual 4K@60Hz HDMI outputs
- Upgraded cooling system with reduced noise levels
- Wake-on-LAN and auto power-on features

**Why it&apos;s perfect for home servers:**
- Latest N150 processor offers optimal efficiency for 24/7 operation
- 2.5GbE networking provides bandwidth for multiple users and high-speed file transfers
- Sufficient resources for running multiple Docker containers
- Enhanced cooling system ensures stable operation under continuous load
- Compatible with Linux, Proxmox, and other server operating systems
- Excellent price-to-performance ratio for basic server needs

**Limitations:**
- Not suitable for 4K transcoding or AI workloads
- Limited to basic virtualization scenarios
- Single network port (though 2.5GbE)

&lt;Button link=&quot;https://amzn.to/4o2IU3D&quot; text=&quot;Check IT&quot; /&gt;

### High-Performance AMD AI Mini PCs

#### [Beelink SER9 Pro Mini PC](https://amzn.to/4o2mGin)

![Beelink SER9 Pro Mini PC](../../assets/images/25/07/Beelink-SER9-Pro.jpg)

The Beelink SER9 Pro represents a significant leap in mini PC capability with its AMD Ryzen AI 9 365 processor. This powerhouse combines traditional computing excellence with cutting-edge AI acceleration, featuring a massive 73 TOPS NPU for local AI workloads. It&apos;s designed for users who want to run AI applications, handle multiple 4K streams, and manage complex home automation systems.

**Key Features:**
- AMD Ryzen AI 9 365 (10C/20T, up to 5.0GHz) with 73 TOPS NPU
- 32GB LPDDR5X 8000MHz for exceptional memory performance
- 1TB PCIe 4.0 SSD with lightning-fast storage speeds
- AMD Radeon 880M graphics (12 cores, 2900MHz) for gaming and media
- Triple display support: 4K@240Hz (HDMI + DP + USB4)
- USB4 (40Gbps) and WiFi 6 (2.4Gbps) connectivity
- MSC2.0 cooling system maintaining 32dB noise levels
- AI voice commands and noise-canceling microphone

**Why it excels as a home server:**
- AI NPU enables local language models, image processing, and smart automation
- High core count handles virtualization and multiple simultaneous tasks
- Exceptional graphics performance for media transcoding and streaming
- Ultra-quiet operation suitable for living spaces
- Future-proof connectivity with USB4 and high-speed networking
- Compact design (135x135x44.7mm) fits anywhere

**Best suited for:**
- AI enthusiasts running local LLMs and computer vision
- Advanced media servers with 4K transcoding
- Development environments requiring substantial compute power
- Smart home hubs with complex automation logic

&lt;Button link=&quot;https://amzn.to/4o2mGin&quot; text=&quot;Check IT&quot; /&gt;

#### [MINISFORUM AI X1 Pro](https://amzn.to/4kT9rh9)

![MINISFORUM AI X1 Pro](../../assets/images/25/07/MINISFORUM-AI-X1-Pro.jpg)


The MINISFORUM AI X1 Pro stands at the pinnacle of mini PC performance with its AMD Ryzen AI 9 HX370 processor and massive 96GB DDR5 configuration. This professional-grade system features an 80 TOPS NPU and support for quad 8K displays, making it suitable for the most demanding home server applications.

**Key Features:**
- AMD Ryzen AI 9 HX370 (12C/24T, up to 5.1GHz) with 80 TOPS NPU
- Massive 96GB DDR5 5600MHz memory (expandable to 128GB)
- 2TB total storage across three PCIe 4.0 SSD slots
- AMD Radeon 890M with 40 RDNA 3.5 compute units
- Quad display support: 8K@60Hz via HDMI 2.1, DP 2.0, dual USB4
- OCuLink port for external GPU connectivity
- Dual 2.5GbE LAN with WiFi 7 and Bluetooth 5.4
- Built-in Copilot AI functionality with fingerprint security

**Why it&apos;s the ultimate home server:**
- Unmatched AI processing power for advanced machine learning tasks
- Enormous memory capacity for large-scale virtualization
- Professional-grade storage performance up to 7000MB/s
- OCuLink support enables desktop-grade GPU acceleration
- Enterprise networking with dual 2.5GbE and WiFi 7
- Advanced cooling maintains 45dB maximum noise
- Perfect for AI researchers and power users

**Ideal applications:**
- Running multiple large language models simultaneously
- Professional content creation and rendering
- High-density virtualization environments
- AI development and model training

&lt;Button link=&quot;https://amzn.to/4kT9rh9&quot; text=&quot;Check IT&quot; /&gt;

#### [GMKtec EVO-X2 AI Mini PC](https://amzn.to/3UrrKiE)

![GMKtec EVO-X2 AI Mini PC](../../assets/images/25/07/GMKtec-EVO-X2.jpg)

The GMKtec EVO-X2 represents the absolute pinnacle of mini PC performance with the AMD Ryzen AI Max+ 395 processor. This beast features 16 Zen 5 cores, 40 RDNA 3.5 GPU compute units, and over 50 AI TOPS, making it more powerful than many desktop workstations.

**Key Features:**
- AMD Ryzen AI Max+ 395 (16C/32T, up to 5.1GHz) - Most powerful x86 APU
- 64GB LPDDR5X 8000MHz in 8-channel configuration
- 1TB PCIe 4.0 SSD with expansion options
- AMD Radeon 8060S iGPU (40 CUs, 2.9GHz) - RTX 4060/4070 laptop equivalent
- Quad 8K display support via HDMI 2.1, DP 1.4, dual USB4
- 2.5GbE + WiFi 7 + Bluetooth 5.4 connectivity
- Triple cooling fans with RGB lighting and 35dB quiet mode
- Runs large language models like Deepseek 32B locally

**Why it&apos;s extraordinary:**
- Unmatched integrated graphics performance for a mini PC
- Can run demanding AI models completely locally
- Gaming performance rivaling dedicated graphics cards
- Unique 8-channel LPDDR5X memory architecture
- Triple fan cooling with customizable RGB lighting
- Perfect for AI enthusiasts and content creators

**Ultimate applications:**
- Local AI model hosting and inference
- High-end gaming server hosting
- Professional video editing and rendering
- Advanced computer vision applications

&lt;Button link=&quot;https://amzn.to/3UrrKiE&quot; text=&quot;Check IT&quot; /&gt;

#### [GMKtec EVO-X1 AI Mini PC](https://amzn.to/4m9Uvwd)


![GMKtec EVO-X1 AI Mini PC](../../assets/images/25/07/GMKtec-EVO-X1.jpg)


The GMKtec EVO-X1 offers high-end performance with the AMD Ryzen AI 9 HX-370 processor, featuring excellent AI capabilities and gaming performance in a more accessible package than the EVO-X2.

**Key Features:**
- AMD Ryzen AI 9 HX-370 (12C/24T, up to 5.1GHz) with 50 TOPS NPU
- 64GB DDR5 with quad-channel LPDDR5X support
- 1TB PCIe 4.0 SSD storage
- AMD Radeon 890M with latest RDNA 3.5 architecture
- Triple 8K display support via HDMI 2.1, DP 2.1, USB4
- OCuLink port for external GPU connectivity
- Dual Intel i226V 2.5GbE + WiFi 6 + Bluetooth 5.2
- Advanced cooling with quiet operation

**Why it&apos;s excellent for servers:**
- Strong AI processing capabilities for smart home automation
- OCuLink support enables desktop-grade GPU performance
- Dual 2.5GbE networking for high-bandwidth applications
- Excellent price-to-performance ratio
- Suitable for both gaming and professional workloads

&lt;Button link=&quot;https://amzn.to/4m9Uvwd&quot; text=&quot;Check IT&quot; /&gt;

### Professional Intel Solutions

#### [ASUS NUC 15 Pro Tall](https://amzn.to/450xy7L)

![ASUS NUC 15 Pro Tall](../../assets/images/25/07/ASUS-NUC-15-Pro-Tall.jpg)


The ASUS NUC 15 Pro Tall brings Intel&apos;s latest Series 2 Core Ultra 7 255H processor to the mini PC market with a focus on AI optimization and professional reliability. This system represents ASUS&apos;s commitment to creating enterprise-grade mini PCs suitable for demanding home server applications.

**Key Features:**
- Intel Series 2 Core Ultra 7 255H with integrated Intel Arc 140T Graphics
- Supports up to 96GB DDR5 RAM for extensive multitasking
- Dual storage bays for flexible configuration
- Intel WiFi 7 with 2.4x faster transfer rates and proximity sensing
- Thunderbolt 4 connectivity for high-speed peripherals
- Quad 4K display support via HDMI 2.1 and Thunderbolt 4
- Tool-less 2.0 chassis for easy upgrades
- MIL-STD-810H certification for durability

**Why it&apos;s ideal for home servers:**
- AI-optimized processor for intelligent automation tasks
- Massive RAM support for virtualization environments
- Enterprise-grade reliability with rigorous testing
- Advanced WiFi 7 for future-proof wireless connectivity
- Professional design suitable for office environments
- Easy maintenance with tool-less access

**Perfect for:**
- Professional home offices requiring reliable computing
- Advanced virtualization with multiple operating systems
- AI development and testing environments
- High-availability home server applications

&lt;Button link=&quot;https://amzn.to/450xy7L&quot; text=&quot;Check IT&quot; /&gt;

#### [ASUS NUC 15 Pro+](https://amzn.to/4lIAxZL)


![ASUS NUC 15 Pro+](../../assets/images/25/07/ASUS-NUC-15-Pro.jpg)



The ASUS NUC 15 Pro+ offers a more affordable entry point into Intel&apos;s latest processor technology while maintaining professional-grade features and build quality.

**Key Features:**
- Intel Series 2 Core Ultra 5 225H with Intel Arc 130T Graphics
- Up to 96GB DDR5 RAM support
- Dual storage configuration options
- Ultra-quiet cooling with 1.2x noise reduction
- WiFi 7 and Bluetooth 5.4 connectivity
- Quad 4K display support with sync power-off feature
- Premium aluminum chassis with tool-less upgrades
- VESA mount included for flexible installation

**Why it&apos;s excellent:**
- Quieter operation ideal for noise-sensitive environments
- Premium build quality with elegant design
- Advanced display management features
- Professional reliability at a more accessible price point

&lt;Button link=&quot;https://amzn.to/4lIAxZL&quot; text=&quot;Check IT&quot; /&gt;

### Extreme Performance Solutions

#### [MINISFORUM MS-A2](https://amzn.to/450QYJM)

![MINISFORUM MS-A2](../../assets/images/25/07/MINISFORUM-MS-A2.jpg)


The MINISFORUM MS-A2 pushes the boundaries of what&apos;s possible in a mini PC form factor with the AMD Ryzen 9 9955HX processor and professional-grade features typically found in workstation-class systems.

**Key Features:**
- AMD Ryzen 9 9955HX (16C/32T, up to 5.4GHz) with Zen5 architecture
- 64GB DDR5 memory with support up to 96GB
- Triple M.2 SSD slots (2280/22110/U.2) supporting up to 23TB total
- PCIe×16 slot for professional graphics cards or high-performance networking
- Dual SFP+ 10G and dual 2.5G LAN ports
- Triple display support with 8K@60Hz capability
- Advanced cooling with three copper heat pipes and turbo fans
- Supports RAID 0/1 configurations

**Why it&apos;s extraordinary:**
- Workstation-class performance in mini PC form factor
- Professional networking with 10G SFP+ ports
- Expandable with full-height professional graphics cards
- Massive storage capacity and flexibility
- Suitable for AI inference and GPU-accelerated computing
- Perfect for high-performance computing workloads

**Ultimate applications:**
- Professional content creation and rendering
- High-performance computing and scientific applications
- Advanced networking and firewall applications
- AI model training and inference
- Professional video editing and streaming

&lt;Button link=&quot;https://amzn.to/450QYJM&quot; text=&quot;Check IT&quot; /&gt;

### ARM Excellence

#### [Apple Mac Mini M4](https://amzn.to/4562Iuw)

![Apple Mac Mini M4](../../assets/images/25/07/Apple-Mac-Mini-M4.jpg)


The 2024 Apple Mac Mini with M4 chip represents the latest evolution in ARM-based computing, bringing Apple Intelligence and exceptional efficiency to the mini PC market. This system offers a unique proposition for users invested in the Apple ecosystem.

**Key Features:**
- Apple M4 chip with 10-core CPU and 10-core GPU
- 24GB unified memory architecture
- 256GB SSD storage (configurable to higher capacities)
- Built for Apple Intelligence with on-device AI processing
- Thunderbolt 4 connectivity for high-speed peripherals
- Gigabit Ethernet (10Gb option available)
- Exceptional energy efficiency with silent operation
- Seamless integration with iPhone and iPad

**Why it&apos;s perfect for Apple users:**
- Unmatched performance-per-watt efficiency
- Native Apple Intelligence support for AI applications
- Silent operation ideal for always-on server tasks
- Excellent integration with Apple ecosystem devices
- Professional-grade media encoding and processing
- Long-term software support from Apple

**Ideal for:**
- Apple ecosystem integration and device management
- Energy-efficient 24/7 server operation
- Media transcoding and streaming within Apple ecosystem
- Development work targeting Apple platforms
- Users prioritizing efficiency and quiet operation

&lt;Button link=&quot;https://amzn.to/4562Iuw&quot; text=&quot;Check IT&quot; /&gt;

## Best Use Cases for 2026

Modern mini PCs in 2026 enable sophisticated home server applications that were previously impossible in such compact form factors:

| Mini PC Category | Ideal Applications | Performance Level |
|------------------|-------------------|-------------------|
| Entry-Level (N150) | Basic NAS, Media streaming, Home automation | 1-2 4K streams, Light containers |
| Mid-Range AI | AI inference, Advanced media, Smart home | Multiple 4K streams, Local LLMs |
| High-End Professional | Virtualization, Development, Content creation | 8K transcoding, Heavy AI workloads |
| ARM Efficiency (M4) | Always-on services, Apple ecosystem, Development | Efficient transcoding, iOS/macOS dev |

### Entry-Level Applications (N150-based systems)

1. **Modern Media Server:**
   - Host Jellyfin or Plex with hardware-accelerated transcoding
   - Stream 1-2 simultaneous 4K streams efficiently
   - Support for modern codecs including AV1

2. **Smart Home Hub:**
   - Run Home Assistant with AI voice control
   - Local processing for smart cameras and sensors
   - Integration with major smart home ecosystems

3. **Network Services:**
   - Pi-hole DNS filtering with advanced analytics
   - Local VPN server with modern encryption
   - Network monitoring with real-time alerting

4. **Development Environment:**
   - Lightweight containerization with Docker
   - Git repositories and basic CI/CD
   - Testing environments for web applications

### AI-Powered Applications (Ryzen AI systems)

1. **Local AI Services:**
   - Run Llama 2/3 models for personal AI assistant
   - Image recognition for security cameras
   - Voice processing and natural language understanding
   - AI-powered home automation decisions

2. **Advanced Media Processing:**
   - Real-time AI upscaling for older content
   - Intelligent content analysis and tagging
   - Multiple simultaneous 4K transcoding streams
   - AI-enhanced audio processing

3. **Professional Workloads:**
   - Local model training for specific use cases
   - Computer vision applications
   - Real-time data analysis and processing
   - AI-assisted content creation tools

### Professional Applications (High-end systems)

1. **Enterprise Virtualization:**
   - Multiple Windows/Linux VMs simultaneously
   - Development and testing environments
   - Isolated security testing environments
   - Legacy application support

2. **Content Creation Pipeline:**
   - 8K video editing and rendering
   - Professional color grading workflows
   - 3D rendering and animation
   - Live streaming production

3. **Advanced Networking:**
   - Software-defined networking (SDN)
   - High-performance firewalls and routing
   - Network function virtualization (NFV)
   - Advanced monitoring and analytics

### ARM Ecosystem Applications (Apple M4)

1. **Apple Intelligence Integration:**
   - Siri server for home automation
   - Intelligent photo and video processing
   - Cross-device synchronization and backup
   - iOS/macOS app development and testing

2. **Efficient Always-On Services:**
   - 24/7 monitoring with minimal power consumption
   - Background AI processing tasks
   - Automated backup and sync services
   - Energy-efficient media transcoding

## Summary

Mini PCs in 2026 are genuinely impressive. You can run AI models locally, stream 8K video, host multiple VMs, and do it all on a machine that uses less power than a desk lamp.

**What to Get:**

- **Budget ($200-400)**: GMKtec N150 for basic servers
- **AI focus ($800-1200)**: Beelink SER9 Pro with 73 TOPS NPU
- **Max power ($1500+)**: GMKtec EVO-X2 for demanding workloads
- **Mac household**: Mac Mini M4 for Apple ecosystem
- **Workstation needs**: MINISFORUM MS-A2 with 10GbE

The gap between mini PCs and full servers keeps shrinking. For home use, mini PCs hit the sweet spot of power, efficiency, and space.

## Quick FAQ

**Can they run AI locally?**
Yes. Newer models with 40+ TOPS NPUs handle LLMs fine.

**How much RAM?**
16GB for basics, 32GB+ for AI/VMs, 64GB if you&apos;re serious.

**Upgradeable?**
RAM and storage usually. Some allow more customization.

**x86 or ARM?**
x86 for compatibility, ARM (Mac M4) for efficiency.

**Network priority?**
2.5GbE minimum. WiFi 7 if you need wireless. USB4 for expansion.</content:encoded><category>self-hosting</category><category>homelab</category></item><item><title>How to Add Users to a Docker Container</title><link>https://www.bitdoze.com/add-users-to-docker-container/</link><guid isPermaLink="true">https://www.bitdoze.com/add-users-to-docker-container/</guid><description>Master Docker user management by learning how to create users, assign permissions, and implement security best practices in containers.</description><pubDate>Sun, 20 Jul 2025 00:00:00 GMT</pubDate><content:encoded>By default, Docker containers run as root. That&apos;s fine for quick tests but bad for production. This guide shows you how to add users properly.

Three ways to handle this:
1. **Dockerfile** - Create users at build time (recommended)
2. **Runtime** - Add users to running containers (temporary)
3. **User mapping** - Match host and container users (for permissions)

Let&apos;s go through each one.

## Why Bother?

Running as root in containers is risky:

- **Security**: If the app gets compromised, the attacker has root
- **Permissions**: Writing files as root messes up host permissions
- **Best practice**: Least privilege principle applies here too

Bottom line: Production containers should not run as root.

## Method 1: Create Users in Dockerfile

Best practice is to set up users when building the image.

### Basic User Creation

```dockerfile
FROM ubuntu:20.04

# Create a non-root user
RUN useradd -m -s /bin/bash appuser

# Switch to the new user
USER appuser

# Set working directory
WORKDIR /home/appuser
```

### Advanced User Setup

```dockerfile
FROM ubuntu:20.04

# Update package list
RUN apt-get update &amp;&amp; apt-get install -y sudo

# Create user with specific UID/GID
RUN groupadd -r appgroup --gid=1001 &amp;&amp; \
    useradd -r -g appgroup --uid=1001 --shell=/bin/bash --create-home appuser

# Add user to sudo group (if needed)
RUN usermod -aG sudo appuser

# Set password (for development only)
RUN echo &apos;appuser:password&apos; | chpasswd

# Switch to non-root user
USER appuser

WORKDIR /home/appuser
```

### Key Commands Explained

| Command | Purpose | Example |
|---------|---------|---------|
| `useradd -m` | Create user with home directory | `useradd -m john` |
| `groupadd` | Create user group | `groupadd developers` |
| `usermod -aG` | Add user to group | `usermod -aG sudo john` |
| `USER` | Set default user for container | `USER appuser` |

Some other docker articles that can help you in your docker journey:

- [Copy Multiple Files in One Layer Using a Dockerfile](https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/)
- [Install Docker &amp; Docker-compose for Ubuntu ARM](https://www.bitdoze.com/install-docker-ubuntu-arm/)
- [Redirect Docker Logs to a Single File](https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/)
- [Environment Variables ARG and ENV in Docker](https://www.bitdoze.com/docker-env-vars/)

## Method 2: Add Users at Runtime

Sometimes you need to create users in an already running container. Not recommended for production, but useful for debugging.

### Create User in Running Container

```bash
# Enter the container
docker exec -it container_name bash

# Create user inside container
useradd -m -s /bin/bash newuser

# Set password
passwd newuser

# Add to sudo group (if needed)
usermod -aG sudo newuser

# Switch to new user
su - newuser
```

### Using Docker Exec with Specific User

```bash
# Run commands as specific user
docker exec -it --user newuser container_name bash

# Or run single commands
docker exec --user newuser container_name whoami
```

## Method 3: Map Host Users to Container

When you mount volumes, file ownership can get messy. This method keeps host and container permissions in sync.

### User ID/Group ID Mapping

```dockerfile
FROM ubuntu:20.04

# Create user with specific UID/GID that matches host user
ARG USER_ID=1000
ARG GROUP_ID=1000

RUN groupadd -g $GROUP_ID appgroup &amp;&amp; \
    useradd -u $USER_ID -g $GROUP_ID -m -s /bin/bash appuser

USER appuser
```

Build with host user IDs:
```bash
docker build --build-arg USER_ID=$(id -u) --build-arg GROUP_ID=$(id -g) -t myapp .
```

### Docker Compose User Mapping

```yaml
version: &apos;3.8&apos;
services:
  app:
    build: .
    user: &quot;${UID}:${GID}&quot;
    volumes:
      - .:/app
    environment:
      - USER_ID=${UID}
      - GROUP_ID=${GID}
```

Run with:
```bash
UID=$(id -u) GID=$(id -g) docker-compose up
```

## Best Practices

### 1. Never Run as Root in Production
```dockerfile
# Good
FROM ubuntu:20.04
RUN useradd -m appuser
USER appuser

# Bad - root by default
FROM ubuntu:20.04
```

### 2. Use Specific UID/GID
Avoid permission headaches with mounted volumes:
```dockerfile
RUN groupadd -g 1001 appgroup &amp;&amp; \
    useradd -u 1001 -g appgroup -m appuser
USER appuser
```

### 3. Use Multi-Stage Builds
Build as root, run as user:
```dockerfile
# Build stage
FROM ubuntu:20.04 as builder
RUN apt-get update &amp;&amp; apt-get install -y build-essential
COPY . /src
WORKDIR /src
RUN make build

# Runtime stage
FROM ubuntu:20.04
RUN useradd -m appuser
USER appuser
COPY --from=builder /src/app /home/appuser/app
```

### 4. Set File Ownership
Use `COPY --chown` to avoid permission issues:
```dockerfile
COPY --chown=appuser:appuser app/ /home/appuser/app/
USER appuser
```

### Quick Checklist

| ✅ Do | ❌ Don&apos;t |
|-------|----------|
| Create dedicated users | Run as root in production |
| Set specific UID/GID | Use random UIDs |
| Set proper ownership | Use 777 permissions |
| Test before deploying | Assume it just works |

## Quick Reference

### Common User Creation Commands
```bash
# Create user with home directory
useradd -m -s /bin/bash username

# Create user with specific UID/GID
useradd -u 1001 -g 1001 -m username

# Add user to group
usermod -aG groupname username

# Change file ownership
chown -R username:groupname /path/to/files
```

### Dockerfile User Examples
```dockerfile
# Simple non-root user
FROM ubuntu:20.04
RUN useradd -m appuser
USER appuser

# User with sudo access
FROM ubuntu:20.04
RUN apt-get update &amp;&amp; apt-get install -y sudo
RUN useradd -m -s /bin/bash appuser
RUN usermod -aG sudo appuser
USER appuser

# User with specific UID/GID
FROM ubuntu:20.04
RUN groupadd -g 1001 appgroup &amp;&amp; \
    useradd -u 1001 -g appgroup -m appuser
USER appuser
```

## TL;DR

1. **Always create non-root users** for production containers
2. **Use specific UID/GID** to avoid permission issues with volumes
3. **Set file ownership** with `COPY --chown`
4. **Test before deploying** - run `whoami` and check permissions

Start simple. Create a non-root user in your Dockerfile. Worry about namespaces and advanced security later.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>How to Exclude Directories or Files When Copying to a Remote Machine</title><link>https://www.bitdoze.com/exclude-directories-files-copy-remote-machine/</link><guid isPermaLink="true">https://www.bitdoze.com/exclude-directories-files-copy-remote-machine/</guid><description>Learn how to exclude specific files and directories when copying to remote servers using rsync and scp commands with practical examples.</description><pubDate>Sun, 20 Jul 2025 00:00:00 GMT</pubDate><content:encoded>You need to copy files to a remote server but skip certain directories like `node_modules` or `.git`. Two options:

- **rsync** - Has built-in exclude support (use this)
- **scp** - No native exclude, needs workarounds

Rsync is the clear winner here.

## Excluding Files or Directories with Rsync

rsync provides powerful exclusion options through the `--exclude` flag:

### Basic Exclusion Syntax
```shell
rsync -av --exclude &apos;pattern&apos; source/ user@remote:/destination/
```

### Common Use Cases

**Exclude specific directories:**
```shell
rsync -av --exclude &apos;node_modules&apos; --exclude &apos;.git&apos; source/ user@remote:/destination/
```

**Exclude by file patterns:**
```shell
rsync -av --exclude &apos;*.log&apos; --exclude &apos;*.tmp&apos; source/ user@remote:/destination/
```

**Exclude multiple items:**
```shell
rsync -av --exclude={&apos;*.log&apos;,&apos;tmp/&apos;,&apos;cache/&apos;} source/ user@remote:/destination/
```

**Using exclude files:**
Create a `.rsync-exclude` file with patterns:
```
*.log
*.tmp
node_modules/
.git/
cache/
```

Then use:
```shell
rsync -av --exclude-from=&apos;.rsync-exclude&apos; source/ user@remote:/destination/
```

### Advanced Patterns

| Pattern | Matches | Example |
|---------|---------|---------|
| `*.txt` | All text files | `file.txt`, `readme.txt` |
| `temp*` | Files starting with &quot;temp&quot; | `temp1`, `temporary` |
| `**/cache/` | Cache directories anywhere | `app/cache/`, `src/cache/` |
| `*.{log,tmp}` | Multiple extensions | `error.log`, `data.tmp` |

## Excluding Files with SCP

## Excluding Files with SCP

SCP doesn&apos;t have built-in exclusion options like rsync, but you can use bash extended globbing patterns.

### Enable Extended Globbing
```shell
shopt -s extglob
```

### Exclude Files with Patterns
```shell
# Copy everything except .txt files
scp !(*.txt) user@remote:/destination/

# Copy everything except specific directories
scp -r !(node_modules|.git) user@remote:/destination/

# Copy everything except log and temp files
scp !(*.log|*.tmp) user@remote:/destination/
```

### Extended Globbing Patterns

| Pattern | Description | Example |
|---------|-------------|---------|
| `!(pattern)` | Matches anything except pattern | `!(*.txt)` excludes text files |
| `*(pattern)` | Matches zero or more occurrences | `*(backup)` matches backup files |
| `+(pattern)` | Matches one or more occurrences | `+(test*)` matches test files |
| `?(pattern)` | Matches zero or one occurrence | `?(config)` matches config file |
| `@(pattern)` | Matches exactly one pattern | `@(*.js|*.css)` matches JS or CSS |

### Practical Examples
```shell
# Copy only source files (exclude build artifacts)
scp -r !(build|dist|node_modules) user@remote:/app/

# Copy configuration files only
scp @(*.conf|*.json|*.yaml) user@remote:/config/
```

**Recommendation**: Use rsync instead of scp for complex exclusion needs, as it&apos;s more reliable and feature-rich.

## Quick Comparison: rsync vs scp

| Feature | rsync | scp |
|---------|-------|-----|
| Built-in exclusion | ✅ `--exclude` | ❌ Requires bash globbing |
| Pattern flexibility | ✅ Very flexible | ⚠️ Limited |
| Resumable transfers | ✅ Yes | ❌ No |
| Incremental sync | ✅ Yes | ❌ No |
| Performance | ✅ Faster for large transfers | ⚠️ Slower |

**Bottom line**: Use rsync for file exclusion tasks - it&apos;s more powerful and easier to use.</content:encoded><category>linux</category><category>linux</category></item><item><title>Meet Kiro: Amazon&apos;s Revolutionary AI IDE That Changes How We Build Software</title><link>https://www.bitdoze.com/kiro-ai-ide/</link><guid isPermaLink="true">https://www.bitdoze.com/kiro-ai-ide/</guid><description>Discover Kiro, Amazon&apos;s new agentic IDE that goes beyond chat-based coding with specs and hooks for production-ready development.</description><pubDate>Tue, 15 Jul 2025 00:00:00 GMT</pubDate><content:encoded>The AI coding landscape just got a major shake-up. Amazon has launched **Kiro**, an agentic IDE that promises to revolutionize how we build software—not just through AI chat, but with a structured approach using **specs** and **hooks**. After spending time with Kiro, I can confidently say this isn&apos;t just another AI coding assistant. It&apos;s a complete rethinking of the development workflow that could change how we go from idea to production.

&lt;Notice type=&quot;success&quot; title=&quot;Free Preview Available&quot;&gt;
  Kiro is currently in public preview with generous free limits! Download now
  and experience spec-driven development at no cost while the preview lasts.
&lt;/Notice&gt;

## What Makes Kiro Different?

![KIRO Interface](@images/25/07/kiro1.webp)

While tools like Cursor, GitHub Copilot, and WindSurf focus primarily on AI-assisted coding through chat interfaces, Kiro takes a fundamentally different approach. Sure, it has excellent &quot;vibe coding&quot; capabilities for quick prototyping, but its real strength lies in **spec-driven development** and **automated hooks** that bridge the gap between prototype and production.

Think of it this way: most AI coding tools help you write code faster, but Kiro helps you build better software systems. It&apos;s the difference between having a smart autocomplete and having an experienced architect guiding your entire development process.

## Getting Started with Kiro

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/GhjAKaUtJKE&quot;
  label=&quot;KIRO AI IDE&quot;
/&gt;

Setting up Kiro is refreshingly straightforward:

### Installation Process

1. Visit [kiro.dev](https://kiro.dev/) and download the installer
2. Run the installer for your OS (Windows, macOS, or Linux)
3. Launch Kiro and complete the setup

### First Run Setup

When you first open Kiro, you&apos;ll go through a quick configuration:

- **Authentication**: Choose from social logins or AWS authentication methods
- **VS Code Migration**: Import your existing VS Code settings and extensions seamlessly
- **Shell Integration**: Allow Kiro to execute commands on your behalf for automated tasks

You can start working immediately by running `kiro .` in your project directory or opening an existing project through the interface.

## The Power of Steering Files

![KIRO  2](@images/25/07/kiro2.webp)

One of Kiro&apos;s standout features is **steering files**—markdown documents that provide context about your project&apos;s architecture, stack, and conventions. This is where Kiro starts to show its sophistication.

### Auto-Generated Context

Navigate to the ghost icon in the sidebar and click &quot;Generate Steering Docs.&quot; Kiro creates three foundational documents:

- **Product**: Your app&apos;s features and business logic
- **Structure**: How your codebase is organized
- **Tech**: Your technology stack and conventions

### Custom Steering Rules

You can create custom steering files for specific needs. For example, if you want Kiro to always write tests before code, create a `test-driven-development.md` file with your requirements. Kiro will expand your initial prompt into a detailed specification that guides all future development.

This context awareness means Kiro doesn&apos;t just generate generic code—it generates code that fits your specific project&apos;s patterns and requirements.

## Spec-Driven Development: The Game Changer

![KIRO spec drive](@images/25/07/kiro3.webp)

Here&apos;s where Kiro truly shines. Instead of the typical back-and-forth of AI chat coding, Kiro uses a structured three-phase approach:

### 1. Requirements Phase

Start with a simple prompt like &quot;Add social sign-in to my app.&quot; Kiro generates a comprehensive `requirements.md` file that includes:

- Detailed user stories
- EARS (Easy Approach to Requirements Syntax) acceptance criteria
- Edge cases and error handling scenarios
- Integration requirements

This isn&apos;t just a feature list—it&apos;s a product manager-quality requirements document that makes your assumptions explicit.

### 2. Design Phase

Based on your requirements and steering files, Kiro creates a technical design document featuring:

- TypeScript interfaces and type definitions
- Database schemas and data models
- API endpoint specifications
- User experience flows
- High-level architecture diagrams

For TypeScript projects, this phase is particularly impressive—Kiro generates production-ready interfaces that consider your existing codebase patterns.

### 3. Implementation Phase

Kiro breaks down the work into sequenced tasks with dependencies clearly mapped. Each task includes:

- Unit and integration test requirements
- Loading states and error handling
- Mobile responsiveness considerations
- Accessibility compliance
- Security validation

You can work through tasks individually, seeing progress indicators and code diffs for each completed item.

## Agent Hooks: Automation That Actually Helps

Agent hooks are Kiro&apos;s answer to the repetitive tasks that slow down development. These event-driven automations trigger when you save, create, or delete files.

### Practical Hook Examples

- **Test Updates**: When you modify a React component, automatically update corresponding test files
- **Documentation Sync**: When you change API endpoints, refresh README files and documentation
- **Security Scanning**: Before commits, scan for leaked credentials or security vulnerabilities
- **Code Standards**: Validate new components against team coding standards

### Team-Wide Consistency

Once committed to Git, hooks enforce standards across your entire team. Everyone benefits from the same quality checks and validation—no more inconsistent code reviews or forgotten documentation updates.

## Beyond the Basics: Advanced Features

### MCP Server Integration

Kiro supports Model Context Protocol (MCP) servers, allowing integration with external tools like:

- Asana for project management
- Figma for design system sync
- Custom APIs and databases
- Third-party development tools

### VS Code Compatibility

Built on Code OSS, Kiro maintains compatibility with VS Code settings and Open VSX extensions. You&apos;re not starting from scratch—your existing workflow transfers seamlessly.

### Multi-Language Support

Kiro works with most popular programming languages and frameworks, adapting its suggestions to your specific tech stack.

## Real-World Impact: Why This Matters

After using Kiro for several projects, the difference is clear. Traditional AI coding tools help you write code faster, but they often leave you with:

- Inconsistent code quality
- Missing edge cases
- Incomplete test coverage
- Documentation that quickly becomes outdated

Kiro addresses these production readiness gaps systematically. The spec-driven approach ensures nothing falls through the cracks, while hooks maintain quality standards automatically.

## Kiro Pricing (2025)

Kiro offers a generous free tier during the preview period, with paid plans coming soon:

&lt;Tabs&gt;
&lt;Tab name=&quot;Kiro Free&quot;&gt;

**Coming Soon - $0/month per user**

Perfect for individual developers and small projects:

&lt;ListCheck&gt;
  - **Agentic capabilities** in the Kiro IDE (limit 50 interactions per month) -
  **Specs** for structured development workflow - **Agent hooks** for automation
  - **Model Context Protocol (MCP)** integration - **Agent steering** for
  project context - **VS Code compatibility** with settings import -
  **Multi-language support** for popular frameworks
&lt;/ListCheck&gt;

**Best for**: Solo developers, learning, small personal projects

&lt;/Tab&gt;
&lt;Tab name=&quot;Kiro Pro&quot;&gt;

**Coming Soon - $19/month per user**

Enhanced limits for professional development:

&lt;ListCheck&gt;
  - **Everything in Kiro Free**, plus: - **Increased limits** for Kiro agentic
  capabilities (total limit: 1,000 interactions per month) - **Priority
  support** and faster response times - **Advanced integrations** with
  development tools - **Team collaboration** features - **Enhanced MCP servers**
  for enterprise tools
&lt;/ListCheck&gt;

**Best for**: Professional developers, small teams, production projects

&lt;/Tab&gt;
&lt;Tab name=&quot;Kiro Pro+&quot;&gt;

**Coming Soon - $39/month per user**

Maximum capabilities for intensive development:

&lt;ListCheck&gt;
  - **Everything in Kiro Pro**, plus: - **Increased limits** for Kiro agentic
  capabilities (total limit: 3,000 interactions per month) - **Advanced team
  features** and collaboration tools - **Custom integrations** and enterprise
  support - **Dedicated support** channel - **Early access** to new features
&lt;/ListCheck&gt;

**Best for**: Large teams, enterprise projects, heavy AI usage

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Preview Pricing&quot;&gt;
  During the public preview, Kiro is completely free with generous limits. Take
  advantage of this opportunity to explore all features without cost!
&lt;/Notice&gt;

## Getting Started Today

Kiro is currently in public preview with generous free limits. Here&apos;s how to dive in:

1. **Download**: Get Kiro from [kiro.dev](https://kiro.dev/)
2. **Tutorial**: Follow the hands-on tutorial that walks you through building a complete feature
3. **Community**: Join the [Discord server](https://discord.com/invite/kirodotdev) for support and feedback

## The Future of AI-Assisted Development

Kiro represents a maturation of AI coding tools. Instead of just making coding faster, it makes software development more systematic and reliable. The combination of specs and hooks addresses real production challenges that other AI tools largely ignore.

For teams serious about building production software with AI assistance, Kiro offers a compelling alternative to the chat-first approach of other tools. It&apos;s not just about writing code—it&apos;s about building better software systems.

Whether you&apos;re a solo developer or part of a larger team, Kiro&apos;s structured approach to AI-assisted development is worth exploring. The future of coding isn&apos;t just about speed—it&apos;s about building the right thing, the right way, with AI as your systematic partner rather than just a smart autocomplete.

Ready to experience spec-driven development? Download Kiro and see how AI can help you build production-ready software, not just prototypes.</content:encoded><category>ai</category><category>kiro</category><category>ide</category></item><item><title>Kimi K2: The Game-Changing AI Model That&apos;s Revolutionizing Agentic Intelligence</title><link>https://www.bitdoze.com/kimi-k2-ai-model/</link><guid isPermaLink="true">https://www.bitdoze.com/kimi-k2-ai-model/</guid><description>Discover Kimi K2, Moonshot AI&apos;s breakthrough model with 1 trillion parameters that excels at coding, tool use, and agentic tasks. Now available on OpenRouter and Groq with blazing-fast speeds.</description><pubDate>Mon, 14 Jul 2025 00:00:00 GMT</pubDate><content:encoded>The AI landscape just witnessed a seismic shift with the release of **Kimi K2**, Moonshot AI&apos;s latest breakthrough that&apos;s redefining what we expect from language models. With 1 trillion total parameters and 32 billion activated parameters, this isn&apos;t just another large language model—it&apos;s a purpose-built agentic intelligence that doesn&apos;t just answer questions, it takes action.

After extensive testing with Kimi K2 on platforms like OpenRouter and Groq, including building a complete solar panel website in Astro using Zed editor, I can confidently say this model represents a new paradigm in AI-assisted development. The speed, capability, and agentic intelligence are genuinely impressive.

&lt;Notice type=&quot;success&quot; title=&quot;Available Now&quot;&gt;
  Kimi K2 is now available on multiple platforms including OpenRouter, Groq, and
  directly through Moonshot AI&apos;s API. Experience next-generation agentic
  intelligence today!
&lt;/Notice&gt;

## What Makes Kimi K2 Revolutionary?

Unlike traditional language models that excel at conversation, Kimi K2 is meticulously optimized for **agentic tasks**. This means it doesn&apos;t just understand your request—it plans, executes, and delivers complete solutions using tools and multi-step reasoning.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ZOgvb9klsHw&quot;
  label=&quot;Kimi K2 Test&quot;
/&gt;

&lt;Notice type=&quot;success&quot; title=&quot;Ready to Get Started?&quot;&gt;
  Try Kimi K2 today on [OpenRouter](https://openrouter.ai/moonshotai/kimi-k2)
  for flexible provider options,
  [Groq](https://console.groq.com/playground?model=moonshotai/kimi-k2-instruct)
  for maximum speed, or [Moonshot AI](https://kimi.com/) for the full
  experience. The agentic AI revolution starts now.
&lt;/Notice&gt;


### Key Technical Specifications

&lt;Tabs&gt;
&lt;Tab name=&quot;Architecture&quot;&gt;

**Mixture-of-Experts (MoE) Design:**

&lt;ListCheck&gt;
  - **Total Parameters**: 1 trillion parameters
  - **Activated Parameters**: 32 billion per forward pass
  - **Expert Configuration**: 384 experts with 8 selected per token
  - **Context Window**: Up to 131,072 tokens
  - **Training Data**: 15.5T tokens with zero training spikes
  - **Optimizer**: Revolutionary MuonClip for stable training
&lt;/ListCheck&gt;

&lt;/Tab&gt;
&lt;Tab name=&quot;Performance&quot;&gt;

**Benchmark Results:**

&lt;ListCheck&gt;
  - **LiveCodeBench**: 53.7% Pass@1 (top-tier coding)
  - **SWE-bench Verified**:
  65.8% single-attempt accuracy
  - **MMLU**: 89.5% exact match
  - **AIME 2025**:
  49.5% average score
  - **Tool Use (Tau2)**: 70.6% weighted average
  - **Math &amp;
  STEM**: State-of-the-art across multiple benchmarks
&lt;/ListCheck&gt;

&lt;/Tab&gt;
&lt;Tab name=&quot;Capabilities&quot;&gt;

**Agentic Intelligence Features:**

&lt;ListCheck&gt;
  - **Advanced Tool Use**: Seamless integration with APIs and external tools
  - **Multi-Step Reasoning**: Complex problem-solving workflows
  - **Code Generation**: Superior performance in multiple programming languages
  - **Data Analysis**: Statistical analysis with visualization generation
  - **Web Development**: Complete application building capabilities
  - **Command Line Operations**: Direct system interaction and file manipulation
&lt;/ListCheck&gt;

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Platform Availability &amp; Pricing

Kimi K2 is accessible through multiple platforms, each offering different advantages:

&lt;Tabs&gt;
&lt;Tab name=&quot;OpenRouter&quot;&gt;

**Flexible Provider Routing:**

| Provider  | Input Cost | Output Cost | Context | Throughput | Latency |
| --------- | ---------- | ----------- | ------- | ---------- | ------- |
| DeepInfra | $0.55/M    | $2.20/M     | 120K    | 7.52 TPS   | 0.89s   |
| NovitaAI  | $0.57/M    | $2.30/M     | 131K    | 10.14 TPS  | 2.03s   |
| Together  | $1.00/M    | $3.00/M     | 131K    | 51.49 TPS  | 1.56s   |
| Groq      | $1.00/M    | $3.00/M     | 131K    | 152.0 TPS  | 4.60s   |

**Best for**: Developers who want provider flexibility and automatic failover

&lt;/Tab&gt;
&lt;Tab name=&quot;Groq&quot;&gt;

**Lightning-Fast Inference:**

&lt;ListCheck&gt;
  - **Speed**: ~250 tokens per second
  - **Input Cost**: $1.00 per 1M tokens
  -**Output Cost**: $3.00 per 1M tokens
  - **Context Window**: 131,072 tokens
  -**Max Output**: 16,384 tokens
  - **Features**: Tool use, JSON mode, structured
  outputs
&lt;/ListCheck&gt;

**Best for**: Applications requiring ultra-fast response times

&lt;/Tab&gt;
&lt;Tab name=&quot;Moonshot AI Direct&quot;&gt;

**Official API Access:**

&lt;ListCheck&gt;
  - **Free Tier**: Available on kimi.com
  - **API Access**: OpenAI-compatible
  interface
  - **Full Features**: Complete tool calling capabilities
  -**Documentation**: Comprehensive at platform.moonshot.ai
  - **Self-Hosting**: Available with vLLM, SGLang, KTransformers
&lt;/ListCheck&gt;

**Best for**: Production applications and custom deployments

&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Speed Comparison&quot;&gt;
  Groq offers the fastest inference at ~250 TPS, while OpenRouter provides the
  most flexibility with multiple provider options and automatic routing.
&lt;/Notice&gt;

## Real-World Testing: Building with Kimi K2

I put Kimi K2 through its paces by building a complete solar panel website using Astro in Zed editor. The results were remarkable:

### Development Experience

&lt;Accordion label=&quot;Project Setup &amp; Architecture&quot; group=&quot;testing&quot;&gt;

**What Kimi K2 Delivered:**

&lt;ListCheck&gt;
  - **Complete Astro project structure** with proper configuration
  - **Responsive design system** using Tailwind CSS
  - **Component architecture**
  with reusable UI elements
  - **SEO optimization** with proper meta tags and
  structured data
  - **Performance optimization** with lazy loading and image
  optimization
  - **Accessibility compliance** following WCAG guidelines
&lt;/ListCheck&gt;

The model understood the project requirements and delivered a production-ready codebase without multiple iterations.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Code Quality &amp; Best Practices&quot; group=&quot;testing&quot;&gt;

**Impressive Capabilities:**

&lt;ListCheck&gt;
  - **Clean, maintainable code** following industry standards
  - **Proper
  TypeScript integration** with type safety
  - **Modern CSS practices** with CSS
  Grid and Flexbox
  - **Component composition** with proper prop handling
  - **Error handling** and edge case management
  - **Documentation** with inline
  comments and README
&lt;/ListCheck&gt;

The generated code felt like it was written by an experienced developer, not an AI.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Agentic Problem Solving&quot; group=&quot;testing&quot;&gt;

**Multi-Step Execution:**

&lt;ListCheck&gt;
  - **Analyzed requirements** and proposed optimal architecture
  - **Created file
  structure** and initialized project dependencies
  - **Built components
  incrementally** with proper testing
  - **Handled styling conflicts** and
  responsive design challenges
  - **Optimized performance** by identifying
  bottlenecks
  - **Deployed and tested** the final application
&lt;/ListCheck&gt;

This wasn&apos;t just code generation—it was genuine software engineering.

&lt;/Accordion&gt;

## Kimi K2 vs Competition

&lt;Tabs&gt;
&lt;Tab name=&quot;vs GPT-4&quot;&gt;

| Feature                | Kimi K2        | GPT-4.1    |
| ---------------------- | -------------- | ---------- |
| Coding (LiveCodeBench) | 53.7%          | 44.7%      |
| Tool Use (AceBench)    | 76.5%          | 80.1%      |
| Math (AIME 2025)       | 49.5%          | 37.0%      |
| Context Window         | 131K           | 128K       |
| Agentic Capabilities   | ✅ Native      | ⚠️ Limited |
| Cost (Input/Output)    | $0.55-1/$2.2-3 | Higher     |

**Winner**: Kimi K2 for coding and agentic tasks

&lt;/Tab&gt;
&lt;Tab name=&quot;vs Claude Sonnet&quot;&gt;

| Feature            | Kimi K2 | Claude Sonnet 4 |
| ------------------ | ------- | --------------- |
| SWE-bench Verified | 65.8%   | 72.7%           |
| MMLU               | 89.5%   | 91.5%           |
| Tool Use           | 76.5%   | 76.2%           |
| Speed (Groq)       | 250 TPS | Not available   |
| Open Source        | ✅ Yes  | ❌ No           |
| Self-Hosting       | ✅ Yes  | ❌ No           |

**Winner**: Close competition, Kimi K2 wins on accessibility

&lt;/Tab&gt;
&lt;Tab name=&quot;vs DeepSeek V3&quot;&gt;

| Feature            | Kimi K2          | DeepSeek V3       |
| ------------------ | ---------------- | ----------------- |
| Coding Performance | 53.7%            | 46.9%             |
| Math Reasoning     | 49.5%            | 46.7%             |
| Tool Use           | 76.5%            | 72.7%             |
| Parameters         | 1T (32B active)  | 671B (37B active) |
| Training Stability | Zero spikes      | Standard          |
| Agentic Focus      | ✅ Purpose-built | ⚠️ General        |

**Winner**: Kimi K2 for specialized agentic applications

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Advanced Agentic Capabilities

What sets Kimi K2 apart is its sophisticated agentic intelligence:

### Real-World Use Cases

&lt;Accordion label=&quot;Data Analysis &amp; Visualization&quot; group=&quot;use-cases&quot;&gt;

**Salary Analysis Example:**
Kimi K2 can perform complex statistical analysis with 16+ tool calls:

&lt;ListCheck&gt;
  - **Data Processing**: Load and clean datasets automatically
  - **Statistical
  Analysis**: Perform ANOVA, t-tests, and correlation analysis
  - **Visualization**: Generate publication-quality charts and graphs
  - **Web
  Development**: Create interactive dashboards and simulators
  - **Report
  Generation**: Produce comprehensive analysis reports
  - **Deployment**: Deploy
  complete web applications
&lt;/ListCheck&gt;

The model handles the entire pipeline from raw data to deployed application.

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Software Development&quot; group=&quot;use-cases&quot;&gt;

**Complete Development Workflows:**

&lt;ListCheck&gt;
  - **Project Planning**: Architecture design and technology selection
  - **Code
  Generation**: Multi-file applications with proper structure
  - **Testing**:
  Unit tests, integration tests, and debugging
  - **Documentation**: README
  files, API docs, and inline comments
  - **Deployment**: CI/CD pipelines and
  production deployment
  - **Maintenance**: Performance optimization and bug
  fixes
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Command Line Operations&quot; group=&quot;use-cases&quot;&gt;

**System Integration:**

&lt;ListCheck&gt;
  - **File Management**: Create, edit, and organize project files
  - **Command
  Execution**: Run build tools, tests, and deployment scripts
  - **Environment
  Setup**: Configure development environments
  - **Package Management**: Install
  and manage dependencies
  - **Git Operations**: Version control and
  collaboration workflows
  - **Server Management**: Deploy and monitor
  applications
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Getting Started with Kimi K2

&lt;Tabs&gt;
&lt;Tab name=&quot;OpenRouter Setup&quot;&gt;

**Quick Start with OpenRouter:**

```javascript
import OpenAI from &quot;openai&quot;;

const openai = new OpenAI({
  baseURL: &quot;https://openrouter.ai/api/v1&quot;,
  apiKey: &quot;YOUR_OPENROUTER_KEY&quot;,
});

const completion = await openai.chat.completions.create({
  model: &quot;moonshotai/kimi-k2&quot;,
  messages: [
    {
      role: &quot;user&quot;,
      content: &quot;Build a React component for a solar panel calculator&quot;,
    },
  ],
  tools: [
    {
      type: &quot;function&quot;,
      function: {
        name: &quot;create_file&quot;,
        description: &quot;Create a new file with content&quot;,
      },
    },
  ],
});
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Groq Integration&quot;&gt;

**Lightning-Fast with Groq:**

```python
from groq import Groq

client = Groq(api_key=&quot;YOUR_GROQ_KEY&quot;)

completion = client.chat.completions.create(
    model=&quot;moonshotai/kimi-k2-instruct&quot;,
    messages=[
        {
            &quot;role&quot;: &quot;user&quot;,
            &quot;content&quot;: &quot;Create an Astro component for a pricing table&quot;
        }
    ],
    tools=[
        {
            &quot;type&quot;: &quot;function&quot;,
            &quot;function&quot;: {
                &quot;name&quot;: &quot;write_file&quot;,
                &quot;description&quot;: &quot;Write content to a file&quot;
            }
        }
    ]
)
```

&lt;/Tab&gt;
&lt;Tab name=&quot;Direct API&quot;&gt;

**Moonshot AI Platform:**

```bash
curl -X POST &quot;https://api.moonshot.cn/v1/chat/completions&quot; \
  -H &quot;Authorization: Bearer YOUR_API_KEY&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &apos;{
    &quot;model&quot;: &quot;kimi-k2-instruct&quot;,
    &quot;messages&quot;: [
      {
        &quot;role&quot;: &quot;user&quot;,
        &quot;content&quot;: &quot;Help me build a complete web application&quot;
      }
    ],
    &quot;tools&quot;: [...]
  }&apos;
```

&lt;/Tab&gt;
&lt;/Tabs&gt;

## Performance Optimization Tips

&lt;Accordion label=&quot;Maximizing Speed&quot; group=&quot;optimization&quot;&gt;

**Platform Selection:**

&lt;ListCheck&gt;
  - **Use Groq** for fastest inference (250+ TPS)
  - **Choose DeepInfra** on
  OpenRouter for balanced speed/cost
  - **Enable streaming** for real-time
  responses
  - **Optimize prompts** for single-shot completions
  - **Use
  structured outputs** for consistent formatting
  - **Implement caching** for
  repeated operations
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Cost Optimization&quot; group=&quot;optimization&quot;&gt;

**Budget-Friendly Strategies:**

&lt;ListCheck&gt;
  - **Start with OpenRouter&apos;s cheapest providers** ($0.55/M input)
  - **Use
  context efficiently** - don&apos;t exceed necessary length
  - **Implement prompt
  caching** for repeated patterns
  - **Batch similar requests** when possible -
  **Monitor usage** with provider dashboards
  - **Consider self-hosting** for
  high-volume applications
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

&lt;Accordion label=&quot;Quality Enhancement&quot; group=&quot;optimization&quot;&gt;

**Best Practices:**

&lt;ListCheck&gt;
  - **Provide clear tool definitions** with explicit parameters
  - **Structure
  complex tasks** into clear steps
  - **Use the full context window** for
  comprehensive analysis
  - **Specify output formats** explicitly
  - **Include
  examples** in your prompts
  - **Test with different providers** to find optimal
  performance
&lt;/ListCheck&gt;

&lt;/Accordion&gt;

## Technical Innovation: MuonClip Optimizer

Kimi K2&apos;s stability comes from groundbreaking training innovations:

### The MuonClip Breakthrough

&lt;Notice type=&quot;info&quot; title=&quot;Technical Deep Dive&quot;&gt;
  Kimi K2 introduces the MuonClip optimizer, solving training instability issues
  that plague large MoE models. This innovation enabled zero training spikes
  across 15.5T tokens.
&lt;/Notice&gt;

**Key Innovations:**

- **QK-Clip Technique**: Prevents attention logit explosions
- **Adaptive Scaling**: Dynamic adjustment based on attention patterns
- **Stable Training**: Zero spikes during massive-scale training
- **Token Efficiency**: Superior performance per training token

This technical foundation enables Kimi K2&apos;s reliable performance at scale.

## Future Roadmap &amp; Limitations

### What&apos;s Coming Next

&lt;ListCheck&gt;
  - **Vision Capabilities**: Multimodal understanding and generation
  - **Extended Thinking**: Chain-of-thought reasoning modes
  - **Enhanced Tool
  Integration**: More sophisticated MCP support
  - **Performance Improvements**:
  Faster inference and lower costs
  - **Specialized Variants**: Domain-specific
  fine-tuned models
&lt;/ListCheck&gt;

### Current Limitations

&lt;Notice type=&quot;warning&quot; title=&quot;Known Issues&quot;&gt;
  Kimi K2 may generate excessive tokens for complex reasoning tasks and can
  experience performance degradation with unclear tool definitions. One-shot
  prompting may be less effective than agentic frameworks for large projects.
&lt;/Notice&gt;

## Conclusion: The Agentic AI Revolution

Kimi K2 represents a fundamental shift from conversational AI to truly agentic intelligence. After extensive testing across multiple platforms and real-world projects, it&apos;s clear this model excels where others struggle—turning ideas into complete, production-ready solutions.

**Key Takeaways:**

&lt;ListCheck&gt;
  - **Exceptional coding performance** that rivals or exceeds GPT-4
  - **True
  agentic capabilities** with multi-step reasoning and tool use
  - **Blazing-fast
  inference** especially on Groq (250+ TPS)
  - **Cost-effective pricing**
  starting at $0.55/M tokens
  - **Open-source availability** for self-hosting and
  customization
  - **Production-ready quality** with proper error handling and
  best practices
&lt;/ListCheck&gt;


&lt;Notice type=&quot;success&quot; title=&quot;Ready to Get Started?&quot;&gt;
  Try Kimi K2 today on [OpenRouter](https://openrouter.ai/moonshotai/kimi-k2)
  for flexible provider options,
  [Groq](https://console.groq.com/playground?model=moonshotai/kimi-k2-instruct)
  for maximum speed, or [Moonshot AI](https://kimi.com/) for the full
  experience. The agentic AI revolution starts now.
&lt;/Notice&gt;

Whether you&apos;re building web applications, analyzing data, or creating complex software systems, Kimi K2 offers a compelling combination of capability, speed, and cost-effectiveness that&apos;s hard to match.

The future of AI-assisted development isn&apos;t just about faster code generation—it&apos;s about intelligent agents that understand, plan, and execute complete solutions. Kimi K2 brings us significantly closer to that future.</content:encoded><category>ai</category><category>kimi-k2</category><category>openrouter</category></item><item><title>Search and Replace Lines with Sed: Master Advanced Techniques</title><link>https://www.bitdoze.com/sed-search-replace/</link><guid isPermaLink="true">https://www.bitdoze.com/sed-search-replace/</guid><description>Master sed&apos;s search and replace functionality with practical examples - find, substitute, and transform text efficiently.</description><pubDate>Thu, 10 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Need to find and replace text in files? Sed&apos;s `s` command handles this in one line. Good for config updates, data cleaning, or quick edits without opening an editor.

## What is `sed`?

**sed** (Stream Editor) processes text line by line without loading the whole file into memory. Good for:
- Automation and scripts
- Large files
- Piping with other Unix tools
- Batch processing

Other sed guides:
- [Delete lines](https://www.bitdoze.com/sed-delete-lines/)
- [Insert or append text](https://www.bitdoze.com/sed-insert-append-text/)
- [Transform text case](https://www.bitdoze.com/sed-change-case/)

## Basic Syntax

```sh
sed &apos;s/old/new/&apos; file.txt        # Replace first occurrence per line
sed &apos;s/old/new/g&apos; file.txt       # Replace all occurrences (global)
sed &apos;s/old/new/i&apos; file.txt       # Ignore case
```

### Delimiters

Use different delimiters when your pattern contains slashes:
```sh
sed &apos;s|/old/path|/new/path|g&apos; config.txt
sed &apos;s#http://old#https://new#g&apos; urls.txt
```

### Key Options

| Option | Function | Example |
|--------|----------|---------|
| `-i` | Edit files in-place | `sed -i &apos;s/old/new/g&apos; file.txt` |
| `-i.bak` | Edit in-place with backup | `sed -i.bak &apos;s/old/new/g&apos; file.txt` |
| `-n` | Suppress default output | `sed -n &apos;s/old/new/p&apos; file.txt` |
| `-e` | Multiple commands | `sed -e &apos;s/old/new/&apos; -e &apos;s/foo/bar/&apos; file.txt` |

**Important**: Test without `-i` first. Sed outputs to stdout by default.

## Common Examples

**Config files:**
```sh
sed &apos;s/localhost/production-server/g&apos; config.ini
sed &apos;s/port=8080/port=80/g&apos; server.conf
```

**Code refactoring:**
```sh
sed &apos;s/oldFunction/newFunction/g&apos; script.js
sed &apos;s/var /let /g&apos; legacy.js
```

**Data cleanup:**
```sh
sed &apos;s/,/;/g&apos; data.csv              # Change delimiter
sed &apos;s/  / /g&apos; document.txt         # Fix double spaces
sed &apos;s/\t/ /g&apos; file.txt             # Tabs to spaces
```

### File Operations

```sh
sed &apos;s/old/new/g&apos; file.txt &gt; newfile.txt   # Save to new file
sed -i &apos;s/old/new/g&apos; file.txt              # Edit in place
sed -i.bak &apos;s/old/new/g&apos; file.txt          # Edit with backup
```

### Multiple Replacements

```sh
sed -e &apos;s/old1/new1/g&apos; -e &apos;s/old2/new2/g&apos; file.txt
sed &apos;s/old1/new1/g; s/old2/new2/g&apos; file.txt
```

### Safety Tips

1. Test without `-i` first
2. Use `-i.bak` for backups
3. Verify with `diff original.txt modified.txt`

## Advanced Pattern Matching with Regular Expressions

Regular expressions unlock sed&apos;s full potential for complex search and replace operations. Master these patterns to handle sophisticated text transformations.

### Basic Regular Expression Elements

**Wildcard character (`.`):**
```sh
sed &apos;s/t.st/test/g&apos; filename        # Matches &quot;test&quot;, &quot;tast&quot;, &quot;t3st&quot;, etc.
sed &apos;s/c.t/cat/g&apos; pets.txt          # Matches &quot;cat&quot;, &quot;cut&quot;, &quot;cot&quot;, etc.
```

**Character classes:**
```sh
sed &apos;s/[aeiou]/X/g&apos; filename        # Replace any vowel with X
sed &apos;s/[0-9]/N/g&apos; filename          # Replace any digit with N
sed &apos;s/[A-Z]/L/g&apos; filename          # Replace uppercase letters with L
```

**Predefined character classes:**
```sh
sed &apos;s/[[:digit:]]/N/g&apos; filename    # Replace digits (same as [0-9])
sed &apos;s/[[:alpha:]]/L/g&apos; filename    # Replace letters
sed &apos;s/[[:space:]]/X/g&apos; filename    # Replace whitespace characters
```

### Quantifiers

**Zero or more (`*`):**
```sh
sed &apos;s/ab*c/X/g&apos; filename          # Matches &quot;ac&quot;, &quot;abc&quot;, &quot;abbc&quot;, &quot;abbbc&quot;
sed &apos;s/[0-9]*/NUM/g&apos; filename      # Matches empty string or any digits
```

**One or more (`\+`):**
```sh
sed &apos;s/[0-9]\+/NUM/g&apos; filename     # Matches one or more digits
sed &apos;s/a\+/A/g&apos; filename           # Matches &quot;a&quot;, &quot;aa&quot;, &quot;aaa&quot;, etc.
```

**Exact occurrences (`\{n\}`):**
```sh
sed &apos;s/[0-9]\{3\}/XXX/g&apos; filename  # Matches exactly 3 digits
sed &apos;s/a\{2,4\}/A/g&apos; filename      # Matches 2 to 4 &apos;a&apos; characters
```

### Anchors and Boundaries

**Line anchors:**
```sh
sed &apos;s/^Error/WARNING/&apos; filename    # Replace &quot;Error&quot; at line start
sed &apos;s/end$/END/&apos; filename          # Replace &quot;end&quot; at line end
sed &apos;s/^$/EMPTY/&apos; filename          # Replace empty lines
```

**Word boundaries:**
```sh
sed &apos;s/\bcat\b/dog/g&apos; filename      # Replace whole word &quot;cat&quot; only
sed &apos;s/\btest\b/exam/g&apos; filename    # Avoids matching &quot;testing&quot; or &quot;retest&quot;
```

### Practical Advanced Examples

**Phone number formatting:**
```sh
# Transform (123) 456-7890 to 123-456-7890
sed &apos;s/(\([0-9]\{3\}\)) \([0-9]\{3\}\)-\([0-9]\{4\}\)/\1-\2-\3/g&apos; contacts.txt
```

**Email extraction and masking:**
```sh
# Replace email addresses with [EMAIL]
sed &apos;s/[a-zA-Z0-9._%+-]\+@[a-zA-Z0-9.-]\+\.[a-zA-Z]\{2,\}/[EMAIL]/g&apos; data.txt
```

**Date format conversion:**
```sh
# Convert MM/DD/YYYY to YYYY-MM-DD
sed &apos;s/\([0-9]\{2\}\)\/\([0-9]\{2\}\)\/\([0-9]\{4\}\)/\3-\1-\2/g&apos; dates.txt
```

**URL protocol updates:**
```sh
# Change HTTP to HTTPS
sed &apos;s/http:\/\/\([^[:space:]]*\)/https:\/\/\1/g&apos; urls.txt
```

### Grouping and Back-references

**Capture groups with `\(\)` and back-references with `\1`, `\2`:**
```sh
# Swap first and last names
sed &apos;s/\([A-Za-z]*\) \([A-Za-z]*\)/\2, \1/g&apos; names.txt

# Duplicate words detection and removal
sed &apos;s/\b\([a-zA-Z]\+\) \1\b/\1/g&apos; text.txt

# Extract filename from path
sed &apos;s/.*\/\([^\/]*\)$/\1/&apos; paths.txt
```

### Complex Pattern Examples

**Log processing:**
```sh
# Extract timestamp from log entries
sed &apos;s/^\[\([0-9-: ]*\)\] .*/\1/&apos; server.log

# Replace IP addresses with [IP]
sed &apos;s/\([0-9]\{1,3\}\.\)\{3\}[0-9]\{1,3\}/[IP]/g&apos; access.log
```

**Code refactoring:**
```sh
# Update function calls: oldFunc(param) -&gt; newFunc(param)
sed &apos;s/oldFunc(\([^)]*\))/newFunc(\1)/g&apos; code.js

# Convert single quotes to double quotes in strings
sed &quot;s/&apos;\([^&apos;]*\)\&quot;/\&quot;\1\&quot;/g&quot; script.js
```

### Extended Regular Expressions

**Using `-E` flag for enhanced patterns:**
```sh
# Multiple alternatives with |
sed -E &apos;s/(cat|dog|bird)/animal/g&apos; pets.txt

# Simplified quantifiers (no escaping needed)
sed -E &apos;s/[0-9]{3}-[0-9]{2}-[0-9]{4}/XXX-XX-XXXX/g&apos; ssn.txt

# Non-capturing groups
sed -E &apos;s/(http|https)://[^[:space:]]*/[URL]/g&apos; text.txt
```

### Testing and Debugging Regular Expressions

**Preview matches before replacement:**
```sh
# Show what would be matched
grep &apos;pattern&apos; filename

# Show line numbers with matches
grep -n &apos;pattern&apos; filename

# Test with sed&apos;s print flag
sed -n &apos;s/pattern/replacement/p&apos; filename
```

**Build patterns incrementally:**
```sh
# Start simple
sed &apos;s/[0-9]/X/&apos; filename

# Add complexity gradually
sed &apos;s/[0-9]\+/NUM/&apos; filename

# Final complex pattern
sed &apos;s/[0-9]\{3\}-[0-9]\{2\}-[0-9]\{4\}/XXX-XX-XXXX/&apos; filename
```

**Pro tip**: Regular expressions can be tricky. Always test your patterns thoroughly on sample data before applying to important files.

## Advanced sed Techniques and Best Practices

Master these advanced techniques to make your sed operations more efficient, precise, and safe.

### Targeted Line Processing

**Limit operations to specific line ranges:**
```sh
sed &apos;1,10s/old/new/g&apos; file.txt      # Replace only in lines 1-10
sed &apos;5,$s/old/new/g&apos; file.txt       # Replace from line 5 to end
sed &apos;10s/old/new/g&apos; file.txt        # Replace only on line 10
```

**Target lines by pattern:**
```sh
sed &apos;/pattern/s/old/new/g&apos; file.txt     # Replace only in lines containing &quot;pattern&quot;
sed &apos;/^#/s/old/new/g&apos; file.txt          # Replace only in comment lines
sed &apos;/ERROR/s/old/new/g&apos; log.txt        # Replace only in error lines
```

### Preview and Testing Techniques

**Preview changes before applying:**
```sh
sed -n &apos;s/old/new/p&apos; file.txt           # Show only changed lines
sed &apos;s/old/new/g&apos; file.txt | head -20   # Preview first 20 lines
sed &apos;s/old/new/g&apos; file.txt | diff file.txt -  # Show differences
```

**Test with line numbers:**
```sh
nl file.txt | sed &apos;s/old/new/g&apos;        # Show line numbers for context
```

### Working with Special Characters

**Escape literal characters:**
```sh
sed &apos;s/\./DOT/g&apos; file.txt              # Escape literal dots
sed &apos;s/\*/STAR/g&apos; file.txt             # Escape literal asterisks
sed &apos;s/\$/DOLLAR/g&apos; file.txt           # Escape literal dollar signs
sed &apos;s/\//SLASH/g&apos; file.txt            # Escape literal forward slashes
```

**Use alternative delimiters:**
```sh
sed &apos;s|/old/path|/new/path|g&apos; file.txt     # Use | for paths
sed &apos;s#http://old#https://new#g&apos; file.txt  # Use # for URLs
sed &apos;s@old@new@g&apos; file.txt                 # Use @ as delimiter
```

### Batch Processing Multiple Files

**Process all files of a type:**
```sh
find . -name &quot;*.txt&quot; -exec sed -i &apos;s/old/new/g&apos; {} \;
find . -name &quot;*.conf&quot; -exec sed -i.bak &apos;s/old/new/g&apos; {} \;
```

**Using xargs for efficiency:**
```sh
find . -name &quot;*.txt&quot; | xargs sed -i &apos;s/old/new/g&apos;
find . -name &quot;*.js&quot; -print0 | xargs -0 sed -i &apos;s/console.log/logger.debug/g&apos;
```

**Loop through files:**
```sh
for file in *.txt; do
    sed -i.backup &apos;s/old/new/g&apos; &quot;$file&quot;
    echo &quot;Processed: $file&quot;
done
```

### Advanced Pattern Techniques

**Using back-references for complex replacements:**
```sh
# Swap two words
sed &apos;s/\(foo\) \(bar\)/\2 \1/g&apos; file.txt

# Duplicate text
sed &apos;s/\(important\)/\1 \1/g&apos; file.txt

# Rearrange data fields
sed &apos;s/\([^,]*\),\([^,]*\),\([^,]*\)/\3,\1,\2/&apos; data.csv
```

**Multiple operations in sequence:**
```sh
sed -e &apos;s/old1/new1/g&apos; -e &apos;s/old2/new2/g&apos; -e &apos;s/old3/new3/g&apos; file.txt
```

**Conditional replacements:**
```sh
# Replace only if line contains specific pattern
sed &apos;/contains_this/{s/old/new/g;}&apos; file.txt

# Replace in specific sections
sed &apos;/START/,/END/{s/old/new/g;}&apos; file.txt
```

### Performance Optimization

**Process large files efficiently:**
```sh
# Stop after first match per line (faster)
sed &apos;s/old/new/&apos; file.txt

# Use specific patterns to reduce processing
sed &apos;/pattern/s/old/new/g&apos; file.txt
```

**Combine operations to reduce passes:**
```sh
# Instead of multiple sed calls
sed &apos;s/old1/new1/g; s/old2/new2/g; s/old3/new3/g&apos; file.txt
```

### Safety and Backup Strategies

**Always backup important files:**
```sh
cp original.txt original.txt.backup
sed -i.$(date +%Y%m%d) &apos;s/old/new/g&apos; original.txt
```

**Test on sample data first:**
```sh
head -100 largefile.txt &gt; sample.txt
sed &apos;s/old/new/g&apos; sample.txt          # Test your pattern
# If good, apply to original:
sed -i.backup &apos;s/old/new/g&apos; largefile.txt
```

**Use version control:**
```sh
git add file.txt                      # Stage current version
sed -i &apos;s/old/new/g&apos; file.txt         # Make changes
git diff                              # Review changes
```

### Practical Workflow Examples

**Configuration file updates:**
```sh
# Update server configuration across multiple files
find /etc/nginx -name &quot;*.conf&quot; -exec sed -i.backup \
    -e &apos;s/old-server.com/new-server.com/g&apos; \
    -e &apos;s/port 8080/port 80/g&apos; {} \;
```

**Code refactoring:**
```sh
# Update function names in JavaScript files
find ./src -name &quot;*.js&quot; -exec sed -i \
    &apos;s/\boldFunction\b/newFunction/g&apos; {} \;
```

**Log processing:**
```sh
# Clean and standardize log files
sed -e &apos;s/DEBUG/[DEBUG]/g&apos; \
    -e &apos;s/ERROR/[ERROR]/g&apos; \
    -e &apos;s/INFO/[INFO]/g&apos; app.log &gt; standardized.log
```

### Common Pitfalls to Avoid

1. **Forgetting to escape special characters**
2. **Not testing patterns before applying to important files**
3. **Using global replacement when you only want first occurrence**
4. **Not backing up files before in-place editing**
5. **Making patterns too broad (matching unintended text)**

### Pro Tips

- Start with simple patterns and add complexity gradually
- Use `grep` to test your patterns before using in `sed`
- Keep a collection of tested sed patterns for reuse
- Document complex regular expressions for future reference
- Consider using `awk` or `perl` for very complex text processing

**Remember**: The key to mastering sed is practice and patience. Build your skills incrementally and always prioritize data safety.

## Conclusion

Mastering `sed` for searching and replacing text is a game-changer for anyone who works with text files regularly. The tricks I&apos;ve shared are just the start to harnessing the full potential of this powerful stream editor. Remember to craft your commands with precision and always double-check your patterns. Whether you&apos;re tweaking a single file or tackling multiple files at once, `sed` can be your best ally—just be sure to back up your data before you dive in. With these strategies in your toolkit, you&apos;re well on your way to becoming a `sed` command wizard. Happy editing!</content:encoded><category>linux</category><category>sed</category></item><item><title>Text-to-Speech with uv: Create Audio from Text in Python</title><link>https://www.bitdoze.com/uv-text-to-speech-script/</link><guid isPermaLink="true">https://www.bitdoze.com/uv-text-to-speech-script/</guid><description>Learn how to build a powerful text-to-speech script using uv that supports multiple TTS engines, voice selection, and audio file saving.</description><pubDate>Tue, 08 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Build a text-to-speech script with `uv` that works on macOS, Windows, and Linux. It picks the best available TTS engine, supports voice selection, and saves audio to MP3. No virtual environment setup needed.

&lt;Notice type=&quot;info&quot; title=&quot;New to uv?&quot;&gt;
    Check out [Getting Started with uv](https://www.bitdoze.com/uv-get-start/) first.
&lt;/Notice&gt;


## Features

- **Multiple TTS engines** - Tries pyttsx3, Google TTS, and system voices
- **Smart selection** - Picks the best engine for your platform
- **Voice selection** - macOS voices like Alex, Samantha, Victoria
- **Speech rate** - Adjust from 50 to 300 words per minute
- **MP3 export** - Save audio for later (requires ffmpeg)
- **Interactive mode** - Type and hear text instantly
- **Cross-platform** - macOS, Windows, Linux

**Note**: Uses `ffmpeg` for MP3 conversion instead of `pydub` for better compatibility.

## The Complete Text-to-Speech Script

Let&apos;s start with our comprehensive TTS script. Save this as `tts.py`:

```python
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
#     &quot;pyttsx3&quot;,
#     &quot;pygame&quot;,
#     &quot;gtts&quot;,
#     &quot;requests&quot;,
# ]
# ///

import pyttsx3
import pygame
import tempfile
import os
import argparse
import sys
import platform
import subprocess
import warnings
from pathlib import Path
from gtts import gTTS
import requests

# Suppress irrelevant warnings from pydub
warnings.filterwarnings(&quot;ignore&quot;, category=SyntaxWarning, module=&quot;pydub&quot;)

def text_to_speech(text, output_file=None, play=True, save_mp3=None, method=&quot;auto&quot;, voice=None, rate=150):
    &quot;&quot;&quot;
    Convert text to speech with options to play and/or save as MP3.

    This function orchestrates the TTS process, trying different methods based on
    user preference and system capabilities.

    Args:
        text (str): The text to convert to speech.
        output_file (str): Temporary WAV file path (optional).
        play (bool): Whether to play the audio.
        save_mp3 (str): Path to save the final MP3 file (optional).
        method (str): TTS method to use (&quot;auto&quot;, &quot;pyttsx3&quot;, &quot;gtts&quot;, &quot;system&quot;).
        voice (str): Voice to use (for system method on macOS).
        rate (int): Speech rate in words per minute.
    &quot;&quot;&quot;
    # If no output file is specified, create a temporary one.
    # We will use a WAV file as the common format for pygame playback.
    temp_dir = tempfile.gettempdir()
    temp_wav_file = os.path.join(temp_dir, &quot;temp_speech.wav&quot;)

    success = False
    used_method = &quot;&quot;

    # --- Smart Method Selection ---
    # Determine the order of TTS engines to try.
    if method == &quot;auto&quot;:
        method_preference = []
        if platform.system() == &quot;Darwin&quot;:
            # On macOS, the native &apos;say&apos; command is the most reliable,
            # especially if a specific voice is requested.
            method_preference.extend([&quot;system&quot;, &quot;pyttsx3&quot;, &quot;gtts&quot;])
        else:
            # On other systems (Linux/Windows), pyttsx3 is a good local default.
            method_preference.extend([&quot;pyttsx3&quot;, &quot;gtts&quot;, &quot;system&quot;])
    else:
        # If a specific method is requested, use only that one.
        method_preference = [method]

    # --- Attempt TTS Conversion ---
    for m in method_preference:
        print(f&quot;🔧 Trying method: {m}...&quot;)
        if m == &quot;pyttsx3&quot;:
            success = try_pyttsx3(text, temp_wav_file, rate)
        elif m == &quot;gtts&quot;:
            success = try_gtts(text, temp_wav_file)
        elif m == &quot;system&quot;:
            # The &apos;system&apos; method is primarily for macOS&apos;s &apos;say&apos; command.
            if platform.system() == &quot;Darwin&quot;:
                success = try_system_say(text, temp_wav_file, voice, rate)

        if success:
            used_method = m
            print(f&quot;✅ Audio generated successfully using &apos;{used_method}&apos;!&quot;)
            break
        else:
            print(f&quot;⚠️  Method &apos;{m}&apos; failed.&quot;)

    if not success:
        print(&quot;❌ All TTS methods failed! Unable to generate audio.&quot;)
        return False

    # --- Post-Processing: Play and Save ---
    try:
        # Play the generated WAV file if requested.
        if play:
            play_audio(temp_wav_file)

        # Convert the temporary WAV to MP3 if a save path is provided.
        if save_mp3:
            # Ensure the source file exists before trying to convert.
            if os.path.exists(temp_wav_file):
                 convert_to_mp3(temp_wav_file, save_mp3)
            else:
                 print(f&quot;❌ Cannot save MP3. Temporary file &apos;{temp_wav_file}&apos; not found.&quot;)

    except Exception as e:
        print(f&quot;❌ Error during post-processing (play/save): {e}&quot;)
        return False
    finally:
        # Clean up the temporary WAV file.
        if os.path.exists(temp_wav_file):
            try:
                os.remove(temp_wav_file)
            except OSError as e:
                print(f&quot;⚠️  Could not remove temporary file: {e}&quot;)

    return True

def try_pyttsx3(text, output_file, rate=150):
    &quot;&quot;&quot;Try to use pyttsx3 for TTS, saving to a WAV file.&quot;&quot;&quot;
    try:
        # --- Initialize Engine with Specific Driver ---
        # Using the correct driver for the OS can prevent errors.
        driver = None
        if platform.system() == &apos;darwin&apos;:
            driver = &apos;nsss&apos;
        elif platform.system() == &apos;win32&apos;:
            driver = &apos;sapi5&apos;
        # For Linux, it will default to &apos;espeak&apos;, which is usually fine.
        engine = pyttsx3.init(driverName=driver)

        engine.setProperty(&apos;rate&apos;, rate)
        engine.setProperty(&apos;volume&apos;, 0.9)

        # Save the speech directly to the specified output file.
        engine.save_to_file(text, output_file)
        engine.runAndWait()

        # Verify that the file was created and is not empty.
        if not os.path.exists(output_file) or os.path.getsize(output_file) == 0:
            raise RuntimeError(&quot;pyttsx3 process completed but created an empty file.&quot;)

        return True
    except Exception as e:
        print(f&quot;⚠️  pyttsx3 error: {e}&quot;)
        return False

def try_gtts(text, output_wav_file):
    &quot;&quot;&quot;
    Try to use Google Text-to-Speech.
    gTTS creates an MP3, which must be converted to WAV for consistent playback.
    &quot;&quot;&quot;
    try:
        # Check for internet connection first.
        requests.get(&quot;https://translate.google.com&quot;, timeout=5)
    except requests.ConnectionError:
        print(&quot;⚠️  No internet connection for Google TTS.&quot;)
        return False

    # Create a temporary file for the MP3 output from gTTS.
    temp_mp3 = tempfile.NamedTemporaryFile(delete=False, suffix=&quot;.mp3&quot;).name
    try:
        # Create gTTS object and save to the temporary MP3 file.
        tts = gTTS(text=text, lang=&apos;en&apos;, slow=False)
        tts.save(temp_mp3)

        # Convert the MP3 to the target WAV file. This requires ffmpeg.
        print(&quot;🔧 Converting gTTS MP3 output to WAV for playback...&quot;)
        convert_mp3_to_wav(temp_mp3, output_wav_file)
        return True
    except Exception as e:
        print(f&quot;⚠️  Google TTS or conversion failed: {e}&quot;)
        return False
    finally:
        # Clean up the temporary MP3 file.
        if os.path.exists(temp_mp3):
            os.remove(temp_mp3)

def try_system_say(text, output_file, voice=None, rate=150):
    &quot;&quot;&quot;Use the native &apos;say&apos; command on macOS to generate a WAV file.&quot;&quot;&quot;
    if platform.system() != &quot;Darwin&quot;:
        return False

    try:
        # Build the &apos;say&apos; command arguments.
        cmd = [&apos;say&apos;]

        # Add voice if specified.
        if voice:
            cmd.extend([&apos;-v&apos;, voice])

        # Add speech rate.
        cmd.extend([&apos;-r&apos;, str(rate)])

        # Add the text to speak.
        cmd.append(text)

        # Specify the output file and a compatible audio format.
        cmd.extend([&apos;-o&apos;, output_file, &apos;--file-format=WAVE&apos;, &apos;--data-format=LEI16@22050&apos;])

        # Execute the command.
        subprocess.run(cmd, check=True, capture_output=True, text=True)
        return True
    except FileNotFoundError:
        print(&quot;⚠️  &apos;say&apos; command not found on this system.&quot;)
        return False
    except subprocess.CalledProcessError as e:
        print(f&quot;⚠️  System &apos;say&apos; command failed: {e.stderr}&quot;)
        return False

def convert_mp3_to_wav(mp3_file, wav_file):
    &quot;&quot;&quot;Convert MP3 to WAV using the ffmpeg command-line tool.&quot;&quot;&quot;
    try:
        cmd = [
            &apos;ffmpeg&apos;,
            &apos;-i&apos;, mp3_file,
            &apos;-acodec&apos;, &apos;pcm_s16le&apos;,  # Standard WAV format
            &apos;-ac&apos;, &apos;1&apos;,              # Mono audio
            &apos;-ar&apos;, &apos;22050&apos;,          # Sample rate for compatibility
            &apos;-y&apos;,                    # Overwrite output file if it exists
            wav_file
        ]
        subprocess.run(cmd, check=True, capture_output=True, text=True)
    except FileNotFoundError:
        print(&quot;❌ &apos;ffmpeg&apos; command not found. MP3 conversion requires ffmpeg to be installed.&quot;)
        print(&quot;💡 HINT: On macOS, run: brew install ffmpeg&quot;)
        raise
    except subprocess.CalledProcessError as e:
        print(f&quot;❌ ffmpeg failed to convert MP3 to WAV: {e.stderr}&quot;)
        raise

def convert_to_mp3(wav_file, mp3_file):
    &quot;&quot;&quot;Convert WAV file to MP3 using the ffmpeg command-line tool.&quot;&quot;&quot;
    try:
        cmd = [
            &apos;ffmpeg&apos;,
            &apos;-i&apos;, wav_file,
            &apos;-acodec&apos;, &apos;libmp3lame&apos;, # Standard MP3 codec
            &apos;-q:a&apos;, &apos;2&apos;,             # Good quality
            &apos;-y&apos;,                    # Overwrite output file if it exists
            mp3_file
        ]
        subprocess.run(cmd, check=True, capture_output=True, text=True)
        print(f&quot;💾 MP3 saved successfully: {mp3_file}&quot;)
    except FileNotFoundError:
        print(&quot;❌ &apos;ffmpeg&apos; command not found. MP3 conversion requires ffmpeg to be installed.&quot;)
        print(&quot;💡 HINT: On macOS, run: brew install ffmpeg&quot;)
        raise
    except subprocess.CalledProcessError as e:
        print(f&quot;❌ ffmpeg failed to convert WAV to MP3: {e.stderr}&quot;)
        raise

def play_audio(file_path):
    &quot;&quot;&quot;Play an audio file using pygame, with a system fallback.&quot;&quot;&quot;
    print(f&quot;🔊 Playing audio from: {file_path}&quot;)
    try:
        # Initialize pygame mixer with settings for better compatibility.
        pygame.mixer.pre_init(frequency=22050, size=-16, channels=1, buffer=512)
        pygame.mixer.init()

        pygame.mixer.music.load(file_path)
        pygame.mixer.music.play()

        print(&quot;🎵 Playing... (Press Ctrl+C to stop)&quot;)
        while pygame.mixer.music.get_busy():
            pygame.time.wait(100)
        print(&quot;✅ Playback finished!&quot;)

    except pygame.error as e:
        print(f&quot;⚠️  Pygame playback error: {e}&quot;)
        print(&quot;🔧 Falling back to system audio player...&quot;)
        try_system_playback(file_path)
    except KeyboardInterrupt:
        pygame.mixer.music.stop()
        print(&quot;\n⏹️  Playback stopped by user.&quot;)
    finally:
        pygame.mixer.quit()

def try_system_playback(file_path):
    &quot;&quot;&quot;Fallback audio playback using system commands.&quot;&quot;&quot;
    try:
        system = platform.system()
        if system == &quot;Darwin&quot;:
            subprocess.run([&apos;afplay&apos;, file_path], check=True)
        elif system == &quot;Linux&quot;:
            # Look for a common audio player on Linux.
            for player in [&apos;paplay&apos;, &apos;aplay&apos;, &apos;mpg123&apos;, &apos;mplayer&apos;]:
                if subprocess.run([&apos;which&apos;, player], capture_output=True).returncode == 0:
                    subprocess.run([player, file_path], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    return
            print(&quot;⚠️  No suitable command-line audio player found on Linux.&quot;)
        elif system == &quot;Windows&quot;:
            os.startfile(file_path)
        else:
            print(f&quot;⚠️  System playback not supported on platform: {system}&quot;)
    except Exception as e:
        print(f&quot;⚠️  System playback failed: {e}&quot;)
        print(f&quot;💡 You can play the file manually: {file_path}&quot;)

def list_voices():
    &quot;&quot;&quot;List available &apos;say&apos; command voices on macOS.&quot;&quot;&quot;
    if platform.system() != &quot;Darwin&quot;:
        print(&quot;Voice listing is only available on macOS via the &apos;say&apos; command.&quot;)
        return
    try:
        result = subprocess.run([&apos;say&apos;, &apos;-v&apos;, &apos;?&apos;], capture_output=True, text=True, check=True)
        print(&quot;Available macOS voices:&quot;)
        print(&quot;-&quot; * 50)
        print(result.stdout)
    except Exception as e:
        print(f&quot;❌ Error listing voices: {e}&quot;)

def main():
    parser = argparse.ArgumentParser(description=&quot;Convert text to speech with play and save options&quot;)
    parser.add_argument(&quot;text&quot;, nargs=&quot;?&quot;, help=&quot;Text to convert to speech. If omitted, enters interactive mode.&quot;)
    parser.add_argument(&quot;-f&quot;, &quot;--file&quot;, help=&quot;Read text from a file.&quot;)
    parser.add_argument(&quot;-s&quot;, &quot;--save&quot;, help=&quot;Save output as an MP3 file at the specified path.&quot;)
    parser.add_argument(&quot;-n&quot;, &quot;--no-play&quot;, action=&quot;store_true&quot;, help=&quot;Do not play the audio.&quot;)
    parser.add_argument(&quot;-r&quot;, &quot;--rate&quot;, type=int, default=175, help=&quot;Speech rate in words per minute (default: 175).&quot;)
    parser.add_argument(&quot;-m&quot;, &quot;--method&quot;, choices=[&quot;auto&quot;, &quot;pyttsx3&quot;, &quot;gtts&quot;, &quot;system&quot;],
                        default=&quot;auto&quot;, help=&quot;Specify the TTS engine to use.&quot;)
    parser.add_argument(&quot;--voice&quot;, help=&quot;For macOS, specify the voice to use (e.g., &apos;Alex&apos;, &apos;Samantha&apos;). See --list-voices.&quot;)
    parser.add_argument(&quot;--list-voices&quot;, action=&quot;store_true&quot;, help=&quot;List available voices for macOS and exit.&quot;)

    args = parser.parse_args()

    if args.list_voices:
        list_voices()
        return 0

    text_to_process = &quot;&quot;
    if args.file:
        try:
            with open(args.file, &apos;r&apos;, encoding=&apos;utf-8&apos;) as f:
                text_to_process = f.read().strip()
        except FileNotFoundError:
            print(f&quot;❌ Error: File not found at &apos;{args.file}&apos;&quot;)
            return 1
    elif args.text:
        text_to_process = args.text
    else:
        # Interactive mode if no text or file is provided
        try:
            print(&quot;🎙️  Entering interactive TTS mode. Type text and press Enter.&quot;)
            print(&quot;   (Type &apos;quit&apos; or &apos;exit&apos; to close)&quot;)
            while True:
                line = input(&quot;&gt; &quot;)
                if line.lower() in [&apos;quit&apos;, &apos;exit&apos;, &apos;q&apos;]:
                    break
                if line:
                     text_to_speech(
                        text=line,
                        play=not args.no_play,
                        save_mp3=None, # Saving is disabled in interactive mode for simplicity
                        method=args.method,
                        voice=args.voice,
                        rate=args.rate
                    )
            return 0
        except (EOFError, KeyboardInterrupt):
            print(&quot;\nExiting interactive mode.&quot;)
            return 0

    if not text_to_process:
        print(&quot;❌ Error: No text provided. Use a command-line argument, a file, or run in interactive mode.&quot;)
        return 1

    print(f&quot;\n📝 Text: {text_to_process[:80]}{&apos;...&apos; if len(text_to_process) &gt; 80 else &apos;&apos;}&quot;)
    print(f&quot;⚙️  Rate: {args.rate} WPM, Method: {args.method}, Play: {not args.no_play}&quot;)

    success = text_to_speech(
        text=text_to_process,
        play=not args.no_play,
        save_mp3=args.save,
        method=args.method,
        voice=args.voice,
        rate=args.rate
    )

    return 0 if success else 1

if __name__ == &quot;__main__&quot;:
    # Initialize pygame here to capture the &quot;Hello&quot; message once.
    if &quot;-n&quot; not in sys.argv and &quot;--no-play&quot; not in sys.argv:
        try:
            # Hide the pygame support prompt
            os.environ[&apos;PYGAME_HIDE_SUPPORT_PROMPT&apos;] = &quot;1&quot;
            import pygame
            pygame.init()
            pygame.quit() # We just want the module loaded
        except ImportError:
            print(&quot;⚠️ Pygame not found, playback will rely on system commands.&quot;)

    sys.exit(main())
```

## Running the Script

The beauty of using `uv` is that you can run this script immediately without any setup. The updated script uses `ffmpeg` for MP3 conversion instead of `pydub`, which provides better compatibility and audio quality. Save the script as `tts.py` and try these commands:

### Prerequisites

For basic text-to-speech functionality, no additional software is needed. However, for MP3 export and Google TTS features, you&apos;ll need `ffmpeg`:

```bash
# macOS
brew install ffmpeg

# Ubuntu/Debian
sudo apt install ffmpeg

# CentOS/RHEL
sudo yum install ffmpeg

# Windows
# Download from https://ffmpeg.org/ and add to PATH
```

### Basic Usage

```bash
# Simple text-to-speech
uv run tts.py &quot;Hello, world!&quot;

# Read from a file
uv run tts.py -f document.txt

# Save as MP3 without playing
uv run tts.py &quot;Save this text&quot; -s output.mp3 -n

# Interactive mode
uv run tts.py
```

### Advanced Voice Features

#### 1. List Available Voices

```bash
uv run tts.py --list-voices
```

This will show you all available voices on your macOS system:

```
Available macOS voices:
--------------------------------------------------
Alex                en_US    # Most people recognize me by my voice.
Samantha           en_US    # Hello, my name is Samantha. I am an English voice.
Victoria           en_GB    # Isn&apos;t it nice to have a conversation with someone who has a different accent?
Daniel             en_GB    # Hello, my name is Daniel. I am a British voice.
Fiona              en_GB    # Hello, my name is Fiona. I am a Scottish voice.
Karen              en_AU    # Hello, my name is Karen. I am an Australian voice.
```

#### 2. Choose a Specific Voice

```bash
# Use a specific voice
uv run tts.py &quot;Hello world&quot; --voice &quot;Samantha&quot;

# Try different voices
uv run tts.py &quot;Hello world&quot; --voice &quot;Alex&quot;
uv run tts.py &quot;Hello world&quot; --voice &quot;Victoria&quot;
```

#### 3. Adjust Speech Rate

```bash
# Slower speech (100 WPM)
uv run tts.py &quot;Hello world&quot; -r 100

# Faster speech (200 WPM)
uv run tts.py &quot;Hello world&quot; -r 200

# Very fast speech (300 WPM)
uv run tts.py &quot;Hello world&quot; -r 300
```

#### 4. Force System Method for Better Quality

```bash
# Force using the system &apos;say&apos; command with voice selection
uv run tts.py &quot;Hello world&quot; -m system --voice &quot;Samantha&quot; -r 160

# Best quality with custom voice and rate
uv run tts.py &quot;This is high quality speech&quot; -m system --voice &quot;Alex&quot; -r 150
```

## Popular macOS Voices

Here are some popular voices you can use:

- **Samantha** - Clear, natural female voice
- **Alex** - Default male voice, very clear
- **Victoria** - British female voice
- **Daniel** - British male voice
- **Fiona** - Scottish female voice
- **Karen** - Australian female voice
- **Jorge** - Spanish male voice
- **Paulina** - Spanish female voice

## Advanced Usage Examples

### Create Audio Books

```bash
# Convert an entire document to MP3
uv run tts.py -f book.txt -s audiobook.mp3 --voice &quot;Samantha&quot; -r 160 -n

# Multiple chapters
uv run tts.py -f chapter1.txt -s chapter1.mp3 --voice &quot;Alex&quot; -r 150 -n
uv run tts.py -f chapter2.txt -s chapter2.mp3 --voice &quot;Alex&quot; -r 150 -n
```

### Voice Comparison

```bash
# Compare different voices for the same text
uv run tts.py &quot;The quick brown fox jumps over the lazy dog&quot; --voice &quot;Alex&quot; -s alex.mp3 -n
uv run tts.py &quot;The quick brown fox jumps over the lazy dog&quot; --voice &quot;Samantha&quot; -s samantha.mp3 -n
uv run tts.py &quot;The quick brown fox jumps over the lazy dog&quot; --voice &quot;Victoria&quot; -s victoria.mp3 -n
```

### Interactive Learning

```bash
# Start interactive mode with custom settings
uv run tts.py --voice &quot;Samantha&quot; -r 140
```

Then type phrases and hear them instantly:
```
🎙️  Entering interactive TTS mode. Type text and press Enter.
   (Type &apos;quit&apos; or &apos;exit&apos; to close)
&gt; Hello, how are you today?
🔧 Trying method: system...
✅ Audio generated successfully using &apos;system&apos;!
🔊 Playing audio...
&gt; The weather is beautiful today.
🔧 Trying method: system...
✅ Audio generated successfully using &apos;system&apos;!
🔊 Playing audio...
&gt; quit
```

## Understanding the Script Architecture

### Smart Engine Selection

The script uses intelligent engine selection based on your platform:

1. **macOS**: Prefers `system` (say command) → `pyttsx3` → `gtts`
2. **Windows/Linux**: Prefers `pyttsx3` → `gtts` → `system`

### Engine Capabilities

| Engine | Pros | Cons | Best For |
|--------|------|------|----------|
| **system** | Highest quality, native voices | macOS only | Quality over speed |
| **pyttsx3** | Fast, offline, cross-platform | Limited voice options | Speed and reliability |
| **gtts** | Natural sounding, many languages | Requires internet | Natural speech |

### Error Handling

The script includes comprehensive error handling:

- **Network failures**: Falls back to offline engines
- **Missing dependencies**: Provides installation hints
- **File errors**: Clear error messages
- **Audio playback issues**: Multiple fallback methods

## Customization Options

### Adding New Voices

To add support for additional TTS engines:

```python
def try_custom_engine(text, output_file, rate=150):
    &quot;&quot;&quot;Add your custom TTS engine here.&quot;&quot;&quot;
    try:
        # Your custom implementation
        return True
    except Exception as e:
        print(f&quot;⚠️  Custom engine failed: {e}&quot;)
        return False

# Add to the method_preference list in text_to_speech()
```

### Language Support

Extend the script for multiple languages:

```python
def try_gtts_multilang(text, output_wav_file, lang=&apos;en&apos;):
    &quot;&quot;&quot;Enhanced gTTS with language support.&quot;&quot;&quot;
    try:
        tts = gTTS(text=text, lang=lang, slow=False)
        # ... rest of implementation
    except Exception as e:
        print(f&quot;⚠️  Multi-language TTS failed: {e}&quot;)
        return False
```

## Common Use Cases

### 1. Content Creation

```bash
# Create podcast intros
uv run tts.py &quot;Welcome to our podcast&quot; --voice &quot;Alex&quot; -s intro.mp3 -n

# Generate voice-overs
uv run tts.py -f script.txt -s voiceover.mp3 --voice &quot;Samantha&quot; -r 160 -n
```

### 2. Accessibility

```bash
# Read web content aloud
uv run tts.py &quot;$(curl -s https://example.com | grep -o &apos;&lt;p&gt;[^&lt;]*&apos; | sed &apos;s/&lt;p&gt;//&apos;)&quot; --voice &quot;Alex&quot;

# Convert emails to speech
uv run tts.py -f email.txt --voice &quot;Samantha&quot; -r 140
```

### 3. Language Learning

```bash
# Practice pronunciation
uv run tts.py &quot;Hello, my name is John&quot; --voice &quot;Alex&quot; -r 120
uv run tts.py &quot;Bonjour, je m&apos;appelle Jean&quot; -m gtts # Different language
```

### 4. Automation

```bash
# System notifications
uv run tts.py &quot;Backup completed successfully&quot; --voice &quot;Alex&quot; -r 180 -n

# Reminder system
uv run tts.py &quot;Time for your meeting&quot; --voice &quot;Samantha&quot;
```

## System Requirements

### macOS
- Built-in `say` command (included)
- **Required for MP3 export**: `ffmpeg` (`brew install ffmpeg`)

### Windows
- Windows Speech API (usually included)
- **Required for MP3 export**: `ffmpeg` (download from https://ffmpeg.org/)

### Linux
- `espeak` or `espeak-ng` package
- **Required for MP3 export**: `ffmpeg` (`sudo apt install ffmpeg`)

## Troubleshooting

### Common Issues

1. **&quot;&apos;ffmpeg&apos; command not found&quot;**
   - **This is required for MP3 export and Google TTS conversion**
   ```bash
   # macOS
   brew install ffmpeg
   
   # Ubuntu/Debian
   sudo apt install ffmpeg
   
   # CentOS/RHEL
   sudo yum install ffmpeg
   
   # Windows
   # Download from https://ffmpeg.org/ and add to PATH
   ```

2. **&quot;pygame not found&quot;**
   - The script will fall back to system audio players
   - Audio playback should still work

3. **&quot;Internet connection required&quot;**
   - Google TTS needs internet connectivity
   - Use `--method pyttsx3` or `--method system` for offline operation

4. **Voice not found**
   - Run `uv run tts.py --list-voices` to see available voices
   - Check spelling and capitalization

5. **&quot;ffmpeg failed to convert&quot;**
   - Ensure ffmpeg is properly installed and in your PATH
   - Try running `ffmpeg -version` to verify installation

### Performance Tips

1. **For fastest performance**: Use `--method pyttsx3`
2. **For best quality**: Use `--method system --voice &quot;Samantha&quot;`
3. **For natural speech**: Use `--method gtts` (requires internet and ffmpeg)
4. **For batch processing**: Use `-n` flag to skip playback
5. **For offline use**: Avoid `gtts` method and MP3 export if ffmpeg is not available

## Advanced Features

### Batch Processing Script

Create a batch processing script:

```python
# batch_tts.py
import os
import subprocess

texts = [
    &quot;Hello world&quot;,
    &quot;This is a test&quot;,
    &quot;Goodbye world&quot;
]

for i, text in enumerate(texts, 1):
    subprocess.run([
        &quot;uv&quot;, &quot;run&quot;, &quot;tts.py&quot;, text,
        &quot;-s&quot;, f&quot;output_{i}.mp3&quot;,
        &quot;--voice&quot;, &quot;Samantha&quot;,
        &quot;-r&quot;, &quot;150&quot;,
        &quot;-n&quot;
    ])
```

### Integration with Other Scripts

The script can be easily integrated into other applications:

```python
# integration_example.py
import subprocess

def speak_text(text, voice=&quot;Alex&quot;, rate=150):
    &quot;&quot;&quot;Wrapper function for the TTS script.&quot;&quot;&quot;
    subprocess.run([
        &quot;uv&quot;, &quot;run&quot;, &quot;tts.py&quot;, text,
        &quot;--voice&quot;, voice,
        &quot;-r&quot;, str(rate)
    ])

# Usage
speak_text(&quot;Hello from my application!&quot;)
```

## Why Use uv for TTS Scripts?

Our text-to-speech script demonstrates several advantages of using `uv`:

1. **Zero Setup**: No virtual environment management needed
2. **Dependency Isolation**: Each script runs in its own environment
3. **Fast Execution**: Dependencies are cached and reused
4. **Cross-Platform**: Works identically on macOS, Windows, and Linux
5. **Reproducible**: PEP 723 metadata ensures consistent behavior
6. **Simplified Dependencies**: Uses fewer Python packages by leveraging system tools like ffmpeg

&lt;Notice type=&quot;info&quot; title=&quot;Learn More About uv Scripts&quot;&gt;
    Want to understand more about running scripts with uv? Check out our detailed guide [Running Test Scripts with uv: No Dependencies Management Required](https://www.bitdoze.com/uv-run-scripts-guide/) for comprehensive script execution techniques.
&lt;/Notice&gt;

## Conclusion

Text-to-speech functionality is now more accessible than ever with `uv`. Our comprehensive script provides enterprise-level features while maintaining simplicity and ease of use. Whether you&apos;re creating audio content, building accessibility features, or experimenting with voice synthesis, this script provides a solid foundation.

The combination of multiple TTS engines, intelligent fallbacks, voice selection, and audio export makes this script suitable for both personal projects and professional applications. The fact that it runs with a single `uv run` command makes it incredibly convenient for quick text-to-speech tasks.

Key takeaways:
- **Multiple engines** - Automatic selection of the best available TTS engine
- **Voice control** - Access to system voices with customization options
- **Cross-platform** - Works consistently across different operating systems
- **Easy to use** - Simple command-line interface with powerful features
- **Extensible** - Easy to modify and extend for specific needs

Ready to start converting text to speech? Save the script and start experimenting with different voices and settings!

## Related Articles

- **Getting Started**: New to uv? Learn the basics in our guide [Getting Started with uv: Setting Up Your Python Project in 2025](https://www.bitdoze.com/uv-get-start/)
- **Script Execution**: Master advanced script techniques with [Running Test Scripts with uv: No Dependencies Management Required](https://www.bitdoze.com/uv-run-scripts-guide/)

### Try the Script Now

```bash
# Save the script as tts.py and try these commands:

# Basic usage
uv run tts.py &quot;Hello, world!&quot;

# List available voices
uv run tts.py --list-voices

# Use a specific voice
uv run tts.py &quot;Hello world&quot; --voice &quot;Samantha&quot;

# Save as MP3
uv run tts.py &quot;Save this text&quot; -s output.mp3

# Interactive mode
uv run tts.py
```

The script will automatically install all required dependencies (`pyttsx3`, `pygame`, `gtts`, `requests`) and provide you with a powerful text-to-speech system ready for immediate use! Note that MP3 conversion requires `ffmpeg` to be installed separately on your system.</content:encoded><category>ai</category><category>uv</category><category>python</category><category>text-to-speech</category></item><item><title>Running Test Scripts with uv: No Dependencies Management Required</title><link>https://www.bitdoze.com/uv-run-scripts-guide/</link><guid isPermaLink="true">https://www.bitdoze.com/uv-run-scripts-guide/</guid><description>Learn how to run Python test scripts instantly with uv without managing virtual environments or installing packages manually.</description><pubDate>Sun, 06 Jul 2025 00:00:00 GMT</pubDate><content:encoded>Run Python scripts with dependencies using `uv` without creating virtual environments or managing packages manually. Just add a header to your script and run it.

&lt;Notice type=&quot;info&quot; title=&quot;New to uv?&quot;&gt;
    Check out [Getting Started with uv](https://www.bitdoze.com/uv-get-start/) first.
&lt;/Notice&gt;

## Why uv?

Traditional Python workflow:
1. Create virtual environment
2. Activate it
3. Install dependencies
4. Run script
5. Clean up

With `uv`:
```bash
uv run --with requests myscript.py
```

Done. uv creates an isolated environment, installs dependencies, runs the script, and cleans up.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ozy02OXTkZo&quot;
  label=&quot;Running Python Scripts with uv: No Dependencies Management Required&quot;
/&gt;


## Running Scripts Without Dependencies

The simplest use case is running a script that only uses Python&apos;s standard library. With `uv`, this is straightforward:

```python
# test_basic.py
import os
import sys
import json

def system_info():
    info = {
        &quot;python_version&quot;: sys.version,
        &quot;platform&quot;: sys.platform,
        &quot;user_home&quot;: os.path.expanduser(&quot;~&quot;),
        &quot;current_directory&quot;: os.getcwd()
    }
    print(json.dumps(info, indent=2))

if __name__ == &quot;__main__&quot;:
    system_info()
```

Execute it with:
```bash
uv run test_basic.py
```

Output:
```json
{
  &quot;python_version&quot;: &quot;3.12.7 (main, Oct  1 2024, 11:15:50)&quot;,
  &quot;platform&quot;: &quot;darwin&quot;,
  &quot;user_home&quot;: &quot;/Users/username&quot;,
  &quot;current_directory&quot;: &quot;/path/to/current/directory&quot;
}
```

## Running Scripts with External Dependencies

Here&apos;s where `uv` really shines. Let&apos;s create a script that needs external packages:

```python
# test_api.py
import requests
import json
from datetime import datetime

def test_github_api():
    &quot;&quot;&quot;Test script to fetch GitHub API data&quot;&quot;&quot;
    try:
        response = requests.get(&quot;https://api.github.com/users/astral-sh&quot;)
        data = response.json()

        print(f&quot;✅ API Response Status: {response.status_code}&quot;)
        print(f&quot;🏢 Organization: {data.get(&apos;name&apos;, &apos;N/A&apos;)}&quot;)
        print(f&quot;📍 Location: {data.get(&apos;location&apos;, &apos;N/A&apos;)}&quot;)
        print(f&quot;👥 Public Repos: {data.get(&apos;public_repos&apos;, 0)}&quot;)
        print(f&quot;⏰ Tested at: {datetime.now().strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)}&quot;)

    except requests.exceptions.RequestException as e:
        print(f&quot;❌ API request failed: {e}&quot;)
    except KeyError as e:
        print(f&quot;❌ Unexpected API response format: {e}&quot;)

if __name__ == &quot;__main__&quot;:
    test_github_api()
```

Run it with the `--with` flag to specify dependencies:
```bash
uv run --with requests test_api.py
```

Output:
```
✅ API Response Status: 200
🏢 Organization: Astral
📍 Location: United States of America
👥 Public Repos: 47
⏰ Tested at: 2025-07-08 08:31:53
```

You can specify multiple dependencies:
```bash
uv run --with requests --with pandas --with matplotlib data_analysis.py
```

Or use version constraints:
```bash
uv run --with &apos;requests&gt;=2.31.0,&lt;3.0.0&apos; --with &apos;pandas&gt;=2.0.0&apos; test_script.py
```

## Using Inline Script Metadata (PEP 723)

For scripts you&apos;ll run multiple times, `uv` supports embedding dependency information directly in the script using Python&apos;s PEP 723 standard. This inline metadata format allows you to declare dependencies and Python requirements directly in your script files.

### Understanding the PEP 723 Header Format

The inline script metadata uses a special comment block at the top of your Python file:

```python
# /// script
# dependencies = [
#   &quot;package-name&gt;=version&quot;,
#   &quot;another-package&quot;
# ]
# requires-python = &quot;&gt;=3.10&quot;
# [tool.uv]
# exclude-newer = &quot;2024-01-01T00:00:00Z&quot;
# ///
```

Let&apos;s break down each field:

#### `dependencies`
- **Purpose**: Lists all Python packages required by your script
- **Format**: Array of package specifications using pip-style syntax
- **Examples**:
  - `&quot;requests&gt;=2.31.0&quot;` - Minimum version constraint
  - `&quot;pandas&gt;=2.0.0,&lt;3.0.0&quot;` - Version range
  - `&quot;rich&quot;` - Latest available version
  - `&quot;django==4.2.7&quot;` - Exact version pin

#### `requires-python`
- **Purpose**: Specifies the minimum Python version required
- **Format**: Version specification using PEP 440 syntax
- **Examples**:
  - `&quot;&gt;=3.10&quot;` - Python 3.10 or newer
  - `&quot;&gt;=3.11,&lt;3.13&quot;` - Python 3.11 or 3.12 only
  - `&quot;==3.12.*&quot;` - Any Python 3.12 version

#### `[tool.uv]` Section
Optional configuration specific to uv:
- **`exclude-newer`**: Only consider packages released before this date (improves reproducibility)
- **`index-url`**: Custom package index URL
- **`extra-index-url`**: Additional package indexes

### Complete Example with Detailed Metadata

Here&apos;s a comprehensive example showing all available PEP 723 fields:

```python
# /// script
# dependencies = [
#   &quot;requests&gt;=2.31.0&quot;,
#   &quot;rich&gt;=13.0.0&quot;,
#   &quot;click&gt;=8.0.0&quot;
# ]
# requires-python = &quot;&gt;=3.10&quot;
# [tool.uv]
# exclude-newer = &quot;2024-12-01T00:00:00Z&quot;
# ///

import requests
import click
from rich.console import Console
from rich.table import Table

console = Console()

@click.command()
@click.option(&apos;--username&apos;, prompt=&apos;GitHub username&apos;, help=&apos;GitHub username to analyze&apos;)
def analyze_github_user(username):
    &quot;&quot;&quot;Analyze a GitHub user&apos;s profile and repositories&quot;&quot;&quot;

    with console.status(f&quot;[bold green]Fetching data for {username}...&quot;):
        try:
            # Fetch user data
            user_response = requests.get(f&quot;https://api.github.com/users/{username}&quot;)
            user_response.raise_for_status()
            user_data = user_response.json()

            # Fetch repositories
            repos_response = requests.get(f&quot;https://api.github.com/users/{username}/repos&quot;)
            repos_response.raise_for_status()
            repos_data = repos_response.json()

        except requests.exceptions.RequestException as e:
            console.print(f&quot;[bold red]Error fetching data: {e}&quot;)
            return

    # Display user information
    console.print(f&quot;\n[bold blue]GitHub User Analysis: {username}[/bold blue]&quot;)
    console.print(f&quot;Name: {user_data.get(&apos;name&apos;, &apos;N/A&apos;)}&quot;)
    console.print(f&quot;Bio: {user_data.get(&apos;bio&apos;, &apos;N/A&apos;)}&quot;)
    console.print(f&quot;Public Repos: {user_data.get(&apos;public_repos&apos;, 0)}&quot;)
    console.print(f&quot;Followers: {user_data.get(&apos;followers&apos;, 0)}&quot;)
    console.print(f&quot;Following: {user_data.get(&apos;following&apos;, 0)}&quot;)

    # Create a table for top repositories
    table = Table(title=f&quot;Top Repositories for {username}&quot;)
    table.add_column(&quot;Repository&quot;, style=&quot;cyan&quot;)
    table.add_column(&quot;Stars&quot;, style=&quot;magenta&quot;)
    table.add_column(&quot;Language&quot;, style=&quot;green&quot;)
    table.add_column(&quot;Description&quot;, style=&quot;yellow&quot;)

    # Sort by stars and take top 10
    top_repos = sorted(repos_data, key=lambda x: x.get(&apos;stargazers_count&apos;, 0), reverse=True)[:10]

    for repo in top_repos:
        table.add_row(
            repo[&apos;name&apos;],
            str(repo.get(&apos;stargazers_count&apos;, 0)),
            repo.get(&apos;language&apos;, &apos;N/A&apos;),
            repo.get(&apos;description&apos;, &apos;N/A&apos;)[:50] + &quot;...&quot; if repo.get(&apos;description&apos;, &apos;&apos;) else &apos;N/A&apos;
        )

    console.print(table)

if __name__ == &quot;__main__&quot;:
    analyze_github_user()
```

The script will automatically install `requests`, `rich`, and `click` before execution.

### More PEP 723 Examples for Different Use Cases

#### Simple Data Analysis Script
```python
# /// script
# dependencies = [
#   &quot;pandas&gt;=2.0.0&quot;,
#   &quot;matplotlib&gt;=3.7.0&quot;
# ]
# requires-python = &quot;&gt;=3.9&quot;
# ///

import pandas as pd
import matplotlib.pyplot as plt

# Your data analysis code here
data = pd.read_csv(&apos;data.csv&apos;)
plt.plot(data[&apos;x&apos;], data[&apos;y&apos;])
plt.show()
```

#### Web API Testing Script
```python
# /// script
# dependencies = [
#   &quot;httpx&gt;=0.25.0&quot;,
#   &quot;pytest&gt;=7.0.0&quot;
# ]
# requires-python = &quot;&gt;=3.8&quot;
# [tool.uv]
# exclude-newer = &quot;2024-11-01T00:00:00Z&quot;
# ///

import httpx
import pytest

def test_api_endpoint():
    response = httpx.get(&quot;https://api.example.com/health&quot;)
    assert response.status_code == 200
```

#### Machine Learning Experiment
```python
# /// script
# dependencies = [
#   &quot;scikit-learn&gt;=1.3.0&quot;,
#   &quot;numpy&gt;=1.24.0&quot;,
#   &quot;joblib&gt;=1.3.0&quot;
# ]
# requires-python = &quot;&gt;=3.10&quot;
# [tool.uv]
# exclude-newer = &quot;2024-10-01T00:00:00Z&quot;
# ///

from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier

# Create sample data and train model
X, y = make_classification(n_samples=1000, n_features=20)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
model = RandomForestClassifier()
model.fit(X_train, y_train)
print(f&quot;Model accuracy: {model.score(X_test, y_test):.2f}&quot;)
```

### Benefits of Using PEP 723 Metadata

1. **Reproducibility**: Exact dependency versions are preserved
2. **Portability**: Scripts work the same way across different environments
3. **Self-documenting**: Dependencies are clearly visible in the script
4. **Version control friendly**: Everything is in one file
5. **No external files**: No need for separate `requirements.txt` or `pyproject.toml`
Save this as `github_analyzer.py` and run it simply with:
```bash
uv run github_analyzer.py
```


## Web Scraping and Markdown Conversion

One of the most powerful use cases for `uv` script execution is web scraping and content conversion. Let&apos;s create a script that accepts a URL, scrapes the content, and converts it to clean markdown format:

```python
# /// script
# dependencies = [
#   &quot;requests&gt;=2.31.0&quot;,
#   &quot;beautifulsoup4&gt;=4.12.0&quot;,
#   &quot;markdownify&gt;=0.11.6&quot;,
#   &quot;typer&gt;=0.9.0&quot;,
#   &quot;rich&gt;=13.0.0&quot;
# ]
# requires-python = &quot;&gt;=3.10&quot;
# ///

import requests
import typer
from bs4 import BeautifulSoup
from markdownify import markdownify as md
from rich.console import Console
from rich.markdown import Markdown
from rich.panel import Panel
from urllib.parse import urljoin, urlparse
import re
from typing import Optional
from datetime import datetime

console = Console()
app = typer.Typer()

def clean_markdown(markdown_text: str) -&gt; str:
    &quot;&quot;&quot;Clean and format markdown text&quot;&quot;&quot;
    # Remove excessive whitespace
    markdown_text = re.sub(r&apos;\n\s*\n\s*\n&apos;, &apos;\n\n&apos;, markdown_text)
    # Remove leading/trailing whitespace
    markdown_text = markdown_text.strip()
    # Fix list formatting
    markdown_text = re.sub(r&apos;\n\s*\*\s*&apos;, &apos;\n* &apos;, markdown_text)
    markdown_text = re.sub(r&apos;\n\s*\d+\.\s*&apos;, &apos;\n1. &apos;, markdown_text)
    return markdown_text

def extract_metadata(soup: BeautifulSoup) -&gt; dict:
    &quot;&quot;&quot;Extract page metadata&quot;&quot;&quot;
    metadata = {}

    # Title
    title_tag = soup.find(&apos;title&apos;)
    metadata[&apos;title&apos;] = title_tag.get_text().strip() if title_tag else &apos;No Title&apos;

    # Meta description
    description_tag = soup.find(&apos;meta&apos;, attrs={&apos;name&apos;: &apos;description&apos;})
    if description_tag:
        metadata[&apos;description&apos;] = description_tag.get(&apos;content&apos;, &apos;&apos;).strip()

    # Open Graph data
    og_title = soup.find(&apos;meta&apos;, property=&apos;og:title&apos;)
    og_description = soup.find(&apos;meta&apos;, property=&apos;og:description&apos;)

    if og_title:
        metadata[&apos;og_title&apos;] = og_title.get(&apos;content&apos;, &apos;&apos;).strip()
    if og_description:
        metadata[&apos;og_description&apos;] = og_description.get(&apos;content&apos;, &apos;&apos;).strip()

    return metadata

@app.command()
def scrape_to_markdown(
    url: str = typer.Argument(..., help=&quot;URL to scrape and convert to markdown&quot;),
    output_file: Optional[str] = typer.Option(None, &quot;--output&quot;, &quot;-o&quot;, help=&quot;Output file path&quot;),
    include_links: bool = typer.Option(True, &quot;--links/--no-links&quot;, help=&quot;Include links in output&quot;),
    include_images: bool = typer.Option(True, &quot;--images/--no-images&quot;, help=&quot;Include images in output&quot;),
    show_preview: bool = typer.Option(True, &quot;--preview/--no-preview&quot;, help=&quot;Show preview in terminal&quot;),
    selector: Optional[str] = typer.Option(None, &quot;--selector&quot;, &quot;-s&quot;, help=&quot;CSS selector for content extraction&quot;)
):
    &quot;&quot;&quot;
    Scrape a webpage and convert it to clean markdown format.

    Examples:
        uv run web_scraper.py https://example.com
        uv run web_scraper.py https://blog.example.com --output article.md
        uv run web_scraper.py https://docs.example.com --selector &quot;article&quot; --no-links
    &quot;&quot;&quot;

    # Validate URL
    if not url.startswith((&apos;http://&apos;, &apos;https://&apos;)):
        url = f&quot;https://{url}&quot;

    try:
        # Fetch the webpage
        with console.status(f&quot;[bold blue]Fetching content from {url}...&quot;):
            headers = {
                &apos;User-Agent&apos;: &apos;Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36&apos;
            }
            response = requests.get(url, headers=headers, timeout=10)
            response.raise_for_status()

        # Parse HTML
        soup = BeautifulSoup(response.content, &apos;html.parser&apos;)

        # Extract metadata
        metadata = extract_metadata(soup)

        # Remove script and style elements
        for script in soup([&quot;script&quot;, &quot;style&quot;, &quot;nav&quot;, &quot;footer&quot;, &quot;header&quot;, &quot;aside&quot;]):
            script.decompose()

        # Find content based on selector or common content areas
        if selector:
            content = soup.select_one(selector)
            if not content:
                console.print(f&quot;[bold red]No content found with selector: {selector}&quot;)
                return
        else:
            # Try common content selectors
            content_selectors = [
                &apos;article&apos;, &apos;main&apos;, &apos;.content&apos;, &apos;#content&apos;, &apos;.post&apos;, &apos;.article&apos;,
                &apos;.entry-content&apos;, &apos;.post-content&apos;, &apos;.article-content&apos;
            ]

            content = None
            for sel in content_selectors:
                content = soup.select_one(sel)
                if content:
                    break

            # If no specific content area found, use body
            if not content:
                content = soup.find(&apos;body&apos;)

        if not content:
            console.print(&quot;[bold red]No content found to extract&quot;)
            return

        # Convert to markdown
        markdown_options = {
            &apos;heading_style&apos;: &apos;ATX&apos;,
            &apos;bullets&apos;: &apos;*&apos;
        }

        # Add elements to convert based on options
        convert_tags = [&apos;p&apos;, &apos;h1&apos;, &apos;h2&apos;, &apos;h3&apos;, &apos;h4&apos;, &apos;h5&apos;, &apos;h6&apos;, &apos;ul&apos;, &apos;ol&apos;, &apos;li&apos;, &apos;strong&apos;, &apos;em&apos;, &apos;code&apos;, &apos;pre&apos;, &apos;blockquote&apos;]

        if include_links:
            convert_tags.extend([&apos;a&apos;])
        if include_images:
            convert_tags.extend([&apos;img&apos;])

        markdown_content = md(str(content), convert=convert_tags, **markdown_options)
        markdown_content = clean_markdown(markdown_content)

        # Create final markdown with metadata
        final_markdown = f&quot;&quot;&quot;---
title: &quot;{metadata[&apos;title&apos;]}&quot;
source_url: &quot;{url}&quot;
scraped_at: &quot;{datetime.now().isoformat()}&quot;
---

# {metadata[&apos;title&apos;]}

**Source:** [{url}]({url})

&quot;&quot;&quot;

        if metadata.get(&apos;description&apos;):
            final_markdown += f&quot;**Description:** {metadata[&apos;description&apos;]}\n\n&quot;

        final_markdown += &quot;---\n\n&quot;
        final_markdown += markdown_content

        # Show preview if requested
        if show_preview:
            console.print(&quot;\n&quot; + &quot;=&quot;*60)
            console.print(&quot;[bold green]Preview of scraped content:&quot;)
            console.print(&quot;=&quot;*60)

            # Show metadata
            metadata_text = f&quot;**Title:** {metadata[&apos;title&apos;]}\n**URL:** {url}\n**Content Length:** {len(markdown_content)} characters&quot;
            console.print(Panel(metadata_text, title=&quot;Metadata&quot;, border_style=&quot;blue&quot;))

            # Show markdown preview (truncated)
            preview_content = markdown_content[:1000] + &quot;...&quot; if len(markdown_content) &gt; 1000 else markdown_content
            console.print(Panel(Markdown(preview_content), title=&quot;Content Preview&quot;, border_style=&quot;green&quot;))

        # Save to file if specified
        if output_file:
            with open(output_file, &apos;w&apos;, encoding=&apos;utf-8&apos;) as f:
                f.write(final_markdown)
            console.print(f&quot;[bold green]✅ Content saved to: {output_file}&quot;)
        else:
            # Output to stdout
            console.print(&quot;\n&quot; + &quot;=&quot;*60)
            console.print(&quot;[bold green]Markdown Output:&quot;)
            console.print(&quot;=&quot;*60)
            console.print(final_markdown)

    except requests.exceptions.RequestException as e:
        console.print(f&quot;[bold red]❌ Error fetching URL: {e}&quot;)
    except Exception as e:
        console.print(f&quot;[bold red]❌ Error processing content: {e}&quot;)

if __name__ == &quot;__main__&quot;:
    app()
```

This powerful web scraping script can be used in multiple ways:

```bash
# Basic usage - scrape and preview
uv run web_scraper.py https://example.com

# Save to file
uv run web_scraper.py https://blog.example.com --output article.md

# Extract specific content using CSS selector
uv run web_scraper.py https://docs.example.com --selector &quot;article&quot;

# Scrape without links or images
uv run web_scraper.py https://news.example.com --no-links --no-images

# Just save without preview
uv run web_scraper.py https://example.com --output content.md --no-preview
```

The script features:
- **Automatic content detection** using common selectors
- **Clean markdown conversion** with proper formatting
- **Metadata extraction** including title, description, and Open Graph data
- **Flexible output options** (file or stdout)
- **Rich terminal preview** with syntax highlighting
- **Error handling** for network issues and parsing errors
- **Customizable content selection** via CSS selectors

## Creating Executable Scripts with Shebang

For scripts you use frequently, you can make them directly executable using a shebang:

```python
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
#   &quot;httpx&gt;=0.25.0&quot;,
#   &quot;typer&gt;=0.9.0&quot;
# ]
# requires-python = &quot;&gt;=3.10&quot;
# ///

import httpx
import typer
from typing import Optional

app = typer.Typer()

@app.command()
def check_website(
    url: str = typer.Argument(..., help=&quot;Website URL to check&quot;),
    timeout: Optional[int] = typer.Option(10, help=&quot;Request timeout in seconds&quot;)
):
    &quot;&quot;&quot;Check if a website is up and running&quot;&quot;&quot;

    if not url.startswith((&apos;http://&apos;, &apos;https://&apos;)):
        url = f&quot;https://{url}&quot;

    try:
        with httpx.Client(timeout=timeout) as client:
            response = client.get(url)

        if response.status_code == 200:
            typer.echo(f&quot;✅ {url} is UP (Status: {response.status_code})&quot;)
            typer.echo(f&quot;Response time: {response.elapsed.total_seconds():.2f}s&quot;)
        else:
            typer.echo(f&quot;⚠️  {url} returned status {response.status_code}&quot;)

    except httpx.RequestError as e:
        typer.echo(f&quot;❌ {url} is DOWN - {e}&quot;)
    except httpx.TimeoutException:
        typer.echo(f&quot;❌ {url} timed out after {timeout} seconds&quot;)

if __name__ == &quot;__main__&quot;:
    app()
```

Make it executable and run it:
```bash
chmod +x website_checker.py
./website_checker.py github.com
```


## Managing Python Versions in Scripts

You can specify Python versions for your scripts:

```bash
# Run with Python 3.11
uv run --python 3.11 test_script.py

# Run with Python 3.12
uv run --python 3.12 test_script.py

# Use specific Python version in inline metadata
```

```python
# /// script
# requires-python = &quot;&gt;=3.11&quot;
# dependencies = [&quot;asyncio&quot;, &quot;aiohttp&quot;]
# ///

import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = [
        &quot;https://httpbin.org/delay/1&quot;,
        &quot;https://httpbin.org/delay/2&quot;,
        &quot;https://httpbin.org/delay/3&quot;
    ]

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    print(f&quot;Fetched {len(results)} URLs successfully!&quot;)

if __name__ == &quot;__main__&quot;:
    asyncio.run(main())
```

## Best Practices for uv Scripts

### 1. Use Inline Metadata for Reusable Scripts
For scripts you&apos;ll run multiple times, embed dependencies directly in the file using PEP 723 syntax.

### 2. Specify Python Version Requirements
Always specify `requires-python` to ensure compatibility.

### 3. Use Version Constraints
Pin dependencies to avoid compatibility issues:
```python
# /// script
# dependencies = [
#   &quot;requests&gt;=2.31.0,&lt;3.0.0&quot;,
#   &quot;pandas&gt;=2.0.0,&lt;3.0.0&quot;
# ]
# ///
```

### 4. Handle Errors Gracefully
Always include error handling for network requests and file operations.

### 5. Make Scripts Self-Documenting
Include docstrings and clear variable names.

## Performance Benefits

The speed difference is remarkable:

- **Traditional approach**: 30-60 seconds for environment setup + dependency installation
- **uv approach**: 1-3 seconds for the same operation

This makes `uv` perfect for:
- **CI/CD pipelines** where speed matters
- **Development workflows** with frequent script execution
- **Data science experimentation** with different libraries
- **System administration tasks** requiring various tools

## Common Use Cases

### 1. API Testing and Monitoring
```bash
# Quick API health check
uv run --with requests api_health_check.py

# Test API with different HTTP methods
uv run --with httpx --with typer api_tester.py --method POST --url https://api.example.com
```

### 2. Data Processing and Analysis
```bash
# Process CSV files
uv run --with pandas --with matplotlib data_processor.py input.csv

# Web scraping and markdown conversion
uv run --with beautifulsoup4 --with requests --with markdownify web_scraper.py https://example.com

# Batch web scraping with analysis
uv run --with beautifulsoup4 --with requests --with markdownify --with pandas batch_scraper.py
```

### 3. System Administration
```bash
# Server monitoring
uv run --with psutil --with click server_monitor.py

# Log analysis
uv run --with click --with rich log_analyzer.py /var/log/app.log
```

### 4. Prototyping and Experimentation
```bash
# Test new libraries quickly
uv run --with fastapi --with uvicorn prototype_api.py

# Machine learning experiments
uv run --with scikit-learn --with matplotlib ml_experiment.py

# Content extraction and conversion
uv run --with beautifulsoup4 --with markdownify --with typer content_converter.py
```

## Conclusion

`uv` revolutionizes Python script execution by eliminating the friction of dependency management. Whether you&apos;re testing APIs, processing data, or prototyping applications, `uv` lets you focus on your code rather than environment setup.

The combination of speed, simplicity, and standards compliance makes `uv` an essential tool for modern Python development. Start using it today for your test scripts and experience the difference!

Key takeaways:
- **Zero setup required** - just write and run
- **Lightning fast** - 10-100x faster than traditional tools
- **Standards compliant** - uses PEP 723 for inline metadata
- **Flexible** - supports ad-hoc dependencies and inline metadata
- **Isolated** - each script runs in its own environment
- **Versatile** - perfect for web scraping, data processing, and content conversion

Ready to supercharge your Python scripting workflow? Install `uv` and start running scripts at the speed of thought!

## Related Articles

- **Getting Started**: New to uv? Learn the basics in our guide [Getting Started with uv: Setting Up Your Python Project in 2025](https://www.bitdoze.com/uv-get-start/)
- **Text-to-Speech**: Build a powerful audio generation script with [Text-to-Speech with uv: Create Audio from Text in Python](https://www.bitdoze.com/uv-text-to-speech-script/)
- **Deployment**: Ready to deploy your uv projects? Check out [Deploying a Python uv Project with Git and Railpack in Dokploy](https://www.bitdoze.com/dokploy-python-railpack-uv/)

### Try the Web Scraping Example

Save the web scraping script as `web_scraper.py` and try it out:

```bash
# Scrape any webpage to markdown
uv run web_scraper.py https://docs.python.org/3/tutorial/

# Save to file
uv run web_scraper.py https://realpython.com/python-requests/ --output tutorial.md

# Extract specific content
uv run web_scraper.py https://github.com/astral-sh/uv --selector &quot;article&quot;
```

The script will automatically install all dependencies (`requests`, `beautifulsoup4`, `markdownify`, `typer`, `rich`) and provide you with clean, formatted markdown output ready for documentation, analysis, or further processing.</content:encoded><category>tools</category><category>uv</category><category>python</category></item><item><title>Fix Warning: Waiting for Transaction Lock on /var/lib/rpm/.rpm.lock</title><link>https://www.bitdoze.com/fix-rpm-transaction-lock-waiting/</link><guid isPermaLink="true">https://www.bitdoze.com/fix-rpm-transaction-lock-waiting/</guid><description>Learn how to resolve the RPM transaction lock error that prevents package installation and management on Linux systems.</description><pubDate>Fri, 27 Jun 2025 00:00:00 GMT</pubDate><content:encoded>Seeing this error when running `yum` or `dnf`?

```sh
warning: waiting for transaction lock on /var/lib/rpm/.rpm.lock
```

Another package process is running, or a previous one crashed and left a lock file behind. Here&apos;s how to fix it.

## What Causes This

1. **Concurrent operations** - Multiple `yum`/`dnf` commands running
2. **Crashed processes** - Previous install/update was interrupted
3. **Stale locks** - System crash left lock files behind

## The Fix

### Step 1: Kill stuck processes
```bash
sudo pkill -9 -f &apos;dnf|yum|rpm&apos;
```

### Step 2: Remove lock files
```bash
sudo rm -f /var/lib/rpm/.rpm.lock
sudo rm -f /var/cache/dnf/metadata_lock.pid
```

### Step 3: Rebuild RPM database (if needed)
```bash
mkdir /var/lib/rpm/backup
cp -a /var/lib/rpm/__db* /var/lib/rpm/backup/
rm -f /var/lib/rpm/__db.[0-9][0-9]*
rpm --rebuilddb
yum clean all
```

## Quick Fix (No Database Rebuild)

If you just need to clear the lock:
```bash
sudo pkill -9 -f &apos;dnf|yum|rpm&apos;
sudo rm -f /var/lib/rpm/.rpm.lock
sudo rm -f /var/cache/dnf/metadata_lock.pid
```

## Verify It Worked

```bash
rpm -qa | head -5
sudo yum update
```

## Prevention

1. Don&apos;t run multiple package commands at once
2. Check what&apos;s running before starting:
   ```bash
   ps aux | grep -E &apos;yum|dnf|rpm&apos;
   ```
3. Use `Ctrl+C` to stop, don&apos;t kill -9
4. Keep automated scripts from overlapping

## Still Broken?

Check logs:
```bash
sudo journalctl -xe | grep -E &apos;yum|dnf|rpm&apos;
ls -la /var/lib/rpm/
```</content:encoded><category>self-hosting</category><category>linux</category></item><item><title>Getting Started with TanStack Start And Convex: Your SaaS Foundation</title><link>https://www.bitdoze.com/tanstack-start-get-start/</link><guid isPermaLink="true">https://www.bitdoze.com/tanstack-start-get-start/</guid><description>Learn how to get started with TanStack Start And Convex and start building your first SaaS</description><pubDate>Tue, 29 Apr 2025 00:00:00 GMT</pubDate><content:encoded>Welcome to the first article in our series on building a modern SaaS application! We&apos;ll use **TanStack Start**, a cutting-edge React framework, alongside [Convex](https://www.convex.dev/), Clerk, Radix UI, and Polar.sh to create a full-stack app.

**Important Note**: TanStack Start is in Alpha/Beta. APIs may change before a stable release. Embrace the innovation, but expect potential updates!

## What is TanStack Start?

[TanStack Start](https://tanstack.com/start/latest) is a modern React framework from the TanStack team, known for libraries like TanStack Query and Router. It offers:

- **File-based Routing**: Similar to Next.js or Remix.
- **Server-Side Rendering (SSR)**: With client-side hydration.
- **Integrated Data Fetching**: Seamless with TanStack Query.
- **Vite Tooling**: Fast development and builds.

## What is Convex?

[Convex](https://www.convex.dev/) is a backend-as-a-service platform that simplifies building scalable, real-time applications. It provides:

- **Realtime Database**: Automatically syncs data changes to clients, ideal for dynamic apps. Learn more in the [Convex Database Docs](https://docs.convex.dev/database).
- **Serverless Functions**: Write [queries](https://docs.convex.dev/functions/query-functions) (for reading data) and [mutations](https://docs.convex.dev/functions/mutation-functions) (for writing data) in TypeScript/JavaScript, hosted by Convex.
- **Type Safety**: Shares types between frontend and backend, reducing errors.
- **Dashboard**: A web-based [Convex Dashboard](https://docs.convex.dev/dashboard) to manage data, view logs, and debug.
- **Additional Features**: File storage, scheduled tasks, and vector search.

Convex integrates seamlessly with TanStack Start via the `@convex-dev/react-query` package, allowing you to fetch and mutate data using TanStack Query hooks.

## Installation (The Easy Way with Convex)

Use the Convex template to scaffold a project with TanStack Start and Convex pre-configured:

```bash
npm create convex@latest -- -t tanstack-start my-saas-app
cd my-saas-app
```

This creates a project in `my-saas-app` with React, TypeScript, TanStack Start, and Convex client setup. The template includes a `convex/` directory for backend logic and pre-configures environment variables.

For manual setup, refer to the [TanStack Start Getting Started Guide](https://tanstack.com/start/latest/docs/getting-started) and integrate Convex in the next article.

## Starting the Development Environment

Run the development server to initialize your app and Convex backend:

```bash
npm run dev
```

You&apos;ll see output like this:

```
&gt; my-saas-app@1.0.0 dev
&gt; npx convex dev --once &amp;&amp; npm-run-all --parallel dev:convex dev:start

? Welcome to Convex! Would you like to login to your account? Start without an account (run Convex locally)
Let&apos;s set up your first project.
? Choose a name: my-saas-app
This command, `npx convex dev`, will run your Convex backend locally and update it with the function you write in the `convex/` directory.
Use `npx convex dashboard` to view and interact with your project from a web UI.
Use `npx convex docs` to read the docs and `npx convex help` to see other commands.
? Continue? Yes
✔ Downloaded Convex backend binary
✔ Downloaded Convex dashboard
✔ Started running a deployment locally at http://127.0.0.1:3210 and saved its:
    name as CONVEX_DEPLOYMENT to .env.local
    URL as VITE_CONVEX_URL to .env.local
Run `npx convex login` at any time to create an account and link this deployment.

Write your Convex functions in convex/
Give us feedback at https://convex.dev/community or support@convex.dev
View the Convex dashboard at http://127.0.0.1:6790/?d=anonymous-my-saas-app

✔ 11:29:40 Convex functions ready! (959.55ms)

&gt; my-saas-app@1.0.0 dev:start
&gt; vinxi dev

&gt; my-saas-app@1.0.0 dev:convex
&gt; convex dev

vinxi v0.4.3
vinxi starting dev server

♻️  Generating routes...
✔ Started running a deployment locally at http://127.0.0.1:3210 and saved its name as CONVEX_DEPLOYMENT to .env.local
Run `npx convex login` at any time to create an account and link this deployment.

Write your Convex functions in convex/
Give us feedback at https://convex.dev/community or support@convex.dev
View the Convex dashboard at http://127.0.0.1:6790/?d=anonymous-my-saas-app

⠋ Preparing Convex functions...
⠙ Preparing Convex functions...

  ➜ Local:    http://localhost:3000/
  ➜ Network:  use --host to expose
✔ 11:29:42 Convex functions ready! (839.54ms)
Warning: A notFoundError was encountered on the route with ID &quot;__root__&quot;, but a notFoundComponent option was not configured...
```

**What’s Happening?**

- **Convex Initialization**: `npx convex dev --once` sets up a local Convex backend, prompting you to name your project (e.g., `my-saas-app`). It runs at `http://127.0.0.1:3210` and saves `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT` to `.env.local`. These variables connect your frontend to the Convex backend.
- **Parallel Execution**: `npm-run-all --parallel dev:convex dev:start` runs the Convex dev server (`npx convex dev`) and Vite dev server (`vinxi dev`) concurrently.
- **Vite Server**: Your app is available at `http://localhost:3000` (port may vary). Open this URL to see your app.
- **Convex Dashboard**: Visit `http://127.0.0.1:6790/?d=anonymous-my-saas-app` to manage your backend data, view logs, and test functions. Learn more about the dashboard in the [Convex Dashboard Docs](https://docs.convex.dev/dashboard).
- **Not Found Warning**: The `notFoundError` indicates TanStack Router needs a custom 404 component, which we’ll address below.

**Tip**: Run `npx convex dashboard` to open the dashboard directly, or `npx convex login` to link your local deployment to a Convex account for cloud syncing.

## Understanding the Project Structure

Key files and directories:

```
.
├── app/
│   ├── routes/             # Page routes
│   │   ├── __root.tsx      # Root layout (HTML structure, global providers)
│   │   └── index.tsx       # Homepage component (&apos;/&apos;)
│   ├── client.tsx          # Client-side entry (hydrates SSR)
│   ├── router.tsx          # Router setup (integrates Convex/Clerk)
│   ├── routeTree.gen.ts    # Auto-generated route tree
│   └── ssr.tsx             # Server-side rendering entry
├── convex/                 # Backend logic (Convex functions, schema)
│   ├── schema.ts           # Database schema (defines tables)
│   └── ...                 # Backend functions (queries, mutations)
├── public/                 # Static assets
├── .env.local              # Environment variables (e.g., VITE_CONVEX_URL)
├── app.config.ts           # TanStack Start config
├── convex.config.ts        # Convex config
├── package.json
├── tsconfig.json           # TypeScript config
└── vite.config.ts          # Vite config
```

- **app/routes/__root.tsx**: Defines the app’s layout, including `&lt;html&gt;`, `&lt;head&gt;`, `&lt;body&gt;`, and common UI like headers. `&lt;Outlet /&gt;` renders child routes.
- **app/routes/index.tsx**: Renders the `/` route (homepage).
- **convex/**: Contains backend logic. `schema.ts` defines your database structure (see [Convex Schema Docs](https://docs.convex.dev/database/schemas)). Other files will hold queries and mutations.
- **.env.local**: Stores sensitive keys like `VITE_CONVEX_URL`. Never commit this file to Git.

## Basic Routing

TanStack Start uses file-based routing via TanStack Router:

- **app/routes/index.tsx**: Uses `createFileRoute(&apos;/&apos;)` to define the homepage.
- **app/routes/__root.tsx**: Uses `createRootRouteWithContext` to set up the root layout and context (e.g., `queryClient` for TanStack Query, `convexClient` for Convex).

## Adding a Header and Footer

Let’s add a header and footer to `app/routes/__root.tsx` to provide consistent navigation and branding across all pages:

```tsx
// app/routes/__root.tsx
import { QueryClient } from &quot;@tanstack/react-query&quot;;
import { createRootRouteWithContext, Link } from &quot;@tanstack/react-router&quot;;
import { Outlet, ScrollRestoration } from &quot;@tanstack/react-router&quot;;
import { Meta, Scripts } from &quot;@tanstack/start&quot;;
import * as React from &quot;react&quot;;
import { ConvexQueryClient } from &quot;@convex-dev/react-query&quot;;
import { ConvexReactClient } from &quot;convex/react&quot;;

interface MyRouterContext {
  queryClient: QueryClient;
  convexClient: ConvexReactClient;
  convexQueryClient: ConvexQueryClient;
}

export const Route = createRootRouteWithContext&lt;MyRouterContext&gt;()({
  head: () =&gt; ({
    meta: [
      { charSet: &quot;utf-8&quot; },
      { name: &quot;viewport&quot;, content: &quot;width=device-width, initial-scale=1&quot; },
      { title: &quot;My SaaS App&quot; },
    ],
  }),
  notFoundComponent: () =&gt; (
    &lt;div style={{ textAlign: &apos;center&apos;, padding: &apos;2rem&apos; }}&gt;
      &lt;h1&gt;404 - Page Not Found&lt;/h1&gt;
      &lt;Link to=&quot;/&quot;&gt;Go Home&lt;/Link&gt;
    &lt;/div&gt;
  ),
  component: RootComponent,
});

function RootComponent() {
  return (
    &lt;RootDocument&gt;
      &lt;header
        style={{
          padding: &apos;1rem&apos;,
          borderBottom: &apos;1px solid #eee&apos;,
          background: &apos;#f8f9fa&apos;,
          position: &apos;sticky&apos;,
          top: 0,
          zIndex: 10,
        }}
        role=&quot;banner&quot;
      &gt;
        &lt;nav aria-label=&quot;Main navigation&quot;&gt;
          &lt;Link
            to=&quot;/&quot;
            style={{
              marginRight: &apos;1rem&apos;,
              fontWeight: &apos;bold&apos;,
              color: &apos;#333&apos;,
              textDecoration: &apos;none&apos;,
            }}
            aria-label=&quot;Home page&quot;
          &gt;
            My SaaS App
          &lt;/Link&gt;
        &lt;/nav&gt;
      &lt;/header&gt;
      &lt;main style={{ padding: &apos;1rem&apos;, minHeight: &apos;80vh&apos; }} role=&quot;main&quot;&gt;
        &lt;Outlet /&gt;
      &lt;/main&gt;
      &lt;footer
        style={{
          padding: &apos;1rem&apos;,
          borderTop: &apos;1px solid #eee&apos;,
          textAlign: &apos;center&apos;,
          background: &apos;#f8f9fa&apos;,
        }}
        role=&quot;contentinfo&quot;
      &gt;
        &lt;p&gt;© {new Date().getFullYear()} My SaaS App. All rights reserved.&lt;/p&gt;
      &lt;/footer&gt;
    &lt;/RootDocument&gt;
  );
}

function RootDocument({ children }: { children: React.ReactNode }) {
  return (
    &lt;html lang=&quot;en&quot;&gt;
      &lt;head&gt;
        &lt;Meta /&gt;
      &lt;/head&gt;
      &lt;body&gt;
        {children}
        &lt;ScrollRestoration /&gt;
        &lt;Scripts /&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
```

**Detailed Explanation of Header and Footer Code**:

- **RootComponent**: This function defines the core layout of your application, rendered for every route. It wraps the `&lt;header&gt;`, `&lt;main&gt;`, and `&lt;footer&gt;` in a `RootDocument` component, which provides the HTML structure (`&lt;html&gt;`, `&lt;head&gt;`, `&lt;body&gt;`).
  - **Purpose**: Ensures consistent UI elements (like navigation and branding) appear on all pages.
  - **Structure**: Uses semantic HTML elements (`&lt;header&gt;`, `&lt;main&gt;`, `&lt;footer&gt;`) for accessibility and SEO.

- **Header**:
  - **Element**: `&lt;header role=&quot;banner&quot;&gt;` marks the header as the primary banner of the page, improving accessibility for screen readers.
  - **Styling**:
    - `padding: &apos;1rem&apos;`: Adds spacing for a clean look.
    - `borderBottom: &apos;1px solid #eee&apos;`: Adds a subtle divider.
    - `background: &apos;#f8f9fa&apos;`: Uses a light gray background for visual distinction.
    - `position: &apos;sticky&apos;, top: 0, zIndex: 10`: Keeps the header fixed at the top during scrolling, ensuring navigation is always accessible.
  - **Navigation**:
    - `&lt;nav aria-label=&quot;Main navigation&quot;&gt;`: Labels the navigation for accessibility.
    - `&lt;Link to=&quot;/&quot; ...&gt;My SaaS App&lt;/Link&gt;`: Uses TanStack Router’s `Link` component for client-side navigation to the homepage (`/`).
      - **Styling**: `fontWeight: &apos;bold&apos;, color: &apos;#333&apos;, textDecoration: &apos;none&apos;` makes the link prominent and removes the default underline.
      - **Accessibility**: `aria-label=&quot;Home page&quot;` provides context for screen readers.
  - **Purpose**: The header provides a consistent navigation bar, starting with a single link to the homepage. Later articles will add more links (e.g., to chat or pricing pages).

- **Main**:
  - **Element**: `&lt;main role=&quot;main&quot;&gt;` designates the primary content area, aiding accessibility.
  - **Styling**: `padding: &apos;1rem&apos;, minHeight: &apos;80vh&apos;` ensures content is spaced and the main area takes up most of the viewport height, preventing the footer from appearing too high on short pages.
  - **Outlet**: `&lt;Outlet /&gt;` is a TanStack Router component that renders the content of the current route (e.g., `index.tsx` for `/`).

- **Footer**:
  - **Element**: `&lt;footer role=&quot;contentinfo&quot;&gt;` marks the footer as supplementary information, enhancing accessibility.
  - **Styling**:
    - `padding: &apos;1rem&apos;`: Adds spacing.
    - `borderTop: &apos;1px solid #eee&apos;`: Adds a divider.
    - `textAlign: &apos;center&apos;`: Centers the text.
    - `background: &apos;#f8f9fa&apos;`: Matches the header’s background for consistency.
  - **Content**: Displays a dynamic copyright notice using the current year (`new Date().getFullYear()`).
  - **Purpose**: Provides a professional touch and space for additional links or information in the future.

- **RootDocument**:
  - **Purpose**: Defines the HTML structure required for SSR and client-side rendering.
  - **Components**:
    - `&lt;Meta /&gt;`: Injects metadata (e.g., `&lt;title&gt;`, `&lt;meta&gt;` tags) defined in the `head` function.
    - `&lt;ScrollRestoration /&gt;`: Ensures scroll position is preserved during navigation.
    - `&lt;Scripts /&gt;`: Includes JavaScript bundles for client-side interactivity.
  - **Accessibility**: Sets `lang=&quot;en&quot;` on `&lt;html&gt;` for language clarity.

- **NotFoundComponent**:
  - Addresses the `notFoundError` warning by providing a custom 404 page.
  - Includes a simple message and a link back to the homepage.

- **Accessibility Considerations**:
  - Semantic elements (`header`, `main`, `footer`) and ARIA roles (`banner`, `main`, `contentinfo`) improve screen reader compatibility.
  - `aria-label` on navigation elements enhances usability for assistive technologies.
  - Styling ensures sufficient contrast (e.g., `#333` text on `#f8f9fa` background).

- **Extensibility**: The header’s `&lt;nav&gt;` can later include additional `&lt;Link&gt;` components for routes like `/chat` or `/pricing`, as shown in later articles.


## Troubleshooting Common Issues

- **Port Conflicts**: If `localhost:3000` is taken, Vite uses another port (check terminal output).
- **Convex Setup Fails**: Ensure internet connectivity. Run `npx convex dev --once` manually if needed. Check the [Convex Troubleshooting Docs](https://docs.convex.dev/troubleshooting).
- **Not Found Warning**: The `notFoundComponent` above resolves this. Alternatively, set `defaultNotFoundComponent` in `app/router.tsx`.
- **Environment Variables**: Verify `.env.local` contains `VITE_CONVEX_URL` and `CONVEX_DEPLOYMENT`. See [Convex Environment Variables](https://docs.convex.dev/deployment/environment-variables).
- **Convex Dashboard Access**: If the dashboard URL fails, run `npx convex dashboard` or check firewall settings.

Next, we’ll integrate Convex for the backend and Clerk for authentication, building on the foundation laid here!</content:encoded><category>web-development</category><category>tanstack</category><category>react</category></item><item><title>Building an AI Agent with Agno and Context7 MCP</title><link>https://www.bitdoze.com/agno-mcp-tools-context7/</link><guid isPermaLink="true">https://www.bitdoze.com/agno-mcp-tools-context7/</guid><description>How to connect Agno to Context7&apos;s MCP server for accessing up-to-date library documentation and code snippets through AI agents</description><pubDate>Thu, 24 Apr 2025 00:00:00 GMT</pubDate><content:encoded>In the rapidly evolving world of software development, staying updated with the latest library documentation and code examples is crucial. By integrating **Agno**, a powerful AI agent framework, with **[Context7&apos;s MCP](https://context7.com) (Managed Context Provider) server**, you can create intelligent AI agents capable of querying real-time documentation and code snippets for any software library. This guide provides a detailed, step-by-step process to set up and deploy such an AI agent, complete with enhanced explanations, best practices, and a robust example.

## Why Use Context7 with Agno?

**Context7** is a specialized service that delivers up-to-date documentation and code examples optimized for large language models (LLMs) and AI-driven code editors. When paired with **Agno**, an AI agent framework designed for extensibility and tool integration, you can build agents that:

- **Dynamically fetch** the latest API documentation for any library.
- **Retrieve relevant code snippets** to demonstrate library usage.
- **Answer complex queries** about library features, usage patterns, or troubleshooting.
- **Streamline developer workflows** by providing instant, context-aware assistance for coding, debugging, and learning.

This integration is ideal for developers, educators, and teams looking to enhance productivity and reduce the time spent searching for documentation.

## Prerequisites

Before diving into the setup, ensure you have the following:

- [**Python 3.12+** installed with uv](https://www.bitdoze.com/uv-get-start/)
- [**Node.js and npm**](https://www.bitdoze.com/install-nodejs-using-nvm-macos-ubuntu/) for running the Context7 MCP server.
- An **OpenAI API key** for powering the AI model.
- Basic familiarity with Python, asynchronous programming, and command-line tools.

## Step 1: Setting Up Your Environment

### Install Required Packages

Install the necessary Python packages using pip:

```bash
uv add agno openai mcp
```

Additionally, ensure you have Node.js installed to run the Context7 MCP server. You can verify this by running:

```bash
node --version
npm --version
```

### Set Environment Variables

Set your OpenAI API key as an environment variable to authenticate API requests:

- **Linux/macOS**:
  ```bash
  export OPENAI_API_KEY=&apos;your-openai-api-key&apos;
  ```

- **Windows (Command Prompt)**:
  ```cmd
  set OPENAI_API_KEY=your-openai-api-key
  ```

Alternatively, you can set the variable in your Python script or a `.env` file using a library like `python-dotenv`.

**Best Practice**: Store sensitive keys in environment variables or a secure vault to prevent accidental exposure in code.

## Step 2: Configure MCP to Use Context7

The Context7 MCP server is launched using a Node.js command. Define the server configuration in your Python code as follows:

```python
mcp_servers_config = {
    &quot;context7&quot;: {
        &quot;command&quot;: &quot;npx&quot;,
        &quot;args&quot;: [&quot;-y&quot;, &quot;@upstash/context7-mcp@latest&quot;]
    }
}
```

This configuration specifies that the `npx` command will run the latest version of the Context7 MCP server. The `-y` flag ensures automatic confirmation for npm prompts.

**Note**: Ensure you have an active internet connection, as `npx` downloads the latest `@upstash/context7-mcp` package on demand.

## Step 3: Understanding Context7 MCP Integration

The Context7 MCP server acts as a bridge between your AI agent and Context7’s vast repository of library documentation and code snippets. Key capabilities include:

- **Dynamic Search**: Query library APIs and documentation in real time.
- **Code Snippet Retrieval**: Fetch practical, up-to-date code examples for specific library features.
- **Natural Language Queries**: Allow the agent to interpret user questions and map them to relevant documentation.
- **Scalability**: Handle queries for thousands of libraries across programming languages and frameworks.

By integrating with Agno, the agent can leverage these capabilities through structured tool calls, making it a powerful assistant for developers.

## Step 4: Creating an AI Agent Using Context7

To create the AI agent, you need to:

1. Initialize the MCPTools with the Context7 command.
2. Set up an Agno agent with an OpenAIChat model (e.g., GPT-4.1-mini).
3. Provide detailed instructions for querying Context7 MCP.

Here’s an example of the initialization code:

```python
from agno.tools.mcp import MCPTools
from agno.agent import Agent
from agno.models.openai import OpenAIChat

async def main():
    # Context7 MCP command
    command = &quot;npx -y @upstash/context7-mcp@latest&quot;


    async with MCPTools(command, args) as mcp_tools:
        agent = Agent(
            name=&quot;Agno Context7 Doc Agent&quot;,
            role=&quot;An AI agent that provides up-to-date library documentation and code snippets using Context7 MCP.&quot;,
            model=OpenAIChat(id=&quot;gpt-4.1-mini&quot;),
            tools=[mcp_tools],
            instructions=&apos;&apos;&apos;
You are an AI assistant that helps users find up-to-date library documentation and code snippets via Context7 MCP.

When a user asks for documentation:
1. Identify the first word or phrase in the user&apos;s request as the library name (e.g., &quot;agno&quot; in &quot;agno mcp tools&quot;).
2. Use the `resolve-library-id` tool with the identified library name to get the Context7-compatible ID.
3. Consider the rest of the user&apos;s request *after* the library name as the topic for the `get-library-docs` tool (e.g., &quot;mcp tools&quot; in &quot;agno mcp tools&quot;).
4. Use the `get-library-docs` tool with the resolved library ID and the topic identified in step 3.
5. Use the default token limit of 5000 unless the user specifies a different number.
6. Return the documentation and code snippets in a clear and concise format based on the tool results.

When constructing MCP tool calls:
- `resolve-library-id` requires a `libraryName` string.
- `get-library-docs` requires a `context7CompatibleLibraryID` string.
- Include the `topic` parameter for `get-library-docs` if a topic was identified.
- Optionally include the `tokens` parameter for `get-library-docs` if specified by the user.

Always explain your reasoning and show tool call results in a user-friendly format.

**Example Interaction**:
User: &quot;requests how to make a GET request&quot;
1. Library name: &quot;requests&quot;
2. Topic: &quot;how to make a GET request&quot;
3. Call `resolve-library-id` with `libraryName=&quot;requests&quot;`
4. Call `get-library-docs` with the resolved ID and `topic=&quot;how to make a GET request&quot;`
5. Format and return the results
&apos;&apos;&apos;,
            show_tool_calls=True,
            add_state_in_messages=True,
            markdown=True
        )

        # Interaction loop will be added in the next step
```

### Key Configuration Details

- **Model**: The `OpenAIChat(id=&quot;gpt-4.1-mini&quot;)` specifies a lightweight, efficient model. You can upgrade to a more powerful model (e.g., `gpt-4.1`) for complex queries.
- **Tools**: The `MCPTools` instance connects the agent to the Context7 MCP server.
- **Instructions**: The detailed instructions ensure the agent correctly interprets user queries and uses the MCP tools effectively.
- **Markdown**: Enabling `markdown=True` ensures formatted, readable output.

## Step 5: Complete Async Example with User Interaction

Below is the complete Python script that sets up the environment, creates the agent, and implements an interactive loop for user queries:

```python
import os
import asyncio
from agno.tools.mcp import MCPTools
from agno.agent import Agent
from agno.models.openai import OpenAIChat

if &quot;OPENAI_API_KEY&quot; not in os.environ:
    raise ValueError(&quot;OPENAI_API_KEY environment variable is not set.&quot;)


def print_welcome():
    print(&apos;&apos;&apos;
Agno Context7 Documentation Agent
---------------------------------
Ask questions about libraries or functions.
Type &apos;exit&apos; or &apos;quit&apos; to stop.
&apos;&apos;&apos;)


async def main():
    print_welcome()

    command = &quot;npx -y @upstash/context7-mcp@latest&quot;

    async with MCPTools(command) as mcp_tools:
        agent = Agent(
            name=&quot;Agno Context7 Doc Agent&quot;,
            role=&quot;An AI assistant that fetches up-to-date docs and code snippets using Context7 MCP.&quot;,
            model=OpenAIChat(id=&quot;gpt-4.1-mini&quot;),
            tools=[mcp_tools],
            instructions=&apos;&apos;&apos;
You are an AI assistant that helps users find up-to-date library documentation and code snippets via Context7 MCP.

When a user asks for documentation:
1. Identify the first word or phrase in the user&apos;s request as the library name (e.g., &quot;agno&quot; in &quot;agno mcp tools&quot;).
2. Use the `resolve-library-id` tool with the identified library name to get the Context7-compatible ID.
3. Consider the rest of the user&apos;s request *after* the library name as the topic for the `get-library-docs` tool (e.g., &quot;mcp tools&quot; in &quot;agno mcp tools&quot;).
4. Use the `get-library-docs` tool with the resolved library ID and the topic identified in step 3.
5. Use the default token limit of 5000 unless the user specifies a different number.
6. Return the documentation and code snippets in a clear and concise format based on the tool results.

When constructing MCP tool calls:
- `resolve-library-id` requires a `libraryName` string.
- `get-library-docs` requires a `context7CompatibleLibraryID` string.
- Include the `topic` parameter for `get-library-docs` if a topic was identified.
- Optionally include the `tokens` parameter for `get-library-docs` if specified by the user.

Always explain your reasoning and show tool call results.
&apos;&apos;&apos;,
            show_tool_calls=True,
            add_state_in_messages=True,
            markdown=True
        )
        while True:
            user_input = input(&quot;\nYou: &quot;).strip()
            if user_input.lower() in {&quot;exit&quot;, &quot;quit&quot;}:
                print(&quot;Goodbye!&quot;)
                break

            try:
                await agent.aprint_response(user_input, stream=True)
            except Exception as e:
                print(f&quot;\n❌ Error: {str(e)}&quot;)


if __name__ == &quot;__main__&quot;:
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print(&quot;\nInterrupted. Exiting.&quot;)
)
```

### Enhancements in the Example

- **Welcome Message**: A clear, formatted welcome message guides users on how to interact with the agent.
- **Error Handling**: Robust exception handling ensures the agent gracefully handles interruptions and errors.
- **Streamed Responses**: The `stream=True` parameter provides real-time output, improving user experience.
- **Example Interaction**: The instructions include a sample query to clarify how the agent processes requests.

## Step 6: Testing the Agent

To test the agent, run the script and try queries like:

- `requests how to make a GET request`
- `numpy array operations`
- `pandas merge dataframes`

The agent will:

1. Extract the library name (e.g., `requests`).
2. Resolve the Context7-compatible ID using `resolve-library-id`.
3. Fetch documentation and snippets for the specified topic (e.g., `how to make a GET request`).
4. Display the results in a formatted, markdown-compatible output.

**Example Output** (for `requests how to make a GET request`):
```
**Library**: requests
**Topic**: how to make a GET request

**Documentation**:
The `requests.get()` method is used to send a GET request to a specified URL.
```
**Code Snippet**:
```python
import requests
response = requests.get(&apos;https://api.example.com/data&apos;)
print(response.json())
```

**Tool Calls**:
1. `resolve-library-id(libraryName=&quot;requests&quot;)` -&gt; `context7_id: requests-py`
2. `get-library-docs(context7CompatibleLibraryID=&quot;requests-py&quot;, topic=&quot;how to make a GET request&quot;)`


## Step 7: Best Practices and Optimization

- **Token Limits**: Adjust the `tokens` parameter in `get-library-docs` for large or small documentation needs.
- **Caching**: Implement caching for frequently queried libraries to reduce MCP server calls.
- **Error Handling**: Log errors to a file or monitoring service for debugging in production.
- **Model Selection**: Use a more powerful model (e.g., `gpt-4.1`) for complex queries involving multiple libraries.
- **Security**: Validate user inputs to prevent injection attacks when constructing tool calls.

## Other MCP tools and AGNO Toolkit

There is a big number off MCP server that can be used you can create any MCP connection with Agno like for Supabase, Airtable and a lot more, you can find the complete list on
[Model Context Protocol servers](https://github.com/modelcontextprotocol/servers)

Agno has his own toolkit with tools that are ready to go and you can easely integrate them. You can see: [Agno Toolkit](https://docs.agno.com/tools/toolkits/toolkits)


## Final Thoughts

Integrating **Context7 MCP** with **Agno** creates a powerful AI agent that delivers real-time, contextually relevant programming documentation and code examples. This setup enhances developer productivity, supports learning, and enables rapid prototyping by providing instant access to the latest library resources.

You can extend this agent by:

- Adding support for multiple MCP servers for different documentation sources.
- Integrating with code editors (e.g., VS Code) for in-editor assistance.
- Building specialized agents for specific domains (e.g., web development, data science).

To package this as a repository, consider creating a GitHub repository with:

- The script as `main.py`.
- A `README.md` explaining setup and usage.
- A `requirements.txt` for dependencies.
- Example queries in a `docs/` folder.</content:encoded><category>ai</category><category>ai-agents</category><category>agno</category><category>uv</category></item><item><title>Crafting Beginner-Friendly Tech Articles with Agno Workflows and Streamlit</title><link>https://www.bitdoze.com/agno-workflow-writing-team/</link><guid isPermaLink="true">https://www.bitdoze.com/agno-workflow-writing-team/</guid><description>Discover how to build a powerful AI-driven workflow with Agno to research, outline, write, and edit technical articles tailored for beginners, all wrapped in a sleek Streamlit interface.</description><pubDate>Tue, 15 Apr 2025 00:00:00 GMT</pubDate><content:encoded>Writing a technical article that’s clear, engaging, and beginner-friendly is no small feat. You need to research credible sources, structure the content logically, write detailed explanations, and polish it to perfection—all while keeping it accessible. What if you could automate this process with AI? Enter [Agno](https://www.agno.com), a lightweight Python library that lets you build multi-agent workflows to handle complex tasks like article writing. Pair it with **Streamlit** for a slick user interface and **uv** for a blazing-fast setup, and you’ve got a powerful tool to create high-quality content in minutes.

In this tutorial, we’ll guide you through building an **Agno Workflow** called `BeginnerArticleWorkflow`, based on the `beginner_article_workflow_streamlit.py` code. This workflow researches a topic, outlines an article, writes beginner-focused sections, and edits the final draft, all displayed in a Streamlit app. We’ll break down the code in detail, explain each component, and show you how to set it up. By the end, you’ll have a fully functional article-writing pipeline that’s perfect for bloggers, educators, or anyone who wants to make tech accessible. Let’s get started!



## What You’ll Build

The `BeginnerArticleWorkflow` automates the creation of technical articles tailored for beginners. Here’s what it does:

- **Researches** a topic using web searches and content extraction, prioritizing beginner-friendly sources.
- **Outlines** the article with a clear, logical structure, including a title and SEO keywords.
- **Writes** detailed sections with code snippets, explanations, and Markdown formatting.
- **Edits** the draft for clarity, consistency, and polish.
- **Caches** intermediate results in a SQLite database to save time.
- **Displays** everything in a **Streamlit** app, where you can input topics and download the article as Markdown.

The workflow uses four **Agno Agents**:
- **Researcher**: Finds and summarizes sources.
- **Outliner**: Creates the article structure.
- **Writer**: Crafts each section.
- **Editor**: Polishes the final draft.

## Prerequisites

Before diving in, ensure you have:

- **Python 3.12** or later (we’ll use `uv` to manage it).
- An **OpenRouter API key** from [OpenRouter](https://openrouter.ai).
- Basic Python knowledge and comfort with the command line.
- A desire to create awesome content with AI!

## Step 1: Setting Up Your Environment with uv

We’ll use **uv**, a super-fast package manager, to set up our project. If you’re new to uv, check our [guide on getting started with uv](https://www.bitdoze.com/uv-get-start/).

### Installing uv

- **macOS/Linux**:
  ```bash
  curl -LsSf https://astral.sh/uv/install.sh | sh
  ```
- **Windows (PowerShell)**:
  ```powershell
  irm https://astral.sh/uv/install.ps1 | iex
  ```
- Verify installation:
  ```bash
  uv --version
  ```

### Creating the Project

- Initialize a new project:
  ```bash
  uv init agno-article-writer
  cd agno-article-writer
  ```
- Pin Python to 3.12:
  ```bash
  uv python pin 3.12
  ```
- Create a virtual environment:
  ```bash
  uv venv
  source .venv/bin/activate  # Windows: .venv\Scripts\activate
  ```
- Install dependencies:
  ```bash
  uv add agno streamlit python-dotenv pydantic openai duckduckgo-search crawl4ai sqlalchemy
  ```

### Setting Up Environment Variables

- Create a `.env` file:
  ```bash
  echo &quot;OPENROUTER_API_KEY=your_openrouter_key&quot; &gt; .env
  ```
- Replace `your_openrouter_key` with your OpenRouter API key.
- **Why?** This keeps your key secure and loads it automatically with `python-dotenv`.

**Pro Tip**: Always use a `.env` file to avoid hardcoding sensitive data in your code.

## Step 2: Understanding the Workflow Structure

The `BeginnerArticleWorkflow` is an Agno Workflow that coordinates four agents to produce a polished article. Let’s explore its high-level structure.

### Workflow Stages

- **Cache Check**: Looks for a cached article to skip redundant work.
- **Research**: Finds beginner-friendly sources and summarizes them.
- **Outline**: Creates a structured article plan.
- **Writing**: Writes each section with clear, beginner-focused content.
- **Editing**: Polishes the draft for consistency and clarity.

### Agents and Their Roles

- **Researcher**: Uses DuckDuckGo for searches and Crawl4ai to extract content, focusing on tutorials and guides.
- **Outliner**: Designs a logical article structure with a catchy title and SEO keywords.
- **Writer**: Crafts detailed sections, explaining technical concepts simply and including code where relevant.
- **Editor**: Refines the draft, ensuring it’s beginner-friendly and well-formatted.

### Pydantic Models

- **ResearchFinding**: Stores a source URL, summary, and optional snippet.
- **ResearchSummary**: Combines multiple findings with an overall summary.
- **ArticleOutline**: Defines the title, sections, and keywords.
- **SectionDraft**: Holds a section’s title and Markdown content.

These models ensure data is structured and validated at each step.

## Step 3: Diving into the Code

Let’s break down the `beginner_article_workflow_streamlit.py` code, explaining each part with a focus on clarity.

### Imports and Setup

```python
import os
import json
import logging
import re
import traceback
import time
from textwrap import dedent
from typing import Dict, Iterator, List, Optional

import streamlit as st
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError

from agno.agent import Agent
from agno.models.openrouter import OpenRouter
from agno.run.response import RunEvent, RunResponse
from agno.storage.sqlite import SqliteStorage
from agno.tools.crawl4ai import Crawl4aiTools
from agno.tools.duckduckgo import DuckDuckGoTools
from agno.workflow import Workflow
```

- **Purpose**: Imports libraries for:
  - File handling (`os`), JSON processing, logging, and regex.
  - Streamlit for the UI, Pydantic for data validation.
  - Agno components for workflows, agents, and tools.
- **Logging Setup**:
  ```python
  logging.basicConfig(level=logging.INFO, format=&apos;%(asctime)s - %(levelname)s - %(message)s&apos;)
  logger = logging.getLogger(__name__)
  ```
  - Logs progress and errors to the terminal, helping debug issues.

- **Environment Variables**:
  ```python
  load_dotenv()
  OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
  ```
  - Loads the API key from `.env` using `python-dotenv`.

### Custom JSON Serializer

```python
def default_serializer(obj):
    &quot;&quot;&quot;JSON serializer for objects not serializable by default json code&quot;&quot;&quot;
    if isinstance(obj, set):
        return list(obj)
    raise TypeError(f&quot;Object of type {type(obj).__name__} is not JSON serializable by default_serializer&quot;)
```

- **Purpose**: Converts sets to lists for JSON serialization, used when passing data to agents.
- **Why?** Some Pydantic models may include sets, which JSON doesn’t support natively.

### Pydantic Models

```python
class ResearchFinding(BaseModel):
    url: str = Field(..., description=&quot;Source URL of the information.&quot;)
    summary: str = Field(..., description=&quot;Concise summary of the key information relevant to the topic.&quot;)
    content_snippet: Optional[str] = Field(None, description=&quot;A relevant short quote or snippet from the source.&quot;)

class ResearchSummary(BaseModel):
    key_findings: List[ResearchFinding] = Field(..., description=&quot;A list of key findings from the research.&quot;)
    overall_summary: str = Field(..., description=&quot;A brief overall synthesis of the research conducted.&quot;)

class ArticleOutline(BaseModel):
    title: str = Field(..., description=&quot;Proposed title for the article, engaging for beginners.&quot;)
    sections: List[str] = Field(..., description=&quot;A list of section titles for the article structure...&quot;)
    keywords: List[str] = Field(..., description=&quot;List of relevant SEO keywords...&quot;)

class SectionDraft(BaseModel):
    section_title: str = Field(..., description=&quot;The title of the section being drafted.&quot;)
    content: str = Field(..., description=&quot;The drafted content for this section, formatted in Markdown...&quot;)
```

- **ResearchFinding**:
  - Captures one source’s URL, a summary, and an optional quote.
  - Example: A tutorial’s URL with a summary of its key points.
- **ResearchSummary**:
  - Aggregates multiple findings and adds a synthesis.
  - Ensures research is structured for the next stage.
- **ArticleOutline**:
  - Defines the article’s title (e.g., “Python for Beginners”), sections (e.g., “What is Python?”), and keywords (e.g., “learn python”).
- **SectionDraft**:
  - Holds one section’s title and content, formatted in Markdown.
- **Why Pydantic?** Enforces data types and validates output, preventing errors like missing fields.

### Workflow Class

```python
class BeginnerArticleWorkflow(Workflow):
    description: str = &quot;Generates beginner-friendly technical articles.&quot;
    researcher: Agent
    outliner: Agent
    writer: Agent
    editor: Agent
```

- **Inherits**: From `agno.workflow.Workflow`, providing caching and session management.
- **Attributes**: Declares four agents as class variables, initialized later.

#### Initialization

```python
def __init__(
    self,
    api_key: str,
    model_id: str,
    max_tokens: int,
    session_id: str,
    storage: Optional[SqliteStorage] = None,
    debug_mode: bool = False,
    max_writer_retries: int = 2,
):
    super().__init__(session_id=session_id, storage=storage, debug_mode=debug_mode)
    self.max_writer_retries = max_writer_retries

    if not api_key:
        raise ValueError(&quot;OpenRouter API Key is required for BeginnerArticleWorkflow.&quot;)

    common_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: max_tokens}
    writer_tokens = max(max_tokens, 8192)
    editor_tokens = max(max_tokens, 8192)
    writer_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: writer_tokens}
    editor_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: editor_tokens}
```

- **Parameters**:
  - `api_key`: OpenRouter key for model access.
  - `model_id`: LLM identifier (e.g., `openrouter/optimus-alpha`).
  - `max_tokens`: Limits model output size.
  - `session_id`: Unique ID for caching.
  - `storage`: SQLite storage for caching.
  - `debug_mode`: Logs extra details if `True`.
  - `max_writer_retries`: Number of retries for writing sections.
- **Token Settings**:
  - Ensures Writer and Editor have at least 8192 tokens for longer outputs.
- **Validation**:
  - Raises an error if no API key is provided.

#### Agent Definitions

Each agent is an `Agent` instance with specific tools and instructions.

- **Researcher**:
  ```python
  self.researcher = Agent(
      name=&quot;TechResearcherBeginnerFocus&quot;,
      model=OpenRouter(**common_model_args),
      tools=[DuckDuckGoTools(search=True, news=True), Crawl4aiTools(max_length=10000)],
      description=&quot;Expert tech researcher finding and synthesizing information...&quot;,
      instructions=dedent(&quot;&quot;&quot;\
          Your goal is to research the given topic thoroughly, focusing on information accessible to beginners...
      &quot;&quot;&quot;),
      response_model=ResearchSummary, markdown=True, add_history_to_messages=False, exponential_backoff=True
  )
  ```
  - **Tools**: DuckDuckGo for searches, Crawl4ai for extracting content.
  - **Instructions**: Prioritizes beginner-friendly sources (tutorials, guides) and outputs a `ResearchSummary`.
  - **Settings**:
    - `markdown=True`: Formats output nicely.
    - `exponential_backoff=True`: Retries on API failures.
    - `add_history_to_messages=False`: No chat history, as it’s a one-shot task.

- **Outliner**:
  ```python
  self.outliner = Agent(
      name=&quot;BeginnerArticleOutliner&quot;,
      model=OpenRouter(**common_model_args),
      description=&quot;Structures technical articles logically for beginners.&quot;,
      instructions=dedent(&quot;&quot;&quot;\
          Given a research summary, create a logical article outline tailored for beginners...
      &quot;&quot;&quot;),
      add_history_to_messages=False, response_model=ArticleOutline, markdown=False, exponential_backoff=True
  )
  ```
  - Takes `ResearchSummary` and produces an `ArticleOutline`.
  - Suggests sections like “Introduction,” “Key Concepts,” and “Next Steps.”
  - Focuses on logical learning progression.

- **Writer**:
  ```python
  self.writer = Agent(
      name=&quot;BeginnerTechWriter&quot;,
      model=OpenRouter(**writer_model_args),
      description=&quot;Writes a detailed, engaging technical article *section* specifically for beginners.&quot;,
      instructions=dedent(&quot;&quot;&quot;\
          You are a skilled senior technical writer specializing in making complex topics easy for **beginners**...
      &quot;&quot;&quot;),
      response_model=SectionDraft, add_history_to_messages=False, markdown=True, exponential_backoff=True
  )
  ```
  - Writes one section at a time, using research and outline.
  - Emphasizes clarity, simple explanations, and Markdown formatting.
  - Explains code step-by-step, e.g., for a Python script:
    ```markdown
    ```python
    print(&quot;Hello, World!&quot;)
    ```
    This line outputs &quot;Hello, World!&quot; to the screen...
    ```

- **Editor**:
  ```python
  self.editor = Agent(
      name=&quot;BeginnerFocusedEditor&quot;,
      model=OpenRouter(**editor_model_args),
      description=&quot;Polishes a full article draft, ensuring clarity for beginners.&quot;,
      instructions=dedent(&quot;&quot;&quot;\
          You are reviewing a complete article draft (in Markdown) assembled from sections...
      &quot;&quot;&quot;),
      add_history_to_messages=False, markdown=True, exponential_backoff=True
  )
  ```
  - Refines the draft for consistency, grammar, and beginner-friendliness.
  - Checks Markdown formatting and section alignment.

#### Caching Methods

```python
def get_cached_data(self, key: str) -&gt; Optional[Dict]:
    return self.session_state.get(key)

def add_data_to_cache(self, key: str, data: BaseModel):
    logger.info(f&quot;Caching data for key: {key}&quot;)
    self.session_state[key] = data.model_dump()

def get_cached_final_article(self, topic_key: str) -&gt; Optional[str]:
    key = f&quot;final_article_{topic_key}&quot;
    return self.session_state.get(key)

def add_final_article_to_cache(self, topic_key: str, article: str):
    key = f&quot;final_article_{topic_key}&quot;
    logger.info(f&quot;Caching final article for key: {key}&quot;)
    self.session_state[key] = article
```

- **Purpose**: Store and retrieve research, outline, sections, and final article.
- **Storage**: Uses `SqliteStorage` to persist data in `tmp/agno_beginner_workflows.db`.
- **Benefit**: Skips redundant API calls, saving time and costs.

#### Run Method

```python
def run(self, topic: str, use_cache: bool = True) -&gt; Iterator[RunResponse]:
    logger.info(f&quot;Starting BeginnerArticleWorkflow for topic: &apos;{topic}&apos;&quot;)
    topic_key = re.sub(r&apos;[^\w\-]+&apos;, &apos;_&apos;, topic).strip(&apos;_&apos;).lower()
```

- **Stages**:
  - **Cache Check**:
    ```python
    if use_cache:
        cached_article = self.get_cached_final_article(topic_key)
        if cached_article:
            logger.info(&quot;Returning cached final article.&quot;)
            yield RunResponse(event=RunEvent.workflow_completed, content=cached_article)
            return
    ```
    - Returns cached article if available.
  - **Research**:
    ```python
    research_cache_key = f&quot;research_{topic_key}&quot;
    research_data: Optional[ResearchSummary] = None
    if use_cache:
        cached_research_dict = self.get_cached_data(research_cache_key)
        if cached_research_dict:
            try:
                research_data = ResearchSummary.model_validate(cached_research_dict)
                logger.info(&quot;Using cached research data.&quot;)
            except ValidationError as e:
                logger.warning(f&quot;Cached research data invalid: {e}. Re-running research.&quot;)
    if research_data is None:
        research_response: RunResponse = self.researcher.run(topic)
        research_data = research_response.content
        self.add_data_to_cache(research_cache_key, research_data)
    ```
    - Checks cache, runs Researcher if needed, and caches results.
  - **Outline**:
    - Similar logic, producing an `ArticleOutline`.
  - **Writing**:
    ```python
    for i, section_title in enumerate(outline_data.sections):
        writer_input_dict = {
            &quot;research_data&quot;: research_data.model_dump(),
            &quot;outline_data&quot;: outline_data.model_dump(),
            &quot;section_title&quot;: section_title
        }
        writer_input_json = json.dumps(writer_input_dict, default=default_serializer)
        for attempt in range(self.max_writer_retries + 1):
            section_response = self.writer.run(writer_input_json)
            if section_response and isinstance(section_response.content, SectionDraft):
                all_section_content[section_title] = section_response.content.content
                break
    ```
    - Writes each section, retries on failure, and stores content.
  - **Assembly**:
    ```python
    assembled_draft_parts = [f&quot;# {outline_data.title}\n&quot;]
    for section_title in outline_data.sections:
        assembled_draft_parts.append(f&quot;\n## {section_title}\n&quot;)
        section_content = all_section_content.get(section_title, f&quot;\n_[Content missing]_\n&quot;)
        assembled_draft_parts.append(section_content.strip() + &quot;\n&quot;)
    assembled_draft = &quot;\n&quot;.join(assembled_draft_parts)
    ```
    - Combines sections into a draft.
  - **Editing**:
    ```python
    editor_input_dict = {&quot;draft_content&quot;: assembled_draft, &quot;outline&quot;: outline_data.model_dump()}
    editor_response: RunResponse = self.editor.run(json.dumps(editor_input_dict, default=default_serializer))
    final_article = editor_response.content
    ```
    - Polishes the draft and caches the result.
  - **Output**:
    ```python
    self.add_final_article_to_cache(topic_key, final_article)
    yield RunResponse(event=RunEvent.workflow_completed, content=final_article)
    ```
    - Yields the final article.

### Streamlit Interface

```python
st.set_page_config(page_title=&quot;Beginner Article Workflow&quot;, page_icon=&quot;✍️&quot;, layout=&quot;wide&quot;)
```

- **Sidebar**:
  - Configures API key, model, max tokens, and caching.
  - Example:
    ```python
    st.session_state.api_key = st.text_input(&quot;OpenRouter API Key&quot;, type=&quot;password&quot;, ...)
    st.session_state.model_id = st.selectbox(&quot;Select Model&quot;, options=available_models, ...)
    ```
- **Main UI**:
  - Displays chat history and accepts topic input.
  - Shows progress and final article, with a download button:
    ```python
    st.download_button(
        label=&quot;Download Article (Markdown)&quot;,
        data=final_article_content,
        file_name=f&quot;{safe_filename}.md&quot;,
        mime=&quot;text/markdown&quot;,
        key=f&quot;download_now_wf_{topic_key}&quot;
    )
    ```

## Step 4: Running the Workflow

- Save the code as `beginner_article_workflow_streamlit.py`.
- Run the app:
  ```bash
  uv run streamlit run beginner_article_workflow_streamlit.py
  ```
- Open `http://localhost:8501` in your browser.

### How to Use It

- **Configure**:
  - Enter your OpenRouter API key (or use `.env`).
  - Choose a model (e.g., `openrouter/optimus-alpha`).
  - Set max tokens (8192 is fine).
  - Enable caching to save time.
- **Enter a Topic**: Try “Introduction to Python for Beginners.”
- **Watch It Work**: The app shows progress and displays the article.
- **Download**: Save the article as a `.md` file.

## Step 5: How It Works in Action

For a topic like “Introduction to Python for Beginners”:

- **Cache Check**: Looks for a cached article in `tmp/agno_beginner_workflows.db`.
- **Research**: Finds tutorials on DuckDuckGo, crawls them, and creates a `ResearchSummary`.
- **Outline**: Produces:
  ```json
  {
      &quot;title&quot;: &quot;Getting Started with Python: A Beginner’s Guide&quot;,
      &quot;sections&quot;: [&quot;What is Python?&quot;, &quot;Setting Up Python&quot;, &quot;Your First Program&quot;, ...],
      &quot;keywords&quot;: [&quot;python tutorial&quot;, &quot;learn python&quot;, &quot;beginner&quot;]
  }
  ```
- **Writing**: Writes sections like:
  ```markdown
  ## Setting Up Python
  Let’s install Python:
  1. Visit [python.org](https://www.python.org)...
  ```
- **Editing**: Ensures clarity and consistency.
- **Output**: Displays the article and caches it.

## Step 6: Example Output

```markdown
# Getting Started with Python: A Beginner’s Guide

## What is Python?
Python is a simple, versatile programming language...

## Setting Up Python
1. **Download**: Go to [python.org](https://www.python.org)...
```

- **Features**:
  - Clear explanations.
  - Step-by-step code breakdowns.
  - Beginner-friendly tone.

## Troubleshooting

- **API Key Issues**:
  - Verify your key in `.env` or the sidebar.
- **No Output**:
  - Check terminal logs.
  - Set `debug_mode=True` in the workflow.
- **Cache Problems**:
  - Delete `tmp/agno_beginner_workflows.db` or disable caching.
- **Slow Response**:
  - Try a faster model like `google/gemini-flash-1.5`.
  - Increase `max_tokens` for longer sections.

## Why Use Agno Workflows?

- **Automation**: Saves hours compared to manual writing.
- **Specialization**: Each agent focuses on one task, improving quality.
- **Caching**: Reduces API costs and speeds up repeats.
- **Streamlit**: Makes it accessible to non-coders.
- **Flexibility**: Easy to tweak for different audiences.

## Next Steps

- **Enhance Research**: Add YouTube or GitHub tools.
- **Customize Output**: Adjust instructions for intermediate learners.
- **Deploy**: Host on Streamlit Cloud.
- **Extend**: Add a keyword optimizer or social media generator.

Explore more in our series:
- [Getting Started with Agno Agents](https://www.bitdoze.com/agno-get-start/)
- [Building an AI Research Squad](https://www.bitdoze.com/agno-squad/)

## Conclusion

You’ve built an AI-powered article-writing pipeline that makes creating beginner-friendly tech content a breeze. With **Agno**, **Streamlit**, and **uv**, you’ve turned a complex task into an automated, user-friendly process. Try topics like “Learn JavaScript” or “AI Basics” and see your AI team shine. Happy writing! 🚀

## Complete Code

```python
# beginner_article_workflow_streamlit.py
import os
import json
import logging
import re
import traceback
import time
from textwrap import dedent
from typing import Dict, Iterator, List, Optional

import streamlit as st
from dotenv import load_dotenv
from pydantic import BaseModel, Field, ValidationError

# Agno Imports
from agno.agent import Agent
from agno.models.openrouter import OpenRouter
from agno.run.response import RunEvent, RunResponse
from agno.storage.sqlite import SqliteStorage
from agno.tools.crawl4ai import Crawl4aiTools
from agno.tools.duckduckgo import DuckDuckGoTools
from agno.workflow import Workflow

# --- Basic Logging Setup ---
logging.basicConfig(level=logging.INFO, format=&apos;%(asctime)s - %(levelname)s - %(message)s&apos;)
logger = logging.getLogger(__name__)

# --- Configuration ---
load_dotenv()
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)

# --- Custom JSON Serializer ---
def default_serializer(obj):
    &quot;&quot;&quot;JSON serializer for objects not serializable by default json code&quot;&quot;&quot;
    if isinstance(obj, set):
        return list(obj)
    raise TypeError(f&quot;Object of type {type(obj).__name__} is not JSON serializable by default_serializer&quot;)

# --- Pydantic Models ---
class ResearchFinding(BaseModel):
    url: str = Field(..., description=&quot;Source URL of the information.&quot;)
    summary: str = Field(..., description=&quot;Concise summary of the key information relevant to the topic.&quot;)
    content_snippet: Optional[str] = Field(None, description=&quot;A relevant short quote or snippet from the source.&quot;)

class ResearchSummary(BaseModel):
    key_findings: List[ResearchFinding] = Field(..., description=&quot;A list of key findings from the research.&quot;)
    overall_summary: str = Field(..., description=&quot;A brief overall synthesis of the research conducted.&quot;)

class ArticleOutline(BaseModel):
    title: str = Field(..., description=&quot;Proposed title for the article, engaging for beginners.&quot;)
    sections: List[str] = Field(..., description=&quot;A list of section titles for the article structure, logical for a beginner learning the topic.&quot;)
    keywords: List[str] = Field(..., description=&quot;List of relevant SEO keywords, including beginner-related terms.&quot;)

class SectionDraft(BaseModel):
    section_title: str = Field(..., description=&quot;The title of the section being drafted.&quot;)
    content: str = Field(..., description=&quot;The drafted content for this section, formatted in Markdown. Include code blocks with explanations, tables, lists, etc. Aim for clarity and detail suitable for beginners.&quot;)

# --- Beginner Article Workflow ---
class BeginnerArticleWorkflow(Workflow):
    &quot;&quot;&quot;
    A workflow that orchestrates agents to research, outline, write (section by section),
    and edit a technical article specifically tailored for beginners.
    Errors during execution will raise Exceptions. Yields final result on completion.
    &quot;&quot;&quot;
    description: str = &quot;Generates beginner-friendly technical articles.&quot;

    researcher: Agent
    outliner: Agent
    writer: Agent
    editor: Agent

    def __init__(
        self,
        api_key: str,
        model_id: str,
        max_tokens: int,
        session_id: str,
        storage: Optional[SqliteStorage] = None,
        debug_mode: bool = False,
        max_writer_retries: int = 2,
    ):
        super().__init__(session_id=session_id, storage=storage, debug_mode=debug_mode)
        self.max_writer_retries = max_writer_retries

        if not api_key:
            raise ValueError(&quot;OpenRouter API Key is required for BeginnerArticleWorkflow.&quot;)

        common_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: max_tokens}
        writer_tokens = max(max_tokens, 8192)
        editor_tokens = max(max_tokens, 8192)
        writer_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: writer_tokens}
        editor_model_args = {&quot;id&quot;: model_id, &quot;api_key&quot;: api_key, &quot;max_tokens&quot;: editor_tokens}

        # Initialize Agents with full prompts
        self.researcher = Agent(
            name=&quot;TechResearcherBeginnerFocus&quot;,
            model=OpenRouter(**common_model_args),
            tools=[DuckDuckGoTools(search=True, news=True), Crawl4aiTools(max_length=10000)],
            description=&quot;Expert tech researcher finding and synthesizing information on dev/tech topics for a beginner audience.&quot;,
            instructions=dedent(&quot;&quot;&quot;\
                Your goal is to research the given topic thoroughly, focusing on information accessible to beginners.
                1. Use DuckDuckGo to find 5-7 highly relevant and recent online sources (articles, docs, blog posts).
                2. **Prioritize:** Official &apos;getting started&apos; guides, tutorials, reputable tech blogs known for clear explanations, and foundational documentation. Avoid overly academic papers or highly advanced discussions unless essential.
                3. For each promising source URL, use `web_crawler` to extract main content.
                4. Synthesize the information, identifying key concepts, simple definitions, introductory code examples, benefits, common use cases, and potential beginner challenges.
                5. Provide a structured summary. Output MUST be `ResearchSummary` JSON.
            &quot;&quot;&quot;),
            response_model=ResearchSummary, markdown=True, add_history_to_messages=False, exponential_backoff=True
        )

        self.outliner = Agent(
            name=&quot;BeginnerArticleOutliner&quot;,
            model=OpenRouter(**common_model_args),
            description=&quot;Structures technical articles logically for beginners.&quot;,
            instructions=dedent(&quot;&quot;&quot;\
                Given a research summary, create a logical article outline tailored for beginners.
                1. **Title:** Craft a compelling title that clearly indicates the topic and suggests it&apos;s beginner-friendly (e.g., &quot;Introduction to X&quot;, &quot;Getting Started with Y&quot;).
                2. **Sections:** Structure the article logically for learning. Start with basics, then build up. Include sections like:
                    * Introduction (What is it? Why care?)
                    * Key Concepts/Terminology (Define important terms simply)
                    * Getting Started / Core How-To (Simple, practical examples)
                    * Code Examples Explained (If applicable, focus on clarity)
                    * Benefits / Use Cases (Why is this useful?)
                    * Potential Challenges for Beginners (Common pitfalls/tips)
                    * Conclusion / Next Steps
                3. **Keywords:** Include relevant SEO keywords, focusing on beginner terms (e.g., &quot;tutorial&quot;, &quot;basics&quot;, &quot;introduction&quot;, &quot;for beginners&quot;).
                4. Output MUST be `ArticleOutline` JSON.
            &quot;&quot;&quot;),
            add_history_to_messages=False, response_model=ArticleOutline, markdown=False, exponential_backoff=True
        )

        self.writer = Agent(
            name=&quot;BeginnerTechWriter&quot;,
            model=OpenRouter(**writer_model_args),
            description=&quot;Writes a detailed, engaging technical article *section* specifically for beginners.&quot;,
            instructions=dedent(&quot;&quot;&quot;\
                You are a skilled senior technical writer specializing in making complex topics easy for **beginners**.
                You will receive:
                a) The overall research summary (for facts).
                b) The article outline (for structure context).
                c) The specific `section_title` you need to write content for.

                Your task is to write the content ONLY for the specified `section_title`, targeting **complete beginners** to this specific topic.
                1. **Accuracy:** Use the research summary for technical facts.
                2. **Clarity is Key:** Explain concepts as simply as possible. Define technical terms immediately. Use analogies or real-world examples if helpful. Avoid unnecessary jargon. Assume minimal prior knowledge.
                3. **Code/Command Explanations:** If including code snippets (```language) or commands (` `):
                    * Provide a **clear, step-by-step explanation** for each line or significant part.
                    * Explain the *purpose* of the code/command.
                    * Describe the expected input and output (if applicable).
                    * Keep initial examples simple.
                4. **Structure &amp; Formatting:**
                    * Use Markdown extensively and correctly: `###` or `####` for sub-headings, **Bold**, *Italics*, ` ` for inline code, ```language ... ``` for blocks, bullet points (`*` or `-`), numbered lists (`1.`, `2.`), tables.
                5. **Engagement:** Start sections engagingly. Write in a slightly personal but professional and encouraging tone.
                6. **Detail:** Aim for sufficient detail to be genuinely helpful to a beginner. Prioritize clarity and thorough explanation over strict word count (~400 words is a rough guide, more is fine if needed for clarity).
                7. **Focus:** Do NOT write the main section title (like `## Section Title`) in your content. Do NOT write content for other sections. Focus *only* on the requested `section_title`.
                8. Output MUST be a `SectionDraft` JSON object containing the `section_title` you were given and the `content` you wrote.
            &quot;&quot;&quot;),
            response_model=SectionDraft, add_history_to_messages=False, markdown=True, exponential_backoff=True
        )

        self.editor = Agent(
            name=&quot;BeginnerFocusedEditor&quot;,
            model=OpenRouter(**editor_model_args),
            description=&quot;Polishes a full article draft, ensuring clarity for beginners.&quot;,
            instructions=dedent(&quot;&quot;&quot;\
                You are reviewing a complete article draft (in Markdown) assembled from sections written for beginners. You will receive the draft and the original outline.
                Your task is to perform final polishing:
                1. **Clarity for Beginners:** Read through from the perspective of someone new to the topic. Is it clear? Is jargon explained? Are explanations thorough enough? Add minor clarifications if needed.
                2. **Consistency:** Ensure consistent terminology, tone, and code/command formatting across sections.
                3. **Flow &amp; Grammar:** Perform minor edits for smooth transitions, grammar, spelling, and punctuation.
                4. **Markdown:** Check for correct Markdown formatting (headings `## Section Title`, code blocks, lists, tables). Ensure headings match the provided outline sections.
                5. **Completeness:** Briefly check if sections seem reasonably detailed based on typical beginner needs (do not rewrite entire sections).
                6. **No Major Rewrites:** Do not add substantial new content or change the core meaning. Focus on polishing and beginner-friendliness.
                7. Return the final, polished Markdown article.
            &quot;&quot;&quot;),
            add_history_to_messages=False, markdown=True, exponential_backoff=True
        )

    # --- Caching Methods ---
    def get_cached_data(self, key: str) -&gt; Optional[Dict]:
        return self.session_state.get(key)

    def add_data_to_cache(self, key: str, data: BaseModel):
        logger.info(f&quot;Caching data for key: {key}&quot;)
        self.session_state[key] = data.model_dump()

    def get_cached_final_article(self, topic_key: str) -&gt; Optional[str]:
        key = f&quot;final_article_{topic_key}&quot;
        return self.session_state.get(key)

    def add_final_article_to_cache(self, topic_key: str, article: str):
        key = f&quot;final_article_{topic_key}&quot;
        logger.info(f&quot;Caching final article for key: {key}&quot;)
        self.session_state[key] = article

    # --- Main Workflow Logic ---
    def run(self, topic: str, use_cache: bool = True) -&gt; Iterator[RunResponse]:
        logger.info(f&quot;Starting BeginnerArticleWorkflow for topic: &apos;{topic}&apos;&quot;)
        topic_key = re.sub(r&apos;[^\w\-]+&apos;, &apos;_&apos;, topic).strip(&apos;_&apos;).lower()

        # 1. Check cache
        if use_cache:
            cached_article = self.get_cached_final_article(topic_key)
            if cached_article:
                logger.info(&quot;Returning cached final article.&quot;)
                yield RunResponse(event=RunEvent.workflow_completed, content=cached_article)
                return

        # --- Stage 1: Research ---
        research_cache_key = f&quot;research_{topic_key}&quot;
        research_data: Optional[ResearchSummary] = None
        logger.info(&quot;--- Starting Research Stage ---&quot;)
        if use_cache:
            cached_research_dict = self.get_cached_data(research_cache_key)
            if cached_research_dict:
                try:
                    research_data = ResearchSummary.model_validate(cached_research_dict)
                    logger.info(&quot;Using cached research data.&quot;)
                except ValidationError as e:
                    logger.warning(f&quot;Cached research data invalid: {e}. Re-running research.&quot;)
        if research_data is None:
            logger.info(&quot;Researching topic (Beginner Focus)...&quot;)
            try:
                research_response: RunResponse = self.researcher.run(topic)
                if not (research_response and isinstance(research_response.content, ResearchSummary)):
                    parsed_content = None
                    if isinstance(research_response.content, (str, dict)):
                        try:
                            data = json.loads(research_response.content) if isinstance(research_response.content, str) else research_response.content
                            parsed_content = ResearchSummary.model_validate(data)
                        except (ValidationError, TypeError, json.JSONDecodeError) as parse_error:
                            logger.warning(f&quot;Research step returned parsable but invalid format: {parse_error}. Content: {research_response.content}&quot;)
                    if not parsed_content:
                        raise Exception(f&quot;Research step failed or returned invalid format. Response: {research_response}&quot;)
                    research_data = parsed_content
                else:
                    research_data = research_response.content
                self.add_data_to_cache(research_cache_key, research_data)
                logger.info(&quot;Research complete.&quot;)
            except Exception as e:
                logger.error(f&quot;Research failed: {e}&quot;)
                logger.error(traceback.format_exc())
                raise Exception(f&quot;❌ Research step failed: {e}&quot;) from e

        # --- Stage 2: Outline ---
        outline_cache_key = f&quot;outline_{topic_key}&quot;
        outline_data: Optional[ArticleOutline] = None
        logger.info(&quot;--- Starting Outline Stage ---&quot;)
        if use_cache:
            cached_outline_dict = self.get_cached_data(outline_cache_key)
            if cached_outline_dict:
                try:
                    outline_data = ArticleOutline.model_validate(cached_outline_dict)
                    logger.info(&quot;Using cached outline data.&quot;)
                except ValidationError as e:
                    logger.warning(f&quot;Cached outline data invalid: {e}. Re-running outline.&quot;)
        if outline_data is None:
            logger.info(&quot;Generating outline (Beginner Structure)...&quot;)
            try:
                outline_response: RunResponse = self.outliner.run(research_data.model_dump_json())
                if not (outline_response and isinstance(outline_response.content, ArticleOutline)):
                    parsed_content = None
                    if isinstance(outline_response.content, (str, dict)):
                        try:
                            data = json.loads(outline_response.content) if isinstance(outline_response.content, str) else outline_response.content
                            parsed_content = ArticleOutline.model_validate(data)
                        except (ValidationError, TypeError, json.JSONDecodeError) as parse_error:
                            logger.warning(f&quot;Outline step returned parsable but invalid format: {parse_error}. Content: {outline_response.content}&quot;)
                    if not parsed_content:
                        raise Exception(f&quot;Outline step failed or returned invalid format. Response: {outline_response}&quot;)
                    outline_data = parsed_content
                else:
                    outline_data = outline_response.content
                self.add_data_to_cache(outline_cache_key, outline_data)
                logger.info(&quot;Outline complete.&quot;)
            except Exception as e:
                logger.error(f&quot;Outline failed: {e}&quot;)
                logger.error(traceback.format_exc())
                raise Exception(f&quot;❌ Outline step failed: {e}&quot;) from e

        # --- Stage 3: Write Sections ---
        all_section_content: Dict[str, str] = {}
        total_sections = len(outline_data.sections)
        logger.info(f&quot;--- Starting Section Writing Stage ({total_sections} sections) ---&quot;)
        writing_failed = False
        for i, section_title in enumerate(outline_data.sections):
            logger.info(f&quot;Writing section {i+1}/{total_sections}: &apos;{section_title}&apos;...&quot;)
            writer_input_dict = {
                &quot;research_data&quot;: research_data.model_dump(),
                &quot;outline_data&quot;: outline_data.model_dump(),
                &quot;section_title&quot;: section_title
            }
            try:
                writer_input_json = json.dumps(writer_input_dict, default=default_serializer)
            except TypeError as json_err:
                logger.error(f&quot;Failed to serialize input for writer section &apos;{section_title}&apos;: {json_err}&quot;)
                logger.error(f&quot;Problematic Dict: {writer_input_dict}&quot;)
                raise Exception(f&quot;❌ Failed to prepare input for writer: {json_err}&quot;) from json_err

            section_content_generated = False
            last_error = &quot;Unknown error&quot;
            for attempt in range(self.max_writer_retries + 1):
                logger.info(f&quot;Writer attempt {attempt+1} for section: &apos;{section_title}&apos;&quot;)
                section_draft = None
                parse_error = None
                section_response = None
                try:
                    section_response = self.writer.run(writer_input_json)
                    if section_response and isinstance(section_response.content, SectionDraft):
                        section_draft = section_response.content
                    elif section_response and isinstance(section_response.content, (str, dict)):
                        data = json.loads(section_response.content) if isinstance(section_response.content, str) else section_response.content
                        section_draft = SectionDraft.model_validate(data)
                    else:
                        logger.warning(f&quot;Writer attempt {attempt+1} returned unexpected type: {type(section_response.content if section_response else None)}&quot;)
                        last_error = f&quot;Unexpected response type: {type(section_response.content if section_response else None)}&quot;
                    if section_draft:
                        all_section_content[section_title] = section_draft.content
                        section_content_generated = True
                        logger.info(f&quot;Writer attempt {attempt+1} successful for section: &apos;{section_title}&apos;&quot;)
                        break
                except (ValidationError, TypeError, json.JSONDecodeError) as e:
                    parse_error = e
                    last_error = f&quot;Validation Error: {e}&quot;
                    logger.warning(f&quot;Writer attempt {attempt+1} failed validation... Response: {section_response.content if section_response else &apos;N/A&apos;}&quot;)
                except Exception as e:
                    parse_error = e
                    last_error = f&quot;Runtime Error: {e}&quot;
                    logger.error(f&quot;Writer attempt {attempt+1} encountered unexpected error...&quot;)
                    logger.error(traceback.format_exc())

                if attempt &lt; self.max_writer_retries:
                    wait_time = 1 * (attempt + 1)
                    logger.info(f&quot;Waiting {wait_time}s...&quot;)
                    time.sleep(wait_time)
                else:
                    logger.error(f&quot;Max retries reached for section &apos;{section_title}&apos;. Skipping.&quot;)
                    all_section_content[section_title] = f&quot;\n\n_[Content generation failed for &apos;{section_title}&apos;. Last Error: {last_error}]_\n\n&quot;
                    writing_failed = True

            if not section_content_generated:
                logger.warning(f&quot;Failed to write section &apos;{section_title}&apos; after retries.&quot;)
        # End of section writing loop

        if writing_failed:
            logger.warning(&quot;Some sections failed generation. Proceeding.&quot;)

        # --- Stage 4: Assemble ---
        logger.info(&quot;--- Starting Assembly Stage ---&quot;)
        assembled_draft_parts = [f&quot;# {outline_data.title}\n&quot;]
        for section_title in outline_data.sections:
            assembled_draft_parts.append(f&quot;\n## {section_title}\n&quot;)
            section_content = all_section_content.get(section_title, f&quot;\n_[Content for &apos;{section_title}&apos; missing or failed generation.]_\n&quot;)
            assembled_draft_parts.append(section_content.strip() + &quot;\n&quot;)
        assembled_draft = &quot;\n&quot;.join(assembled_draft_parts)
        logger.info(&quot;Assembly complete.&quot;)

        # --- Stage 5: Edit ---
        logger.info(&quot;--- Starting Editing Stage ---&quot;)
        final_article = assembled_draft
        try:
            editor_input_dict = {&quot;draft_content&quot;: assembled_draft, &quot;outline&quot;: outline_data.model_dump()}
            try:
                editor_input_json = json.dumps(editor_input_dict, default=default_serializer)
            except TypeError as json_err:
                logger.error(f&quot;Failed to serialize input for editor: {json_err}&quot;)
                raise Exception(f&quot;❌ Failed to prepare input for editor: {json_err}&quot;) from json_err

            editor_response: RunResponse = self.editor.run(editor_input_json)
            if editor_response and editor_response.content and isinstance(editor_response.content, str):
                final_article = editor_response.content
                logger.info(&quot;Editing complete.&quot;)
            else:
                logger.warning(f&quot;Editor failed or returned empty/invalid content. Using assembled draft.&quot;)
        except Exception as e:
            logger.error(f&quot;Editor failed: {e}&quot;)
            logger.error(traceback.format_exc())
            logger.warning(f&quot;Editing step failed: {e}. Using assembled draft.&quot;)

        # --- Completion ---
        self.add_final_article_to_cache(topic_key, final_article)
        logger.info(&quot;Workflow completed successfully.&quot;)
        yield RunResponse(
            event=RunEvent.workflow_completed,
            content=final_article
        )

# --- Streamlit UI ---
st.set_page_config(page_title=&quot;Beginner Article Workflow&quot;, page_icon=&quot;✍️&quot;, layout=&quot;wide&quot;)
# Sidebar setup...
with st.sidebar:
    st.title(&quot;⚙️ Configuration&quot;)
    st.session_state.api_key = st.text_input(
        &quot;OpenRouter API Key&quot;, type=&quot;password&quot;, key=&quot;api_key_input_wf&quot;,
        value=st.session_state.get(&quot;api_key&quot;, OPENROUTER_API_KEY or &quot;&quot;), help=&quot;Required.&quot;
    )
    available_models = [
        &quot;openrouter/optimus-alpha&quot;, &quot;openai/gpt-4o&quot;, &quot;google/gemini-1.5-pro&quot;,
        &quot;mistralai/mistral-large-latest&quot;, &quot;meta-llama/llama-3.1-70b-instruct&quot;,
        &quot;google/gemini-flash-1.5&quot;, &quot;openrouter/auto&quot;,
    ]
    default_model = &quot;openrouter/optimus-alpha&quot;
    st.session_state.model_id = st.selectbox(
        &quot;Select Model&quot;, options=available_models,
        index=available_models.index(st.session_state.get(&quot;model_id&quot;, default_model)) if st.session_state.get(&quot;model_id&quot;, default_model) in available_models else available_models.index(default_model),
        key=&quot;model_select_wf&quot;, help=&quot;Choose LLM.&quot;
    )
    st.session_state.max_tokens = st.slider(
        &quot;Base Max Completion Tokens&quot;, min_value=2048, max_value=16384,
        value=st.session_state.get(&quot;max_tokens&quot;, 8192), step=1024,
        key=&quot;max_tokens_slider_wf&quot;, help=&quot;Base tokens.&quot;
    )
    st.session_state.use_cache = st.toggle(&quot;Use Cache&quot;, value=True, key=&quot;use_cache_wf&quot;, help=&quot;Reuse results.&quot;)
    db_file = &quot;tmp/agno_beginner_workflows.db&quot;
    os.makedirs(&quot;tmp&quot;, exist_ok=True)
    st.sidebar.caption(f&quot;Cache DB: {db_file}&quot;)
    if st.button(&quot;Clear Chat History&quot;, key=&quot;clear_chat_wf&quot;):
        st.session_state.messages_wf = []
        st.experimental_rerun()

# Main Chat Interface...
st.title(&quot;✍️ Agno Workflow: Beginner Article Writer&quot;)
st.markdown(&quot;Enter a topic...&quot;)
if &quot;messages_wf&quot; not in st.session_state:
    st.session_state.messages_wf = []
# History display loop...
for msg_index, message_info in enumerate(st.session_state.messages_wf):
    role = message_info.get(&quot;role&quot;, &quot;assistant&quot;)
    content = message_info.get(&quot;content&quot;, &quot;&quot;)
    is_final_article = message_info.get(&quot;is_final&quot;, False)
    is_error = message_info.get(&quot;is_error&quot;, False)
    with st.chat_message(role):
        st.markdown(content, unsafe_allow_html=is_error)
        if is_final_article:
            query_for_filename = message_info.get(&quot;topic&quot;, &quot;article&quot;)
            safe_filename = re.sub(r&apos;[^\w\-]+&apos;, &apos;_&apos;, query_for_filename).strip(&apos;_&apos;).lower() or &quot;article&quot;
            st.download_button(
                label=&quot;Download Article (Markdown)&quot;,
                data=content,
                file_name=f&quot;{safe_filename}.md&quot;,
                mime=&quot;text/markdown&quot;,
                key=f&quot;download_hist_wf_{msg_index}&quot;
            )

# User input and workflow execution...
if user_query := st.chat_input(&quot;Enter article topic...&quot;):
    api_key = st.session_state.api_key
    model_id = st.session_state.model_id
    max_tokens = st.session_state.max_tokens
    use_cache = st.session_state.use_cache
    if not api_key:
        st.error(&quot;🚨 Please enter API Key.&quot;)
    else:
        st.session_state.messages_wf.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_query})
        with st.chat_message(&quot;user&quot;):
            st.markdown(user_query)
        with st.chat_message(&quot;assistant&quot;):
            output_placeholder = st.empty()
            output_placeholder.markdown(&quot;⏳ Workflow running... Please check logs for detailed progress.&quot;)
            final_article_content = None
            error_message = None
            try:
                storage = SqliteStorage(table_name=&quot;beginner_article_workflows&quot;, db_file=db_file)
                topic_key = re.sub(r&apos;[^\w\-]+&apos;, &apos;_&apos;, user_query).strip(&apos;_&apos;).lower() or &quot;article&quot;
                session_id = f&quot;beginner-article-{topic_key}&quot;
                workflow = BeginnerArticleWorkflow(
                    api_key=api_key,
                    model_id=model_id,
                    max_tokens=max_tokens,
                    session_id=session_id,
                    storage=storage,
                    debug_mode=True
                )
                # Run Workflow &amp; Handle Final Output
                for response in workflow.run(topic=user_query, use_cache=use_cache):
                    if response.event == RunEvent.workflow_completed:
                        final_article_content = response.content
                        break
                    else:
                        logger.warning(f&quot;Received unexpected event type: {response.event}&quot;)
                # Display Final Result or Error
                output_placeholder.empty()
                if final_article_content:
                    output_placeholder.markdown(final_article_content)
                    st.session_state.messages_wf.append({
                        &quot;role&quot;: &quot;assistant&quot;,
                        &quot;content&quot;: final_article_content,
                        &quot;is_final&quot;: True,
                        &quot;topic&quot;: user_query
                    })
                    safe_filename = topic_key or &quot;article&quot;
                    st.download_button(
                        label=&quot;Download Article (Markdown)&quot;,
                        data=final_article_content,
                        file_name=f&quot;{safe_filename}.md&quot;,
                        mime=&quot;text/markdown&quot;,
                        key=f&quot;download_now_wf_{topic_key}&quot;
                    )
            # Catch exceptions raised from the workflow run
            except Exception as e:
                error_message_detail = f&quot;{type(e).__name__}: {str(e)}&quot;
                error_message_display = f&quot;❌ **Workflow Failed:**\n```\n{error_message_detail}\n```\n(Check logs for full traceback)&quot;
                logger.error(f&quot;Workflow execution failed: {error_message_detail}&quot;)
                logger.error(traceback.format_exc())
                output_placeholder.empty()
                output_placeholder.markdown(error_message_display)
                st.session_state.messages_wf.append({
                    &quot;role&quot;: &quot;assistant&quot;,
                    &quot;content&quot;: error_message_display,
                    &quot;is_error&quot;: True
                })
            # Check for premature exit
            if final_article_content is None and error_message is None:
                warn_msg = &quot;⚠️ Workflow finished, but no content was generated and no error was caught. Check workflow logic and logs.&quot;
                output_placeholder.empty()
                output_placeholder.markdown(warn_msg)
                st.session_state.messages_wf.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: warn_msg, &quot;is_error&quot;: True})
```</content:encoded><category>ai</category><category>ai-agents</category><category>agno</category><category>streamlit</category></item><item><title>Building a Multi-Agent Research Team with Google ADK, Tavily Search, and Crawl4AI</title><link>https://www.bitdoze.com/google-adk-multi-agent-search/</link><guid isPermaLink="true">https://www.bitdoze.com/google-adk-multi-agent-search/</guid><description>Learn how to create a team of specialized AI agents using Google&apos;s Agent Development Kit (ADK) to search the web, analyze URLs, and summarize content in a collaborative workflow.</description><pubDate>Mon, 14 Apr 2025 00:00:00 GMT</pubDate><content:encoded>After getting started with [Google&apos;s Agent Development Kit (ADK)](/google-adk-start/), the next step in harnessing its power is building multi-agent systems where specialized AI agents collaborate to accomplish more complex tasks. ADK&apos;s architecture makes it perfect for creating agent teams where each member has a specific role, and they work together through a coordinator agent that orchestrates the workflow.

In this tutorial, we&apos;ll build a sophisticated research team of AI agents using Google ADK. Our agent team will be capable of searching the internet with Tavily Search via LangChain, extracting and analyzing content from URLs with Crawl4AI, and summarizing findings—all working together through a coordinator agent that manages the collaborative process. We&apos;ll also set up our system to work with the built-in `adk web` tool for a seamless interactive experience.

## What We&apos;ll Build: The Research Assistant Team

We&apos;ll create a multi-agent system with four specialized agents working together:

| Agent | Role | Tools | Special Abilities |
|-------|------|-------|-------------------|
| **Coordinator** | Team leader that manages the workflow and delegates tasks to the right specialist | Agent-as-a-Tool for all specialists | Understands which agent to call based on user query |
| **Search Agent** | Handles web searches to find relevant information | Tavily Search (LangChain) | Returns structured search results with links and snippets |
| **Content Extractor** | Analyzes web pages in depth | Crawl4AI (custom tool) | Extracts, parses, and structures content from URLs |
| **Summarizer** | Condenses information into clear summaries | None (pure LLM reasoning) | Creates concise, accurate summaries at different lengths |

This structure follows the &quot;Coordinator/Dispatcher&quot; pattern with the Coordinator as the central agent that routes tasks to specialists, which are integrated as tools rather than using LLM-driven delegation. Let&apos;s build each component step by step.

## Prerequisites

Before starting, make sure you have:

1. Python 3.9+ installed
2. A Google AI Studio API key (from [aistudio.google.com](https://aistudio.google.com))
3. A Tavily API key (from [tavily.com](https://tavily.com))
4. Familiarity with the basic ADK concepts covered in our [first tutorial](/google-adk-start/)

## Setting Up Your Project

First, let&apos;s set up our project structure and dependencies:

```bash
# Create project directory
mkdir adk-research-team
cd adk-research-team

# Create virtual environment with uv
uv init
uv venv
```
Now let&apos;s install the necessary packages:

```bash
# Install required packages
uv add google-adk langchain-community tavily-python python-dotenv crawl4ai
```

Create a `.env` file in your project root to store your API keys:

```bash
# Create .env file for API keys
touch .env
```

Add your API keys to the `.env` file:

```ini
# .env file
GOOGLE_API_KEY=your_google_ai_studio_api_key
TAVILY_API_KEY=your_tavily_api_key
GOOGLE_GENAI_USE_VERTEXAI=&quot;False&quot;
```

## Project Structure

To ensure our system works properly with `adk web`, we need to set up our project with a specific file structure:

```
adk-research-team/
├── .env                       # API keys
└── agent_module/              # Agent package
    ├── __init__.py            # Makes it a proper Python package
    ├── agent.py               # Main file with root_agent
    ├── search_agent.py        # Search specialist
    ├── content_extractor.py   # Content analysis specialist
    └── summarizer.py          # Summarization specialist
```

First, create the `agent_module` directory and initialize it as a Python package:

```bash
mkdir -p agent_module
touch agent_module/__init__.py
```

Add the following to `agent_module/__init__.py`:

```python agent_module/__init__.py
# This file makes agent_module a proper Python package
# The import below ensures that agent.py is accessible
from . import agent
```

## Creating Our Specialized Agents

Let&apos;s implement each of our specialized agents one by one, starting with the most fundamental ones and working our way up to the coordinator.

### 1. The Search Agent with Tavily Search

```bash
touch agent_module/search_agent.py
```

Now, let&apos;s implement the search agent:

```python agent_module/search_agent.py
from dotenv import load_dotenv
import os
from google.adk import Agent
from google.adk.tools.langchain_tool import LangchainTool
from langchain_community.tools import TavilySearchResults

# Load environment variables
load_dotenv()

def create_search_agent():
    &quot;&quot;&quot;
    Creates an agent specialized in web searching using Tavily Search.
    &quot;&quot;&quot;
    # Check if API key is available
    tavily_api_key = os.getenv(&quot;TAVILY_API_KEY&quot;)
    if not tavily_api_key:
        raise ValueError(&quot;TAVILY_API_KEY not found in environment variables&quot;)

    # Create Tavily Search tool with LangChain
    tavily_tool_instance = TavilySearchResults(
        max_results=5,  # Return 5 results per search
        search_depth=&quot;advanced&quot;,  # Use advanced search for more comprehensive results
        include_answer=True,  # Include a direct answer when possible
        include_raw_content=True,  # Include the raw content from search results
        include_images=False  # Don&apos;t include images in the results
    )

    # Wrap the LangChain tool for ADK
    adk_tavily_tool = LangchainTool(tool=tavily_tool_instance)

    # Create and return the search agent
    search_agent = Agent(
        name=&quot;search_agent&quot;,
        model=&quot;gemini-2.0-flash&quot;,
        description=&quot;A specialized agent that searches the web for information using Tavily Search API.&quot;,
        instruction=&quot;&quot;&quot;You are a web research specialist.

        When asked to find information about a topic, craft an effective search query and use the TavilySearchResults tool.

        After receiving search results:
        1. Parse the response which may contain a direct answer and multiple search results.
        2. Format the results in a clear, structured way, with each result showing the title, link, and a brief preview of the content.
        3. Highlight the most relevant results based on the original query.
        4. If Tavily provided a direct answer, present that first as the most likely answer.

        If the search doesn&apos;t return useful results, suggest refined search terms for a follow-up search.

        Avoid making up information - only report what is found in the search results.
        &quot;&quot;&quot;,
        tools=[adk_tavily_tool]
    )

    return search_agent
```

### 2. The Content Extractor with Crawl4AI

```bash
touch agent_module/content_extractor.py
```

Now, let&apos;s implement the content extractor tool and agent with a fix for the CrawlResult title attribute issue:

```python agent_module/content_extractor.py
from dotenv import load_dotenv
import os
import asyncio
from crawl4ai import AsyncWebCrawler
from google.adk import Agent
from google.adk.tools import FunctionTool
from google.adk.tools.tool_context import ToolContext

# Load environment variables
load_dotenv()

async def extract_content_from_url(url: str, include_headers: bool = True, tool_context: ToolContext = None) -&gt; dict:
    &quot;&quot;&quot;
    Extracts content from a URL using Crawl4AI.

    Args:
        url (str): The URL to extract content from.
        include_headers (bool): Whether to include headings in the extraction.
        tool_context (ToolContext, optional): Tool context for ADK.

    Returns:
        dict: A dictionary containing the extracted content, metadata, and status.
    &quot;&quot;&quot;
    try:
        # Create an instance of AsyncWebCrawler
        async with AsyncWebCrawler() as crawler:
            # Run the crawler on the URL
            result = await crawler.arun(
                url=url,
                include_headers=include_headers
            )

            # Create a structured response
            # FIX: Get the page title safely from the result
            page_title = getattr(result, &apos;title&apos;, None)
            if page_title is None:
                # Try to extract title from markdown or use URL as fallback
                page_title = url.split(&apos;/&apos;)[-1] if &apos;/&apos; in url else url

                # Try to find title in markdown if available
                if hasattr(result, &apos;markdown&apos;) and result.markdown:
                    # Look for # headers in markdown
                    lines = result.markdown.split(&apos;\n&apos;)
                    for line in lines:
                        if line.startswith(&apos;# &apos;):
                            page_title = line.replace(&apos;# &apos;, &apos;&apos;)
                            break

            response = {
                &quot;status&quot;: &quot;success&quot;,
                &quot;title&quot;: page_title,
                &quot;url&quot;: url,
                &quot;markdown_content&quot;: result.markdown[:10000] if hasattr(result, &apos;markdown&apos;) else &quot;No content extracted&quot;,  # Limit content to 10k chars
                &quot;content_length&quot;: len(result.markdown) if hasattr(result, &apos;markdown&apos;) else 0,
                &quot;headers&quot;: [h.text for h in result.headers] if hasattr(result, &apos;headers&apos;) and include_headers else [],
                &quot;word_count&quot;: len(result.markdown.split()) if hasattr(result, &apos;markdown&apos;) else 0,
                &quot;has_truncated_content&quot;: len(result.markdown) &gt; 10000 if hasattr(result, &apos;markdown&apos;) else False
            }

            # Optional: Store the full content in session state for the summarizer to use
            if tool_context and hasattr(result, &apos;markdown&apos;):
                tool_context.state[f&quot;extracted_content_{url}&quot;] = result.markdown

            return response
    except Exception as e:
        return {
            &quot;status&quot;: &quot;error&quot;,
            &quot;url&quot;: url,
            &quot;error_message&quot;: str(e)
        }

def create_content_extractor_agent():
    &quot;&quot;&quot;
    Creates an agent specialized in extracting and analyzing content from URLs.
    &quot;&quot;&quot;
    # Create the FunctionTool for content extraction
    extract_content_tool = FunctionTool(func=extract_content_from_url)

    # Create and return the content extractor agent
    extractor_agent = Agent(
        name=&quot;content_extractor&quot;,
        model=&quot;gemini-2.0-flash&quot;,
        description=&quot;A specialized agent that extracts and analyzes content from web pages using Crawl4AI.&quot;,
        instruction=&quot;&quot;&quot;You are a web content analysis specialist.

        When given a URL, use the extract_content_from_url tool to fetch and analyze its content.

        After extracting content:
        1. Report the page title and basic metadata (word count, if content was truncated).
        2. List the main headers to provide an overview of the page structure.
        3. Highlight key information found in the content that&apos;s most relevant to the user&apos;s request.
        4. Note if there were any errors during extraction.

        When extracting content from multiple URLs, organize the information clearly by URL.

        If the extraction fails, explain the error and suggest possible solutions.
        &quot;&quot;&quot;,
        tools=[extract_content_tool]
    )

    return extractor_agent
```

### 3. The Summarizer Agent

```bash
touch agent_module/summarizer.py
```

```python agent_module/summarizer.py
from dotenv import load_dotenv
from google.adk import Agent
from google.adk.tools.tool_context import ToolContext

# Load environment variables
load_dotenv()

def summarize_content(content: str, summary_length: str = &quot;medium&quot;, tool_context: ToolContext = None) -&gt; dict:
    &quot;&quot;&quot;
    Tool to save content for the agent to summarize.

    This doesn&apos;t actually summarize the content itself - it just saves the content
    to the session state so the LLM can access it directly.

    Args:
        content (str): The text content to summarize.
        summary_length (str): The desired length of the summary (&quot;short&quot;, &quot;medium&quot;, or &quot;long&quot;).
        tool_context (ToolContext, optional): Tool context for ADK.

    Returns:
        dict: A dictionary with status information.
    &quot;&quot;&quot;
    if tool_context:
        tool_context.state[&quot;content_to_summarize&quot;] = content
        tool_context.state[&quot;requested_summary_length&quot;] = summary_length

        # Calculate some metrics on the content
        word_count = len(content.split())

        return {
            &quot;status&quot;: &quot;success&quot;,
            &quot;message&quot;: f&quot;Content saved for summarization (word count: {word_count}). Ready to generate a {summary_length} summary.&quot;,
            &quot;word_count&quot;: word_count
        }
    else:
        return {
            &quot;status&quot;: &quot;error&quot;,
            &quot;message&quot;: &quot;Tool context not available. Cannot store content.&quot;
        }

def create_summarizer_agent():
    &quot;&quot;&quot;
    Creates an agent specialized in summarizing content.
    &quot;&quot;&quot;
    # Create a simple tool to save content for summarization
    summarize_content_tool = summarize_content

    # Create and return the summarizer agent
    summarizer_agent = Agent(
        name=&quot;summarizer&quot;,
        model=&quot;gemini-2.0-flash&quot;,
        description=&quot;A specialized agent that summarizes content at various detail levels.&quot;,
        instruction=&quot;&quot;&quot;You are a professional content summarizer.

        First, use the summarize_content tool to load the content into memory.

        Then, summarize the content stored in state[&apos;content_to_summarize&apos;] according to the requested length in state[&apos;requested_summary_length&apos;]:

        - &quot;short&quot;: A concise summary in 1-3 sentences, capturing only the essential point.
        - &quot;medium&quot;: A balanced summary in 1-3 paragraphs, covering key points and some supporting details.
        - &quot;long&quot;: A comprehensive summary in multiple paragraphs, preserving nuances and important contexts.

        Always structure summaries with clear headings and bullet points when appropriate.

        Prioritize accuracy over brevity - never include information not found in the original text.

        For technical or complex content, preserve the key terminology used in the original text.
        &quot;&quot;&quot;,
        tools=[summarize_content_tool]
    )

    return summarizer_agent
```

### 4. The Main Agent File with Root Agent

Finally, let&apos;s create our main agent file that will expose the root_agent to ADK web:

```bash
touch agent_module/agent.py
```

```python agent_module/agent.py
from dotenv import load_dotenv
import os
from google.adk import Agent
from google.adk.tools.agent_tool import AgentTool

# Import our specialized agents
from agent_module.search_agent import create_search_agent
from agent_module.content_extractor import create_content_extractor_agent
from agent_module.summarizer import create_summarizer_agent

# Load environment variables
load_dotenv()

# Create instances of our specialized agents
search_agent = create_search_agent()
extractor_agent = create_content_extractor_agent()
summarizer_agent = create_summarizer_agent()

# Wrap the specialized agents as tools
search_tool = AgentTool(agent=search_agent)
extractor_tool = AgentTool(agent=extractor_agent)
summarizer_tool = AgentTool(agent=summarizer_agent)

# Define the root agent that ADK web will use
root_agent = Agent(
    name=&quot;research_coordinator&quot;,
    model=&quot;gemini-2.0-flash&quot;,
    description=&quot;A coordinator agent that manages a team of specialized research agents.&quot;,
    instruction=&quot;&quot;&quot;You are the coordinator of a research assistant team with specialized agents.

    Your team includes:
    - search_agent: Finds information on the web using Tavily Search.
    - content_extractor: Analyzes and extracts content from specific URLs.
    - summarizer: Creates concise summaries of content at different levels of detail.

    Based on user requests, delegate tasks to the appropriate specialist:

    1. If the user needs to find information on a topic, use the search_agent.
    2. If the user provides a specific URL or wants to analyze a web page, use the content_extractor.
    3. If the user needs a summary of content, use the summarizer.
    4. For complex research tasks, coordinate multiple agents in sequence:
       - First search for relevant information
       - Then extract detailed content from the most promising URLs
       - Finally summarize the findings

    Always present the results from your specialists in a clear, organized manner.
    When coordinating multi-step research, explain the research process to the user.

    Remember: You are responsible for the final response to the user, so ensure it fully addresses their request.
    &quot;&quot;&quot;,
    tools=[search_tool, extractor_tool, summarizer_tool]
)
```

## Using the ADK Web Interface

Now that we&apos;ve created our multi-agent system and fixed the content extractor issue, let&apos;s use the ADK built-in web interface to interact with it. First, make sure you&apos;re in the right directory (where your `.env` file is located), then run:

```bash
adk web
```

This will start a local web server and provide you with a URL (typically http://localhost:8000 or http://127.0.0.1:8000). Open this URL in your browser to access the ADK web interface.

In the interface, you&apos;ll be presented with a list of available agents. You should see the &quot;research_coordinator&quot; agent listed. Select it and you&apos;ll be able to chat with your multi-agent research team through the clean interface.

Here are some example queries to try:

1. **Search for Information**: &quot;What are the latest advancements in quantum computing?&quot;
2. **Analyze a URL**: &quot;Can you analyze the content at https://www.reuters.com/technology/ and tell me what it&apos;s about?&quot;
3. **Get a Summary**: &quot;Summarize this article in a medium length: [paste article text]&quot;
4. **Multi-step Research**: &quot;Research the environmental impact of electric vehicles, analyze the top result in detail, and provide a short summary of the key findings.&quot;

## How the System Works

Let&apos;s break down how our multi-agent system functions:

1. **The Coordinator as Central Hub**:
   - All user requests go to the coordinator agent
   - The coordinator analyzes the request and decides which specialist to call
   - It uses the AgentTool wrapper to &quot;call&quot; specialist agents as if they were functions

2. **Communication Via Session State**:
   - Agents share information through the session state dictionary
   - For example, when the content extractor analyzes a URL, it stores the full content in the session state with a key like `extracted_content_{url}`
   - Later, the summarizer can access this content without having to download it again

3. **Using LangChain Tools in ADK**:
   - The LangchainTool wrapper makes it easy to use the Tavily Search functionality in our ADK agent
   - This demonstrates ADK&apos;s flexibility in integrating with other AI frameworks

4. **Custom Tool Implementation**:
   - Our Crawl4AI integration shows how to create custom tools with FunctionTool
   - The improved error handling in the extract_content_from_url function ensures it works reliably even with different versions of Crawl4AI

## Extending the System

The modular design of this agent team makes it easy to extend with new capabilities:

| Potential Enhancements | Implementation Approach |
|------------------------|-------------------------|
| PDF Document Analysis | Add a new specialist agent with a PDF parsing tool |
| Translation Services | Create a translation agent with a language API tool |
| Data Visualization | Add an agent that can generate charts from extracted data |
| Sentiment Analysis | Implement a specialized agent for analyzing sentiment in content |
| Citation Management | Add a tool to extract and format citations from academic sources |

## Troubleshooting Common Issues

Here are some common issues you might encounter when working with ADK web and multi-agent systems:

| Issue | Solution |
|-------|----------|
| **Agent Not Showing in ADK Web** | Ensure you have a `root_agent` variable defined in your `agent.py` file and that your `__init__.py` properly imports it with `from . import agent`. |
| **&quot;No attribute &apos;title&apos;&quot; Error** | This has been fixed by adding fallback logic in the `extract_content_from_url` function to handle missing attributes in the CrawlResult object. |
| **Import Errors** | Ensure you&apos;re using the correct import structure (`from google.adk import Agent`). |
| **API Key Issues** | Double-check that your `.env` file is in the right location (at the project root, not inside agent_module) and that keys are loaded properly with `load_dotenv()`. |
| **Tool Not Found** | Make sure tool functions are properly wrapped (LangchainTool, FunctionTool) and added to the agent&apos;s tools list. |
| **AttributeError with Crawl4AI** | Always use `hasattr()` checks before accessing attributes of third-party library objects to handle API changes gracefully. |

## Conclusion

In this tutorial, we&apos;ve built a sophisticated multi-agent research system using Google&apos;s Agent Development Kit. By combining specialized agents for web search with Tavily, content extraction, and summarization, we&apos;ve created a powerful team that can perform complex research tasks with a clear division of responsibilities.

One of the great advantages of using ADK is the ability to quickly test and interact with your agents using the built-in `adk web` tool, which provides a clean, intuitive interface without requiring you to build a frontend. This makes development and testing significantly faster.

We&apos;ve also learned how to handle potential issues with third-party libraries like Crawl4AI by building robust error handling into our tools. This makes our multi-agent system more reliable and maintainable.

This architecture demonstrates several key ADK concepts:
- Using the Coordinator/Dispatcher pattern for agent orchestration
- Integrating third-party tools from LangChain (Tavily Search)
- Creating custom tools with FunctionTool
- Implementing the Agent-as-a-Tool pattern with AgentTool
- Sharing information through session state
- Exposing your agent for use with `adk web`
- Building robust error handling for third-party dependencies

As you continue exploring ADK, consider how this multi-agent approach could solve other complex problems by breaking them down into specialized components that work together seamlessly. The possibilities are endless, from customer service automation to data analysis pipelines and beyond.

Happy building with Google ADK!</content:encoded><category>ai</category><category>ai-agents</category><category>adk</category></item><item><title>How to Build Your First Agent with Google Agent Development Kit (ADK)</title><link>https://www.bitdoze.com/google-adk-start/</link><guid isPermaLink="true">https://www.bitdoze.com/google-adk-start/</guid><description>Learn how you can start building your first agent with Google Agent Development Kit (ADK) and add memory and tool use to browse the web.</description><pubDate>Thu, 10 Apr 2025 00:00:00 GMT</pubDate><content:encoded>Artificial Intelligence (AI) agents are transforming the way we interact with technology, acting as autonomous assistants capable of reasoning, planning, and executing tasks. From virtual helpers to complex multi-agent systems, the demand for accessible tools to build these agents is on the rise. Enter Google’s Agent Development Kit (ADK), an open-source Python framework launched on April 9, 2025, designed to simplify the creation of sophisticated AI agents. Integrated tightly with the Google ecosystem—particularly Gemini models and Vertex AI—ADK empowers developers to build, test, and deploy agents with ease, whether for simple tasks or intricate collaborative workflows.

This article will guide you step-by-step through building your very first agent using ADK. We&apos;ll cover setting up your development environment using the fast package installer uv, connecting your agent to a Gemini model via the Google AI Studio API, giving your agent the ability to remember information using Session State, and enhancing it with real-time web search capabilities through the Tavily search tool. By the end, you&apos;ll have a functional agent ready for further exploration!

## What is the Google Agent Development Kit (ADK)?

The Google Agent Development Kit (ADK) is an open-source Python framework introduced by Google on April 9, 2025, aimed at simplifying the development of AI agents. Built with integration in mind, ADK leverages Google’s powerful AI infrastructure—such as the Gemini family of models and Vertex AI—to enable developers to create agents that can reason, use tools, and collaborate in multi-agent systems. Whether you&apos;re building a single-purpose assistant or a team of specialized agents, ADK provides the scaffolding to make it happen efficiently.

### Key Features of ADK
- **Tool Integration**: Agents can call external APIs, perform web searches, or execute custom functions, making them versatile problem-solvers.
- **Multi-Agent Collaboration**: ADK supports the creation of agent teams that work together, sharing tasks and memory for complex workflows.
- **Google Ecosystem Compatibility**: Seamless integration with Google Cloud, Vertex AI, and Gemini models ensures high performance and scalability.
- **Open-Source Flexibility**: Being open-source, ADK allows customization and community contributions, fostering rapid innovation.
- **Ease of Use**: With pre-built templates and a straightforward API, even beginners can get started quickly.

### Why Use ADK for Building Agents?
ADK stands out in a crowded field of AI development tools due to its balance of simplicity and power. Unlike more generic frameworks, its tight integration with Google’s AI offerings—like Gemini’s advanced reasoning capabilities—gives developers access to cutting-edge technology without needing to build everything from scratch. Additionally, its open-source nature means you’re not locked into a proprietary system, offering freedom to adapt your agent to unique use cases. Whether you’re automating a personal task or prototyping a business solution, ADK provides a robust starting point.

In short, ADK is ideal for anyone looking to harness AI agents without getting bogged down in complexity. It’s a bridge between powerful AI models and practical, real-world applications—perfect for your first agent-building adventure.


## Build Your First Agent with Google Agent Development Kit (ADK)

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/rLSj47zkTa8&quot;
  label=&quot;How to Build Your First Agent with Google Agent Development Kit (ADK)&quot;
/&gt;


### Prerequisites

Before we dive into building your first agent with the Google Agent Development Kit (ADK) and `uv`, let’s ensure you have everything you need to follow along. This section outlines the tools, accounts, and basic knowledge required to complete the project.

#### Required Tools and Accounts

1. **Python 3.9+**
   - ADK requires Python 3.9 or higher. We’ll use `uv` to manage the Python version, so you don’t need it pre-installed—`uv` can handle that for you. However, having a base Python installation (e.g., via your system package manager or [python.org](https://www.python.org)) simplifies the initial `uv` setup.

2. **uv**
   - The star of our project management show! You’ll install `uv` as a standalone binary (no Python required upfront). We’ll cover the installation steps in the next section.

3. **Google AI Studio API Key**
   - To power our agent with the Gemini 2.0 Flash model, you’ll need an API key from Google AI Studio. Sign up or log in at [aistudio.google.com](https://aistudio.google.com), navigate to the API section, and generate a key. We’ll configure it securely using environment variables.

4. **Tavily API Key**
   - For search functionality, we’ll integrate the Tavily search tool, which provides real-time web search results tailored for AI agents. Get a free API key by signing up at [tavily.com](https://tavily.com). You’ll need this to enable your agent to fetch information from the web.

5. **Text Editor or IDE**
   - Use any editor you prefer—VS Code, PyCharm, or even a simple text editor like Notepad++ will work. You’ll be writing Python code and editing configuration files.

#### Basic Knowledge Assumptions

This guide assumes you have:
- **Basic Python Knowledge**: Familiarity with Python syntax, functions, and running scripts. You don’t need to be an expert, but you should know how to write and execute a simple Python program.
- **Understanding of Virtual Environments**: A general idea of why virtual environments are useful (isolating project dependencies). If you’ve used `venv` or `pip` before, you’re set.
- **API Basics**: Awareness of what an API key is and how it’s used to authenticate requests. No deep API expertise is required—we’ll walk through the setup.

With these in place, you’re ready to set up your project and start building. In the next section, we’ll install `uv`, initialize your project, and add the necessary dependencies to get ADK up and running.


### Setting Up Your Project with uv

Now that you have the prerequisites sorted, let’s set up your Python project using `uv`. This section walks you through installing `uv`, initializing a new project, creating a virtual environment, and adding the necessary dependencies for the Google Agent Development Kit (ADK) and Tavily search integration. By using `uv` instead of `pip`, you’ll experience a faster, more streamlined workflow.

#### Installing uv

`uv` is a standalone tool written in Rust, so you don’t need a pre-existing Python installation to get started. As of April 2025, the latest version is available from the Astral team’s official site. Here’s how to install it:

- **On macOS/Linux**:
  Open your terminal and run:
  ```bash
  curl -LsSf https://astral.sh/uv/install.sh | sh
  ```
  This downloads and installs `uv` into `~/.local/bin`. After installation, ensure it’s in your PATH by restarting your terminal or running:
  ```bash
  source ~/.local/bin/env
  ```

- **On Windows**:
  In PowerShell, execute:
  ```powershell
  irm https://astral.sh/uv/install.ps1 | iex
  ```
  The installer adds `uv` to your PATH automatically, but you might need to restart PowerShell to use it.

Verify the installation:
```bash
uv --version
```
You should see output like `uv 0.6.6` (or a later version by April 2025). If it fails, check your PATH or reinstall.

#### Initializing a New Python Project

With `uv` installed, let’s create a new project:

1. **Initialize the Project**:
   Run:
   ```bash
   uv init my-adk-agent
   cd my-adk-agent
   ```
   This creates a directory `my-adk-agent` with a basic structure:
   ```
   my-adk-agent/
   ├── .python-version  # Pins Python version (e.g., &quot;3.12&quot;)
   ├── main.py         # Starter script
   ├── pyproject.toml  # Project configuration
   └── README.md       # Project documentation
   ```
   The `pyproject.toml` file is pre-populated with minimal metadata:
   ```toml
   [project]
   name = &quot;my-adk-agent&quot;
   version = &quot;0.1.0&quot;
   description = &quot;Add your description here&quot;
   readme = &quot;README.md&quot;
   requires-python = &quot;&gt;=3.12&quot;
   dependencies = []
   ```

2. **Set the Python Version**:
   The default Python version in `.python-version` (e.g., 3.12) works for ADK, but let’s ensure we’re using 3.12 explicitly:
   ```bash
   uv python pin 3.12
   ```
   If 3.12 isn’t installed, `uv` can fetch it:
   ```bash
   uv python install 3.12
   ```

#### Setting Up a Virtual Environment

Unlike `pip` and `venv`, `uv` simplifies environment creation:
```bash
uv venv
```
This creates a `.venv` directory in your project root using Python 3.12. You’ll see:
```
Using Python 3.12.x
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
```
You can activate it manually if needed (`source .venv/bin/activate` on macOS/Linux, `.venv\Scripts\activate` on Windows), but `uv run` will handle this automatically later.

#### Adding ADK and Dependencies

Now, let’s add the ADK package and Tavily support:

1. **Add Google ADK**:
   ```bash
   uv add google-adk
   ```
   This updates `pyproject.toml`:
   ```toml
   [project]
   dependencies = [
       &quot;google-adk&gt;=x.x.x&quot;,  # Latest version as of April 2025
   ]
   ```

2. **Add Tavily Support**:
   Since we’ll use Tavily via LangChain’s integration, install `langchain-community` and `tavily-python`:
   ```bash
   uv add langchain-community tavily-python
   ```
   The `pyproject.toml` now includes:
   ```toml
   [project]
   dependencies = [
       &quot;google-adk&gt;=x.x.x&quot;,
       &quot;langchain-community&gt;=x.x.x&quot;,
       &quot;tavily-python&gt;=x.x.x&quot;,
   ]
   ```

3. **Sync the Environment**:
   Install all dependencies into `.venv`:
   ```bash
   uv sync
   ```
   You’ll see output listing installed packages (e.g., `google-adk`, `langchain-community`, `tavily-python`, and their dependencies) in mere milliseconds—`uv`’s speed shines here compared to `pip`.

Your project is now set up with `uv`, ready to build an ADK agent. In the next section, we’ll configure the Google AI Studio API to power our agent with the Gemini model.

Okay, here is the next section:

### Connecting to Gemini via AI Studio

Your ADK agent needs a way to communicate with the Gemini Large Language Model (LLM) that powers its intelligence. This communication happens via secure API calls, which require authentication credentials, typically an API key. Without a valid key, the LLM service will deny the agent&apos;s requests, preventing it from working.

For development and prototyping, Google AI Studio provides an easy way to get an API key.

**Get Your API Key**

1.  Go to the [Google AI Studio website](https://aistudio.google.com/apikey).
2.  If you don&apos;t have one already, create an API key. Copy this key securely – you&apos;ll need it in the next step.

**Configure Your Environment**

The best way to provide the API key to your ADK application is through environment variables, commonly managed using a `.env` file within your project directory.

1.  Inside your agent project folder (the one containing your `agent.py` file), create a new file named `.env`.
2.  Open the `.env` file and add the following lines, replacing `&quot;PASTE_YOUR_ACTUAL_API_KEY_HERE&quot;` with the key you copied from AI Studio:

    ```dotenv
    # Use Google AI Studio (not Vertex AI)
    GOOGLE_GENAI_USE_VERTEXAI=&quot;False&quot;

    # Your API Key
    GOOGLE_API_KEY=&quot;PASTE_YOUR_ACTUAL_API_KEY_HERE&quot;
    ```

The `GOOGLE_GENAI_USE_VERTEXAI=&quot;False&quot;` line explicitly tells ADK to use the Google AI backend (which uses the `GOOGLE_API_KEY`) instead of the Vertex AI backend. ADK will automatically load these variables when your agent runs, allowing it to authenticate securely with the Gemini API.



Okay, here is the next section:

### Building Your First Basic Agent

With the environment set up and the API key configured, let&apos;s create the core of our agent application.

**Set Up the Project Structure**

A recommended structure helps keep your agent code organized. In your main project directory (where you created the `.venv` folder), create a structure like this:

```
google-adk/
├── agent_module/
│   ├── __init__.py
│   └── agent.py
├── .venv/
└── .env
```

1.  Create a folder named `agent_module`. This will hold your agent&apos;s code.
2.  Inside `agent_module`, create an empty file named `__init__.py`. This tells Python to treat the folder as a package. Add the following line to it:
    ```python
    # agent_module/__init__.py
    from . import agent
    ```
3.  Inside `agent_module`, create the main file for your agent&apos;s logic: `agent.py`.
4.  You should already have the `.env` file in the `your_agent_project` directory from the previous step.

**Define Your Agent**

Now, open `agent.py` and define your first agent using the `Agent` class from ADK. This class is the central piece for defining your agent&apos;s identity, model, and instructions.

```python
# agent_module/agent.py
from google.adk.agents import Agent

# Define the root agent for your application
root_agent = Agent(
    # A unique name for your agent [cite: 167, 639]
    name=&quot;my_first_adk_agent&quot;,

    # Specify the Gemini model to use (ensure compatibility with AI Studio key) [cite: 19, 56, 168, 645]
    model=&quot;gemini-2.0-flash-exp&quot;, # Or &quot;gemini-1.0-pro&quot; etc.

    # A brief description of what the agent does [cite: 169, 642]
    description=&quot;A basic agent that can chat.&quot;,

    # Instructions guiding the agent&apos;s behavior and persona [cite: 171, 648]
    instruction=&quot;You are a friendly and helpful assistant. Respond concisely to user queries.&quot;,

    # Initially, we won&apos;t add specific tools [cite: 172, 659]
    tools=[]
)

print(f&quot;Agent &apos;{root_agent.name}&apos; defined.&quot;)
```

In this code:
* `name`: Gives your agent a unique ID.
* `model`: Tells the agent which Gemini model to use for thinking and responding. We&apos;re using a recent Flash model suitable for general chat.
* `description`: Briefly summarizes the agent&apos;s purpose, which is especially useful in multi-agent setups.
* `instruction`: Provides the core guidance to the LLM on how it should behave.
* `tools`: An empty list for now, as this basic agent won&apos;t use external tools yet.

**Running Your Agent (Briefly)**

ADK provides command-line tools to interact with your agent. Navigate your terminal to the `your_agent_project` directory (the one containing `agent_module` and `.env`) and you can try:

* `adk web`: This launches an interactive web UI in your browser, allowing you to chat with your agent.
* `adk run agent_module`: This lets you chat with the agent directly in your terminal.

We won&apos;t delve deep into running the agent just yet, as we first need to add memory and tools, but this gives you an idea of how to interact with it later.



### Adding Memory with Session State

Our basic agent can chat, but it has no memory. Each time you interact with it, it starts fresh, forgetting everything from previous turns. To build more engaging and useful agents, we need to give them the ability to remember context and past interactions. ADK achieves this through **Session State**.

**What is Session State?**

Session State is essentially a memory bank for each individual conversation session. Technically, it&apos;s a Python dictionary associated with a specific user session. Information stored in this dictionary persists across multiple turns within that single conversation. Both your agent and its tools can read from and write to this state, allowing them to:

* Remember user preferences.
* Recall information mentioned earlier in the conversation.
* Personalize responses based on past interactions.

**Using `InMemorySessionService`**

For this tutorial, we&apos;ll use ADK&apos;s `InMemorySessionService`. As the name suggests, it stores the session history and state directly in your computer&apos;s memory. This is perfect for development and testing, though the memory will be lost when your application stops.

**Setting Up the Runner and Session Service**

To manage sessions and run your agent, you need two more components: `InMemorySessionService` and `Runner`. Let&apos;s add the setup code, typically in a main script or alongside your agent definition:

```python
# Add these imports to your agent.py or a main script
from google.adk.sessions import InMemorySessionService
from google.adk.runners import Runner
from google.adk.agents import Agent # Assuming root_agent is defined as before

# Define constants for the app and session identification
APP_NAME = &quot;my_first_adk_app&quot;
USER_ID = &quot;user_test_1&quot; # Identifier for the user
SESSION_ID = &quot;session_abc_123&quot; # Identifier for this specific conversation

# 1. Create the Session Service instance
session_service = InMemorySessionService()
print(&quot;Session Service created.&quot;)

# 2. Create the specific session (can initialize state here if needed)
session = session_service.create_session(
    app_name=APP_NAME,
    user_id=USER_ID,
    session_id=SESSION_ID
    # state={&quot;initial_key&quot;: &quot;initial_value&quot;} # Optional: initial state
)
print(f&quot;Session &apos;{SESSION_ID}&apos; created.&quot;)

# 3. Create the Runner instance
# The Runner orchestrates the agent&apos;s execution and uses the session service
runner = Runner(
    agent=root_agent, # Your agent defined earlier
    app_name=APP_NAME,
    session_service=session_service
)
print(f&quot;Runner created for agent &apos;{root_agent.name}&apos;.&quot;)

# (You would then use &apos;runner.run()&apos; or &apos;runner.run_async()&apos; to interact)
```

**Accessing State in Tools**

The primary way tools interact with memory is through the `ToolContext` object. If you define a tool function that accepts `tool_context: ToolContext` as its last argument, ADK automatically provides this object when the tool is called.

```python
# Example tool demonstrating state access
from google.adk.tools.tool_context import ToolContext

def remember_something(data_to_remember: str, tool_context: ToolContext) -&gt; dict:
    &quot;&quot;&quot;Reads previous data from state and saves new data.&quot;&quot;&quot;
    print(&quot;--- Tool: remember_something called ---&quot;)

    # Read from state (use .get() for safety)
    previous_data = tool_context.state.get(&quot;user_data&quot;, &quot;nothing&quot;)
    print(f&quot;--- Tool: Found previous data: &apos;{previous_data}&apos; ---&quot;) #

    # Write new data to state
    tool_context.state[&quot;user_data&quot;] = data_to_remember # [cite: 354]
    print(f&quot;--- Tool: Saved new data: &apos;{data_to_remember}&apos; ---&quot;)

    return {&quot;status&quot;: &quot;success&quot;, &quot;message&quot;: f&quot;I remembered &apos;{data_to_remember}&apos;. Before that, I knew about &apos;{previous_data}&apos;.&quot;}

# (You would add &apos;remember_something&apos; to the agent&apos;s &apos;tools&apos; list)
```

This allows tools to dynamically adapt their behavior based on what&apos;s happened earlier in the conversation.

**Quick Note on `output_key`:** ADK also offers a shortcut. By setting `output_key=&quot;some_name&quot;` when defining an `Agent`, the agent&apos;s final text response for each turn will automatically be saved into `session.state[&quot;some_name&quot;]`.

Now that our agent has a way to remember things, let&apos;s give it some tools to interact with the outside world!


### Enhancing Your Agent with Tools: Tavily Search

Our agent can now chat and remember things within a session. However, its knowledge is limited to what the underlying Gemini model was trained on. To make it truly powerful, we need to give it access to real-time information and external capabilities. In ADK, this is done using **Tools**.

Tools are functions or services that allow your agent to interact with the outside world – fetch data from APIs, query databases, perform calculations, or, as we&apos;ll do now, search the web.

**Integrating Third-Party Tools: LangChain &amp; Tavily**

ADK is designed to be extensible and plays well with other popular AI frameworks. We can easily integrate tools built for libraries like LangChain. We&apos;ll use ADK&apos;s `LangchainTool` wrapper to incorporate the Tavily search API, a service designed specifically for AI agents to get up-to-date search results.

**Setup for Tavily**

1.  **Install Dependencies:** Add the necessary LangChain and Tavily libraries using `uv`:
    ```bash
    # Run this in your activated virtual environment
    uv add langchain_community tavily-python
    ```

2.  **Get Tavily API Key:**
    * Sign up for a free API key at the [Tavily website](https://tavily.com/).
    * Add this key to your `.env` file:
        ```dotenv
        # .env file content (add this line)
        TAVILY_API_KEY=&quot;PASTE_YOUR_TAVILY_API_KEY_HERE&quot;
        ```
        Make sure your application loads environment variables from this file (ADK often does this automatically, or you can use a library like `python-dotenv`).

**Using the `LangchainTool` Wrapper**

Now, let&apos;s integrate Tavily into our agent code:

1.  **Import necessary classes** in your `agent.py`:
    ```python
    # Add these imports
    from google.adk.tools.langchain_tool import LangchainTool
    from langchain_community.tools import TavilySearchResults
    import os # To potentially check for the API key
    ```

2.  **Instantiate and Wrap the Tool:** Create an instance of the LangChain tool and wrap it for ADK:
    ```python
    # Instantiate the LangChain Tavily tool
    # You can configure options like max_results
    tavily_search_tool_instance = TavilySearchResults(
        max_results=3,
        include_answer=True # Ask Tavily to provide a direct answer if possible
    )

    # Wrap it with ADK&apos;s LangchainTool
    adk_tavily_tool = LangchainTool(tool=tavily_search_tool_instance)

    print(&quot;Tavily search tool wrapped for ADK.&quot;)
    ```

**Update Your Agent Definition**

Finally, modify your agent definition to include the new tool and update its instructions so it knows when to use it:

```python
# Modify your existing root_agent definition in agent.py

root_agent = Agent(
    name=&quot;my_first_adk_agent&quot;, # Or maybe rename to &quot;research_agent&quot;
    model=&quot;gemini-2.0-flash-exp&quot;,
    description=&quot;A helpful assistant that can search the web for current information.&quot;,
    instruction=&quot;&quot;&quot;You are a friendly and helpful assistant.
If the user asks for information that might require up-to-date details, recent events, or searching the web, use the &apos;TavilySearchResults&apos; tool.
Otherwise, answer directly based on your knowledge.
Remember information provided earlier in the conversation using session state.&quot;&quot;&quot;, # Added mention of memory

    # Add the wrapped Tavily tool to the list
    tools=[adk_tavily_tool] # Add any other tools like &apos;remember_something&apos; here too if desired
)

print(f&quot;Agent &apos;{root_agent.name}&apos; updated with Tavily tool.&quot;)

# Ensure your Runner is using this updated &apos;root_agent&apos; instance
runner = Runner(
    agent=root_agent, # Make sure this uses the updated agent
    app_name=APP_NAME,
    session_service=session_service
)
```

Now, when you run your agent and ask a question requiring current information (e.g., &quot;What&apos;s the latest news about AI agents?&quot; or &quot;What&apos;s the weather in London right now?&quot;), the agent, guided by its instructions, should recognize the need to use the `TavilySearchResults` tool to fetch and provide a relevant, up-to-date answer.


### Complete code:

```python
# agent_module/agent.py

import os
import asyncio
from dotenv import load_dotenv

from google.adk.sessions import InMemorySessionService
from google.adk.runners import Runner
from google.adk.agents import Agent
from google.adk.tools.tool_context import ToolContext
from google.adk.tools.langchain_tool import LangchainTool
from langchain_community.tools import TavilySearchResults
from google.genai import types # For creating message Content/Parts

# --- Load Environment Variables ---
# Ensure you have a .env file in the parent directory with your keys
# Requires: pip install python-dotenv (or uv install python-dotenv)
load_dotenv()

# Check if keys are loaded (optional but good practice)
if not os.getenv(&quot;GOOGLE_API_KEY&quot;):
    print(&quot;Warning: GOOGLE_API_KEY environment variable not set.&quot;)
if not os.getenv(&quot;TAVILY_API_KEY&quot;):
    print(&quot;Warning: TAVILY_API_KEY environment variable not set.&quot;)

# --- Tool Definitions ---

def remember_something(data_to_remember: str, tool_context: ToolContext) -&gt; dict:
    &quot;&quot;&quot;
    Reads previous data from state (&apos;user_data&apos;) and saves new data
    to the same state key. Used to demonstrate agent memory.
    &quot;&quot;&quot;
    print(&quot;--- Tool: remember_something called ---&quot;)
    # Read from state (use .get() for safety, provide default)
    previous_data = tool_context.state.get(&quot;user_data&quot;, &quot;nothing&quot;)
    print(f&quot;--- Tool: Found previous data in state[&apos;user_data&apos;]: &apos;{previous_data}&apos; ---&quot;)

    # Write new data to state
    tool_context.state[&quot;user_data&quot;] = data_to_remember
    print(f&quot;--- Tool: Saved new data to state[&apos;user_data&apos;]: &apos;{data_to_remember}&apos; ---&quot;)

    return {&quot;status&quot;: &quot;success&quot;, &quot;message&quot;: f&quot;Okay, I&apos;ve noted down &apos;{data_to_remember}&apos;. Before that, I had noted &apos;{previous_data}&apos;.&quot;}

# --- Tool Setup ---

# Instantiate the LangChain Tavily tool
tavily_search_tool_instance = TavilySearchResults(
    max_results=3,             # Limit the number of search results
    include_answer=True        # Ask Tavily to provide a direct answer if possible
)

# Wrap it with ADK&apos;s LangchainTool for compatibility
adk_tavily_tool = LangchainTool(tool=tavily_search_tool_instance)

print(&quot;Tavily search tool wrapped for ADK.&quot;)

# --- Agent Definition ---

root_agent = Agent(
    name=&quot;my_adk_agent_with_tools&quot;,
    # Ensure this model is available with your GOOGLE_API_KEY (AI Studio)
    model=&quot;gemini-2.0-flash-exp&quot;, # Or gemini-1.0-pro, etc.
    description=&quot;A helpful assistant that can search the web using Tavily and remember user data.&quot;,
    instruction=&quot;&quot;&quot;You are a friendly and helpful assistant.
1. If the user asks for information that might require up-to-date details, recent events, or specific web searching, use the &apos;TavilySearchResults&apos; tool.
2. If the user asks you to remember something specific, use the &apos;remember_something&apos; tool to save it. Also mention what was previously remembered if anything.
3. Use information remembered earlier (from session state) if relevant to the current query.
4. Otherwise, answer directly based on your general knowledge.&quot;&quot;&quot;,

    # Include both tools the agent can use
    tools=[adk_tavily_tool, remember_something]
)

print(f&quot;Agent &apos;{root_agent.name}&apos; defined with tools.&quot;)

# --- Session and Runner Setup ---

APP_NAME = &quot;my_first_adk_app&quot;
USER_ID = &quot;user_dev_1&quot; # Identifier for the user
SESSION_ID = &quot;session_main_1&quot; # Identifier for this specific conversation

# Create the Session Service instance (stores memory)
session_service = InMemorySessionService()
print(&quot;Session Service created.&quot;)

# Create the specific session for this user and app
session = session_service.create_session(
    app_name=APP_NAME,
    user_id=USER_ID,
    session_id=SESSION_ID
    # state={&quot;user_data&quot;: &quot;initial value&quot;} # Optional: prime the state memory
)
print(f&quot;Session &apos;{SESSION_ID}&apos; created.&quot;)

# Create the Runner instance (orchestrates agent execution)
runner = Runner(
    agent=root_agent,
    app_name=APP_NAME,
    session_service=session_service
)
print(f&quot;Runner created for agent &apos;{root_agent.name}&apos;.&quot;)

# --- Interaction Logic ---

async def run_conversation():
    &quot;&quot;&quot;Runs a simple interactive loop to chat with the agent.&quot;&quot;&quot;
    print(&quot;\n--- Starting Conversation (type &apos;quit&apos; to exit) ---&quot;)
    while True:
        try:
            user_query = input(&quot;You: &quot;)
            if user_query.lower() == &apos;quit&apos;:
                print(&quot;Exiting conversation.&quot;)
                break

            # Prepare the user message in ADK format
            content = types.Content(role=&apos;user&apos;, parts=[types.Part(text=user_query)])

            final_response_text = &quot;Agent did not produce a final response.&quot;

            # Use run_async to process the message and get events
            async for event in runner.run_async(user_id=USER_ID, session_id=SESSION_ID, new_message=content):
                # You can uncomment below to see all events (tool calls, etc.)
                # print(f&quot;  [Event] Author: {event.author}, Type: {type(event).__name__}, Final: {event.is_final_response()}&quot;)

                # Look for the final response event
                if event.is_final_response():
                    if event.content and event.content.parts:
                        # Assuming text response in the first part
                        final_response_text = event.content.parts[0].text
                    elif event.actions and event.actions.escalate:
                        final_response_text = f&quot;Agent escalated: {event.error_message or &apos;No specific message.&apos;}&quot;
                    break # Stop processing events for this turn

            print(f&quot;Agent: {final_response_text}&quot;)

            # Optional: Print current state for debugging
            # current_session = session_service.get_session(APP_NAME, USER_ID, SESSION_ID)
            # print(f&quot;  (Debug State: {current_session.state})&quot;)

        except Exception as e:
            print(f&quot;An error occurred: {e}&quot;)

# --- Run the Application ---

if __name__ == &quot;__main__&quot;:
    # Check dependencies are installed
    try:
        import langchain_community
        import tavily
    except ImportError:
        print(&quot;Error: Missing dependencies. Please run:&quot;)
        print(&quot;uv add langchain_community tavily-python python-dotenv&quot;)
        exit()

    # Run the asynchronous conversation loop
    try:
        asyncio.run(run_conversation())
    except RuntimeError as e:
         # Handle specific error when running asyncio.run in an already running loop (like Jupyter/Colab)
        if &quot;cannot be called from a running event loop&quot; in str(e):
            print(&quot;\nCannot start a new event loop. If in a Jupyter Notebook, run:&quot;)
            print(&quot;await run_conversation()&quot;)
        else:
            raise e # Re-raise other runtime errors
```

### Run in terminal
You can use terminal to run the tool, with:

```sh
uv run python -m agent_module.agent
```

This will provide something like:

```sh
Tavily search tool wrapped for ADK.
Agent &apos;my_adk_agent_with_tools&apos; defined with tools.
Session Service created.
Session &apos;session_main_1&apos; created.
Runner created for agent &apos;my_adk_agent_with_tools&apos;.
&lt;frozen runpy&gt;:128: RuntimeWarning: &apos;agent_module.agent&apos; found in sys.modules after import of package &apos;agent_module&apos;, but prior to execution of &apos;agent_module.agent&apos;; this may result in unpredictable behaviour
Tavily search tool wrapped for ADK.
Agent &apos;my_adk_agent_with_tools&apos; defined with tools.
Session Service created.
Session &apos;session_main_1&apos; created.
Runner created for agent &apos;my_adk_agent_with_tools&apos;.

--- Starting Conversation (type &apos;quit&apos; to exit) ---
You: what are the latest news on tech in april 2025
Warning: there are non-text parts in the response: [&apos;function_call&apos;],returning concatenated text result from text parts,check out the non text parts for full response from model.
Agent: In April 2025, some of the biggest tech news included: Apple releasing new software updates, Amazon developing an AI model to enhance customer experience, Google announcing a quantum computing breakthrough, and NVIDIA advancing confidential computing on its Blackwell infrastructure.

You:
```

## Conclusion

Congratulations\! You&apos;ve successfully built your first AI agent using the Google Agent Development Kit. We&apos;ve walked through the essential steps:

  * **Setting up** your development environment using `uv` for package management.
  * **Connecting** your agent to the powerful Gemini models via a Google AI Studio API key.
  * **Defining** a basic agent structure with its core identity and instructions.
  * **Adding memory** using Session State, enabling contextual conversations.
  * **Integrating** a third-party tool (Tavily Search) using ADK&apos;s wrappers, giving your agent access to real-time web information.

This journey demonstrates the flexibility and power of ADK. You&apos;ve seen how quickly you can get a basic agent running and how easily you can enhance it with memory and external tools. ADK provides the building blocks for everything from simple bots to complex, multi-agent systems capable of sophisticated orchestration and leveraging a rich tool ecosystem.

**Where to Go Next?**

Your first agent is just the beginning\! Here are some ideas to continue your exploration of ADK:

  * **Explore the Tutorials:** Dive deeper into concepts like multi-agent delegation, advanced state management, and safety callbacks by following the official ADK tutorials.
  * **Integrate Real APIs:** Replace mock data or simple tools with real-world APIs for weather, finance, or other services.
  * **Add More Tools:** Experiment with other built-in tools like Code Execution or Vertex AI Search, or integrate more tools from LangChain or CrewAi.
  * **Build Agent Teams:** Design systems with multiple specialized agents collaborating on tasks .
  * **Persistent Memory:** Investigate storing session state more permanently using databases instead of just in memory.
  * **Check out Samples:** Look at the [ADK Sample Agents](https://www.google.com/search?q=http://github.com/google/adk-samples) for more examples.

The [official ADK documentation](https://google.github.io/adk-docs/)  is your comprehensive resource for all features, API references, and advanced guides.

You now have a solid foundation for building intelligent, capable agents with the Google Agent Development Kit. Happy building\!</content:encoded><category>ai</category><category>ai-agents</category><category>adk</category><category>uv</category></item><item><title>Building Your AI Research Squad with Agno, Streamlit, and uv</title><link>https://www.bitdoze.com/agno-squad/</link><guid isPermaLink="true">https://www.bitdoze.com/agno-squad/</guid><description>Learn how to create a powerful team of specialized AI agents using Agno, Streamlit, and uv. This comprehensive guide walks you through setting up your own research assistant team that can search the web, analyze YouTube videos, crawl websites, and more!</description><pubDate>Wed, 09 Apr 2025 00:00:00 GMT</pubDate><content:encoded>Remember that scene in Ocean&apos;s Eleven where George Clooney assembles a specialized team, each member with unique skills for the perfect heist? That&apos;s essentially what we&apos;re doing today, except instead of breaking into casinos, we&apos;re breaking into the world of knowledge. And instead of risking prison time, we&apos;re just risking a higher cloud computing bill!

In the rapidly evolving AI landscape, single-purpose agents are giving way to coordinated teams of AI specialists. These teams can accomplish complex tasks that would be difficult for a single agent to handle effectively. Think of it as the difference between asking a general practitioner about a rare neurological condition versus consulting with a team of specialists. The collective intelligence always wins.

Agno, a lightweight Python library for building AI agents, makes this multi-agent approach remarkably accessible. When combined with Streamlit for beautiful interfaces and uv (a lightning-fast Python package manager), you get a toolkit that&apos;s both powerful and practical. You can check more on Agno on: [Agno get started](https://www.bitdoze.com/agno-get-start/) article.

By the end of this tutorial, you&apos;ll have a team of AI specialists that can:

- Search the web for up-to-date information
- Extract and analyze content from websites
- Break down YouTube videos
- Send professional emails
- Explore GitHub repositories
- Track trends on Hacker News
- Synthesize information from all these sources

The best part? Your users will interact with this team through a clean, intuitive Streamlit interface that you can deploy anywhere.

Let&apos;s get building!



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/WWCE7INpWfs&quot;
  label=&quot;Building Your AI Research Squad with Agno, Streamlit, and uv&quot;
/&gt;


## Prerequisites and Environment Setup with uv

Before we dive into agent creation, let&apos;s set up our development environment. We&apos;ll use `uv`, the turbo-charged alternative to pip that&apos;s up to 100x faster and built in Rust (because everything cool these days seems to be built in Rust).

### Why uv?

Imagine waiting for a pizza delivery. `pip` is like that delivery guy who gets lost, takes wrong turns, and delivers your pizza lukewarm an hour later. `uv` is the delivery rocket that has your pizza at your doorstep before you even finish placing the order. It&apos;s that fast.

### Installing uv

For macOS/Linux:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

For Windows:
```powershell
irm https://astral.sh/uv/install.ps1 | iex
```

### Setting Up Your Project

Let&apos;s create a fresh project and install our dependencies:

```bash
mkdir ai-research-team
cd ai-research-team

# Create and activate a virtual environment
uv venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install dependencies at warp speed
uv add agno streamlit python-dotenv duckduckgo-search crawl4ai youtube-transcript-api resend pygithub hackernews
```

### Environment Variables

Our agent team needs API keys to access various services. Create a `.env` file in your project directory:

```
OPENROUTER_API_KEY=your_openrouter_key
EMAIL_FROM=your_email@example.com
EMAIL_TO=recipient@example.com
GITHUB_ACCESS_TOKEN=your_github_token
RESEND_API_KEY=your_resend_key
```

You can obtain these keys from:
- [OpenRouter](https://openrouter.ai) - For accessing various language models
- [GitHub](https://github.com/settings/tokens) - For GitHub repository access
- [Resend](https://resend.com) - For email capabilities

## Understanding Agno Agents - Core Concepts

Before we start building our dream team, let&apos;s understand what makes Agno agents tick. Think of Agno as the talent scout, trainer, and manager for your AI squad—it handles all the complex machinery so you can focus on creating agents with superpowers.

### What Makes an Agno Agent?

At its core, an Agno agent consists of four essential components:

1. **Model**: The brain of your agent. This is typically a large language model (LLM) like OpenAI&apos;s models or, in our case, models accessed via OpenRouter like Quasar Alpha.

2. **Tools**: Special abilities your agent can use to interact with the world. These range from web searches (DuckDuckGo) to sending emails (Resend) or analyzing YouTube videos.

3. **Instructions**: The playbook for your agent. These are specific guidelines that shape how the agent approaches problems.

4. **Memory**: The agent&apos;s ability to remember previous interactions, which can be stored in databases like SQLite.

### The Agent Lifecycle

When a user sends a query to an Agno agent, a fascinating process unfolds:

1. **Input Processing**: The agent receives the user&apos;s message.
2. **Context Assembly**: The agent gathers relevant context, including its instructions and history.
3. **Tool Selection**: The agent decides if and which tools to use (like searching the web).
4. **Response Generation**: The LLM generates a response based on all available information.
5. **Memory Update**: The interaction is stored in the agent&apos;s memory for future reference.

This cycle happens seamlessly behind the scenes, giving users the impression of conversing with a knowledgeable entity rather than a complex piece of software.

### Agent vs. Team Modes

Agno supports two primary ways to organize your AI workforce:

1. **Individual Agents**: Specialized entities focused on specific tasks. Like hiring an expert consultant.

2. **Teams**: Collections of agents coordinated to tackle complex tasks. Like assembling a specialized task force.

Our project will use the &quot;coordinate&quot; team mode, where a team leader (coordinator) breaks down complex tasks, assigns them to specialists, and synthesizes their outputs into a cohesive whole. It&apos;s like having a project manager who knows exactly which team member to tap for each subtask.

| Mode | Best For | Real-World Analogy |
|------|----------|--------------------|
| Individual Agent | Focused tasks with clear boundaries | Solo consultant |
| Team (Coordinate) | Complex tasks requiring multiple specialties | Project team with manager |

Now that we understand the foundations, let&apos;s start building our specialized agents!

## Specialized Agents - Creating Each Team Member

Now comes the fun part—assembling our dream team of AI specialists! Think of this as casting for an Ocean&apos;s Eleven-style heist, but instead of stealing diamonds, we&apos;re extracting knowledge. Let&apos;s meet our crew of digital specialists, each with unique skills and a well-defined role.

### The Internet Searcher - Your Web Detective

First up is our web detective, capable of finding the latest information across the internet. This agent is essential for real-time data that isn&apos;t in our knowledge base.

```python
search_agent = Agent(
    name=&quot;InternetSearcher&quot;,
    model=model,
    tools=[DuckDuckGoTools(search=True, news=False)],
    add_history_to_messages=True,
    num_history_responses=3, # Limit history passed to agent
    description=&quot;Expert at finding information online.&quot;,
    instructions=[
        &quot;Use duckduckgo_search for web queries.&quot;,
        &quot;Cite sources with URLs.&quot;,
        &quot;Focus on recent, reliable information.&quot;
    ],
    add_datetime_to_instructions=True, # Add time context
    markdown=True,
    exponential_backoff=True # Add robustness
)
```

**Key Features:**
- **DuckDuckGoTools**: Our agent&apos;s magnifying glass for investigating the web
- **add_history_to_messages**: Keeps track of previous search results
- **exponential_backoff**: Handles rate limits gracefully (because even digital detectives need coffee breaks)

### The Web Crawler - Your Content Extractor

Next is our data extraction specialist, who can pull detailed content from specific websites when you need more than just search results.

```python
crawler_agent = Agent(
    name=&quot;WebCrawler&quot;,
    model=model,
    tools=[Crawl4aiTools(max_length=None)], # No content length limit
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Extracts content from specific websites.&quot;,
    instructions=[
        &quot;Use web_crawler to extract content from provided URLs.&quot;,
        &quot;Summarize key points and include the URL.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)
```

**Key Features:**
- **Crawl4aiTools**: A specialized tool for extracting web content
- **max_length=None**: Gets the full content without truncation

### The YouTube Analyst - Your Video Interpreter

Our media specialist can watch and analyze YouTube videos, extracting both captions and metadata for comprehensive insights.

```python
youtube_agent = Agent(
    name=&quot;YouTubeAnalyst&quot;,
    model=model,
    tools=[YouTubeTools()],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Analyzes YouTube videos.&quot;,
    instructions=[
        &quot;Extract captions and metadata for YouTube URLs.&quot;,
        &quot;Summarize key points and include the video URL.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)
```

**Key Features:**
- **YouTubeTools**: Extracts captions and metadata from videos
- Access to both what was said and video information

### The Email Assistant - Your Communications Expert

Need to share findings via email? This agent handles professional communications with style and precision.

```python
email_agent = Agent(
    name=&quot;EmailAssistant&quot;,
    model=model,
    tools=[ResendTools(from_email=EMAIL_FROM, api_key=RESEND_API_KEY)],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Sends emails professionally.&quot;,
    instructions=[
        &quot;send professional emails based on context or user request.&quot;,
        f&quot;Default recipient is {EMAIL_TO}, but use recipient specified in the query if provided.&quot;,
        &quot;Include URLs and links clearly.&quot;,
        &quot;Ensure the tone is professional and courteous.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)
```

**Key Features:**
- **ResendTools**: Professional email sending capabilities
- Configurable sender and default recipient

### The GitHub Researcher - Your Code Explorer

For technical research, our GitHub specialist can dive into repositories, pull requests, and code discussions.

```python
github_agent = Agent(
    name=&quot;GitHubResearcher&quot;,
    model=model,
    tools=[GithubTools(access_token=GITHUB_ACCESS_TOKEN)],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Explores GitHub repositories.&quot;,
    instructions=[
        &quot;Search repositories or list pull requests based on user query.&quot;,
        &quot;Include repository URLs and summarize findings concisely.&quot;
    ],
    markdown=True,
    exponential_backoff=True,
    add_datetime_to_instructions=True
)
```

**Key Features:**
- **GithubTools**: Access to GitHub&apos;s vast ecosystem
- Time-aware instructions for relevance

### The HackerNews Monitor - Your Tech Trend Tracker

To stay on top of tech discussions and innovations, our HackerNews specialist monitors trending stories and discussions.

```python
hackernews_agent = Agent(
    name=&quot;HackerNewsMonitor&quot;,
    model=model,
    tools=[HackerNewsTools()],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Tracks Hacker News trends.&quot;,
    instructions=[
        &quot;Fetch top stories using get_top_hackernews_stories.&quot;,
        &quot;Summarize discussions and include story URLs.&quot;
    ],
    markdown=True,
    exponential_backoff=True,
    add_datetime_to_instructions=True
)
```

**Key Features:**
- **HackerNewsTools**: Access to the pulse of tech discussions
- Time-aware for tracking trending topics

### The Generalist - Your Synthesis Expert

Finally, our jack-of-all-trades handles general queries and synthesizes information from the specialists.

```python
general_agent = Agent(
    name=&quot;GeneralAssistant&quot;,
    model=model,
    add_history_to_messages=True,
    num_history_responses=5, # More history for context
    description=&quot;Handles general queries and synthesizes information from specialists.&quot;,
    instructions=[
        &quot;Answer general questions or combine specialist inputs.&quot;,
        &quot;If specialists provide information, synthesize it clearly.&quot;,
        &quot;If a query doesn&apos;t fit other specialists, attempt to answer directly.&quot;,
        &quot;Maintain a professional tone.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)
```

**Key Features:**
- No specific tools—this agent is all about synthesis and general knowledge
- Access to more history for comprehensive context

### Common Agent Features Explained

Let&apos;s break down some configuration options that appear across our agents:

| Parameter | Purpose | Benefit |
|-----------|---------|----------|
| `add_history_to_messages` | Includes chat history in context | Maintains conversation flow |
| `num_history_responses` | Limits history length | Prevents context overflow |
| `markdown` | Enables formatted output | Better readability |
| `exponential_backoff` | Retry strategy for failures | Improves reliability |
| `add_datetime_to_instructions` | Adds timestamp to instructions | Time-aware responses |

With our specialized team members defined, we&apos;re ready for the next step: bringing them together under a coordinated team structure!

## Coordinating with Team Mode - Building the Whole Squad

We have our specialized agents ready to go, but they&apos;re just individual experts without a way to collaborate. Now it&apos;s time to bring them together under Agno&apos;s &quot;coordinate&quot; team mode—think of it as appointing a project manager who knows exactly which specialist to call for each part of a complex task.

### Creating the Research Team

Here&apos;s where we define our team structure and how the agents will work together:

```python
# --- Team Initialization (in Session State) ---
def initialize_team():
    &quot;&quot;&quot;Initializes or re-initializes the research team.&quot;&quot;&quot;
    return Team(
        name=&quot;ResearchAssistantTeam&quot;,
        mode=&quot;coordinate&quot;,
        model=model,
        members=[
            search_agent,
            crawler_agent,
            youtube_agent,
            email_agent,
            github_agent,
            hackernews_agent,
            general_agent
        ],
        description=&quot;Coordinates specialists to handle research tasks.&quot;,
        instructions=[
            &quot;Analyze the query and assign tasks to specialists.&quot;,
            &quot;Delegate based on task type:&quot;,
            &quot;- Web searches: InternetSearcher&quot;,
            &quot;- URL content: WebCrawler&quot;,
            &quot;- YouTube videos: YouTubeAnalyst&quot;,
            &quot;- Emails: EmailAssistant&quot;,
            &quot;- GitHub queries: GitHubResearcher&quot;,
            &quot;- Hacker News: HackerNewsMonitor&quot;,
            &quot;- General or synthesis: GeneralAssistant&quot;,
            &quot;Synthesize responses into a cohesive answer.&quot;,
            &quot;Cite sources and maintain clarity.&quot;,
            &quot;Always check previous conversations in memory before responding.&quot;,
            &quot;When asked about previous information or to recall something mentioned before, refer to your memory of past interactions.&quot;,
            &quot;Use all relevant information from memory when answering follow-up questions.&quot;
        ],
        success_criteria=&quot;The user&apos;s query has been thoroughly answered with information from all relevant specialists.&quot;,
        enable_agentic_context=True,      # Coordinator maintains context
        share_member_interactions=True, # Members see previous member interactions in context
        show_members_responses=False,     # Don&apos;t show raw member responses in final output
        markdown=True,
        show_tool_calls=False,            # Don&apos;t show raw tool calls in final output
        enable_team_history=True,         # Pass history between coordinator/members
        num_of_interactions_from_history=5 # Limit history passed
    )

if &quot;team&quot; not in st.session_state:
    st.session_state.team = initialize_team()
```

### How Team Coordination Works

Let&apos;s break down what&apos;s happening in this &quot;coordinate&quot; mode:

1. **Team Creation**: We create a `Team` object with a collection of specialized agents as members.

2. **Coordinator Role**: The team operates in &quot;coordinate&quot; mode, where the model specified (in our case, the same `model` we used for individual agents) acts as a coordinator.

3. **Task Delegation**: When a user query comes in, the coordinator analyzes it and decides which specialist(s) to involve.

4. **Information Flow**: The coordinator sends sub-tasks to the appropriate agents, collects their responses, and synthesizes a final answer.

5. **Memory Management**: With `enable_team_history=True`, both the coordinator and members have access to conversation history, making follow-up questions seamless.

### Team Configuration Options Explained

Let&apos;s explore the key configuration options that make our team effective:

| Parameter | Purpose | Impact |
|-----------|---------|--------|
| `mode=&quot;coordinate&quot;` | Sets the team operation pattern | Creates a hierarchical structure with a coordinator |
| `enable_agentic_context` | Gives the coordinator persistent context | Maintains awareness across interactions |
| `share_member_interactions` | Shares specialist outputs between members | Creates collaborative awareness |
| `show_members_responses` | Controls raw output visibility | Set to `False` for clean final responses |
| `enable_team_history` | Enables history access for all | Creates memory continuity for follow-ups |

### The &quot;Success Criteria&quot; Explained

One of the most powerful features of Agno&apos;s team mode is the ability to define success criteria. This gives the coordinator clear guidance on when a task is considered complete:

```python
success_criteria=&quot;The user&apos;s query has been thoroughly answered with information from all relevant specialists.&quot;
```

This simple statement has a profound impact—it tells the coordinator to keep working (and delegating) until it has gathered enough information from the right specialists to provide a comprehensive answer.

Think of it as setting the standard for what constitutes a &quot;job well done&quot; for your AI team. Without this, the coordinator might rush to conclusions or miss important specialist input.

With our team structure defined, we&apos;re ready to create the interface that will bring this powerful AI squad to life—let&apos;s build our Streamlit app!

## Streamlit Integration - Giving Your Team a Face

Now that we have a powerful research team humming under the hood, it&apos;s time to build an intuitive UI with Streamlit. Think of this as giving your AI Ocean&apos;s Eleven crew a sleek command center—or at the very least, a chat window that doesn&apos;t look like it&apos;s from 1995.

### Building the Streamlit UI

Streaming is the name of the game here—users want to see responses appearing in real-time, just like in ChatGPT or Claude. Let&apos;s set up our Streamlit app to deliver that experience:

```python
# --- Streamlit UI ---
st.title(&quot;🤖 Research Assistant Team&quot;)
st.markdown(&quot;&quot;&quot;
This team coordinates specialists to assist with:
- 🔍 Web searches
- 🌐 Website content extraction
- 📺 YouTube video analysis
- 📧 Email drafting/sending
- 💻 GitHub repository exploration
- 📰 Hacker News trends
- 🧠 General queries and synthesis
&quot;&quot;&quot;)

# Display chat messages from history
for message in st.session_state.messages:
    with st.chat_message(message[&quot;role&quot;]):
        st.markdown(message[&quot;content&quot;])

# Handle user input
user_query = st.chat_input(&quot;Ask the research team anything...&quot;)

if user_query:
    # Add user message to chat history
    st.session_state.messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_query})

    # Display user message
    with st.chat_message(&quot;user&quot;):
        st.markdown(user_query)

    # Display team response (Streaming)
    with st.chat_message(&quot;assistant&quot;):
        message_placeholder = st.empty()
        full_response = &quot;&quot;
        try:
            # Use stream=True for the team run
            response_stream: Iterator[RunResponse] = st.session_state.team.run(user_query, stream=True) # Ensure type hint

            for chunk in response_stream:
                # Check if content is present and a string
                if chunk.content and isinstance(chunk.content, str):
                    full_response += chunk.content
                    message_placeholder.markdown(full_response + &quot;▌&quot;) # Add cursor effect
            message_placeholder.markdown(full_response) # Final response without cursor

            # Update memory debug information for display
            if hasattr(st.session_state.team, &apos;memory&apos;) and hasattr(st.session_state.team.memory, &apos;messages&apos;):
                try:
                    # Extract only role and content safely
                    st.session_state.memory_dump = [
                        {&quot;role&quot;: m.role if hasattr(m, &apos;role&apos;) else &apos;unknown&apos;,
                         &quot;content&quot;: m.content if hasattr(m, &apos;content&apos;) else str(m)}
                        for m in st.session_state.team.memory.messages
                    ]
                except Exception as e:
                    st.session_state.memory_dump = f&quot;Error accessing memory messages: {str(e)}&quot;
            else:
                st.session_state.memory_dump = &quot;Team memory object or messages not found/accessible.&quot;

            # Add the final assistant response to Streamlit&apos;s chat history
            st.session_state.messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: full_response})

        except Exception as e:
            st.exception(e) # Show full traceback in Streamlit console for debugging
            error_message = f&quot;An error occurred: {str(e)}\n\nPlease check your API keys and tool configurations. Try rephrasing your query.&quot;
            st.error(error_message)
            message_placeholder.markdown(f&quot;⚠️ {error_message}&quot;)
            # Add error message to history for context
            st.session_state.messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: f&quot;Error: {str(e)}&quot;})
```

### The Sidebar - Configuration and Debugging

Every great app needs a sidebar for configuration options and debugging information. Here&apos;s how we&apos;ve structured ours:

```python
# --- Sidebar ---
with st.sidebar:
    st.title(&quot;Team Settings&quot;)

    # Memory debug section
    if st.checkbox(&quot;Show Team Memory Contents&quot;, value=False):
        st.subheader(&quot;Team Memory Contents (Debug)&quot;)
        if &quot;memory_dump&quot; in st.session_state:
            try:
                # Use pformat for potentially complex structures
                memory_str = pformat(st.session_state.memory_dump, indent=2, width=80)
                st.code(memory_str, language=&quot;python&quot;)
            except Exception as format_e:
                st.warning(f&quot;Could not format memory dump: {format_e}&quot;)
                st.json(st.session_state.memory_dump) # Fallback to json
        else:
            st.info(&quot;No memory contents to display yet. Interact with the team first.&quot;)

    st.markdown(f&quot;**Session ID**: `{st.session_state.team_session_id}`&quot;)
    st.markdown(f&quot;**Model**: {model_name}&quot;)

    # Memory information
    st.subheader(&quot;Team Memory&quot;)
    st.markdown(&quot;This team remembers conversations within this browser session. Clearing the chat resets the memory.&quot;)

    # Clear chat button
    if st.button(&quot;Clear Chat &amp; Reset Team&quot;):
        st.session_state.messages = []
        st.session_state.team_session_id = f&quot;streamlit-team-session-{int(time.time())}&quot; # New ID for clarity
        st.session_state.team = initialize_team() # Re-initialize the team to reset its state
        if &quot;memory_dump&quot; in st.session_state:
            del st.session_state.memory_dump # Clear the dump
        st.rerun()

    st.title(&quot;About&quot;)
    st.markdown(&quot;&quot;&quot;
    **How it works**:
    - The team coordinator analyzes your query.
    - Tasks are delegated to specialists (Searcher, Crawler, YouTube Analyst, Email, GitHub, HackerNews, General).
    - Responses are synthesized into a final answer.
    - Team memory retains context within this session.

    **Example queries**:
    - &quot;What are the latest AI breakthroughs?&quot;
    - &quot;Crawl agno.com and summarize the homepage.&quot;
    - &quot;Summarize the YouTube video: https://www.youtube.com/watch?v=dQw4w9WgXcQ&quot;
    - &quot;Draft an email to contact@example.com introducing our research services.&quot;
    - &quot;Find popular AI repositories on GitHub created in the last month.&quot;
    - &quot;What&apos;s trending on Hacker News today?&quot;
    - &quot;What was the first question I asked you?&quot; (tests memory)
    &quot;&quot;&quot;)
```

### How Streamlit and Agno Work Together

Let&apos;s break down the integration points between Streamlit and our Agno team:

| Streamlit Feature | Purpose | Integration with Agno |
|-------------------|---------|----------------------|
| `st.session_state` | Maintains app state across interactions | Stores team instance and conversation history |
| `st.chat_message` | Creates chat bubbles for conversation | Displays user queries and team responses |
| `st.empty()` | Creates placeholder for streaming | Updated chunk by chunk with team&apos;s streamed response |
| Sidebar components | Provides configuration and debug options | Shows team memory and allows session reset |

The magic happens in the streaming response loop. When a user submits a query:

1. The query is added to Streamlit&apos;s chat history
2. It&apos;s passed to the Agno team via `team.run(query, stream=True)`
3. As chunks of the response arrive, they&apos;re added to the placeholder, giving that satisfying real-time effect
4. The final response is stored in session history for future context

### Error Handling - When Things Go Sideways

We&apos;ve built in robust error handling to ensure your users don&apos;t see cryptic stack traces:

- API key issues, rate limits, or tool failures are caught and displayed as friendly error messages
- The team&apos;s session remains intact, allowing users to try again with a different query
- Debug information is available in the sidebar for troubleshooting

This resilient approach means your Streamlit app won&apos;t crash even if one of your specialist agents encounters an issue—the show must go on!

## Adding Memory and Session Management

We&apos;ve built a powerful team and a slick UI, but there&apos;s one more crucial ingredient: **memory**. Just like Ocean&apos;s team would be pretty useless if they forgot the casino layout halfway through the heist, our AI team needs to remember previous interactions to be truly effective.

### Session State: Streamlit&apos;s Secret Weapon

Streamlit provides a built-in session state system that persists across interactions within a browser session. We&apos;re using this to store three key elements:

1. **Team Instance**: The entire research team with all its member agents
2. **Message History**: All previous exchanges with the user
3. **Session ID**: A unique identifier for this particular conversation

Here&apos;s how we initialize these components:

```python
# --- Session State Initialization ---
# Initialize team_session_id for this specific browser session
if &quot;team_session_id&quot; not in st.session_state:
    st.session_state.team_session_id = f&quot;streamlit-team-session-{int(time.time())}&quot;
# Initialize chat message history
if &quot;messages&quot; not in st.session_state:
    st.session_state.messages = []
```

This simple initialization ensures that each new browser session gets a fresh team instance and message history, while maintaining continuity within the session.

### Agno&apos;s Memory Architecture

Agno provides three types of memory for our team:

1. **Chat History**: The sequence of interactions between the user and the team
2. **Agentic Context**: The coordinator&apos;s understanding of the ongoing conversation
3. **Team History**: Shared context across all team members

Let&apos;s look at the memory-specific settings in our team configuration:

```python
enable_agentic_context=True,      # Coordinator maintains context
share_member_interactions=True,     # Members see previous member interactions
enable_team_history=True,           # Pass history between coordinator/members
num_of_interactions_from_history=5  # Limit history passed
```

### The Memory Flow in Action

When a user submits a query, an elegant memory dance begins:

1. The query is added to Streamlit&apos;s session state messages
2. It&apos;s passed to the Agno team, which accesses its own history
3. The coordinator examines the query in the context of previous interactions
4. Individual agents receive relevant portions of the history when assigned tasks
5. The final response is added back to session state messages

This continuous loop ensures that conversations feel natural and coherent. Ask &quot;What was my first question?&quot; and the team will actually know!

### Balancing Memory and Performance

Memory is powerful, but it comes with a cost. We&apos;ve implemented several optimizations to keep things running smoothly:

| Strategy | Implementation | Benefit |
|----------|----------------|--------|
| Limited History | `num_of_interactions_from_history=5` | Prevents context overflow |
| Selective Display | `show_members_responses=False` | Cleaner output, smaller history |
| Debug Toggle | Sidebar checkbox for memory inspection | On-demand memory visibility |
| Reset Button | &quot;Clear Chat &amp; Reset Team&quot; | Fresh start when needed |

These strategies ensure our team stays quick and responsive even in long conversations.


## Run the Team  of Agents:

### Complete Code:
Below is the complete code, you should add it in main.py file:



```python
# app.py
import os
import streamlit as st
from dotenv import load_dotenv
import time
from pprint import pformat
from typing import Iterator # Added for type hinting

# Agno Imports
from agno.agent import Agent
from agno.models.openrouter import OpenRouter
from agno.team import Team
from agno.run.response import RunResponse # Added for type hinting
from agno.tools.duckduckgo import DuckDuckGoTools
from agno.tools.crawl4ai import Crawl4aiTools
from agno.tools.youtube import YouTubeTools
from agno.tools.resend import ResendTools
from agno.tools.github import GithubTools
from agno.tools.hackernews import HackerNewsTools

# --- Configuration ---
# Load environment variables from .env file
load_dotenv()

# Check for essential API keys
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
EMAIL_FROM = os.getenv(&quot;EMAIL_FROM&quot;)
EMAIL_TO = os.getenv(&quot;EMAIL_TO&quot;) # Default recipient
GITHUB_ACCESS_TOKEN = os.getenv(&quot;GITHUB_ACCESS_TOKEN&quot;)
RESEND_API_KEY = os.getenv(&quot;RESEND_API_KEY&quot;) # ResendTools requires this

# Simple validation for required keys
required_keys = {
    &quot;OPENROUTER_API_KEY&quot;: OPENROUTER_API_KEY,
    &quot;EMAIL_FROM&quot;: EMAIL_FROM,
    &quot;EMAIL_TO&quot;: EMAIL_TO,
    &quot;GITHUB_ACCESS_TOKEN&quot;: GITHUB_ACCESS_TOKEN,
    &quot;RESEND_API_KEY&quot;: RESEND_API_KEY
}

missing_keys = [name for name, key in required_keys.items() if not key]

if missing_keys:
    st.error(f&quot;Missing required environment variables: {&apos;, &apos;.join(missing_keys)}. Please set them in your .env file or system environment.&quot;)
    st.stop() # Stop execution if keys are missing

# Set Streamlit page configuration
st.set_page_config(
    page_title=&quot;Research Assistant Team&quot;,
    page_icon=&quot;🧠&quot;,
    layout=&quot;wide&quot;
)

# --- Model Initialization ---
# Initialize OpenRouter model only, no fallback
try:
    model = OpenRouter(id=&quot;openrouter/optimus-alpha&quot;, api_key=OPENROUTER_API_KEY)
    model_name = &quot;OpenRouter (openrouter/optimus-alpha)&quot;
    st.sidebar.info(f&quot;Using model: {model_name}&quot;)
except Exception as e:
    st.error(f&quot;Failed to initialize OpenRouter model: {e}&quot;)
    st.stop()


# --- Session State Initialization ---
# Initialize team_session_id for this specific browser session
if &quot;team_session_id&quot; not in st.session_state:
    st.session_state.team_session_id = f&quot;streamlit-team-session-{int(time.time())}&quot;
# Initialize chat message history
if &quot;messages&quot; not in st.session_state:
    st.session_state.messages = []

# --- Agent Definitions ---
# Define specialized agents
search_agent = Agent(
    name=&quot;InternetSearcher&quot;,
    model=model,
    tools=[DuckDuckGoTools(search=True, news=False)],
    add_history_to_messages=True,
    num_history_responses=3, # Limit history passed to agent
    description=&quot;Expert at finding information online.&quot;,
    instructions=[
        &quot;Use duckduckgo_search for web queries.&quot;,
        &quot;Cite sources with URLs.&quot;,
        &quot;Focus on recent, reliable information.&quot;
    ],
    add_datetime_to_instructions=True, # Add time context
    markdown=True,
    exponential_backoff=True # Add robustness
)

crawler_agent = Agent(
    name=&quot;WebCrawler&quot;,
    model=model,
    tools=[Crawl4aiTools(max_length=None)], # Consider setting a sensible max_length
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Extracts content from specific websites.&quot;,
    instructions=[
        &quot;Use web_crawler to extract content from provided URLs.&quot;,
        &quot;Summarize key points and include the URL.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)

youtube_agent = Agent(
    name=&quot;YouTubeAnalyst&quot;,
    model=model,
    tools=[YouTubeTools()],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Analyzes YouTube videos.&quot;,
    instructions=[
        &quot;Extract captions and metadata for YouTube URLs.&quot;,
        &quot;Summarize key points and include the video URL.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)

email_agent = Agent(
    name=&quot;EmailAssistant&quot;,
    model=model,
    tools=[ResendTools(from_email=EMAIL_FROM, api_key=RESEND_API_KEY)], # Pass required args
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Sends emails professionally.&quot;,
    instructions=[
        &quot;send professional emails based on context or user request.&quot;,
        f&quot;Default recipient is {EMAIL_TO}, but use recipient specified in the query if provided.&quot;,
        &quot;Include URLs and links clearly.&quot;,
        &quot;Ensure the tone is professional and courteous.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)

github_agent = Agent(
    name=&quot;GitHubResearcher&quot;,
    model=model,
    tools=[GithubTools(access_token=GITHUB_ACCESS_TOKEN)], # Pass required args
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Explores GitHub repositories.&quot;,
    instructions=[
        &quot;Search repositories or list pull requests based on user query.&quot;,
        &quot;Include repository URLs and summarize findings concisely.&quot;
    ],
    markdown=True,
    exponential_backoff=True,
    add_datetime_to_instructions=True
)

hackernews_agent = Agent(
    name=&quot;HackerNewsMonitor&quot;,
    model=model,
    tools=[HackerNewsTools()],
    add_history_to_messages=True,
    num_history_responses=3,
    description=&quot;Tracks Hacker News trends.&quot;,
    instructions=[
        &quot;Fetch top stories using get_top_hackernews_stories.&quot;,
        &quot;Summarize discussions and include story URLs.&quot;
    ],
    markdown=True,
    exponential_backoff=True,
    add_datetime_to_instructions=True
)

# Generalist Agent (No KB in this version)
general_agent = Agent(
    name=&quot;GeneralAssistant&quot;,
    model=model,
    add_history_to_messages=True,
    num_history_responses=5, # Can access slightly more history
    description=&quot;Handles general queries and synthesizes information from specialists.&quot;,
    instructions=[
        &quot;Answer general questions or combine specialist inputs.&quot;,
        &quot;If specialists provide information, synthesize it clearly.&quot;,
        &quot;If a query doesn&apos;t fit other specialists, attempt to answer directly.&quot;,
        &quot;Maintain a professional tone.&quot;
    ],
    markdown=True,
    exponential_backoff=True
)

# --- Team Initialization (in Session State) ---
def initialize_team():
    &quot;&quot;&quot;Initializes or re-initializes the research team.&quot;&quot;&quot;
    return Team(
        name=&quot;ResearchAssistantTeam&quot;,
        mode=&quot;coordinate&quot;,
        model=model,
        members=[
            search_agent,
            crawler_agent,
            youtube_agent,
            email_agent,
            github_agent,
            hackernews_agent,
            general_agent
        ],
        description=&quot;Coordinates specialists to handle research tasks.&quot;,
        instructions=[
            &quot;Analyze the query and assign tasks to specialists.&quot;,
            &quot;Delegate based on task type:&quot;,
            &quot;- Web searches: InternetSearcher&quot;,
            &quot;- URL content: WebCrawler&quot;,
            &quot;- YouTube videos: YouTubeAnalyst&quot;,
            &quot;- Emails: EmailAssistant&quot;,
            &quot;- GitHub queries: GitHubResearcher&quot;,
            &quot;- Hacker News: HackerNewsMonitor&quot;,
            &quot;- General or synthesis: GeneralAssistant&quot;,
            &quot;Synthesize responses into a cohesive answer.&quot;,
            &quot;Cite sources and maintain clarity.&quot;,
            &quot;Always check previous conversations in memory before responding.&quot;,
            &quot;When asked about previous information or to recall something mentioned before, refer to your memory of past interactions.&quot;,
            &quot;Use all relevant information from memory when answering follow-up questions.&quot;
        ],
        success_criteria=&quot;The user&apos;s query has been thoroughly answered with information from all relevant specialists.&quot;,
        enable_agentic_context=True,      # Coordinator maintains context
        share_member_interactions=True, # Members see previous member interactions in context
        show_members_responses=False,     # Don&apos;t show raw member responses in final output
        markdown=True,
        show_tool_calls=False,            # Don&apos;t show raw tool calls in final output
        enable_team_history=True,         # Pass history between coordinator/members
        num_of_interactions_from_history=5 # Limit history passed
    )

if &quot;team&quot; not in st.session_state:
    st.session_state.team = initialize_team()


# --- Streamlit UI ---
st.title(&quot;🤖 Research Assistant Team&quot;)
st.markdown(&quot;&quot;&quot;
This team coordinates specialists to assist with:
- 🔍 Web searches
- 🌐 Website content extraction
- 📺 YouTube video analysis
- 📧 Email drafting/sending
- 💻 GitHub repository exploration
- 📰 Hacker News trends
- 🧠 General queries and synthesis
&quot;&quot;&quot;)

# Display chat messages from history
for message in st.session_state.messages:
    with st.chat_message(message[&quot;role&quot;]):
        st.markdown(message[&quot;content&quot;])

# Handle user input
user_query = st.chat_input(&quot;Ask the research team anything...&quot;)

if user_query:
    # Add user message to chat history
    st.session_state.messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_query})

    # Display user message
    with st.chat_message(&quot;user&quot;):
        st.markdown(user_query)

    # Display team response (Streaming)
    with st.chat_message(&quot;assistant&quot;):
        message_placeholder = st.empty()
        full_response = &quot;&quot;
        try:
            # Use stream=True for the team run
            response_stream: Iterator[RunResponse] = st.session_state.team.run(user_query, stream=True) # Ensure type hint

            for chunk in response_stream:
                # Check if content is present and a string
                if chunk.content and isinstance(chunk.content, str):
                    full_response += chunk.content
                    message_placeholder.markdown(full_response + &quot;▌&quot;) # Add cursor effect
            message_placeholder.markdown(full_response) # Final response without cursor

            # Update memory debug information for display
            if hasattr(st.session_state.team, &apos;memory&apos;) and hasattr(st.session_state.team.memory, &apos;messages&apos;):
                try:
                    # Extract only role and content safely
                    st.session_state.memory_dump = [
                        {&quot;role&quot;: m.role if hasattr(m, &apos;role&apos;) else &apos;unknown&apos;,
                         &quot;content&quot;: m.content if hasattr(m, &apos;content&apos;) else str(m)}
                        for m in st.session_state.team.memory.messages
                    ]
                except Exception as e:
                    st.session_state.memory_dump = f&quot;Error accessing memory messages: {str(e)}&quot;
            else:
                st.session_state.memory_dump = &quot;Team memory object or messages not found/accessible.&quot;

            # Add the final assistant response to Streamlit&apos;s chat history
            st.session_state.messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: full_response})

        except Exception as e:
            st.exception(e) # Show full traceback in Streamlit console for debugging
            error_message = f&quot;An error occurred: {str(e)}\n\nPlease check your API keys and tool configurations. Try rephrasing your query.&quot;
            st.error(error_message)
            message_placeholder.markdown(f&quot;⚠️ {error_message}&quot;)
            # Add error message to history for context
            st.session_state.messages.append({&quot;role&quot;: &quot;assistant&quot;, &quot;content&quot;: f&quot;Error: {str(e)}&quot;})

# --- Sidebar ---
with st.sidebar:
    st.title(&quot;Team Settings&quot;)

    # Memory debug section
    if st.checkbox(&quot;Show Team Memory Contents&quot;, value=False):
        st.subheader(&quot;Team Memory Contents (Debug)&quot;)
        if &quot;memory_dump&quot; in st.session_state:
            try:
                # Use pformat for potentially complex structures
                memory_str = pformat(st.session_state.memory_dump, indent=2, width=80)
                st.code(memory_str, language=&quot;python&quot;)
            except Exception as format_e:
                st.warning(f&quot;Could not format memory dump: {format_e}&quot;)
                st.json(st.session_state.memory_dump) # Fallback to json
        else:
            st.info(&quot;No memory contents to display yet. Interact with the team first.&quot;)

    st.markdown(f&quot;**Session ID**: `{st.session_state.team_session_id}`&quot;)
    st.markdown(f&quot;**Model**: {model_name}&quot;)

    # Memory information
    st.subheader(&quot;Team Memory&quot;)
    st.markdown(&quot;This team remembers conversations within this browser session. Clearing the chat resets the memory.&quot;)

    # Clear chat button
    if st.button(&quot;Clear Chat &amp; Reset Team&quot;):
        st.session_state.messages = []
        st.session_state.team_session_id = f&quot;streamlit-team-session-{int(time.time())}&quot; # New ID for clarity
        st.session_state.team = initialize_team() # Re-initialize the team to reset its state
        if &quot;memory_dump&quot; in st.session_state:
            del st.session_state.memory_dump # Clear the dump
        st.rerun()

    st.title(&quot;About&quot;)
    st.markdown(&quot;&quot;&quot;
    **How it works**:
    - The team coordinator analyzes your query.
    - Tasks are delegated to specialists (Searcher, Crawler, YouTube Analyst, Email, GitHub, HackerNews, General).
    - Responses are synthesized into a final answer.
    - Team memory retains context within this session.

    **Example queries**:
    - &quot;What are the latest AI breakthroughs?&quot;
    - &quot;Crawl agno.com and summarize the homepage.&quot;
    - &quot;Summarize the YouTube video: https://www.youtube.com/watch?v=dQw4w9WgXcQ&quot;
    - &quot;Draft an email to contact@example.com introducing our research services.&quot;
    - &quot;Find popular AI repositories on GitHub created in the last month.&quot;
    - &quot;What&apos;s trending on Hacker News today?&quot;
    - &quot;What was the first question I asked you?&quot; (tests memory)
    &quot;&quot;&quot;)
```

### Run the Team:

```bash
uv run streamlit run main.py
```


## Troubleshooting and Best Practices

Even the best-planned heists encounter unexpected challenges, and your AI research squad is no exception. Let&apos;s talk about some common issues and how to overcome them.

### API Key Management

The most common setup issue is missing or invalid API keys. We&apos;ve built in robust validation to catch these early:

```python
# Simple validation for required keys
required_keys = {
    &quot;OPENROUTER_API_KEY&quot;: OPENROUTER_API_KEY,
    &quot;EMAIL_FROM&quot;: EMAIL_FROM,
    &quot;EMAIL_TO&quot;: EMAIL_TO,
    &quot;GITHUB_ACCESS_TOKEN&quot;: GITHUB_ACCESS_TOKEN,
    &quot;RESEND_API_KEY&quot;: RESEND_API_KEY
}

missing_keys = [name for name, key in required_keys.items() if not key]

if missing_keys:
    st.error(f&quot;Missing required environment variables: {&apos;, &apos;.join(missing_keys)}. Please set them in your .env file or system environment.&quot;)
    st.stop() # Stop execution if keys are missing
```

### Connection and Rate Limit Handling

When working with multiple external APIs, you&apos;ll occasionally hit rate limits or connection issues. Our solution is the `exponential_backoff` parameter, which we&apos;ve added to all our agents:

```python
exponential_backoff=True  # Add robustness
```

This simple addition implements a sophisticated retry strategy that waits progressively longer between attempts, dramatically improving reliability.

### Model Fallback Strategies

Depending solely on one model provider can be risky. A more resilient approach is to configure model fallbacks:

```python
# Alternative implementation (not in current code)
model = OpenRouter(
    id=&quot;openrouter/optimus-alpha&quot;,
    api_key=OPENROUTER_API_KEY,
    fallback_models=[
        &quot;openai/gpt-4-turbo&quot;,
        &quot;anthropic/claude-3-opus&quot;
    ]
)
```

This ensures that if one model is unavailable, your team gracefully switches to alternatives.

### Memory Debugging

When conversation history seems off, use the debug toggle in the sidebar to inspect the team&apos;s memory:

```python
# Memory debug section
if st.checkbox(&quot;Show Team Memory Contents&quot;, value=False):
    st.subheader(&quot;Team Memory Contents (Debug)&quot;)
    if &quot;memory_dump&quot; in st.session_state:
        try:
            # Use pformat for potentially complex structures
            memory_str = pformat(st.session_state.memory_dump, indent=2, width=80)
            st.code(memory_str, language=&quot;python&quot;)
        except Exception as format_e:
            st.warning(f&quot;Could not format memory dump: {format_e}&quot;)
            st.json(st.session_state.memory_dump) # Fallback to json
    else:
        st.info(&quot;No memory contents to display yet. Interact with the team first.&quot;)
```



### Optimizing Team Design

If your team feels sluggish or uncoordinated, consider these optimizations:

1. **Specialized Tools**: Ensure each agent has only the tools it truly needs
2. **Clear Instructions**: Revisit agent instructions to avoid overlapping responsibilities
3. **Success Criteria**: Set specific success criteria for the team coordinator
4. **History Limits**: Adjust `num_of_interactions_from_history` to balance context and speed
5. **Stream Responses**: Always use `stream=True` for a more responsive user experience

## Conclusion - Your AI Research Team in Action

Congratulations! You&apos;ve just built a sophisticated AI research team that would make Danny Ocean proud. Your squad isn&apos;t just a collection of chatbots—it&apos;s a coordinated team of specialists that can search the web, crawl websites, analyze YouTube videos, communicate via email, explore GitHub, track tech trends, and synthesize information into cohesive responses.

Let&apos;s recap what we&apos;ve accomplished:

1. **Environment Setup**: A lightning-fast development environment with `uv`
2. **Specialized Agents**: A crew of AI specialists, each with unique tools and abilities
3. **Team Coordination**: A sophisticated delegation system that routes tasks to the right expert
4. **Sleek UI**: A responsive Streamlit interface with real-time streaming responses
5. **Memory Management**: Persistent context that enables natural, ongoing conversations

### What Makes This Solution Special

The power of this approach lies in its modularity and extensibility. Need another specialist? Add a new agent with the right tools. Want to switch LLM providers? Swap out OpenRouter for another model. The architecture adapts to your needs without breaking what already works.

Compared to single-agent solutions, our team approach offers:

| Aspect | Single Agent | Agent Team |
|--------|-------------|------------|
| Specialization | Jack of all trades | Domain experts |
| Tool Usage | One agent switching between tools | Right tool for each agent |
| Response Quality | Generic, broader knowledge | Deep expertise in specific areas |
| Adaptability | Limited to one thinking pattern | Multiple approaches to problems |

### Next Steps and Expansions

Now that you have your research team up and running, here are some exciting ways to enhance it:

1. **Add More Specialists**: Create agents for social media monitoring, data analysis, or language translation
2. **Persistent Database**: Switch from SQLite to PostgreSQL for production-grade storage
3. **Knowledge Bases**: Add vector stores to give agents specialized knowledge in their domains
4. **Custom UI**: Build a branded interface with Streamlit Components or graduate to a web framework
5. **Feedback Loop**: Implement user ratings to help agents improve over time

### The Future of AI Teams

As AI continues to evolve, the multi-agent approach will become increasingly powerful. By building your research team with Agno and Streamlit today, you&apos;re ahead of the curve in a rapidly advancing field. The combination of specialized knowledge, coordinated teamwork, and human-like memory creates an AI experience that feels less like a tool and more like a true research partner.

So go ahead—ask your team something complex and watch as it splits the work, gathers information, and crafts a response that draws on multiple sources of expertise. It&apos;s not just impressive; it&apos;s a glimpse into the future of AI assistance. Your research squad is ready for action, and the possibilities are limited only by your imagination.

Now that&apos;s a heist worth celebrating! 🎉</content:encoded><category>ai</category><category>ai-agents</category><category>agno</category><category>streamlit</category></item><item><title>From Zero to Agent Hero: Getting Started with Agno Agents, uv, and a Dash of RAG Magic</title><link>https://www.bitdoze.com/agno-get-start/</link><guid isPermaLink="true">https://www.bitdoze.com/agno-get-start/</guid><description>Learn how to create powerful AI agents with Agno 2.x in minutes! This beginner-friendly guide walks you through setup, tools, memory, RAG, and multi-agent teams using uv</description><pubDate>Fri, 14 Mar 2025 00:00:00 GMT</pubDate><content:encoded>Picture this: it’s 2026, and you’re ready to unleash an AI sidekick that doesn’t just chat, but searches the web, remembers that you love spicy Thai food, and dives into PDFs faster than you can say “where’s my coffee?” Enter [Agno](https://www.agno.com), an open-source Python framework for building AI agents. Paired with **`uv`**, the Rust-powered package manager that leaves `pip` in the dust (think 10-100x faster), you’re about to embark on a coding adventure that’s equal parts thrilling and hilarious.

This guide is a full refresh for **Agno 2.x** — as of August 2026 the current release is **2.8.6**. If you’ve seen older Agno tutorials (including my original March 2025 version of this very article), brace yourself: the API got a major overhaul in v2.0. Old favorites like `SqliteAgentStorage`, `PDFUrlKnowledgeBase`, and `Agent(team=[...])` are gone, replaced by a cleaner, more powerful toolkit. Everything below is verified against the real thing, so the code will just work.

We’ll turbocharge your setup with `uv`, then craft an Agno agent that evolves from a chatty newbie to a memory-savvy, **Retrieval-Augmented Generation (RAG)** maestro, powered by LanceDB and DuckDuckGo. We’ll cap it off with a two-agent dream team that collaborates like peanut butter and jelly—or better yet, chicken and galangal. With code snippets, witty asides, and troubleshooting tips, you’ll be laughing your way to AI mastery.

&lt;Notice type=&quot;info&quot; title=&quot;Updated for Agno 2.x&quot;&gt;
This article was originally published in March 2025 against Agno 1.x. It has been rewritten and verified against **Agno 2.8.6** (July 2026). The embedded video below shows the older API in action — the code in this guide is current.
&lt;/Notice&gt;


## **Getting Started with Agno Agents**


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/ynsbfbuO2As&quot;
  label=&quot;Agno Agents, UV &amp; RAG: Your Secret Weapon (They Won&apos;t See It Coming!)&quot;
/&gt;


### **Step 1: Turbocharge Your Setup with uv—Python Management at Warp Speed**

Before we unleash Agno’s powers, we need a lightning-fast foundation. That’s where **uv** comes in—a package manager from the Astral crew (the same folks behind `ruff`) that’s so quick, it’ll have you wondering why you ever tolerated `pip`’s leisurely pace. Let’s get it rolling!

You can check more on [how you can get started with uv](https://www.bitdoze.com/uv-get-start/)

#### **Installing uv**
For macOS/Linux:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```

For Windows (PowerShell):

```powershell
irm https://astral.sh/uv/install.ps1 | iex
```

Check it’s alive:

```bash
uv --version  # Expect &quot;uv 0.9&quot; or newer
```

#### **Initializing a Project**
Time to kick off your Agno adventure:

```bash
uv init agno-adventure
cd agno-adventure
```

This whips up a tidy project structure: `pyproject.toml` for dependencies, `.python-version`, `main.py` to code in, a `README.md`, and—new in recent uv versions—a fresh `git` repo. Lock in Python 3.12 for consistency:

```bash
uv python pin 3.12
```

#### **Setting Up the Environment**
Now, create a virtual environment faster than you can blink:

```bash
uv venv
source .venv/bin/activate  # Windows: .venv\Scripts\activate
```

Load up the essentials for our AI escapade. Note the **extras** — Agno splits its integrations into opt-in extras, so you only install what you need:

```bash
uv add &quot;agno[openai,lancedb,pdf,ddg,sqlite]&quot; typer rich
```

That single line pulls in:
- **`agno`** — the framework itself, plus:
  - `openai` — the OpenAI model provider (GPT models are Agno’s default)
  - `lancedb` — our vector database for RAG later
  - `pdf` — the PDF reader (`pypdf` under the hood)
  - `ddg` — DuckDuckGo web search (via the modern `ddgs` package, which replaced the old `duckduckgo-search` library)
  - `sqlite` — SQLite session storage (`sqlalchemy` + friends)
- **`typer`** and **`rich`** — for the snazzy interactive CLI we’ll build in Step 5

Agno needs an OpenAI API key to flex its muscles, so set it up:

```bash
export OPENAI_API_KEY=&quot;sk-your-key-here&quot;
```

**Pro Tip:** Keep your keys in a `.env` file. Bonus: `uv run` auto-loads `.env` from your project directory, so no extra tooling needed. Just add `OPENAI_API_KEY=your-key-here` to `.env` and run scripts with `uv run python main.py`.

**Why uv?** It’s not just fast—it’s a one-stop shop replacing `pip`, `venv`, and more, with a sleek workflow that saves you from dependency nightmares. Think of it as the turbo engine powering your Agno rocket.


### **Step 2: Your First Agno Agent—Simple, Yet Chatty**

Let’s meet **Agno**, the star of our show. It’s an open-source Python framework that makes building AI agents as easy as ordering takeout—but way more fun. Our first agent? A cheerful chatterbox ready to brighten your day.

#### **The Code**
Edit `main.py`:

```python
from agno.agent import Agent

agent = Agent(
    model=&quot;openai:gpt-5.5&quot;,
    description=&quot;You&apos;re a cheerful AI pal who loves a good chat!&quot;,
    markdown=True
)

agent.print_response(&quot;Hey! What&apos;s cooking today?&quot;, stream=True)
```

Run it:

```bash
uv run python main.py
```

#### **How It Works**
- **`Agent`**: The heart of Agno, this class is your agent’s command center, letting you define its personality and powers.
- **`model=&quot;openai:gpt-5.5&quot;`**: Agno 2.x uses **model string references** like `&quot;provider:model-id&quot;`. This is the modern, recommended way to pick a model — the string resolves to the right model class under the hood (for OpenAI it maps to `OpenAIResponses`). You can swap in any provider: `&quot;anthropic:claude-sonnet-4-5&quot;`, `&quot;google:gemini-3-pro&quot;`, `&quot;ollama:llama4&quot;`, you name it. The older class-based style (`from agno.models.openai import OpenAIChat`) still imports for backwards compatibility, but string refs are cleaner and easier to switch.
- **`description`**: Sets the vibe. Here, we’ve got a peppy pal who’s all about good vibes.
- **`markdown=True`**: Spices up responses with formatting—because plain text is so last decade.
- **`print_response`**: Streams the reply in real-time, like watching your agent think out loud.

You’ll get a response like, “Hey there! Just here to spice up your day—what’s on the menu?” It’s basic, but it’s alive!

#### **More About Agno**
Agno’s lightweight design means it’s nimble yet powerful, perfect for crafting agents that scale from simple chats to complex tasks. Unlike heavier frameworks, it’s built for speed and flexibility, letting you add features like tools and memory without breaking a sweat. And since v2.0, Agno is more than a library: it ships with **AgentOS**, a runtime that serves your agents as REST APIs with tracing, session isolation, and RBAC — we’ll touch on that in the conclusion.

#### **Troubleshooting**
- **“ModuleNotFoundError”**: Forgot a package? Run `uv add &quot;agno[openai]&quot;` and try again.
- **Silent Agent**: Check your `OPENAI_API_KEY`. No key, no chat—it’s like forgetting to plug in your coffee maker.



### **Step 3: Adding DuckDuckGo Tools—Your Web-Surfing Sidekick**

Our agent’s charming but clueless about the world. Let’s hook it up with **DuckDuckGo** tools so it can surf the web like a pro.

#### **The Code**
Update `main.py`:

```python
from agno.agent import Agent
from agno.tools.duckduckgo import DuckDuckGoTools

agent = Agent(
    model=&quot;openai:gpt-5.5&quot;,
    description=&quot;You&apos;re a web-savvy AI explorer!&quot;,
    tools=[DuckDuckGoTools()],
    markdown=True
)

agent.print_response(&quot;What&apos;s the buzz in New York right now?&quot;, stream=True)
```

Run it:

```bash
uv run python main.py
```

#### **How It Works**
- **`DuckDuckGoTools`**: Equips your agent with a web search superpower. It decides when to use it based on the question—smart, right?
- **Tool calls in the terminal**: The old `show_tool_calls=True` parameter is gone in Agno 2.x — but you don’t need it. When you stream a response, the CLI printer shows tool calls as they happen, like a behind-the-scenes director’s cut. For full trace-level detail, add `debug_mode=True` to the `Agent` or run the agent through AgentOS and inspect the trace UI.
- **Output**: Expect something like, “*Calling DuckDuckGo…* Here’s the latest from NYC!” It’s now a worldly conversationalist.

#### **Agno’s Tool Power**
Agno’s tool system is modular brilliance. `DuckDuckGoTools` is just one option—Agno supports a growing toolbox you can mix and match to suit your needs, from APIs to custom Python functions (just pass any function in `tools=[...]`). It’s like giving your agent a utility belt!

#### **Troubleshooting**
- **No Web Results**: Ensure the `ddg` extra is in your arsenal—run `uv add &quot;agno[ddg]&quot;`.
- **Stuck?**: Rate limits might be the culprit. Take a breather and retry. Add `debug_mode=True` to the `Agent` for a deeper look at what’s tripping it up.


### **Step 4: Memory That Sticks—From Forgetful to Faithful**

Our agent’s got charisma but forgets everything the moment you blink. Let’s give it a memory upgrade with Agno’s **SQLite-backed storage**, turning it into a loyal companion.

#### **The Code**
Create `memory_agent.py`:

```python
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from rich.pretty import pprint

agent = Agent(
    model=&quot;openai:gpt-5.5&quot;,
    description=&quot;You&apos;re an AI with a memory like an elephant!&quot;,
    db=SqliteDb(db_file=&quot;tmp/agent_storage.db&quot;),
    add_history_to_context=True,
    num_history_runs=3,
    update_memory_on_run=True,
    session_id=&quot;my_chat_session&quot;,
    markdown=True
)

agent.print_response(&quot;I love spicy Thai food. What&apos;s your favorite cuisine?&quot;)
agent.print_response(&quot;What did I just say I love?&quot;)

# Inspect what the agent remembers
pprint(agent.get_session_messages(session_id=&quot;my_chat_session&quot;))
pprint(agent.get_user_memories())
```

Run it:

```bash
uv run python memory_agent.py
```

#### **How It Works**
The memory API got a big cleanup in Agno 2.x — the old `SqliteAgentStorage` class and the whole `agno.storage` package are gone. Here’s the new model:
- **`db=SqliteDb(db_file=...)`**: Stores sessions, chat history, and extracted memories in a SQLite database. No extra service needed.
- **`add_history_to_context=True` + `num_history_runs=3`**: Feeds the last few runs (each with all its messages) into the prompt, giving conversational context. (These replaced the old `add_history_to_messages` / `num_history_responses`.)
- **`update_memory_on_run=True`**: Enables **automatic memory** — after each run, Agno extracts durable facts about the user (preferences, goals) and stores them keyed by `user_id`. The alternative is **agentic memory** (`enable_agentic_memory=True`), where the model itself decides when to inspect, create, or update memories during a run. Pick one mode per agent.
- **`session_id`**: Links interactions under one session—use the same ID, and it’s like picking up where you left off.
- **`get_session_messages()` / `get_user_memories()`**: The v2 way to peek inside — chat history and extracted facts, respectively. (The old `agent.memory.messages` attribute is no more.)

Ask about Thai food, then test its recall. It&apos;ll proudly declare, &quot;You love spicy Thai food!&quot; Memory unlocked!

#### **Agno&apos;s Memory Magic**
Agno separates three concepts cleanly:
- **Memory**: Extracted facts about a user, scoped by `user_id`, shared across sessions.
- **Chat history**: Messages and tool calls from previous runs, scoped by `session_id`, for conversational continuity.
- **Session state**: Application data (carts, task lists, counters) managed by your code or tools.

This flexibility makes Agno ideal for agents that need to learn and grow with you.

#### **Troubleshooting**
- **Amnesia**: Same `session_id`? Check `tmp/` exists (create it with `mkdir tmp` if needed).
- **No Storage**: Run `uv add &quot;agno[sqlite]&quot;` — it’s the backbone of SQLite storage.

**Pro Tip:** For big projects, swap `SqliteDb` for `PostgresDb` from `agno.db.postgres` via `uv add &quot;agno[postgres]&quot;`. More power, same simplicity!

---

### **Step 5: RAG with LanceDB—Knowledge Is Your Superpower**

Time to make your agent a **Thai cuisine expert** with **RAG** (Retrieval-Augmented Generation). Using LanceDB, it’ll pull recipes from PDFs and back them up with web smarts—interactive style!

#### **The Code**
Create `rag_agent.py`:

```python
import typer
from rich.prompt import Prompt

from agno.agent import Agent
from agno.knowledge.knowledge import Knowledge
from agno.knowledge.embedder.openai import OpenAIEmbedder
from agno.knowledge.reader.pdf_reader import PDFReader
from agno.vectordb.lancedb import LanceDb
from agno.vectordb.search import SearchType
from agno.tools.duckduckgo import DuckDuckGoTools

# LanceDB Vector DB
vector_db = LanceDb(
    table_name=&quot;recipes&quot;,
    uri=&quot;tmp/lancedb&quot;,
    search_type=SearchType.hybrid,
    embedder=OpenAIEmbedder(id=&quot;text-embedding-3-small&quot;),
)

# Knowledge Base
knowledge = Knowledge(
    vector_db=vector_db,
    readers=[PDFReader()],
)

def lancedb_agent(user: str = &quot;user&quot;):
    agent = Agent(
        model=&quot;openai:gpt-5.5&quot;,
        description=&quot;You&apos;re a Thai cuisine expert with web backup!&quot;,
        user_id=user,
        knowledge=knowledge,
        search_knowledge=True,
        tools=[DuckDuckGoTools()],
        instructions=[
            &quot;Search the knowledge base for Thai recipes first.&quot;,
            &quot;Use DuckDuckGo if more info is needed.&quot;
        ],
        markdown=True
    )

    print(f&quot;Session ID: {agent.session_id}\n&quot;)

    while True:
        message = Prompt.ask(f&quot;[bold] :sunglasses: {user} [/bold]&quot;)
        if message in (&quot;exit&quot;, &quot;bye&quot;):
            break
        agent.print_response(message, stream=True)

if __name__ == &quot;__main__&quot;:
    # Load the PDF into the knowledge base (idempotent - safe to run every time)
    knowledge.insert(url=&quot;https://agno-public.s3.amazonaws.com/recipes/ThaiRecipes.pdf&quot;)
    typer.run(lancedb_agent)
```

Run it:

```bash
uv run python rag_agent.py
```

#### **How It Works**
The knowledge API was completely restructured in Agno 2.x — `PDFUrlKnowledgeBase` is gone. Here’s the new shape:
- **`Knowledge`**: The single knowledge-base class (from `agno.knowledge.knowledge`). It combines a vector DB, optional readers, and content ingestion.
- **`readers=[PDFReader()]`**: Tells the knowledge base how to parse PDFs (the `pdf` extra installs `pypdf` under the hood). Readers now live under `agno.knowledge.reader` — there are ready-made ones for PDF, DOCX, CSV, Markdown, Excel, YouTube, Wikipedia, and more.
- **`knowledge.insert(url=...)`**: The v2 replacement for `knowledge_base.load(recreate=True)` — downloads the file, parses it with the matching reader, chunks it, and embeds it into the vector DB. It’s **idempotent by default** (`upsert=True`), so re-running the script won’t duplicate content. You can also insert by local `path=`, raw `text_content=`, or even `topics=` for query-based loading.
- **`LanceDb` + `SearchType.hybrid`**: Still the same great combo. Hybrid blends keyword and semantic searches for max accuracy — and since LanceDB moved to native full-text search, you no longer need the `tantivy` package.
- **`OpenAIEmbedder`**: Moved in v2 — it now lives at `agno.knowledge.embedder.openai`. Same idea: converts text to embeddings using `text-embedding-3-small`.
- **`search_knowledge=True`**: The v2 flag that lets the agent search its knowledge base during a run (agentic RAG — the agent decides when to retrieve).
- **`typer`/`Prompt`**: Keeps the chat going until you say “bye”—perfect for recipe hunting!
- **Output**: Ask, “How do I make chicken and galangal coconut soup?” It’ll dig into the PDF, then surf the web if needed.

#### **Agno’s RAG Edge**
RAG combines retrieval (from LanceDB) with generation (via GPT), making your agent a knowledge ninja. Agno supports 19 vector databases — from local options like LanceDB and ChromaDB to managed services like Pinecone and Weaviate — and lets you swap them by changing a few lines.

#### **Troubleshooting**
- **PDF Won’t Load**: Verify the URL and run `uv add &quot;agno[pdf,lancedb]&quot;`.
- **Embedding Errors**: `OpenAIEmbedder` needs the same `OPENAI_API_KEY` as the chat model — check your `.env`.
- **No Chat Prompt**: Add `uv add typer rich` for the interactive goodies.
- **Duplicate Content**: Don’t worry — `insert()` upserts by default, so re-running is safe.

**Pro Tip:** Add more sources to `knowledge.insert()` — cookbooks, travel guides, markdown docs, whatever—to create a custom knowledge empire.


### **Step 6: Team of Two—Chef and Researcher Duo**

Why stop at one agent when you can have a **dynamic duo**? Let’s pair a Thai chef with a web researcher for a collab that’s pure magic.

#### **The Code**
Create `team_agent.py`:

```python
from agno.agent import Agent
from agno.db.sqlite import SqliteDb
from agno.knowledge.knowledge import Knowledge
from agno.knowledge.embedder.openai import OpenAIEmbedder
from agno.knowledge.reader.pdf_reader import PDFReader
from agno.vectordb.lancedb import LanceDb
from agno.vectordb.search import SearchType
from agno.tools.duckduckgo import DuckDuckGoTools
from agno.team import Team, TeamMode

# Shared knowledge base
vector_db = LanceDb(
    table_name=&quot;recipes&quot;,
    uri=&quot;tmp/lancedb&quot;,
    search_type=SearchType.hybrid,
    embedder=OpenAIEmbedder(id=&quot;text-embedding-3-small&quot;),
)
knowledge = Knowledge(vector_db=vector_db, readers=[PDFReader()])
knowledge.insert(url=&quot;https://agno-public.s3.amazonaws.com/recipes/ThaiRecipes.pdf&quot;)

# Chef Agent
chef = Agent(
    name=&quot;ThaiChef&quot;,
    role=&quot;Thai cuisine expert&quot;,
    model=&quot;openai:gpt-5.5&quot;,
    knowledge=knowledge,
    search_knowledge=True,
    instructions=[&quot;Provide detailed Thai recipes from the knowledge base.&quot;],
    markdown=True
)

# Researcher Agent
researcher = Agent(
    name=&quot;WebResearcher&quot;,
    role=&quot;Web info gatherer&quot;,
    model=&quot;openai:gpt-5.5&quot;,
    tools=[DuckDuckGoTools()],
    instructions=[&quot;Search the web for supplementary info when asked.&quot;],
    markdown=True
)

# Team Leader
team = Team(
    name=&quot;Thai Team&quot;,
    members=[chef, researcher],
    mode=TeamMode.coordinate,
    db=SqliteDb(db_file=&quot;tmp/team_storage.db&quot;),
    instructions=[
        &quot;Ask ThaiChef for recipes first.&quot;,
        &quot;If more context is needed, consult WebResearcher.&quot;,
        &quot;Blend their inputs into a cohesive answer.&quot;
    ],
    markdown=True
)

team.print_response(&quot;Tell me about Thai chicken soup and its cultural significance.&quot;, stream=True)
```

Run it:

```bash
uv run python team_agent.py
```

#### **How It Works**
Multi-agent collaboration got a dedicated class in Agno 2.x — `Agent(team=[...])` is gone. Say hello to `agno.team.Team`:
- **`Team(members=[...])`**: The team leader coordinates its member agents, delegating tasks based on their `role`s and synthesizing results. Members can even be nested teams.
- **`TeamMode`**: Makes collaboration styles explicit — `coordinate` (default; decompose work, delegate, synthesize), `route` (send to a single specialist), `broadcast` (same task to all members), or `tasks` (task-list loop until done).
- **`ThaiChef`**: Recipe guru, pulling from the PDF via LanceDB — `OpenAIEmbedder` produces the embeddings, `search_knowledge=True` lets it retrieve during runs.
- **`WebResearcher`**: Web sleuth, digging up cultural context with DuckDuckGo.
- **`db=SqliteDb(...)`**: Keeps the team’s sessions and history sharp across runs.
- **Output**: You’ll get a recipe *and* a story—like, “This soup’s a Thai staple, tied to ancient herbal traditions!”

#### **Agno’s Team Spirit**
Teams are a game-changer. Each member has a role, tools, and knowledge, while the leader delegates like a pro. It’s lightweight yet robust, designed to scale without bogging down—perfect for complex tasks.

#### **Troubleshooting**
- **Team Mute**: Add `debug_mode=True` to the `Team` to spy on the chatter.
- **Storage Snag**: Ensure the `SqliteDb` import is there and `tmp/` exists.
- **Slow Start**: `knowledge.insert()` upserts by default, so you can move the insert out of the hot path (or guard it with a file check) once the PDF is loaded.

**Pro Tip:** Add a third agent—like a spice specialist—to turn your duo into a trio of culinary geniuses.

## **Conclusion: Your Agno Journey Takes Flight!**

You’ve just gone from zero to AI hero! With **uv**’s blazing speed, you set up a pro environment in seconds. Then, with **Agno 2.x**, you built an agent that chats, surfs, remembers with SQLite, masters RAG with LanceDB, and teams up for epic results. This is Python in 2026—fast, fun, and downright fierce.

**Key Wins:**
- **uv**: Your setup’s new best friend—say goodbye to sluggish installs.
- **Agno**: Lightweight, modular, and speedy, with memory, tools, and RAG that make your agents brilliant.
- **Teamwork**: Multi-agent collab that tackles big questions with ease.

**What’s Next?** Two directions worth chasing:
1. **Dive into Agno’s extras** — multimodal inputs (images, audio, video), workflows (deterministic agent pipelines), and 100+ pre-built toolkits.
2. **Ship it with AgentOS** — Agno’s runtime turns your agent into a production REST API with streaming, tracing, session isolation, and JWT-based RBAC. Run it locally with `uv pip install &quot;agno[os]&quot;`, serve your agent with `AgentOS(agents=[...])`, and manage it from the UI at [os.agno.com](https://os.agno.com).

Oh, and one more thing—go whip up that Thai chicken soup your agent’s been raving about. You’ve got the code, the skills, and the laughs—go conquer the AI universe!</content:encoded><category>ai</category><category>ai-agents</category><category>agno</category><category>uv</category></item><item><title>Deploying a Python uv Project with Git and Railpack in Dokploy</title><link>https://www.bitdoze.com/dokploy-python-railpack-uv/</link><guid isPermaLink="true">https://www.bitdoze.com/dokploy-python-railpack-uv/</guid><description>See how you can host your project easily with Dokploy, Railpack, and uv</description><pubDate>Wed, 12 Mar 2025 10:00:00 GMT</pubDate><content:encoded>Dokploy is an open-source platform that simplifies deploying applications on your VPS using Docker and Traefik. With its Git integration and support for Railpack—a versatile build provider—you can deploy Python projects managed with uv effortlessly. In this guide, we’ll deploy a FastHTML-based Python project using Git and Railpack within Dokploy, detailing the project setup, Railpack configuration with railpack.json, and deployment steps.

In the past I have covered [dokploy installation](https://www.bitdoze.com/dokploy-install/) and [uv get started](https://www.bitdoze.com/uv-get-started/) + [fasthtml get started](https://www.bitdoze.com/fasthtml-start/), you can check them for more details on each.


Dokploy, Railpack, and uv form a powerful stack for self-hosted Python deployments. Let’s get started!

## What Are uv, Railpack, and Dokploy?

### uv
[uv](https://docs.astral.sh/uv/), developed by Astral, is a Rust-based Python package and project manager that outperforms tools like `pip` and `poetry`. It’s 10-100x faster at resolving and installing dependencies, using a `uv.lock` file for reproducibility and `pyproject.toml` for configuration.

### Railpack
[Railpack](https://railpack.com/) builds and deploys applications, supporting Python with package managers like uv. It detects Python projects via files such as main.py or pyproject.toml, installs dependencies, and configures a production environment using a railpack.json file if provided.

### Dokploy
[Dokploy](https://dokploy.com/) is a self-hosted deployment solution that orchestrates applications via Docker, with Traefik for routing and load balancing. It supports Git-based deployments and multiple build types, including Railpack, allowing you to push code to a repository and have Dokploy build and deploy it automatically.

## Deploying a Python uv Project with Git and Railpack in Dokploy


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/652DqHl7Ppo&quot;
  label=&quot;Deploying a Python uv Project with Git and Railpack in Dokploy&quot;
/&gt;

### Prerequisites

Before starting, ensure you have:
- **Dokploy** installed on a VPS (follow the [installation guide](https://www.bitdoze.com/dokploy-install/)).
- **uv** installed locally (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
- **Git** installed locally and a Git repository (e.g., GitHub, GitLab).
- **Docker** running on your Dokploy server (included by default).
- Access to your Dokploy dashboard (e.g., `http://your-server-ip:3000`).

We’ll deploy a FastHTML app as an example.



### Step 1: Set Up Your uv-Managed Python Project

Create a Python project with `uv` and FastHTML.

1. **Initialize the Project**
   ```bash
   uv init my-fasthtml-app
   cd my-fasthtml-app
   ```
   This generates:
   ```
   my-fasthtml-app/
   ├── .python-version  # e.g., &quot;3.12&quot;
   ├── main.py         # Starter script
   ├── pyproject.toml  # Project config
   └── README.md       # Documentation
   ```

2. **Add Dependencies**
   Install FastHTML:
   ```bash
   uv venv
   source .venv/bin/activate
   uv add python-fasthtml
   ```
   This updates `pyproject.toml` and creates a `uv.lock` file.

3. **Write a FastHTML App**
   Edit `main.py`:
   ```python
   from fasthtml.common import *

   app, rt = fast_app()

   @rt(&quot;/&quot;)
   def get():
       return Div(P(&quot;Hello from uv and Dokploy!&quot;))

   serve()
   ```

4. **Test Locally**
   ```bash
   uv run main.py
   ```
   Visit `http://localhost:5001` to verify it works.

5. **Initialize Git**
   ```bash
   git init
   git add .
   git commit -m &quot;first commit&quot;
   git branch -M main
   git remote add origin git@github.com:user/my-fasthtml-app.git
   git push -u origin main
   ```


### Step 2: Configure Railpack for Dokploy

Railpack detects Python projects via `main.py`, `pyproject.toml`, or `uv.lock` and supports `uv` natively. You don&apos;t need to do anything but `railpack.json` file can be used to customize the build and deployment for more advanced configurations.

 **Understand Railpack Defaults**
   - **Detection**: Recognizes `main.py`, `pyproject.toml`, and `uv.lock`.
   - **Versions**: Defaults to Python 3.13.2, overridable with `.python-version` or `RAILPACK_PYTHON_VERSION`.
   - **Install**: For `uv.lock`, Railpack uses `uv` to install dependencies (assumed to be `uv sync`).
   - **Start**: Defaults to `python main.py` if no framework is detected.
   - **Runtime**: Sets variables like `PYTHONUNBUFFERED=1`.



### Step 3: Set Up Dokploy with Git

Deploy using Dokploy’s Git integration and Railpack.

1. **Log In to Dokploy**
   Open your dashboard (e.g., `http://your-server-ip:3000`).

2. **Create a New Project**
   - Go to **Projects** &gt; **New Project**.
   - Name it `my-uv-app` and save.

3. **Add an Application**
   - Click **New Application**.
   - **Name**: `my-uv-app`.
   - **Git Repository**: `https://github.com/yourusername/my-uv-app.git`.
   - **Branch**: `main`.
   - **Build Type**: **Railpack** (select custom if Railpack isn’t listed).
   - Save.

![dokploy project](@images/25/03/dokploy-project.png)



4. **Configure Environment Variables (Optional)**
   - In the **Environment** tab, add what env varialbes you need for your project.

5. **Set Up a Domain**
   - In the **Domains** tab, add a custom domain (e.g., `my-uv-app.yourdomain.com`) be sure the domain is properly configured.
   - Enable HTTPS for custom domains.

![dokploy domamain](@images/25/03/dokploy-domain.png)

6. **Deploy the Application**
   - Click **Deploy**.
   - Dokploy clones the repo, uses Railpack  to build, and deploys the container.


### Step 4: Verify the Deployment

1. **Check the Logs**
   - In the **Logs** tab, confirm `uv sync` and the app starting.

2. **Test the App**
   - Visit your URL (e.g., `http://my-uv-app-yourserver.dokploy.app`).
   - Expect: `&lt;div&gt;&lt;p&gt;Hello from uv and Dokploy!&lt;/p&gt;&lt;/div&gt;`.



### Step 5: Automate Future Deployments

1. **Enable Auto-Deploy**
   - In settings, enable **Auto Deploy** for `main`.
   - Push updates to trigger redeployments.

2. **Example Update**
   Edit `main.py`:
   ```python
   @rt(&quot;/&quot;)
   def get():
       return Div(P(&quot;Updated: Hello from uv and Dokploy!&quot;))
   ```
   ```bash
   git add main.py
   git commit -m &quot;Update greeting&quot;
   git push origin main
   ```



## Why Use Dokploy with uv and Railpack?

- **Self-Hosted Flexibility**: Running Dokploy on your own VPS gives you full control over your infrastructure, avoiding the constraints and costs of managed cloud platforms. You dictate the hardware, security policies, and scaling options, making it ideal for privacy-conscious projects or custom setups.
- **Speed and Efficiency**: `uv`’s lightning-fast dependency resolution—often 10-100x quicker than `pip`—pairs perfectly with Railpack’ streamlined build process, reducing deployment times significantly. This combination minimizes downtime and accelerates iteration cycles, crucial for rapid development and testing.
- **Automation and Workflow Integration**: Git-driven deployments through Dokploy enable a seamless CI/CD pipeline. Push changes to your repository, and Dokploy automatically rebuilds and redeploys your app using Railpack, eliminating manual intervention. This automation integrates effortlessly with existing Git workflows, enhancing team productivity.
- **Reproducibility**: `uv`’s `uv.lock` ensures consistent dependency versions across environments, while Railpack’ configuration in `railpack.json` locks in build and runtime steps. Together, they guarantee your app behaves the same locally and in production, reducing “works on my machine” issues.
- **Modern Tooling Synergy**: In 2025, `uv` and Railpack represent cutting-edge Python tooling, leveraging Rust’s performance and modern build practices. Dokploy ties these together with a user-friendly interface, making advanced deployment accessible without sacrificing power.



## Conclusion

Deploying a `uv`-managed FastHTML project with Git and Railpack in Dokploy offers an efficient, customizable, and forward-thinking approach to Python application hosting. The `railpack.json` configuration lets you precisely define how your project is built and run, from installing `uv` and syncing dependencies to launching your FastHTML app. By pushing your code to a Git repository and configuring Dokploy, your application goes live on your own infrastructure in minutes—self-hosted, secure, and poised for growth.

This stack not only simplifies deployment but also empowers you with the tools to iterate quickly, scale confidently, and maintain full ownership of your environment. Whether you’re building a small prototype or a production-ready service, Dokploy, `uv`, and Railpack deliver a modern deployment experience tailored to 2025’s demands.

For updating your deployed apps, see our guide on [How to Update Docker Compose Stacks in Dokploy](https://www.bitdoze.com/dokploy-update-docker-compose/).</content:encoded><category>self-hosting</category><category>dokploy</category><category>uv</category><category>python</category></item><item><title>Getting Started with uv: Setting Up Your Python Project in 2026</title><link>https://www.bitdoze.com/uv-get-start/</link><guid isPermaLink="true">https://www.bitdoze.com/uv-get-start/</guid><description>See how you can get started with uv, a next-generation Python package and project manager written in Rust by the Astral team.</description><pubDate>Wed, 12 Mar 2025 00:00:00 GMT</pubDate><content:encoded>Python’s ecosystem has long been a powerhouse for developers, but managing dependencies, virtual environments, and Python versions has often felt clunky with traditional tools like `pip` and `venv`. Enter `uv`, a next-generation Python package and project manager written in Rust by the Astral team (known for the popular `ruff` linter).

Launched in February 2024, `uv` has rapidly gained traction for its speed (10-100x faster than `pip`), seamless integration with existing workflows, and all-in-one approach to Python project management. In this article, we’ll walk through how to start using `uv` and set up a Python project from scratch, leveraging the latest features as of March 2026.

![wv speed image](@images/25/03/uv-speed.svg)
## What is uv?

`uv` is an all-in-one Python tool designed to replace a patchwork of utilities like `pip` (package installer), `venv` (virtual environment manager), and even `poetry` (project manager). Built in Rust, it boasts performance that’s 10-100x faster than traditional tools, thanks to its optimized dependency resolver and aggressive caching strategy. It’s not just a package installer—it’s a full-fledged project manager that handles Python versions, virtual environments, dependencies, and script execution with ease.

Key features of `uv` include:
- **Speed**: Installs packages and resolves dependencies in seconds, not minutes.
- **Unified Workflow**: Combines environment creation, package management, and script running into one tool.
- **Standards Compliance**: Uses the standard `pyproject.toml` for configuration and a cross-platform `uv.lock` file for reproducible builds.
- **No Manual Activation**: Automatically manages virtual environments without requiring you to activate them manually.

As of March 2026, `uv` has matured significantly since its initial release, with stable support for project management, Python version handling, and seamless integration into existing workflows. Let’s see how to set it up.

## Setting Up Your Python Project with `uv`
In this section we are going to set up a Python project using `uv`. We are going to install `uv` and create a new project.

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/XkjzP0_fHnU&quot;
  label=&quot;uv python beginners&quot;
/&gt;


&lt;Notice type=&quot;info&quot; title=&quot;Information Notice&quot;&gt;
    For seeing how you can easely deploy uv project on your VPS you can check our article [Deploying a Python uv Project with Git and Railpack in Dokploy](https://www.bitdoze.com/dokploy-python-railpack-uv/)
&lt;/Notice&gt;

### Step 1: Installing uv

To use `uv`, you first need to install it. Unlike many Python tools, `uv` doesn’t require a pre-existing Python installation because it can manage Python versions itself. However, it’s recommended to install it directly rather than via `pip` to avoid dependency conflicts with your system Python. Here’s how to install the latest version (as of March 2026, version 0.6.5 is available on PyPI, but always check [astral.sh](https://astral.sh) for updates):

#### On macOS/Linux

Open your terminal and run:

```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
This downloads and installs `uv` as a standalone binary.

Log:

```sh
downloading uv 0.6.6 aarch64-apple-darwin
no checksums to verify
installing to /Users/user/.local/bin
  uv
  uvx
everything&apos;s installed!

To add $HOME/.local/bin to your PATH, either restart your shell or run:

    source $HOME/.local/bin/env (sh, bash, zsh)
    source $HOME/.local/bin/env.fish (fish)
WARNING: The following commands are shadowed by other commands in your PATH: uv uvx
```


#### On Windows

Using PowerShell:


```powershell
irm https://astral.sh/uv/install.ps1 | iex
```

#### Verify Installation

After installation, confirm it worked by checking the version:

```bash
uv --version
```
You should see something like `uv 0.6.6`. If not, ensure `uv` is added to your system PATH (the installer usually handles this, but you might need to restart your terminal).

Log:

```sh
╰─❯ uv --version
uv 0.6.6 (c1a0bb85e 2026-03-12)
```


### Step 2: Initializing a New Project

With `uv` installed, let’s create a new Python project. `uv` makes this a breeze with the `uv init` command, which sets up a basic project structure.

1. **Create a Project Directory**:

   Navigate to where you want your project and run:

   ```bash
   uv init my-python-project
   cd my-python-project
   ```

      This sets up a minimal project structure tailored for quick starts. As of March 2026, running `uv init` generates the following files and directories in your project root:
      ```
      my-python-project/
    ├── .python-version  # Specifies the pinned Python version (e.g., &quot;3.12&quot;)
    ├── main.py         # A simple starter Python script
    ├── pyproject.toml  # Project configuration file
    └── README.md       # Basic project documentation (empty by default)
      ```
      *Note:* Depending on your setup, you might also see hidden directories like `.git` (if you initialize a Git repository) or `.ropeproject` (if you&apos;re using an IDE like PyCharm with Rope for refactoring). These are not created by `uv` itself but may appear based on your environment or subsequent actions.

      The `pyproject.toml` file is the core of your project, following the PEP 621 standard. It looks something like this:
      ```toml
      [project]
      name = &quot;my-python-project&quot;
      version = &quot;0.1.0&quot;
      description = &quot;Add your description here&quot;
      readme = &quot;README.md&quot;
      requires-python = &quot;&gt;=3.13&quot;
      dependencies = []
      ```
      The `main.py` file comes with a basic &quot;Hello, World!&quot; example:
      ```python
          def main():
        print(&quot;Hello from my-python-project!&quot;)


        if __name__ == &quot;__main__&quot;:
        main()

      ```
          The `.python-version` file pins the Python version (e.g., `3.13`), and `README.md` starts as an empty file ready for your project description.

   2. **Set a Python Version (Optional)**
      The `.python-version` file is created automatically by `uv init`, typically defaulting to the latest Python version available on your system (e.g., 3.12). To change it to a specific version:
      ```bash
      uv python pin 3.12
      ```
      This updates `.python-version` and ensures consistency across machines.  Be sure to update the `requires-python = &quot;&gt;=3.12&quot;` before in `pyproject.toml`.



### Step 3: Setting Up a Virtual Environment

With traditional tools, you’d manually create a virtual environment using `python -m venv`. `uv` simplifies this with the `uv venv` command, automatically placing it in a `.venv` folder in your project root.

Run:
```bash
uv venv
```
You’ll see output like:
```
Using Python 3.12.7
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
```

To activate it:
- **macOS/Linux**: `source .venv/bin/activate`
- **Windows**: `.venv\Scripts\activate`

However, `uv` often eliminates the need to manually activate environments by handling this automatically with commands like `uv run` (more on that later).


### Step 4: Adding Dependencies

Now, let’s add some packages to your project. `uv` manages dependencies via `pyproject.toml`, and you can add them interactively or manually.

#### Interactive Method

To add `requests` (a popular HTTP library):
```bash
uv add requests
```
This updates `pyproject.toml` with:
```toml
[project]
dependencies = [
    &quot;requests&gt;=2.32.3&quot;,
]
```
It also creates a `uv.lock` file, a cross-platform lockfile ensuring reproducible builds by pinning exact versions.




#### Manual Method

*Add the packages to `pyproject.toml`*

Edit `pyproject.toml` directly. For example:
```toml
[project]
name = &quot;my-python-project&quot;
version = &quot;0.1.0&quot;
requires-python = &quot;&gt;=3.12&quot;
dependencies = [
    &quot;fastapi&gt;=0.115.6&quot;,
    &quot;pandas&gt;=2.2.1&quot;,
]
[dependency-groups]
dev = [&quot;pytest&gt;=8.3.4&quot;]
```
Here, `fastapi` and `pandas` are production dependencies, while `pytest` is a development dependency.

**Installing Dependencies**

To install all dependencies (including dev ones) into your virtual environment:
```bash
uv sync
```
```bash
❯ uv sync
Resolved 25 packages in 1.37s
Prepared 18 packages in 3.28s
Installed 18 packages in 51ms
 + annotated-types==0.7.0
 + anyio==4.8.0
 + fastapi==0.115.11
 + iniconfig==2.0.0
 + numpy==2.2.3
 + packaging==24.2
 + pandas==2.2.3
 + pluggy==1.5.0
 + pydantic==2.10.6
 + pydantic-core==2.27.2
 + pytest==8.3.5
 + python-dateutil==2.9.0.post0
 + pytz==2025.1
 + six==1.17.0
 + sniffio==1.3.1
 + starlette==0.46.1
 + typing-extensions==4.12.2
 + tzdata==2025.1
```


This reads `pyproject.toml`, resolves dependencies, and installs them into `.venv`. If you only want production dependencies, use:
```bash
uv sync --no-dev
```

The speed of `uv sync` is remarkable—often completing in milliseconds compared to minutes with `pip`.

### Step 5: Writing and Running Code

Let’s create a simple script. In `my_python_project/main.py`, add:
```python
import requests

def fetch_data():
    response = requests.get(&quot;https://api.github.com&quot;)
    print(response.json())

if __name__ == &quot;__main__&quot;:
    fetch_data()
```

To run it, use:
```bash
uv run python main.py
```
`uv run` ensures the script runs in the project&apos;s virtual environment, installing dependencies if needed. You can also run it directly:
```bash
uv run main.py
```

&lt;Notice type=&quot;info&quot; title=&quot;Advanced Script Execution&quot;&gt;
    Want to learn more about running standalone scripts with uv without creating full projects? Check out our comprehensive guide on [Running Test Scripts with uv: No Dependencies Management Required](https://www.bitdoze.com/uv-run-scripts-guide/) for advanced script execution techniques.
&lt;/Notice&gt;

### Step 6: Managing Your Project

As your project grows, `uv` offers tools to keep it organized.

- **Update Dependencies**
  To upgrade packages to their latest compatible versions:
  ```bash
  uv sync --upgrade
  ```

- **Export to requirements.txt**
  If you need a traditional `requirements.txt`:
  ```bash
  uv export --format requirements-txt &gt; requirements.txt
  ```

- **Run Commands**
  Execute any command in the project environment:
  ```bash
  uv run pytest
  ```



### Step 7: Managing Python Versions
List installed Python versions:
```bash
uv python list
```
Install a specific version:
```bash
uv python install 3.11
```



### Step 8: Removing Packages with uv

As your project evolves, you might need to remove a package that’s no longer needed. With `uv`, uninstalling dependencies is just as straightforward as adding them, and it keeps your project configuration and environment in sync.

1. **Remove a Package**
   Suppose you added `requests` earlier but no longer need it. To remove it:
   ```bash
   uv remove requests
   ```
   This command:
   - Deletes `requests` from the `[project.dependencies]` section in your `pyproject.toml`.
   - Uninstalls the package from the virtual environment (`.venv`).
   - Updates the `uv.lock` file to reflect the change, ensuring reproducibility.

   For example, if your `pyproject.toml` originally had:
   ```toml
   [project]
   name = &quot;my-python-project&quot;
   version = &quot;0.1.0&quot;
   description = &quot;A new Python project&quot;
   requires-python = &quot;&gt;=3.12&quot;
   dependencies = [
       &quot;requests&gt;=2.31.0&quot;,
   ]
   ```
   After running `uv remove requests`, it becomes:
   ```toml
   [project]
   name = &quot;my-python-project&quot;
   version = &quot;0.1.0&quot;
   description = &quot;A new Python project&quot;
   requires-python = &quot;&gt;=3.12&quot;
   dependencies = []
   ```

2. **Remove a Development Dependency**
   If you installed a development dependency like `pytest` with `uv add --dev pytest` and want to remove it:
   ```bash
   uv remove --dev pytest
   ```
   This targets the `[dependency-groups.dev]` section in `pyproject.toml` and removes `pytest` from both the configuration and the virtual environment.

3. **Sync the Environment (Optional)**
   While `uv remove` typically updates the environment automatically, you can ensure everything is consistent by running:
   ```bash
   uv sync
   ```
   This reconciles the virtual environment with the updated `pyproject.toml` and `uv.lock`, removing any orphaned packages.

4. **Manual Removal (Not Recommended)**
   If you manually edit `pyproject.toml` to remove a dependency (e.g., deleting `requests` from the `dependencies` list), `uv` won’t automatically uninstall it from `.venv` until you run `uv sync`. Stick to `uv remove` to avoid this extra step and keep your project clean.



## Why Choose uv in 2026?

By March 2026, `uv` has solidified its place as a game-changer in Python development. Its speed alone—often installing dependencies 10-20x faster than `pip`—is a compelling reason to switch. Add to that its seamless integration with modern standards (`pyproject.toml`, `uv.lock`), automatic Python version management, and a streamlined workflow, and it’s clear why developers, including the FastAPI team, have adopted it.

For beginners, `uv` reduces the complexity of managing Python environments. For pros, it saves time and ensures reproducibility. Whether you’re building a small script or a large application, `uv` adapts to your needs.


## What&apos;s Next?

Now that you&apos;ve mastered the basics of uv project management, you might want to explore more advanced capabilities:

- **Script Execution**: Learn how to run standalone Python scripts without creating full projects in our guide [Running Test Scripts with uv: No Dependencies Management Required](https://www.bitdoze.com/uv-run-scripts-guide/)
- **Text-to-Speech**: Build a powerful audio generation script with [Text-to-Speech with uv: Create Audio from Text in Python](https://www.bitdoze.com/uv-text-to-speech-script/)
- **Deployment**: See how to deploy uv projects in our article [Deploying a Python uv Project with Git and Railpack in Dokploy](https://www.bitdoze.com/dokploy-python-railpack-uv/)

## Conclusion

`uv` is a game-changer for Python developers in 2025, offering a fast, unified, and intuitive way to manage projects. From installation to dependency management, it reduces friction and lets you focus on coding. To stay updated, check the official documentation at [docs.astral.sh/uv](https://docs.astral.sh/uv) or the GitHub repo at [github.com/astral-sh/uv](https://github.com/astral-sh/uv).

Ready to try it? Install `uv`, initialize a project, and experience Python development at warp speed. Happy coding!</content:encoded><category>tools</category><category>uv</category><category>python</category></item><item><title>Building an Astro Blog Theme from Scratch with WindSurf AI</title><link>https://www.bitdoze.com/windsurd-build-astro-blog/</link><guid isPermaLink="true">https://www.bitdoze.com/windsurd-build-astro-blog/</guid><description>Discover how I built a fast, responsive Astro blog theme from scratch using WindSurf AI and Tailwind CSS 4.</description><pubDate>Tue, 11 Mar 2025 00:00:00 GMT</pubDate><content:encoded>Have you ever wanted to build a sleek, modern blog from scratch with minimal effort? In this article, I’ll walk you through how I used **[WindSurf](https://go.bitdoze.com/windsurf)**, an AI-powered IDE, to create a fully functional blog theme using **Astro** and **Tailwind CSS 4**. Whether you&apos;re a beginner or a seasoned developer, you can follow along and replicate this process—starting from the initial setup to a polished, responsive website. Let’s dive in!

## What Is WindSurf?

WindSurf is an innovative IDE that leverages AI to help you build applications by simply chatting with it. Unlike traditional coding tools, WindSurf allows you to describe what you want, and it generates the code for you. It’s free to use with some limitations, but I opted for the Pro Plan (more on that later). With features like file inspection and advanced language models (like Claude Sonnet 3.7), it’s a game-changer for rapid development.

I recently used WindSurf to revamp my Astro-based blog, incorporating Tailwind CSS 4, and the results were stunning—a responsive, fast, and feature-rich site built in just a few days. Here’s how I did it, step by step.

&lt;Button text=&quot;Check WindSurf&quot; url=&quot;https://go.bitdoze.com/windsurf&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

## Initial Setup: Laying the Foundation

Before WindSurf could work its magic, I needed a basic Astro project. Here’s how I kicked things off:

### Step 1: Installing Astro
I started by creating a new Astro project using the official CLI:

```bash
npm create astro@latest
```

- **Prompts**:
  - Directory: `.` (current folder)
  - Template: A basic, minimal starter
  - Dependencies: Yes
  - Git: Yes

This gave me a clean Astro setup with a dev server (`npm run dev`) and Git initialized—ready for customization.

### Step 2: Adding Tailwind CSS 4
Next, I integrated Tailwind CSS 4 for styling:

```bash
npx astro add tailwind
```

- Installed: `@tailwindcss/vite@^4.0.6` and `tailwindcss@^4.0.6`
- Created: `./src/styles/global.css` for Tailwind styles
- Updated: `astro.config.mjs` to include Tailwind via Vite plugins

I imported the stylesheet in my layout file (`src/layouts/Layout.astro`) to ensure styles applied globally.

### Step 3: Enhancing with Typography
For better Markdown formatting, I added the Tailwind Typography plugin:

```bash
npm install -D @tailwindcss/typography
```

This made prose content (like blog posts) look polished without extra effort.

### Step 4: Adding MDX Support
To enable richer content with MDX, I ran:

```bash
npx astro add mdx
```

This updated `astro.config.mjs` to include the `@astrojs/mdx` integration, allowing me to mix Markdown and JSX.

### Step 5: Incorporating Astro Icons
For lightweight icons, I added the `astro-icon` package:

```bash
npx astro add astro-icon
npm install @iconify-json/mdi
```

This integrated icons from the Material Design Icons set (`mdi`), enhancing the UI with minimal overhead.

With these foundations in place, I was ready to let WindSurf take over.

&lt;Button text=&quot;Check WindSurf&quot; url=&quot;https://go.bitdoze.com/windsurf&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;

## Building the Blog with WindSurf


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/8rTkTd93ymo&quot;
  label=&quot;WindSurf Build Astro Blog&quot;
/&gt;

### Chatting My Way to a Blog Theme
Once my Astro project was set up, I opened WindSurf and started chatting. My goal: a blog theme with a header, footer, pagination, categories, tags, dark/light mode, and more—all using Tailwind 4 and Astro 5.4. Here’s how it unfolded:

- **First Prompt**: “Build a blog with Astro and Tailwind CSS 4. Use theme variables and Tailwind.” WindSurf analyzed my project and began scaffolding.
- **Global CSS**: I asked it to define custom colors and fonts in `src/styles/global.css`. It created a flexible theme setup, though I didn’t enforce strict rules initially.
- **Features**: I requested a dark/light mode toggle, pagination, and a responsive menu. WindSurf updated files like layouts and components on the fly.

### Key Features of the Blog
WindSurf delivered a feature-packed theme:
- **Header**: A clean, responsive header with a mobile menu.
- **Pages**: Authors, categories, tags, and series (tutorials) with pagination.
- **Posts**: Breadcrumbs, table of contents, related posts (tag-based), and social sharing.
- **Search**: A client-side search using Fuse.js—no React, just Astro.
- **Widgets**: Accordions, tabs, buttons, and notification boxes for richer content.
- **SEO**: Optimized components for better discoverability.

The site scored near-perfect on Google PageSpeed (100 on mobile!), thanks to Astro’s image optimization and Tailwind’s efficient CSS.


## WindSurf in Action: Behind the Scenes

### How I Used It
- **Write Mode**: I used WindSurf’s “write” mode, which auto-updates files without manual approval—perfect for speed.
- **Multi-Tasking**: I’d ask for 3-4 features at once (e.g., “Add header, footer, and pagination”). This minimized credit usage while maximizing output.
- **Error Handling**: If something broke, WindSurf flagged it without consuming credits—a nice touch.

### Challenges and Fixes
Tailwind 4 was new, and WindSurf didn’t know it perfectly. For example:
- **Dark Mode**: Initially buggy, but I guided it to fix the toggle.
- **Custom Widgets**: I requested accordions and tabs; it delivered, though I tweaked sizing later.

### Credits and Pricing
I used the Pro Plan ($15/month):
- **500 Prompt Credits**: For AI interactions.
- **1,500 Flow Credits**: For file edits and actions.
After ~8 hours of work, I used just 77 prompt credits and half my flow credits—super efficient!



## The Result: A Public Astro Theme

In 3 days, I built **Bit Doze-Astro Theme**, a public repository (link in the description) with:
- Responsive design (Tailwind 4)
- Dark/light mode
- Fast load times (Astro 5.4 + Cloudflare hosting)
- Demo content for easy setup

It’s not 100% perfect—some code could be cleaner—but it’s a solid starting point. I even used WindSurf to write the README!


## Why WindSurf Rocks

Compared to other AI tools (like Cursor or Replit), WindSurf stands out:
- **Smarter**: Powered by Claude Sonnet 3.7, it understands context well.
- **Faster**: Write mode skips tedious approvals.
- **Affordable**: Free tier available, and the Pro Plan is cost-effective.

I’ve tried rivals like Try (ByteDance) and VS Code plugins, but WindSurf’s seamless integration and results blew me away.


## Get Started Yourself

Ready to try it? Here’s how:
1. Install Astro and dependencies (see “Initial Setup”).
2. Grab WindSurf (free tier or Pro with my referral link—500 bonus credits!).
3. Chat your way to a custom project.

Check my **Bit Doze-Astro Theme** repo for inspiration, and watch my next video on hosting it for free. Like and subscribe if you enjoyed this journey—happy coding!


&lt;Button text=&quot;Check WindSurf&quot; url=&quot;https://go.bitdoze.com/windsurf&quot; size=&quot;lg&quot; color=&quot;blue&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;
&lt;Button text=&quot;Bit Doze Theme&quot; url=&quot;https://github.com/bitdoze/bitdoze-astro-theme&quot; size=&quot;lg&quot; color=&quot;green&quot; variant=&quot;solid&quot; icon=&quot;arrow-right&quot; iconPosition=&quot;right&quot; /&gt;</content:encoded><category>ai</category><category>astro</category><category>windsurf</category></item><item><title>Adding User Authentication and Admin Controls to Your FastHTML AI Title Generator</title><link>https://www.bitdoze.com/fasthtml-user-auth/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-user-auth/</guid><description>Learn how to implement GitHub OAuth authentication, email-based user registration, role-based access control, and user-specific history dashboards in your FastHTML AI Title Generator. This tutorial covers creating a users database, implementing multi-authentication methods, and building admin-only views.</description><pubDate>Tue, 04 Mar 2025 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;


Welcome to the next installment in our FastHTML series! In previous tutorials, we built an AI Title Generator and enhanced it with a SQLite database to track generation history. Today, we&apos;ll take our application to the next level by implementing:

1. User authentication with GitHub OAuth
2. Traditional email/password registration and login
3. Role-based access control (regular users vs. admins)
4. User-specific history dashboards
5. Admin-only views for monitoring all user activity



By the end of this tutorial, your application will have a complete user system where:
- Users can sign up and log in with either GitHub or email/password
- The title generator tool is protected and only available to logged-in users
- Each user can see their own history dashboard
- Administrators can view all users&apos; history and manage user accounts

Let&apos;s get started!


## Overview

This table provides a comprehensive overview of the authentication system implemented in our AI Title Generator application. It&apos;s designed to help beginners understand the various components, their purposes, and how they interact.

| Component | Files | Functions | Description | Key Features |
|-----------|-------|-----------|-------------|--------------|
| **Database Layer** | `db/database.py`, `db/user_dao.py`, `db/history_dao.py` | `Database._initialize_db()`, `Database._hash_password()`, `UserDAO.create_user()`, `UserDAO.authenticate_email()`, etc. | The foundation that manages data persistence and security | **SQLite database** with user and history tables, **secure password hashing** with PBKDF2, **foreign key relationships** between users and history, **automatic admin account creation** |
| **Authentication Services** | `auth/auth_manager.py`, `auth/email_auth.py`, `auth/github_auth.py` | `AuthManager.login_user()`, `AuthManager.is_admin()`, `EmailAuth.authenticate()`, `GitHubAuth.get_auth_url()`, etc. | Handles all authentication-related logic including verification, sessions, and permissions | **Multiple authentication methods** (email + GitHub), **session management**, **role-based access control**, **secure password validation** |
| **UI Components** | `components/header.py`, `components/page_layout.py` | `header()`, `page_layout()` | Provides consistent, authentication-aware UI elements across pages | **Dynamic navigation** based on auth status, **admin-specific UI elements**, **context-aware highlighting** of current page, **session integration** |
| **Public Pages** | `pages/home.py`, `pages/login.py`, `pages/register.py` | `home_page()`, `login_page()`, `register_page()` | Pages accessible without authentication | **Responsive landing page**, **login form** with error handling, **registration form** with validation, **OAuth integration buttons** |
| **Protected User Pages** | `pages/title_generator.py`, `pages/history.py` | `title_generator_form()`, `history_page()`, `history_detail_page()` | Pages that require user authentication | **Tool access restrictions**, **user-specific history views**, **data filtering** based on user ID, **statistics and insights** for users |
| **Admin Pages** | `pages/admin.py` | `admin_dashboard()`, `admin_users_page()`, `admin_history_page()` | Pages that require admin authentication | **User management interface**, **role assignment controls**, **global data visibility**, **administrative actions** (delete users, etc.) |
| **Route Handlers** | `main.py` | Functions like `home()`, `email_login()`, `admin_users()`, `generate_titles()`, etc. | Connects URLs to page content and processes form submissions | **Authentication checks**, **form processing**, **response generation**, **error handling** |
| **GitHub OAuth** | `auth/github_auth.py`, `main.py` | `github_login()`, `github_callback()`, `GitHubAuth.get_auth_url()`, `GitHubAuth.authenticate()` | Handles the GitHub OAuth authentication flow | **Authorization code exchange**, **API integration**, **user profile retrieval**, **token management** |
| **Email Authentication** | `auth/email_auth.py`, `main.py` | `email_register()`, `email_login()`, `EmailAuth.validate_registration()`, `EmailAuth.authenticate()` | Handles traditional email/password authentication | **Secure registration**, **password validation**, **login verification**, **password hashing** |

## Authentication Flow Explanation

1. **Registration Flow**:
   - User visits `/register` and fills out the form
   - `email_register()` route handler receives the form data
   - `EmailAuth.validate_registration()` checks format and requirements
   - `UserDAO.create_user()` creates the user with a hashed password
   - User is redirected to login with success message

2. **Email Login Flow**:
   - User visits `/login` and enters email/password
   - `email_login()` route handler receives the form data
   - `EmailAuth.authenticate()` verifies credentials
   - `UserDAO.authenticate_email()` checks password hash
   - `AuthManager.login_user()` sets session data
   - User is redirected to home page

3. **GitHub Login Flow**:
   - User clicks &quot;Sign in with GitHub&quot; button
   - `github_login()` route handler redirects to GitHub
   - User authenticates on GitHub and grants permissions
   - GitHub redirects back with an authorization code
   - `github_callback()` route handler receives the code
   - `GitHubAuth.authenticate()` exchanges code for access token
   - `GitHubAuth.get_user_info()` retrieves user profile
   - `UserDAO.find_or_create_github_user()` finds or creates user
   - `AuthManager.login_user()` sets session data
   - User is redirected to home page

4. **Authorization Check Flow**:
   - User attempts to access a protected route (e.g., `/title-generator`)
   - `require_auth()` function checks session data
   - If not authenticated, redirects to login
   - If admin page and not admin, redirects to home
   - Otherwise, allows access to the requested page

## Session and Authentication State

- Authentication state is stored in the session via `AuthManager.login_user()`
- The session contains user ID, username, and admin status
- Session data is cryptographically signed and secure
- The `header()` function uses session data to show appropriate navigation
- The `require_auth()` function uses session data to control page access
- Session is cleared on logout via `AuthManager.logout_user()`

## Role-Based Access Control

- **Anonymous users** can only access public pages:
  - Home page (with limited content)
  - Login page
  - Registration page

- **Authenticated users** can access:
  - Home page (with personalized content)
  - Title generator tool
  - Their own history records
  - Their account settings (if implemented)

- **Admin users** additionally can access:
  - Admin dashboard
  - User management interface
  - All users&apos; history records
  - Administrative actions (make/remove admins, delete users)

## Database Schema Overview

### Users Table
- `id`: Primary key
- `username`: Display name (unique)
- `email`: Email address (unique)
- `password_hash`: Securely hashed password
- `salt`: Unique salt for password hashing
- `github_id`: GitHub user ID (for OAuth users)
- `is_admin`: Boolean admin status flag
- `created_at`: Account creation timestamp
- `last_login`: Last successful login timestamp

### Title History Table
- `id`: Primary key
- `user_id`: Foreign key to users table
- `topic`: The title generation topic
- `platform`: Selected platform
- `style`: Selected style
- `number_of_titles`: Number of titles requested
- `titles`: JSON string of generated titles
- `created_at`: Generation timestamp

This comprehensive authentication system provides a secure, flexible foundation that can be easily extended with additional features like email verification, password reset, more OAuth providers, or team collaboration features.


## Project Structure Updates

We&apos;ll expand our existing project structure with new authentication-related files:

```
ai-title-generator/
├── main.py                   # Updated with auth routes
├── config.py                 # Updated with auth settings
├── ai_service.py             # Unchanged
├── auth/                     # New directory for authentication code
│   ├── __init__.py
│   ├── auth_manager.py       # Authentication logic
│   ├── email_auth.py         # Email/password authentication
│   └── github_auth.py        # GitHub OAuth integration
├── db/                       # Database directory
│   ├── __init__.py
│   ├── database.py           # Updated for user management
│   ├── history_dao.py        # Updated for user association
│   └── user_dao.py           # New user data access object
├── components/               # UI components
│   ├── __init__.py
│   ├── header.py             # Updated with auth links
│   ├── footer.py             # Unchanged
│   └── page_layout.py        # Unchanged
├── pages/                    # Pages directory
│   ├── __init__.py
│   ├── home.py               # Updated with auth-aware content
│   ├── title_generator.py    # Updated to require login
│   ├── history.py            # Updated for user-specific history
│   ├── login.py              # New login page
│   ├── register.py           # New registration page
│   └── admin.py              # New admin dashboard
├── tools/                    # Tools directory (unchanged)
│   ├── __init__.py
│   └── title_generator.py    # Unchanged
└── tools.db                  # SQLite database
```
## User Authentication and Admin Controls to Your FastHTML
### Step 1: Updating the Database Structure

First, we need to extend our database to support user management. Let&apos;s update our database module:

**File: `db/database.py` (Updated)**

```python
import sqlite3
import os
from contextlib import contextmanager
import config
import hashlib
import secrets

class Database:
    &quot;&quot;&quot;Handles database connections and initialization.&quot;&quot;&quot;

    def __init__(self, db_path=None):
        &quot;&quot;&quot;
        Initialize the database connection.

        Args:
            db_path: Path to the SQLite database file (defaults to config setting)
        &quot;&quot;&quot;
        # If db_path is None or empty, use a default path
        self.db_path = db_path or config.DB_PATH
        if not self.db_path:
            # Set default path if DB_PATH is empty
            self.db_path = &quot;tools.db&quot;
        self._initialize_db()

    def _initialize_db(self):
        &quot;&quot;&quot;Create database tables if they don&apos;t exist.&quot;&quot;&quot;
        with self.get_connection() as conn:
            cursor = conn.cursor()

            # Create the users table
            cursor.execute(&apos;&apos;&apos;
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT UNIQUE,
                email TEXT UNIQUE,
                password_hash TEXT,
                salt TEXT,
                github_id TEXT UNIQUE,
                is_admin BOOLEAN DEFAULT 0,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                last_login TIMESTAMP
            )
            &apos;&apos;&apos;)

            # Create the title_history table with user_id foreign key
            cursor.execute(&apos;&apos;&apos;
            CREATE TABLE IF NOT EXISTS title_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER,
                topic TEXT NOT NULL,
                platform TEXT NOT NULL,
                style TEXT NOT NULL,
                number_of_titles INTEGER NOT NULL,
                titles TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                FOREIGN KEY (user_id) REFERENCES users(id)
            )
            &apos;&apos;&apos;)

            # Check if we need to create an admin user
            cursor.execute(&quot;SELECT COUNT(*) FROM users WHERE is_admin = 1&quot;)
            if cursor.fetchone()[0] == 0 and config.ADMIN_EMAIL and config.ADMIN_PASSWORD:
                # Create admin user if credentials are provided in config
                salt = secrets.token_hex(16)
                password_hash = self._hash_password(config.ADMIN_PASSWORD, salt)

                cursor.execute(&apos;&apos;&apos;
                INSERT INTO users (username, email, password_hash, salt, is_admin)
                VALUES (?, ?, ?, ?, 1)
                &apos;&apos;&apos;, (&apos;admin&apos;, config.ADMIN_EMAIL, password_hash, salt))

            conn.commit()

    @staticmethod
    def _hash_password(password, salt):
        &quot;&quot;&quot;
        Hash a password with the given salt using PBKDF2.

        Args:
            password: The plain text password
            salt: The salt to use

        Returns:
            str: The hashed password
        &quot;&quot;&quot;
        # Use PBKDF2 with SHA-256, 100,000 iterations
        return hashlib.pbkdf2_hmac(
            &apos;sha256&apos;,
            password.encode(&apos;utf-8&apos;),
            salt.encode(&apos;utf-8&apos;),
            100000
        ).hex()

    @contextmanager
    def get_connection(self):
        &quot;&quot;&quot;
        Context manager for database connections.

        Yields:
            sqlite3.Connection: Active database connection
        &quot;&quot;&quot;
        # Check if db_path has a directory component
        db_dir = os.path.dirname(self.db_path)

        # Only try to create directories if there&apos;s a directory path
        if db_dir:
            os.makedirs(db_dir, exist_ok=True)

        # Connect to the database
        conn = sqlite3.connect(self.db_path)

        # Configure connection
        conn.row_factory = sqlite3.Row  # Use dictionary-like rows

        try:
            yield conn
        finally:
            conn.close()

# Create a singleton instance
db = Database()
```

**Explanation**:
- We&apos;ve created a new `users` table with fields for both email/password and GitHub authentication
- We&apos;ve added a `user_id` foreign key to the `title_history` table to associate records with users
- We&apos;ve implemented secure password hashing using PBKDF2 with SHA-256 and a unique salt for each user
- We&apos;ve added code to create an initial admin user if configured in settings


**File: `components/page_layout.py` (Updated)**

```python
from fasthtml.common import *
from .header import header
from .footer import footer

def page_layout(title, content, current_page=&quot;/&quot;, session=None):
    &quot;&quot;&quot;
    Creates a consistent page layout with header and footer.

    Args:
        title: The page title
        content: The main content components
        current_page: The current page path
        session: The session object for auth status

    Returns:
        A complete HTML page
    &quot;&quot;&quot;
    return Html(
        Head(
            Title(title),
            Meta(charset=&quot;UTF-8&quot;),
            Meta(name=&quot;viewport&quot;, content=&quot;width=device-width, initial-scale=1.0&quot;),
            # Include Tailwind CSS for styling
            Script(src=&quot;https://cdn.tailwindcss.com&quot;),
        ),
        Body(
            Div(
                header(current_page, session),
                Main(
                    Div(
                        content,
                        cls=&quot;container mx-auto px-4 py-8&quot;
                    ),
                    cls=&quot;flex-grow&quot;
                ),
                footer(),
                cls=&quot;flex flex-col min-h-screen&quot;
            )
        )
    )
```

Next, let&apos;s create a DAO for user management:

**File: `db/user_dao.py`**

```python
from typing import Optional, Dict, Any, List
import secrets
from datetime import datetime
from .database import db

class UserDAO:
    &quot;&quot;&quot;Data Access Object for user management.&quot;&quot;&quot;

    @staticmethod
    def create_user(username: str, email: str, password: Optional[str] = None, github_id: Optional[str] = None) -&gt; int:
        &quot;&quot;&quot;
        Create a new user with email/password or GitHub authentication.

        Args:
            username: The username
            email: The user&apos;s email
            password: The user&apos;s password (optional)
            github_id: GitHub user ID (optional)

        Returns:
            int: ID of the new user or 0 if creation failed
        &quot;&quot;&quot;
        try:
            with db.get_connection() as conn:
                cursor = conn.cursor()

                # Check if user already exists
                cursor.execute(
                    &quot;SELECT id FROM users WHERE email = ? OR username = ? OR (github_id = ? AND github_id IS NOT NULL)&quot;,
                    (email, username, github_id)
                )

                if cursor.fetchone():
                    # User already exists
                    return 0

                # Prepare values for insertion
                password_hash = None
                salt = None

                if password:
                    # Generate salt and hash password for email auth
                    salt = secrets.token_hex(16)
                    password_hash = db._hash_password(password, salt)

                cursor.execute(&apos;&apos;&apos;
                INSERT INTO users (username, email, password_hash, salt, github_id, is_admin)
                VALUES (?, ?, ?, ?, ?, 0)
                &apos;&apos;&apos;, (username, email, password_hash, salt, github_id))

                conn.commit()
                return cursor.lastrowid
        except Exception as e:
            print(f&quot;Error creating user: {e}&quot;)
            return 0

    @staticmethod
    def authenticate_email(email: str, password: str) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Authenticate a user with email and password.

        Args:
            email: The user&apos;s email
            password: The user&apos;s password

        Returns:
            Dict or None: User record if authentication succeeds, None otherwise
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Get user record by email
            cursor.execute(
                &quot;SELECT id, username, email, password_hash, salt, is_admin FROM users WHERE email = ?&quot;,
                (email,)
            )

            user = cursor.fetchone()
            if not user or not user[&apos;password_hash&apos;] or not user[&apos;salt&apos;]:
                return None

            # Hash the provided password with the stored salt
            password_hash = db._hash_password(password, user[&apos;salt&apos;])

            # Check if password matches
            if password_hash != user[&apos;password_hash&apos;]:
                return None

            # Update last login time
            cursor.execute(
                &quot;UPDATE users SET last_login = ? WHERE id = ?&quot;,
                (datetime.now().isoformat(), user[&apos;id&apos;])
            )
            conn.commit()

            # Return user info
            return dict(user)

    @staticmethod
    def find_or_create_github_user(github_id: str, username: str, email: Optional[str]) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Find existing GitHub user or create a new one.

        Args:
            github_id: GitHub user ID
            username: The username from GitHub
            email: The email from GitHub (may be None)

        Returns:
            Dict or None: User record if found or created, None on error
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Try to find user by GitHub ID
            cursor.execute(
                &quot;SELECT id, username, email, is_admin FROM users WHERE github_id = ?&quot;,
                (github_id,)
            )

            user = cursor.fetchone()
            if user:
                # Update last login time
                cursor.execute(
                    &quot;UPDATE users SET last_login = ? WHERE id = ?&quot;,
                    (datetime.now().isoformat(), user[&apos;id&apos;])
                )
                conn.commit()
                return dict(user)

            # Create new user
            # Use GitHub username with random suffix if email not provided
            user_email = email or f&quot;{username}-{secrets.token_hex(4)}@github.user&quot;

            try:
                cursor.execute(&apos;&apos;&apos;
                INSERT INTO users (username, email, github_id, is_admin)
                VALUES (?, ?, ?, 0)
                &apos;&apos;&apos;, (username, user_email, github_id))

                user_id = cursor.lastrowid
                conn.commit()

                # Return the new user info
                return {
                    &apos;id&apos;: user_id,
                    &apos;username&apos;: username,
                    &apos;email&apos;: user_email,
                    &apos;is_admin&apos;: 0
                }
            except Exception as e:
                print(f&quot;Error creating GitHub user: {e}&quot;)
                return None

    @staticmethod
    def get_user_by_id(user_id: int) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get a user by ID.

        Args:
            user_id: The user ID

        Returns:
            Dict or None: User record if found, None otherwise
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(
                &quot;SELECT id, username, email, is_admin, created_at, last_login FROM users WHERE id = ?&quot;,
                (user_id,)
            )

            user = cursor.fetchone()
            return dict(user) if user else None

    @staticmethod
    def get_all_users(limit: int = 100, offset: int = 0) -&gt; List[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get all users with pagination.

        Args:
            limit: Maximum number of users to return
            offset: Number of users to skip

        Returns:
            List of user records
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            SELECT id, username, email, is_admin, created_at, last_login,
                   (github_id IS NOT NULL) as is_github_user
            FROM users
            ORDER BY created_at DESC
            LIMIT ? OFFSET ?
            &apos;&apos;&apos;, (limit, offset))

            return [dict(user) for user in cursor.fetchall()]

    @staticmethod
    def set_admin_status(user_id: int, is_admin: bool) -&gt; bool:
        &quot;&quot;&quot;
        Change a user&apos;s admin status.

        Args:
            user_id: The user ID
            is_admin: True to make admin, False to remove admin status

        Returns:
            bool: True if successful, False otherwise
        &quot;&quot;&quot;
        try:
            with db.get_connection() as conn:
                cursor = conn.cursor()

                cursor.execute(
                    &quot;UPDATE users SET is_admin = ? WHERE id = ?&quot;,
                    (1 if is_admin else 0, user_id)
                )

                conn.commit()
                return cursor.rowcount &gt; 0
        except Exception as e:
            print(f&quot;Error setting admin status: {e}&quot;)
            return False

    @staticmethod
    def delete_user(user_id: int) -&gt; bool:
        &quot;&quot;&quot;
        Delete a user and all their data.

        Args:
            user_id: The user ID

        Returns:
            bool: True if successful, False otherwise
        &quot;&quot;&quot;
        try:
            with db.get_connection() as conn:
                cursor = conn.cursor()

                # Delete user&apos;s history records
                cursor.execute(&quot;DELETE FROM title_history WHERE user_id = ?&quot;, (user_id,))

                # Delete user
                cursor.execute(&quot;DELETE FROM users WHERE id = ?&quot;, (user_id,))

                conn.commit()
                return cursor.rowcount &gt; 0
        except Exception as e:
            print(f&quot;Error deleting user: {e}&quot;)
            return False
```

**Explanation**:
- We&apos;ve created a comprehensive `UserDAO` with methods for:
  - Creating new users (either with email/password or GitHub authentication)
  - Authenticating users with email/password
  - Finding or creating users based on GitHub information
  - Retrieving user information
  - Managing users (admin rights, deletion)
- We update the `last_login` timestamp whenever a user logs in
- We handle edge cases like GitHub users without emails
- We include cascade deletion to remove a user&apos;s history when they&apos;re deleted

Now let&apos;s update the history DAO to associate records with users:

**File: `db/history_dao.py` (Updated)**

```python
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from .database import db

class HistoryDAO:
    &quot;&quot;&quot;Data Access Object for title generation history.&quot;&quot;&quot;

    @staticmethod
    async def save_generation(
        user_id: int,
        topic: str,
        platform: str,
        style: str,
        number_of_titles: int,
        titles: List[str]
    ) -&gt; int:
        &quot;&quot;&quot;
        Save a title generation record to the database.

        Args:
            user_id: ID of the user who generated the titles
            topic: The topic of the generation
            platform: The platform selected
            style: The style selected
            number_of_titles: Number of titles requested
            titles: List of generated titles

        Returns:
            int: ID of the new record
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Convert titles list to JSON string
            titles_json = json.dumps(titles)

            cursor.execute(&apos;&apos;&apos;
            INSERT INTO title_history
                (user_id, topic, platform, style, number_of_titles, titles)
            VALUES (?, ?, ?, ?, ?, ?)
            &apos;&apos;&apos;, (user_id, topic, platform, style, number_of_titles, titles_json))

            conn.commit()
            return cursor.lastrowid

    @staticmethod
    def get_user_history(user_id: int, limit: int = 100, offset: int = 0) -&gt; List[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get history records for a specific user with pagination.

        Args:
            user_id: The user ID
            limit: Maximum number of records to return
            offset: Number of records to skip

        Returns:
            List of history records as dictionaries
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            SELECT id, topic, platform, style, number_of_titles, titles, created_at
            FROM title_history
            WHERE user_id = ?
            ORDER BY created_at DESC
            LIMIT ? OFFSET ?
            &apos;&apos;&apos;, (user_id, limit, offset))

            # Convert row objects to dictionaries
            result = []
            for row in cursor.fetchall():
                record = dict(row)
                # Parse titles from JSON string
                record[&apos;titles&apos;] = json.loads(record[&apos;titles&apos;])
                # Format timestamp for display
                created_at = datetime.fromisoformat(record[&apos;created_at&apos;].replace(&apos;Z&apos;, &apos;+00:00&apos;))
                record[&apos;created_at_formatted&apos;] = created_at.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)
                result.append(record)

            return result

    @staticmethod
    def get_all_history(limit: int = 100, offset: int = 0) -&gt; List[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get all history records with pagination.

        Args:
            limit: Maximum number of records to return
            offset: Number of records to skip

        Returns:
            List of history records as dictionaries
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            SELECT h.id, h.user_id, u.username, h.topic, h.platform, h.style,
                   h.number_of_titles, h.titles, h.created_at
            FROM title_history h
            LEFT JOIN users u ON h.user_id = u.id
            ORDER BY h.created_at DESC
            LIMIT ? OFFSET ?
            &apos;&apos;&apos;, (limit, offset))

            # Convert row objects to dictionaries
            result = []
            for row in cursor.fetchall():
                record = dict(row)
                # Parse titles from JSON string
                record[&apos;titles&apos;] = json.loads(record[&apos;titles&apos;])
                # Format timestamp for display
                created_at = datetime.fromisoformat(record[&apos;created_at&apos;].replace(&apos;Z&apos;, &apos;+00:00&apos;))
                record[&apos;created_at_formatted&apos;] = created_at.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)
                result.append(record)

            return result

    @staticmethod
    def get_history_by_id(record_id: int, user_id: Optional[int] = None) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get a specific history record by ID.

        Args:
            record_id: The ID of the record to retrieve
            user_id: Optional user ID to restrict access

        Returns:
            Dictionary with record data or None if not found or not owned by user
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            query = &apos;&apos;&apos;
            SELECT h.id, h.user_id, u.username, h.topic, h.platform, h.style,
                   h.number_of_titles, h.titles, h.created_at
            FROM title_history h
            LEFT JOIN users u ON h.user_id = u.id
            WHERE h.id = ?
            &apos;&apos;&apos;

            params = [record_id]

            # Add user filtering if specified
            if user_id is not None:
                query += &quot; AND h.user_id = ?&quot;
                params.append(user_id)

            cursor.execute(query, params)

            row = cursor.fetchone()
            if not row:
                return None

            record = dict(row)
            # Parse titles from JSON string
            record[&apos;titles&apos;] = json.loads(record[&apos;titles&apos;])
            # Format timestamp for display
            created_at = datetime.fromisoformat(record[&apos;created_at&apos;].replace(&apos;Z&apos;, &apos;+00:00&apos;))
            record[&apos;created_at_formatted&apos;] = created_at.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)

            return record

    @staticmethod
    def delete_history(record_id: int, user_id: Optional[int] = None) -&gt; bool:
        &quot;&quot;&quot;
        Delete a history record by ID.

        Args:
            record_id: The ID of the record to delete
            user_id: Optional user ID to restrict deletion to user&apos;s records

        Returns:
            bool: True if record was deleted, False if not found or not owned by user
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            query = &quot;DELETE FROM title_history WHERE id = ?&quot;
            params = [record_id]

            # Add user filtering if specified
            if user_id is not None:
                query += &quot; AND user_id = ?&quot;
                params.append(user_id)

            cursor.execute(query, params)

            conn.commit()
            return cursor.rowcount &gt; 0

    @staticmethod
    def get_user_stats(user_id: int) -&gt; Dict[str, Any]:
        &quot;&quot;&quot;
        Get generation statistics for a user.

        Args:
            user_id: The user ID

        Returns:
            Dictionary with statistics
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Get total generations
            cursor.execute(
                &quot;SELECT COUNT(*) FROM title_history WHERE user_id = ?&quot;,
                (user_id,)
            )
            total_generations = cursor.fetchone()[0]

            # Get platform breakdown
            cursor.execute(&apos;&apos;&apos;
            SELECT platform, COUNT(*) as count
            FROM title_history
            WHERE user_id = ?
            GROUP BY platform
            ORDER BY count DESC
            &apos;&apos;&apos;, (user_id,))

            platforms = {row[&apos;platform&apos;]: row[&apos;count&apos;] for row in cursor.fetchall()}

            # Get style breakdown
            cursor.execute(&apos;&apos;&apos;
            SELECT style, COUNT(*) as count
            FROM title_history
            WHERE user_id = ?
            GROUP BY style
            ORDER BY count DESC
            &apos;&apos;&apos;, (user_id,))

            styles = {row[&apos;style&apos;]: row[&apos;count&apos;] for row in cursor.fetchall()}

            # Get latest generation date
            cursor.execute(
                &quot;SELECT MAX(created_at) FROM title_history WHERE user_id = ?&quot;,
                (user_id,)
            )
            latest_date = cursor.fetchone()[0]

            return {
                &apos;total_generations&apos;: total_generations,
                &apos;platforms&apos;: platforms,
                &apos;styles&apos;: styles,
                &apos;latest_date&apos;: latest_date
            }
```

**Explanation**:
- We&apos;ve updated the `HistoryDAO` to associate records with users by adding a `user_id` parameter
- We&apos;ve added a new method `get_user_history` to get history records for a specific user
- We&apos;ve updated existing methods to optionally filter by user ID for security
- We&apos;ve joined the history and users tables to include usernames in history records
- We&apos;ve added statistics functions to gather insights about a user&apos;s generation patterns

### Step 2: Setting Up Authentication

Now let&apos;s set up our authentication system. First, let&apos;s update the config file:

**File: `config.py` (Updated)**

```python
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# API configuration
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
OPENROUTER_BASE_URL = &quot;https://openrouter.ai/api/v1&quot;

# Default model to use
DEFAULT_MODEL = os.getenv(&quot;DEFAULT_MODEL&quot;, &quot;openai/gpt-3.5-turbo&quot;)

# Database settings
DB_PATH = os.getenv(&quot;DB_PATH&quot;, &quot;tools.db&quot;)

# Authentication settings
SECRET_KEY = os.getenv(&quot;SECRET_KEY&quot;, &quot;your-secret-key-change-in-production&quot;)

# GitHub OAuth settings
GITHUB_CLIENT_ID = os.getenv(&quot;GITHUB_CLIENT_ID&quot;)
GITHUB_CLIENT_SECRET = os.getenv(&quot;GITHUB_CLIENT_SECRET&quot;)
GITHUB_REDIRECT_URI = os.getenv(&quot;GITHUB_REDIRECT_URI&quot;, &quot;/auth/github/callback&quot;)

# Admin user (created on first run if provided)
ADMIN_EMAIL = os.getenv(&quot;ADMIN_EMAIL&quot;)
ADMIN_PASSWORD = os.getenv(&quot;ADMIN_PASSWORD&quot;)

# Session expiration (in seconds)
SESSION_EXPIRY = int(os.getenv(&quot;SESSION_EXPIRY&quot;, &quot;604800&quot;))  # 7 days default

# Application settings
DEBUG = os.getenv(&quot;DEBUG&quot;, &quot;True&quot;).lower() == &quot;true&quot;
APP_NAME = &quot;AI Title Generator&quot;
```

Now, let&apos;s implement the authentication manager:

**File: `auth/auth_manager.py`**

```python
from typing import Optional, Dict, Any, Tuple
from db.user_dao import UserDAO
import config

class AuthManager:
    &quot;&quot;&quot;
    Authentication manager that handles user sessions and permissions.
    &quot;&quot;&quot;

    @staticmethod
    def login_user(session, user_data: Dict[str, Any]) -&gt; None:
        &quot;&quot;&quot;
        Log in a user by setting session data.

        Args:
            session: The session object
            user_data: User data to store in session
        &quot;&quot;&quot;
        # Store minimal user data in session
        session[&quot;user_id&quot;] = user_data[&quot;id&quot;]
        session[&quot;username&quot;] = user_data[&quot;username&quot;]
        session[&quot;is_admin&quot;] = bool(user_data[&quot;is_admin&quot;])

    @staticmethod
    def logout_user(session) -&gt; None:
        &quot;&quot;&quot;
        Log out a user by clearing session data.

        Args:
            session: The session object
        &quot;&quot;&quot;
        # Clear all user-related session data
        session.pop(&quot;user_id&quot;, None)
        session.pop(&quot;username&quot;, None)
        session.pop(&quot;is_admin&quot;, None)

    @staticmethod
    def get_current_user(session) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get the currently logged-in user from session.

        Args:
            session: The session object

        Returns:
            Dict or None: User data if logged in, None otherwise
        &quot;&quot;&quot;
        user_id = session.get(&quot;user_id&quot;)
        if not user_id:
            return None

        # Get full user data from database
        return UserDAO.get_user_by_id(user_id)

    @staticmethod
    def is_authenticated(session) -&gt; bool:
        &quot;&quot;&quot;
        Check if a user is authenticated.

        Args:
            session: The session object

        Returns:
            bool: True if authenticated, False otherwise
        &quot;&quot;&quot;
        return &quot;user_id&quot; in session

    @staticmethod
    def is_admin(session) -&gt; bool:
        &quot;&quot;&quot;
        Check if the current user is an admin.

        Args:
            session: The session object

        Returns:
            bool: True if admin, False otherwise
        &quot;&quot;&quot;
        return session.get(&quot;is_admin&quot;, False)

    @staticmethod
    def require_auth(session) -&gt; Tuple[bool, Optional[str]]:
        &quot;&quot;&quot;
        Check if authentication is required.

        Args:
            session: The session object

        Returns:
            Tuple[bool, Optional[str]]: (is_authorized, redirect_url)
        &quot;&quot;&quot;
        if not AuthManager.is_authenticated(session):
            return False, &quot;/login&quot;
        return True, None

    @staticmethod
    def require_admin(session) -&gt; Tuple[bool, Optional[str]]:
        &quot;&quot;&quot;
        Check if admin authentication is required.

        Args:
            session: The session object

        Returns:
            Tuple[bool, Optional[str]]: (is_authorized, redirect_url)
        &quot;&quot;&quot;
        if not AuthManager.is_authenticated(session):
            return False, &quot;/login&quot;

        if not AuthManager.is_admin(session):
            return False, &quot;/&quot;

        return True, None
```

**Explanation**:
- The `AuthManager` class handles common authentication tasks:
  - Logging users in and out
  - Getting the current user&apos;s information
  - Checking if a user is authenticated
  - Checking if a user is an admin
  - Requiring authentication for specific routes
- We store minimal user data in the session for performance and security
- The `require_auth` and `require_admin` methods return both a boolean and a redirect URL, making them convenient to use in route handlers

Next, let&apos;s implement email authentication:

**File: `auth/email_auth.py`**

```python
from typing import Dict, Any, Optional, Tuple
from db.user_dao import UserDAO
import re

class EmailAuth:
    &quot;&quot;&quot;
    Handles email/password authentication.
    &quot;&quot;&quot;

    @staticmethod
    def validate_registration(username: str, email: str, password: str, confirm_password: str) -&gt; Tuple[bool, str]:
        &quot;&quot;&quot;
        Validate registration input.

        Args:
            username: The username
            email: The email address
            password: The password
            confirm_password: Password confirmation

        Returns:
            Tuple[bool, str]: (is_valid, error_message)
        &quot;&quot;&quot;
        # Check username length
        if len(username) &lt; 3 or len(username) &gt; 30:
            return False, &quot;Username must be between 3 and 30 characters&quot;

        # Check username format (letters, numbers, underscores, hyphens)
        if not re.match(r&apos;^[a-zA-Z0-9_-]+$&apos;, username):
            return False, &quot;Username can only contain letters, numbers, underscores, and hyphens&quot;

        # Check email format
        if not re.match(r&apos;^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$&apos;, email):
            return False, &quot;Invalid email format&quot;

        # Check password length
        if len(password) &lt; 8:
            return False, &quot;Password must be at least 8 characters long&quot;

        # Check password strength (at least one uppercase, one lowercase, one digit)
        if not (re.search(r&apos;[A-Z]&apos;, password) and re.search(r&apos;[a-z]&apos;, password) and re.search(r&apos;[0-9]&apos;, password)):
            return False, &quot;Password must contain at least one uppercase letter, one lowercase letter, and one digit&quot;

        # Check password match
        if password != confirm_password:
            return False, &quot;Passwords do not match&quot;

        return True, &quot;&quot;

    @staticmethod
    def register_user(username: str, email: str, password: str) -&gt; Tuple[bool, str, Optional[int]]:
        &quot;&quot;&quot;
        Register a new user with email and password.

        Args:
            username: The username
            email: The email address
            password: The password

        Returns:
            Tuple[bool, str, Optional[int]]: (success, message, user_id)
        &quot;&quot;&quot;
        # Create user in database
        user_id = UserDAO.create_user(username=username, email=email, password=password)

        if user_id == 0:
            return False, &quot;Username or email already exists&quot;, None

        return True, &quot;Registration successful&quot;, user_id

    @staticmethod
    def authenticate(email: str, password: str) -&gt; Tuple[bool, str, Optional[Dict[str, Any]]]:
        &quot;&quot;&quot;
        Authenticate a user with email and password.

        Args:
            email: The email address
            password: The password

        Returns:
            Tuple[bool, str, Optional[Dict]]: (success, message, user_data)
        &quot;&quot;&quot;
        if not email or not password:
            return False, &quot;Email and password are required&quot;, None

        user_data = UserDAO.authenticate_email(email, password)

        if not user_data:
            return False, &quot;Invalid email or password&quot;, None

        return True, &quot;Authentication successful&quot;, user_data
```

Now let&apos;s continue with the GitHub OAuth implementation:

**File: `auth/github_auth.py`**

```python
from typing import Dict, Any, Optional, Tuple
import os
import requests
from db.user_dao import UserDAO
import config

class GitHubAuth:
    &quot;&quot;&quot;
    Handles GitHub OAuth authentication.
    &quot;&quot;&quot;

    @staticmethod
    def get_auth_url(state: str = &quot;&quot;) -&gt; str:
        &quot;&quot;&quot;
        Get the GitHub OAuth authorization URL.

        Args:
            state: Optional state parameter for CSRF protection

        Returns:
            str: The authorization URL
        &quot;&quot;&quot;
        params = {
            &apos;client_id&apos;: config.GITHUB_CLIENT_ID,
            &apos;redirect_uri&apos;: config.GITHUB_REDIRECT_URI,
            &apos;scope&apos;: &apos;read:user user:email&apos;,
            &apos;state&apos;: state
        }

        query_string = &apos;&amp;&apos;.join([f&quot;{key}={params[key]}&quot; for key in params])
        return f&quot;https://github.com/login/oauth/authorize?{query_string}&quot;

    @staticmethod
    def get_access_token(code: str) -&gt; Optional[str]:
        &quot;&quot;&quot;
        Exchange authorization code for access token.

        Args:
            code: The authorization code from GitHub

        Returns:
            Optional[str]: The access token or None if failed
        &quot;&quot;&quot;
        url = &quot;https://github.com/login/oauth/access_token&quot;

        headers = {
            &quot;Accept&quot;: &quot;application/json&quot;
        }

        data = {
            &quot;client_id&quot;: config.GITHUB_CLIENT_ID,
            &quot;client_secret&quot;: config.GITHUB_CLIENT_SECRET,
            &quot;code&quot;: code,
            &quot;redirect_uri&quot;: config.GITHUB_REDIRECT_URI
        }

        response = requests.post(url, headers=headers, data=data)

        if response.status_code != 200:
            return None

        json_response = response.json()
        return json_response.get(&quot;access_token&quot;)

    @staticmethod
    def get_user_info(access_token: str) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get GitHub user information using access token.

        Args:
            access_token: The GitHub access token

        Returns:
            Optional[Dict]: User information or None if failed
        &quot;&quot;&quot;
        headers = {
            &quot;Authorization&quot;: f&quot;Bearer {access_token}&quot;,
            &quot;Accept&quot;: &quot;application/json&quot;
        }

        # Get user profile
        response = requests.get(&quot;https://api.github.com/user&quot;, headers=headers)

        if response.status_code != 200:
            return None

        user_data = response.json()

        # Get user emails if email is not public
        if not user_data.get(&quot;email&quot;):
            email_response = requests.get(&quot;https://api.github.com/user/emails&quot;, headers=headers)

            if email_response.status_code == 200:
                emails = email_response.json()

                # Find primary email
                for email in emails:
                    if email.get(&quot;primary&quot;) and email.get(&quot;verified&quot;):
                        user_data[&quot;email&quot;] = email.get(&quot;email&quot;)
                        break

        return user_data

    @staticmethod
    def authenticate(code: str) -&gt; Tuple[bool, str, Optional[Dict[str, Any]]]:
        &quot;&quot;&quot;
        Authenticate a user with GitHub OAuth code.

        Args:
            code: The authorization code from GitHub

        Returns:
            Tuple[bool, str, Optional[Dict]]: (success, message, user_data)
        &quot;&quot;&quot;
        # Exchange code for access token
        access_token = GitHubAuth.get_access_token(code)

        if not access_token:
            return False, &quot;Failed to get access token from GitHub&quot;, None

        # Get user info
        github_user_info = GitHubAuth.get_user_info(access_token)

        if not github_user_info:
            return False, &quot;Failed to get user information from GitHub&quot;, None

        # Extract relevant info
        github_id = str(github_user_info.get(&quot;id&quot;))
        username = github_user_info.get(&quot;login&quot;)
        email = github_user_info.get(&quot;email&quot;)

        # Find or create user in database
        user_data = UserDAO.find_or_create_github_user(
            github_id=github_id,
            username=username,
            email=email
        )

        if not user_data:
            return False, &quot;Failed to create user&quot;, None

        return True, &quot;Authentication successful&quot;, user_data
```

### Step 3: Creating Authentication Pages

Now let&apos;s create the login and registration pages:

**File: `pages/login.py`**

```python
from fasthtml.common import *

def login_page(error_message=None, success_message=None):
    &quot;&quot;&quot;
    Create the login page.

    Args:
        error_message: Optional error message to display
        success_message: Optional success message to display

    Returns:
        Components representing the login page
    &quot;&quot;&quot;
    # Create alert for error or success message
    message_alert = None
    if error_message:
        message_alert = Div(
            P(error_message, cls=&quot;text-sm&quot;),
            cls=&quot;bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4&quot;
        )
    elif success_message:
        message_alert = Div(
            P(success_message, cls=&quot;text-sm&quot;),
            cls=&quot;bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4&quot;
        )

    return Div(
        # Page title
        H1(&quot;Sign In&quot;, cls=&quot;text-3xl font-bold text-center text-gray-800 mb-6&quot;),

        # Login Form
        Div(
            # Message alert
            message_alert if message_alert else &quot;&quot;,

            # Email login form
            Form(
                H2(&quot;Sign in with Email&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),

                # Email field
                Div(
                    Label(&quot;Email&quot;, For=&quot;email&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;email&quot;,
                        id=&quot;email&quot;,
                        name=&quot;email&quot;,
                        placeholder=&quot;you@example.com&quot;,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Password field
                Div(
                    Label(&quot;Password&quot;, For=&quot;password&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;password&quot;,
                        id=&quot;password&quot;,
                        name=&quot;password&quot;,
                        placeholder=&quot;Your password&quot;,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    cls=&quot;mb-6&quot;
                ),

                # Submit button
                Button(
                    &quot;Sign In&quot;,
                    type=&quot;submit&quot;,
                    cls=&quot;w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;
                ),

                action=&quot;/auth/email/login&quot;,
                method=&quot;post&quot;,
                cls=&quot;mb-6&quot;
            ),

            # Divider
            Div(
                Div(cls=&quot;flex-grow border-t border-gray-300&quot;),
                Span(&quot;OR&quot;, cls=&quot;flex-shrink mx-4 text-gray-500&quot;),
                Div(cls=&quot;flex-grow border-t border-gray-300&quot;),
                cls=&quot;flex items-center my-6&quot;
            ),

            # GitHub login button
            Div(
                A(
                    Div(
                        Img(src=&quot;https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png&quot;, alt=&quot;GitHub Logo&quot;, cls=&quot;w-5 h-5 mr-2&quot;),
                        Span(&quot;Sign in with GitHub&quot;),
                        cls=&quot;flex items-center justify-center&quot;
                    ),
                    href=&quot;/auth/github/login&quot;,
                    cls=&quot;w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # Registration link
            Div(
                P(
                    &quot;Don&apos;t have an account? &quot;,
                    A(&quot;Register here&quot;, href=&quot;/register&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
                    cls=&quot;text-sm text-gray-600 text-center&quot;
                )
            ),

            cls=&quot;bg-white p-8 rounded-lg shadow-md max-w-md mx-auto&quot;
        )
    )
```

**File: `pages/register.py`**

```python
from fasthtml.common import *

def register_page(error_message=None):
    &quot;&quot;&quot;
    Create the registration page.

    Args:
        error_message: Optional error message to display

    Returns:
        Components representing the registration page
    &quot;&quot;&quot;
    # Create alert for error message
    error_alert = None
    if error_message:
        error_alert = Div(
            P(error_message, cls=&quot;text-sm&quot;),
            cls=&quot;bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4&quot;
        )

    return Div(
        # Page title
        H1(&quot;Create an Account&quot;, cls=&quot;text-3xl font-bold text-center text-gray-800 mb-6&quot;),

        # Registration Form
        Div(
            # Error alert
            error_alert if error_alert else &quot;&quot;,

            Form(
                # Username field
                Div(
                    Label(&quot;Username&quot;, For=&quot;username&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;text&quot;,
                        id=&quot;username&quot;,
                        name=&quot;username&quot;,
                        placeholder=&quot;Choose a username&quot;,
                        required=True,
                        minlength=3,
                        maxlength=30,
                        pattern=&quot;[a-zA-Z0-9_-]+&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    P(&quot;Only letters, numbers, underscores, and hyphens&quot;, cls=&quot;text-xs text-gray-500 mt-1&quot;),
                    cls=&quot;mb-4&quot;
                ),

                # Email field
                Div(
                    Label(&quot;Email&quot;, For=&quot;email&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;email&quot;,
                        id=&quot;email&quot;,
                        name=&quot;email&quot;,
                        placeholder=&quot;you@example.com&quot;,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Password field
                Div(
                    Label(&quot;Password&quot;, For=&quot;password&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;password&quot;,
                        id=&quot;password&quot;,
                        name=&quot;password&quot;,
                        placeholder=&quot;Create a password&quot;,
                        required=True,
                        minlength=8,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    P(&quot;At least 8 characters with uppercase, lowercase, and number&quot;, cls=&quot;text-xs text-gray-500 mt-1&quot;),
                    cls=&quot;mb-4&quot;
                ),

                # Confirm password field
                Div(
                    Label(&quot;Confirm Password&quot;, For=&quot;confirm_password&quot;, cls=&quot;block text-sm font-medium text-gray-700 mb-1&quot;),
                    Input(
                        type=&quot;password&quot;,
                        id=&quot;confirm_password&quot;,
                        name=&quot;confirm_password&quot;,
                        placeholder=&quot;Confirm your password&quot;,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500&quot;
                    ),
                    cls=&quot;mb-6&quot;
                ),

                # Submit button
                Button(
                    &quot;Create Account&quot;,
                    type=&quot;submit&quot;,
                    cls=&quot;w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;
                ),

                action=&quot;/auth/email/register&quot;,
                method=&quot;post&quot;,
                cls=&quot;mb-6&quot;
            ),

            # Divider
            Div(
                Div(cls=&quot;flex-grow border-t border-gray-300&quot;),
                Span(&quot;OR&quot;, cls=&quot;flex-shrink mx-4 text-gray-500&quot;),
                Div(cls=&quot;flex-grow border-t border-gray-300&quot;),
                cls=&quot;flex items-center my-6&quot;
            ),

            # GitHub login button
            Div(
                A(
                    Div(
                        Img(src=&quot;https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png&quot;, alt=&quot;GitHub Logo&quot;, cls=&quot;w-5 h-5 mr-2&quot;),
                        Span(&quot;Sign up with GitHub&quot;),
                        cls=&quot;flex items-center justify-center&quot;
                    ),
                    href=&quot;/auth/github/login&quot;,
                    cls=&quot;w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-700 hover:bg-gray-50&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # Login link
            Div(
                P(
                    &quot;Already have an account? &quot;,
                    A(&quot;Sign in here&quot;, href=&quot;/login&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
                    cls=&quot;text-sm text-gray-600 text-center&quot;
                )
            ),

            cls=&quot;bg-white p-8 rounded-lg shadow-md max-w-md mx-auto&quot;
        )
    )
```

### Step 4: Creating Admin Pages

Now, let&apos;s create admin pages for managing users and viewing all history:

**File: `pages/admin.py`**


```python
from fasthtml.common import *
from db.user_dao import UserDAO
from db.history_dao import HistoryDAO

def admin_dashboard():
    &quot;&quot;&quot;
    Create the admin dashboard page.

    Returns:
        Components representing the admin dashboard
    &quot;&quot;&quot;
    return Div(
        # Page header
        H1(&quot;Admin Dashboard&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Admin menu
        Div(
            A(
                Div(
                    Div(
                        &quot;Users&quot;,
                        cls=&quot;text-xl font-semibold mb-2&quot;
                    ),
                    P(&quot;Manage user accounts, set admin privileges&quot;, cls=&quot;text-sm text-gray-600&quot;),
                    cls=&quot;p-4&quot;
                ),
                href=&quot;/admin/users&quot;,
                cls=&quot;block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 mb-4&quot;
            ),

            A(
                Div(
                    Div(
                        &quot;Title Generation History&quot;,
                        cls=&quot;text-xl font-semibold mb-2&quot;
                    ),
                    P(&quot;View all users&apos; title generation history&quot;, cls=&quot;text-sm text-gray-600&quot;),
                    cls=&quot;p-4&quot;
                ),
                href=&quot;/admin/history&quot;,
                cls=&quot;block bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 mb-4&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )

def admin_users_page(page=1, error_message=None, success_message=None):
    &quot;&quot;&quot;
    Create the admin users management page.

    Args:
        page: Current page number
        error_message: Optional error message
        success_message: Optional success message

    Returns:
        Components representing the admin users page
    &quot;&quot;&quot;
    # Get users with pagination
    limit = 10
    offset = (page - 1) * limit
    users = UserDAO.get_all_users(limit=limit, offset=offset)

    # Create message alert if needed
    message_alert = None
    if error_message:
        message_alert = Div(
            P(error_message, cls=&quot;text-sm&quot;),
            cls=&quot;bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4&quot;
        )
    elif success_message:
        message_alert = Div(
            P(success_message, cls=&quot;text-sm&quot;),
            cls=&quot;bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4&quot;
        )

    # Create user rows
    user_rows = []
    for user in users:
        # Format dates
        created_at = user.get(&apos;created_at&apos;, &apos;N/A&apos;)
        if created_at and created_at != &apos;N/A&apos;:
            created_at = created_at.split(&apos;T&apos;)[0]  # Simple date format

        last_login = user.get(&apos;last_login&apos;, &apos;Never&apos;)
        if last_login and last_login != &apos;Never&apos;:
            last_login = last_login.split(&apos;T&apos;)[0]  # Simple date format

        # Create user row
        user_rows.append(
            Tr(
                Td(user[&apos;username&apos;], cls=&quot;px-6 py-4 whitespace-nowrap&quot;),
                Td(user[&apos;email&apos;], cls=&quot;px-6 py-4 whitespace-nowrap&quot;),
                Td(
                    Span(
                        &quot;GitHub&quot; if user.get(&apos;is_github_user&apos;) else &quot;Email&quot;,
                        cls=f&quot;px-2 py-1 text-xs rounded-full {&apos;bg-purple-200 text-purple-800&apos; if user.get(&apos;is_github_user&apos;) else &apos;bg-blue-200 text-blue-800&apos;}&quot;
                    ),
                    cls=&quot;px-6 py-4 whitespace-nowrap&quot;
                ),
                Td(created_at, cls=&quot;px-6 py-4 whitespace-nowrap&quot;),
                Td(last_login, cls=&quot;px-6 py-4 whitespace-nowrap&quot;),
                Td(
                    Span(
                        &quot;Admin&quot; if user.get(&apos;is_admin&apos;) else &quot;User&quot;,
                        cls=f&quot;px-2 py-1 text-xs rounded-full {&apos;bg-red-200 text-red-800&apos; if user.get(&apos;is_admin&apos;) else &apos;bg-gray-200 text-gray-800&apos;}&quot;
                    ),
                    cls=&quot;px-6 py-4 whitespace-nowrap&quot;
                ),
                Td(
                    Div(
                        # Toggle admin status
                        Form(
                            Button(
                                &quot;Remove Admin&quot; if user.get(&apos;is_admin&apos;) else &quot;Make Admin&quot;,
                                type=&quot;submit&quot;,
                                cls=f&quot;{&apos;bg-gray-500 hover:bg-gray-600&apos; if user.get(&apos;is_admin&apos;) else &apos;bg-blue-500 hover:bg-blue-600&apos;} text-white text-xs py-1 px-2 rounded mr-2&quot;
                            ),
                            action=f&quot;/admin/users/{user[&apos;id&apos;]}/{&apos;remove-admin&apos; if user.get(&apos;is_admin&apos;) else &apos;make-admin&apos;}&quot;,
                            method=&quot;post&quot;,
                            cls=&quot;inline&quot;
                        ),

                        # Delete user
                        Form(
                            Button(
                                &quot;Delete&quot;,
                                type=&quot;submit&quot;,
                                cls=&quot;bg-red-500 hover:bg-red-600 text-white text-xs py-1 px-2 rounded&quot;
                            ),
                            action=f&quot;/admin/users/{user[&apos;id&apos;]}/delete&quot;,
                            method=&quot;post&quot;,
                            cls=&quot;inline&quot;
                        ),

                        cls=&quot;flex&quot;
                    ),
                    cls=&quot;px-6 py-4 whitespace-nowrap&quot;
                ),
                cls=&quot;bg-white border-b&quot;
            )
        )

    # Build pagination controls
    current_page = page
    pagination = Div(
        Div(
            A(&quot;← Previous&quot;,
              href=f&quot;/admin/users?page={current_page - 1}&quot; if current_page &gt; 1 else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if current_page &gt; 1 else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            Span(f&quot;Page {current_page}&quot;,
                 cls=&quot;px-4 py-2&quot;),
            A(&quot;Next →&quot;,
              href=f&quot;/admin/users?page={current_page + 1}&quot; if len(users) == limit else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if len(users) == limit else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            cls=&quot;flex items-center justify-center space-x-2&quot;
        ),
        cls=&quot;mt-6&quot;
    )

    return Div(
        # Breadcrumb navigation
        Div(
            A(&quot;Admin Dashboard&quot;, href=&quot;/admin&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
            Span(&quot; / &quot;, cls=&quot;text-gray-500&quot;),
            Span(&quot;Users&quot;, cls=&quot;font-semibold&quot;),
            cls=&quot;mb-4 text-sm&quot;
        ),

        # Page header
        H1(&quot;User Management&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Message alert
        message_alert if message_alert else &quot;&quot;,

        # Users table
        Div(
            Table(
                Thead(
                    Tr(
                        Th(&quot;Username&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Email&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Auth Type&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Created&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Last Login&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Role&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        Th(&quot;Actions&quot;, cls=&quot;px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider&quot;),
                        cls=&quot;bg-gray-50&quot;
                    )
                ),
                Tbody(
                    *user_rows if user_rows else [
                        Tr(
                            Td(&quot;No users found&quot;, colspan=&quot;7&quot;, cls=&quot;px-6 py-4 text-center text-gray-500 italic&quot;)
                        )
                    ]
                ),
                cls=&quot;min-w-full divide-y divide-gray-200&quot;
            ),
            cls=&quot;bg-white shadow overflow-x-auto rounded-lg&quot;
        ),

        # Pagination
        pagination,

        cls=&quot;max-w-6xl mx-auto px-4 sm:px-6 lg:px-8&quot;
    )

def admin_history_page(page=1):
    &quot;&quot;&quot;
    Create the admin history view page.

    Args:
        page: Current page number

    Returns:
        Components representing the admin history page
    &quot;&quot;&quot;
    # Get all history with pagination
    limit = 20
    offset = (page - 1) * limit
    history_records = HistoryDAO.get_all_history(limit=limit, offset=offset)

    # Build history cards
    history_cards = []
    if not history_records:
        history_cards.append(
            Div(
                P(&quot;No generation history found.&quot;, cls=&quot;text-gray-600 italic&quot;),
                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            )
        )
    else:
        for record in history_records:
            # Limit displayed titles to first 3 for compactness
            display_titles = record[&apos;titles&apos;][:3]
            has_more = len(record[&apos;titles&apos;]) &gt; 3

            title_items = []
            for title in display_titles:
                title_items.append(Li(title, cls=&quot;mb-1&quot;))

            if has_more:
                title_items.append(
                    Li(
                        A(f&quot;...and {len(record[&apos;titles&apos;]) - 3} more&quot;,
                          href=f&quot;/admin/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline italic&quot;),
                        cls=&quot;mt-2&quot;
                    )
                )

            history_cards.append(
                Div(
                    # Header with date and record info
                    Div(
                        Div(
                            H3(record[&apos;topic&apos;][:50] + (&quot;...&quot; if len(record[&apos;topic&apos;]) &gt; 50 else &quot;&quot;),
                               cls=&quot;text-lg font-semibold&quot;),
                            P(f&quot;{record[&apos;platform&apos;]} • {record[&apos;style&apos;]} • {record[&apos;number_of_titles&apos;]} titles&quot;,
                              cls=&quot;text-sm text-gray-600&quot;),
                            cls=&quot;flex-grow&quot;
                        ),
                        Div(
                            Span(f&quot;User: {record[&apos;username&apos;] or &apos;Unknown&apos;}&quot;,
                                 cls=&quot;text-sm text-gray-700 mr-3&quot;),
                            P(record[&apos;created_at_formatted&apos;],
                              cls=&quot;text-xs text-gray-500&quot;),
                            cls=&quot;text-right&quot;
                        ),
                        cls=&quot;flex justify-between items-start mb-3&quot;
                    ),

                    # Title preview
                    Div(
                        H4(&quot;Generated Titles:&quot;, cls=&quot;font-medium mb-2&quot;),
                        Ul(
                            *title_items,
                            cls=&quot;list-disc pl-5 text-gray-700&quot;
                        ),
                        cls=&quot;mb-3&quot;
                    ),

                    # Actions
                    Div(
                        A(&quot;View Details&quot;,
                          href=f&quot;/admin/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline text-sm mr-4&quot;),
                        Form(
                            Button(
                                &quot;Delete&quot;,
                                type=&quot;submit&quot;,
                                cls=&quot;text-red-600 hover:underline text-sm&quot;
                            ),
                            action=f&quot;/admin/history/{record[&apos;id&apos;]}/delete&quot;,
                            method=&quot;post&quot;,
                            cls=&quot;inline&quot;
                        ),
                        cls=&quot;flex justify-end&quot;
                    ),

                    cls=&quot;bg-white p-6 rounded-lg shadow-md mb-4&quot;
                )
            )

    # Build pagination controls
    current_page = page
    pagination = Div(
        Div(
            A(&quot;← Previous&quot;,
              href=f&quot;/admin/history?page={current_page - 1}&quot; if current_page &gt; 1 else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if current_page &gt; 1 else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            Span(f&quot;Page {current_page}&quot;,
                 cls=&quot;px-4 py-2&quot;),
            A(&quot;Next →&quot;,
              href=f&quot;/admin/history?page={current_page + 1}&quot; if len(history_records) == limit else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if len(history_records) == limit else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            cls=&quot;flex items-center justify-center space-x-2&quot;
        ),
        cls=&quot;mt-6&quot;
    )

    return Div(
        # Breadcrumb navigation
        Div(
            A(&quot;Admin Dashboard&quot;, href=&quot;/admin&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
            Span(&quot; / &quot;, cls=&quot;text-gray-500&quot;),
            Span(&quot;History&quot;, cls=&quot;font-semibold&quot;),
            cls=&quot;mb-4 text-sm&quot;
        ),

        # Page header
        H1(&quot;All Title Generation History&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # History records
        Div(
            *history_cards,
            cls=&quot;&quot;
        ),

        # Pagination
        pagination,

        cls=&quot;max-w-4xl mx-auto&quot;
    )

def admin_history_detail(record_id: int):
    &quot;&quot;&quot;
    Admin view for a specific history record.

    Args:
        record_id: ID of the history record

    Returns:
        Components representing the history detail
    &quot;&quot;&quot;
    # Get the history record (no user_id filter for admin)
    record = HistoryDAO.get_history_by_id(record_id)

    if not record:
        return Div(
            H1(&quot;Record Not Found&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The requested history record could not be found.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/admin/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Create list items for each title
    title_items = []
    for i, title in enumerate(record[&apos;titles&apos;]):
        title_items.append(
            Li(
                Div(
                    P(title, cls=&quot;font-medium&quot;),
                    Button(
                        &quot;Copy&quot;,
                        type=&quot;button&quot;,
                        onclick=f&quot;navigator.clipboard.writeText(&apos;{title.replace(&apos;\&apos;&apos;, &apos;\\\&apos;&apos;)}&apos;); this.textContent = &apos;Copied!&apos;; setTimeout(() =&gt; this.textContent = &apos;Copy&apos;, 2000);&quot;,
                        cls=&quot;ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;p-3 border-b last:border-b-0&quot;
            )
        )

    return Div(
        # Breadcrumb navigation
        Div(
            A(&quot;Admin Dashboard&quot;, href=&quot;/admin&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
            Span(&quot; / &quot;, cls=&quot;text-gray-500&quot;),
            A(&quot;History&quot;, href=&quot;/admin/history&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
            Span(&quot; / &quot;, cls=&quot;text-gray-500&quot;),
            Span(&quot;Record Details&quot;, cls=&quot;font-semibold&quot;),
            cls=&quot;mb-4 text-sm&quot;
        ),

        # Page header
        H1(&quot;Title Generation Details&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Record details
        Div(
            # Metadata
            Div(
                H2(&quot;Generation Information&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                Div(
                    Div(
                        Strong(&quot;User:&quot;),
                        P(record[&apos;username&apos;] or &quot;Unknown&quot;, cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Date &amp; Time:&quot;),
                        P(record[&apos;created_at_formatted&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Topic:&quot;),
                        P(record[&apos;topic&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Platform:&quot;),
                        P(record[&apos;platform&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Style:&quot;),
                        P(record[&apos;style&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Number of Titles:&quot;),
                        P(str(record[&apos;number_of_titles&apos;]), cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    cls=&quot;bg-gray-50 p-4 rounded-lg mb-6&quot;
                ),

                # Titles section
                H2(&quot;Generated Titles&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                P(&quot;Click &apos;Copy&apos; to copy any title to your clipboard.&quot;, cls=&quot;text-gray-600 mb-3&quot;),
                Ul(
                    *title_items,
                    cls=&quot;border rounded divide-y mb-6&quot;
                ),

                # Action buttons
                Div(
                    A(&quot;Back to History&quot;,
                      href=&quot;/admin/history&quot;,
                      cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                    Form(
                        Button(
                            &quot;Delete Record&quot;,
                            type=&quot;submit&quot;,
                            cls=&quot;bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded&quot;
                        ),
                        action=f&quot;/admin/history/{record_id}/delete&quot;,
                        method=&quot;post&quot;
                    ),
                    cls=&quot;flex&quot;
                ),

                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )
```

Now let&apos;s continue with updating the header and home page to support authentication:

### Step 5: Updating UI Components for Authentication

Let&apos;s update the header to include authentication-related links:

**File: `components/header.py` (Updated)**

```python
from fasthtml.common import *
import config
from auth.auth_manager import AuthManager

def header(current_page=&quot;/&quot;, session=None):
    &quot;&quot;&quot;
    Creates a consistent header with navigation.

    Args:
        current_page: The current page path
        session: The session object for auth status

    Returns:
        A Header component with navigation
    &quot;&quot;&quot;
    # Get authentication status
    is_authenticated = AuthManager.is_authenticated(session) if session else False
    is_admin = AuthManager.is_admin(session) if session else False
    username = session.get(&quot;username&quot;, &quot;&quot;) if session else &quot;&quot;

    # Define navigation items based on auth status
    nav_items = [
        (&quot;Home&quot;, &quot;/&quot;, True)  # Always show home
    ]

    # Add auth-required items if authenticated
    if is_authenticated:
        nav_items.append((&quot;Title Generator&quot;, &quot;/title-generator&quot;, True))
        nav_items.append((&quot;My History&quot;, &quot;/history&quot;, True))

        # Add admin link if admin
        if is_admin:
            nav_items.append((&quot;Admin&quot;, &quot;/admin&quot;, False))

    # Build navigation links
    nav_links = []
    for title, path, show_mobile in nav_items:
        is_current = current_page == path
        link_class = &quot;text-white hover:text-gray-300 px-3 py-2&quot;
        if is_current:
            link_class += &quot; font-bold underline&quot;

        # Add responsive visibility classes
        if not show_mobile:
            link_class += &quot; hidden md:block&quot;  # Hide on mobile

        nav_links.append(
            Li(
                A(title, href=path, cls=link_class)
            )
        )

    # Build auth links based on authentication status
    auth_links = []
    if is_authenticated:
        # User dropdown menu
        auth_links.append(
            Div(
                # Username display
                Span(f&quot;Hello, {username}&quot;, cls=&quot;text-white mr-2 hidden md:inline-block&quot;),

                # Logout link
                A(&quot;Logout&quot;, href=&quot;/auth/logout&quot;, cls=&quot;text-white hover:text-gray-300 bg-red-600 hover:bg-red-700 px-3 py-2 rounded&quot;),
                cls=&quot;flex items-center&quot;
            )
        )
    else:
        # Login/Register links
        auth_links.append(
            Div(
                A(&quot;Login&quot;, href=&quot;/login&quot;, cls=&quot;text-white hover:text-gray-300 px-3 py-2 mr-2&quot;),
                A(&quot;Register&quot;, href=&quot;/register&quot;, cls=&quot;text-white hover:text-gray-300 bg-blue-700 hover:bg-blue-800 px-3 py-2 rounded&quot;),
                cls=&quot;flex items-center&quot;
            )
        )

    return Header(
        Div(
            # Logo and app name
            A(config.APP_NAME, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),

            # Mobile menu button (simplified - no JS toggle)
            Button(
                Span(&quot;☰&quot;, cls=&quot;text-2xl&quot;),
                cls=&quot;md:hidden text-white focus:outline-none&quot;
            ),

            # Main navigation
            Nav(
                Ul(
                    *nav_links,
                    cls=&quot;flex space-x-2&quot;
                ),
                cls=&quot;hidden md:flex&quot;  # Hide on mobile
            ),

            # Auth links
            Div(
                *auth_links,
                cls=&quot;ml-auto&quot;
            ),

            cls=&quot;container mx-auto flex items-center justify-between px-4 py-3&quot;
        ),
        cls=&quot;bg-blue-600 shadow-md&quot;
    )
```

Now let&apos;s update the home page to display different content based on authentication status:

**File: `pages/home.py` (Updated)**

```python
from fasthtml.common import *
import config
from auth.auth_manager import AuthManager

def home(session=None):
    &quot;&quot;&quot;
    Defines the home page content.

    Args:
        session: The session object for auth status

    Returns:
        Components representing the home page content
    &quot;&quot;&quot;
    # Check if user is authenticated
    is_authenticated = AuthManager.is_authenticated(session) if session else False
    is_admin = AuthManager.is_admin(session) if session else False
    username = session.get(&quot;username&quot;, &quot;&quot;) if session else &quot;&quot;

    # Hero content varies based on authentication
    if is_authenticated:
        hero_content = Div(
            H1(f&quot;Welcome back, {username}!&quot;,
               cls=&quot;text-4xl font-bold text-center text-gray-800 mb-4&quot;),
            P(&quot;Continue creating engaging titles for your content with AI assistance.&quot;,
              cls=&quot;text-xl text-center text-gray-600 mb-6&quot;),
            Div(
                A(&quot;Generate New Titles&quot;,
                  href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                A(&quot;View My History&quot;,
                  href=&quot;/history&quot;,
                  cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                *([A(&quot;Admin Dashboard&quot;,
                     href=&quot;/admin&quot;,
                     cls=&quot;ml-3 bg-purple-600 hover:bg-purple-700 text-white font-bold py-2 px-4 rounded&quot;)] if is_admin else []),
                cls=&quot;flex justify-center flex-wrap gap-y-2&quot;
            ),
            cls=&quot;py-12&quot;
        )
    else:
        hero_content = Div(
            H1(config.APP_NAME,
               cls=&quot;text-4xl font-bold text-center text-gray-800 mb-4&quot;),
            P(&quot;Create engaging titles for your content with AI assistance.&quot;,
              cls=&quot;text-xl text-center text-gray-600 mb-6&quot;),
            Div(
                A(&quot;Sign In&quot;,
                  href=&quot;/login&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                A(&quot;Register&quot;,
                  href=&quot;/register&quot;,
                  cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                cls=&quot;flex justify-center&quot;
            ),
            cls=&quot;py-12&quot;
        )

    return Div(
        # Hero section with conditional content
        hero_content,

        # Features section
        Div(
            H2(&quot;Features&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                # Feature 1
                Div(
                    H3(&quot;Platform-Specific&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Generate titles optimized for blogs, YouTube, social media, and more.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Feature 2
                Div(
                    H3(&quot;Multiple Styles&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Choose from professional, casual, clickbait, or informative styles.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Feature 3
                Div(
                    H3(&quot;AI-Powered&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Utilizes advanced AI models to craft engaging, relevant titles.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-6&quot;
            ),
            cls=&quot;py-8&quot;
        ),

        # How it works section
        Div(
            H2(&quot;How It Works&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                # Step 1
                Div(
                    Div(
                        &quot;1&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Enter Your Topic&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Describe what your content is about in detail.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Step 2
                Div(
                    Div(
                        &quot;2&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Choose Settings&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Select the platform and style that matches your needs.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Step 3
                Div(
                    Div(
                        &quot;3&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Get Results&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Review multiple title options and choose your favorite.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-6&quot;
            ),
            cls=&quot;py-8&quot;
        )
    )
```


### Step 6: Update History Page for User-Specific Views

**File: `pages/history.py` (Updated)** (continued)

```python
from fasthtml.common import *
from db.history_dao import HistoryDAO
from auth.auth_manager import AuthManager

def history_page(session, page: int = 1, records_per_page: int = 10):
    &quot;&quot;&quot;
    Defines the history page content for the current user.

    Args:
        session: The session object containing user info
        page: Current page number (1-based)
        records_per_page: Number of records per page

    Returns:
        Components representing the history page content
    &quot;&quot;&quot;
    # Get current user ID
    user_id = session.get(&quot;user_id&quot;)
    if not user_id:
        return Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;User not authenticated&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Login&quot;, href=&quot;/login&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Get user stats
    stats = HistoryDAO.get_user_stats(user_id)

    # Calculate offset for pagination
    offset = (page - 1) * records_per_page

    # Get history records for this user
    history_records = HistoryDAO.get_user_history(
        user_id=user_id,
        limit=records_per_page,
        offset=offset
    )

    # Build history cards
    history_cards = []
    if not history_records:
        history_cards.append(
            Div(
                P(&quot;No generation history found. Try generating some titles first!&quot;,
                  cls=&quot;text-gray-600 italic&quot;),
                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            )
        )
    else:
        for record in history_records:
            # Limit displayed titles to first 3 for compactness
            display_titles = record[&apos;titles&apos;][:3]
            has_more = len(record[&apos;titles&apos;]) &gt; 3

            title_items = []
            for title in display_titles:
                title_items.append(Li(title, cls=&quot;mb-1&quot;))

            if has_more:
                title_items.append(
                    Li(
                        A(f&quot;...and {len(record[&apos;titles&apos;]) - 3} more&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline italic&quot;),
                        cls=&quot;mt-2&quot;
                    )
                )

            history_cards.append(
                Div(
                    # Header with date and record info
                    Div(
                        Div(
                            H3(record[&apos;topic&apos;][:50] + (&quot;...&quot; if len(record[&apos;topic&apos;]) &gt; 50 else &quot;&quot;),
                               cls=&quot;text-lg font-semibold&quot;),
                            P(f&quot;{record[&apos;platform&apos;]} • {record[&apos;style&apos;]} • {record[&apos;number_of_titles&apos;]} titles&quot;,
                              cls=&quot;text-sm text-gray-600&quot;),
                            cls=&quot;flex-grow&quot;
                        ),
                        P(record[&apos;created_at_formatted&apos;],
                          cls=&quot;text-xs text-gray-500&quot;),
                        cls=&quot;flex justify-between items-start mb-3&quot;
                    ),

                    # Title preview
                    Div(
                        H4(&quot;Generated Titles:&quot;, cls=&quot;font-medium mb-2&quot;),
                        Ul(
                            *title_items,
                            cls=&quot;list-disc pl-5 text-gray-700&quot;
                        ),
                        cls=&quot;mb-3&quot;
                    ),

                    # Actions
                    Div(
                        A(&quot;View Details&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline text-sm mr-4&quot;),
                        A(&quot;Delete&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}/delete&quot;,
                          cls=&quot;text-red-600 hover:underline text-sm&quot;),
                        cls=&quot;flex justify-end&quot;
                    ),

                    cls=&quot;bg-white p-6 rounded-lg shadow-md mb-4&quot;
                )
            )

    # Build pagination controls
    current_page = page
    # For simplicity, we&apos;ll just have prev/next buttons
    pagination = Div(
        Div(
            A(&quot;← Previous&quot;,
              href=f&quot;/history?page={current_page - 1}&quot; if current_page &gt; 1 else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if current_page &gt; 1 else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            Span(f&quot;Page {current_page}&quot;,
                 cls=&quot;px-4 py-2&quot;),
            A(&quot;Next →&quot;,
              href=f&quot;/history?page={current_page + 1}&quot; if len(history_records) == records_per_page else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if len(history_records) == records_per_page else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            cls=&quot;flex items-center justify-center space-x-2&quot;
        ),
        cls=&quot;mt-6&quot;
    )

    # Create stats summary
    stats_summary = None
    if stats[&apos;total_generations&apos;] &gt; 0:
        # Get top platform and style
        top_platform = next(iter(stats[&apos;platforms&apos;])) if stats[&apos;platforms&apos;] else &quot;None&quot;
        top_style = next(iter(stats[&apos;styles&apos;])) if stats[&apos;styles&apos;] else &quot;None&quot;

        stats_summary = Div(
            H2(&quot;Your Statistics&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
            Div(
                Div(
                    H3(&quot;Total Generations&quot;, cls=&quot;text-sm font-medium text-gray-500&quot;),
                    P(str(stats[&apos;total_generations&apos;]), cls=&quot;text-2xl font-bold&quot;),
                    cls=&quot;text-center p-4 bg-white rounded-lg shadow&quot;
                ),
                Div(
                    H3(&quot;Top Platform&quot;, cls=&quot;text-sm font-medium text-gray-500&quot;),
                    P(top_platform, cls=&quot;text-2xl font-bold&quot;),
                    cls=&quot;text-center p-4 bg-white rounded-lg shadow&quot;
                ),
                Div(
                    H3(&quot;Top Style&quot;, cls=&quot;text-sm font-medium text-gray-500&quot;),
                    P(top_style, cls=&quot;text-2xl font-bold&quot;),
                    cls=&quot;text-center p-4 bg-white rounded-lg shadow&quot;
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-4 mb-6&quot;
            ),
            cls=&quot;mb-8&quot;
        )

    return Div(
        # Page header
        H1(&quot;Your Generation History&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Stats summary
        stats_summary if stats_summary else &quot;&quot;,

        # Call to action if no history
        Div(
            A(&quot;Generate New Titles&quot;,
              href=&quot;/title-generator&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;mb-6 text-center&quot;
        ) if not history_records else &quot;&quot;,

        # Records container
        Div(
            *history_cards,
            cls=&quot;&quot;
        ),

        # Pagination
        pagination if history_records else &quot;&quot;,

        cls=&quot;max-w-4xl mx-auto&quot;
    )

def history_detail_page(record_id: int, session):
    &quot;&quot;&quot;
    Defines the history detail page content for a specific record.

    Args:
        record_id: ID of the history record to display
        session: The session object containing user info

    Returns:
        Components representing the history detail page
    &quot;&quot;&quot;
    # Get current user ID
    user_id = session.get(&quot;user_id&quot;)
    if not user_id:
        return Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;User not authenticated&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Login&quot;, href=&quot;/login&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Check if user is admin
    is_admin = AuthManager.is_admin(session)

    # Get the history record
    # For admin, don&apos;t filter by user_id
    if is_admin:
        record = HistoryDAO.get_history_by_id(record_id)
    else:
        record = HistoryDAO.get_history_by_id(record_id, user_id=user_id)

    if not record:
        return Div(
            H1(&quot;Record Not Found&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The requested history record could not be found or you don&apos;t have permission to view it.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Create list items for each title
    title_items = []
    for i, title in enumerate(record[&apos;titles&apos;]):
        title_items.append(
            Li(
                Div(
                    P(title, cls=&quot;font-medium&quot;),
                    Button(
                        &quot;Copy&quot;,
                        type=&quot;button&quot;,
                        onclick=f&quot;navigator.clipboard.writeText(&apos;{title.replace(&apos;\&apos;&apos;, &apos;\\\&apos;&apos;)}&apos;); this.textContent = &apos;Copied!&apos;; setTimeout(() =&gt; this.textContent = &apos;Copy&apos;, 2000);&quot;,
                        cls=&quot;ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;p-3 border-b last:border-b-0&quot;
            )
        )

    return Div(
        # Page header
        H1(&quot;Title Generation Details&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Record details
        Div(
            # Metadata
            Div(
                H2(&quot;Generation Information&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                Div(
                    Div(
                        Strong(&quot;Date &amp; Time:&quot;),
                        P(record[&apos;created_at_formatted&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Topic:&quot;),
                        P(record[&apos;topic&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Platform:&quot;),
                        P(record[&apos;platform&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Style:&quot;),
                        P(record[&apos;style&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Number of Titles:&quot;),
                        P(str(record[&apos;number_of_titles&apos;]), cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    cls=&quot;bg-gray-50 p-4 rounded-lg mb-6&quot;
                ),

                # Titles section
                H2(&quot;Generated Titles&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                P(&quot;Click &apos;Copy&apos; to copy any title to your clipboard.&quot;, cls=&quot;text-gray-600 mb-3&quot;),
                Ul(
                    *title_items,
                    cls=&quot;border rounded divide-y mb-6&quot;
                ),

                # Action buttons
                Div(
                    A(&quot;Generate Similar&quot;,
                      href=f&quot;/title-generator?topic={record[&apos;topic&apos;]}&amp;platform={record[&apos;platform&apos;]}&amp;style={record[&apos;style&apos;]}&amp;number_of_titles={record[&apos;number_of_titles&apos;]}&quot;,
                      cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Back to History&quot;,
                      href=&quot;/history&quot;,
                      cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Delete Record&quot;,
                      href=f&quot;/history/{record_id}/delete&quot;,
                      cls=&quot;bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded&quot;),
                    cls=&quot;flex flex-wrap gap-y-2&quot;
                ),

                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )

def delete_confirm_page(record_id: int, session):
    &quot;&quot;&quot;
    Confirmation page for deleting a history record.

    Args:
        record_id: ID of the record to delete
        session: The session object containing user info

    Returns:
        Components representing the confirmation page
    &quot;&quot;&quot;
    # Get current user ID
    user_id = session.get(&quot;user_id&quot;)
    if not user_id:
        return Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;User not authenticated&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Login&quot;, href=&quot;/login&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Check if user is admin
    is_admin = AuthManager.is_admin(session)

    # Get record to show details in confirmation
    # For admin, don&apos;t filter by user_id
    if is_admin:
        record = HistoryDAO.get_history_by_id(record_id)
    else:
        record = HistoryDAO.get_history_by_id(record_id, user_id=user_id)

    if not record:
        return Div(
            H1(&quot;Record Not Found&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The requested history record could not be found or you don&apos;t have permission to delete it.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    return Div(
        H1(&quot;Confirm Deletion&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        Div(
            P(&quot;Are you sure you want to delete this history record?&quot;, cls=&quot;text-lg mb-4&quot;),

            # Record summary
            Div(
                P(f&quot;Topic: {record[&apos;topic&apos;][:100]}{&apos;...&apos; if len(record[&apos;topic&apos;]) &gt; 100 else &apos;&apos;}&quot;,
                  cls=&quot;mb-2&quot;),
                P(f&quot;Platform: {record[&apos;platform&apos;]}&quot;, cls=&quot;mb-2&quot;),
                P(f&quot;Created: {record[&apos;created_at_formatted&apos;]}&quot;, cls=&quot;mb-2&quot;),
                cls=&quot;bg-gray-100 p-4 rounded-lg mb-6&quot;
            ),

            P(&quot;This action cannot be undone.&quot;, cls=&quot;text-red-600 mb-6&quot;),

            # Form with confirmation button
            Form(
                Div(
                    Button(&quot;Yes, Delete Record&quot;,
                           type=&quot;submit&quot;,
                           cls=&quot;bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Cancel&quot;,
                      href=f&quot;/history/{record_id}&quot; if not is_admin else f&quot;/admin/history/{record_id}&quot;,
                      cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                    cls=&quot;flex&quot;
                ),
                method=&quot;post&quot;,
                action=f&quot;/history/{record_id}/delete&quot; if not is_admin else f&quot;/admin/history/{record_id}/delete&quot;
            ),

            cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
        ),

        cls=&quot;max-w-2xl mx-auto&quot;
    )
```

### Step 7: Update the Main Application File

Finally, let&apos;s update our main application file to include the authentication and user management features:

**File: `main.py` (Updated)**

```python
from fasthtml.common import *
from fasthtml.oauth import GitHubAppClient
import os

# Import configuration
import config

# Import page content
from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results
from pages.history import history_page, history_detail_page, delete_confirm_page
from pages.login import login_page
from pages.register import register_page
from pages.admin import admin_dashboard, admin_users_page, admin_history_page, admin_history_detail

# Import the page layout component
from components.page_layout import page_layout
from components.header import header

# Import authentication services
from auth.auth_manager import AuthManager
from auth.email_auth import EmailAuth
from auth.github_auth import GitHubAuth

# Import DAO classes
from db.history_dao import HistoryDAO
from db.user_dao import UserDAO

# Import title generator tool
from tools.title_generator import TitleGenerator

# Initialize the FastHTML application
app = FastHTML()

# Initialize title generator tool
title_generator = TitleGenerator()

# Initialize GitHub OAuth client
github_client = GitHubAppClient(
    client_id=config.GITHUB_CLIENT_ID,
    client_secret=config.GITHUB_CLIENT_SECRET
)

# Helper function to check auth and redirect if needed
def require_auth(session, admin_required=False):
    &quot;&quot;&quot;
    Check if user is authenticated and has required permissions.

    Args:
        session: The session object
        admin_required: Whether admin access is required

    Returns:
        Redirect response or None if authenticated with correct permissions
    &quot;&quot;&quot;
    if not AuthManager.is_authenticated(session):
        return RedirectResponse(&apos;/login&apos;, status_code=303)

    if admin_required and not AuthManager.is_admin(session):
        return RedirectResponse(&apos;/&apos;, status_code=303)

    return None

# Public Pages

@app.get(&quot;/&quot;)
def home(session=None):
    &quot;&quot;&quot;Handler for the home page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Home - {config.APP_NAME}&quot;,
        content=home_page(session),
        current_page=&quot;/&quot;,
        session=session
    )

@app.get(&quot;/login&quot;)
def login(error_message: str = None, success_message: str = None):
    &quot;&quot;&quot;Handler for the login page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Sign In - {config.APP_NAME}&quot;,
        content=login_page(error_message, success_message),
        current_page=&quot;/login&quot;
    )

@app.get(&quot;/register&quot;)
def register(error_message: str = None):
    &quot;&quot;&quot;Handler for the registration page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Register - {config.APP_NAME}&quot;,
        content=register_page(error_message),
        current_page=&quot;/register&quot;
    )

# Authentication Routes

@app.post(&quot;/auth/email/register&quot;)
def email_register(
    username: str,
    email: str,
    password: str,
    confirm_password: str
):
    &quot;&quot;&quot;Handler for email registration.&quot;&quot;&quot;
    # Validate registration input
    is_valid, error_message = EmailAuth.validate_registration(
        username=username,
        email=email,
        password=password,
        confirm_password=confirm_password
    )

    if not is_valid:
        return page_layout(
            title=f&quot;Register - {config.APP_NAME}&quot;,
            content=register_page(error_message),
            current_page=&quot;/register&quot;
        )

    # Create user
    success, message, user_id = EmailAuth.register_user(
        username=username,
        email=email,
        password=password
    )

    if not success:
        return page_layout(
            title=f&quot;Register - {config.APP_NAME}&quot;,
            content=register_page(message),
            current_page=&quot;/register&quot;
        )

    # Redirect to login with success message
    return page_layout(
        title=f&quot;Sign In - {config.APP_NAME}&quot;,
        content=login_page(success_message=&quot;Registration successful! Please sign in.&quot;),
        current_page=&quot;/login&quot;
    )

@app.post(&quot;/auth/email/login&quot;)
def email_login(email: str, password: str, session):
    &quot;&quot;&quot;Handler for email login.&quot;&quot;&quot;
    # Authenticate user
    success, message, user_data = EmailAuth.authenticate(
        email=email,
        password=password
    )

    if not success:
        return page_layout(
            title=f&quot;Sign In - {config.APP_NAME}&quot;,
            content=login_page(error_message=message),
            current_page=&quot;/login&quot;
        )

    # Log in user by setting session data
    AuthManager.login_user(session, user_data)

    # Redirect to home page
    return RedirectResponse(&apos;/&apos;, status_code=303)

@app.get(&quot;/auth/github/login&quot;)
def github_login():
    &quot;&quot;&quot;Handler for GitHub login.&quot;&quot;&quot;
    # Redirect to GitHub OAuth authorization URL
    auth_url = GitHubAuth.get_auth_url()
    return RedirectResponse(auth_url, status_code=303)

@app.get(&quot;/auth/github/callback&quot;)
def github_callback(code: str, session, state: str = None):
    &quot;&quot;&quot;Handler for GitHub OAuth callback.&quot;&quot;&quot;
    # Authenticate with GitHub
    success, message, user_data = GitHubAuth.authenticate(code)

    if not success:
        return page_layout(
            title=f&quot;Sign In - {config.APP_NAME}&quot;,
            content=login_page(error_message=message),
            current_page=&quot;/login&quot;
        )

    # Log in user by setting session data
    AuthManager.login_user(session, user_data)

    # Redirect to home page
    return RedirectResponse(&apos;/&apos;, status_code=303)

@app.get(&quot;/auth/logout&quot;)
def logout(session):
    &quot;&quot;&quot;Handler for logout.&quot;&quot;&quot;
    # Clear session data
    AuthManager.logout_user(session)

    # Redirect to login page
    return RedirectResponse(&apos;/login&apos;, status_code=303)

# Protected Routes - Title Generator

@app.get(&quot;/title-generator&quot;)
def title_generator_page(
    session,
    topic: str = &quot;&quot;,
    platform: str = &quot;Blog&quot;,
    style: str = &quot;Professional&quot;,
    number_of_titles: str = &quot;5&quot;
):
    &quot;&quot;&quot;Handler for the title generator page route.&quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;Title Generator - {config.APP_NAME}&quot;,
        content=title_generator_form(),
        current_page=&quot;/title-generator&quot;,
        session=session
    )

@app.post(&quot;/title-generator/generate&quot;)
async def generate_titles(
    session,
    topic: str,
    platform: str,
    style: str,
    number_of_titles: str
):
    &quot;&quot;&quot;
    Handler for processing title generation requests.
    &quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    # Get user ID from session
    user_id = session.get(&quot;user_id&quot;)

    try:
        # Validate inputs
        if not topic:
            error_message = Div(
                H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
                P(&quot;Please provide a topic for your titles.&quot;, cls=&quot;mb-4&quot;),
                A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
                cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
            )

            return page_layout(
                title=f&quot;Error - {config.APP_NAME}&quot;,
                content=error_message,
                current_page=&quot;/title-generator&quot;,
                session=session
            )

        # Convert number_of_titles to integer
        num_titles = int(number_of_titles)

        # Generate titles
        titles = await title_generator.generate_titles(
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles
        )

        # Save to history database with user ID
        history_id = await HistoryDAO.save_generation(
            user_id=user_id,
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles,
            titles=titles
        )

        # Return the results page
        return page_layout(
            title=f&quot;Generated Titles - {config.APP_NAME}&quot;,
            content=title_generator_results(
                topic=topic,
                platform=platform,
                style=style,
                titles=titles,
                history_id=history_id
            ),
            current_page=&quot;/title-generator&quot;,
            session=session
        )
    except Exception as e:
        # Handle errors
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(f&quot;An error occurred while generating titles: {str(e)}&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/title-generator&quot;,
            session=session
        )

# Protected Routes - User History

@app.get(&quot;/history&quot;)
def history(session, page: int = 1):
    &quot;&quot;&quot;Handler for the user history page route.&quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;Your History - {config.APP_NAME}&quot;,
        content=history_page(session, page=page),
        current_page=&quot;/history&quot;,
        session=session
    )

@app.get(&quot;/history/{record_id:int}&quot;)
def history_detail(record_id: int, session):
    &quot;&quot;&quot;Handler for the history detail page route.&quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;History Details - {config.APP_NAME}&quot;,
        content=history_detail_page(record_id=record_id, session=session),
        current_page=&quot;/history&quot;,
        session=session
    )

@app.get(&quot;/history/{record_id:int}/delete&quot;)
def confirm_delete(record_id: int, session):
    &quot;&quot;&quot;Handler for the delete confirmation page.&quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;Confirm Deletion - {config.APP_NAME}&quot;,
        content=delete_confirm_page(record_id=record_id, session=session),
        current_page=&quot;/history&quot;,
        session=session
    )

@app.post(&quot;/history/{record_id:int}/delete&quot;)
def delete_record(record_id: int, session):
    &quot;&quot;&quot;Handler for processing record deletion.&quot;&quot;&quot;
    # Check authentication
    auth_redirect = require_auth(session)
    if auth_redirect:
        return auth_redirect

    # Get user ID from session
    user_id = session.get(&quot;user_id&quot;)
    is_admin = AuthManager.is_admin(session)

    # Try to delete the record (for admin, don&apos;t filter by user_id)
    if is_admin:
        success = HistoryDAO.delete_history(record_id)
    else:
        success = HistoryDAO.delete_history(record_id, user_id=user_id)

    if success:
        # Show success message and redirect to history page
        success_message = Div(
            H1(&quot;Record Deleted&quot;, cls=&quot;text-3xl font-bold text-green-600 mb-4&quot;),
            P(&quot;The history record has been successfully deleted.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Record Deleted - {config.APP_NAME}&quot;,
            content=success_message,
            current_page=&quot;/history&quot;,
            session=session
        )
    else:
        # Show error message
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The record could not be deleted or doesn&apos;t exist.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/history&quot;,
            session=session
        )

# Protected Routes - Admin

@app.get(&quot;/admin&quot;)
def admin(session):
    &quot;&quot;&quot;Handler for the admin dashboard page route.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;Admin Dashboard - {config.APP_NAME}&quot;,
        content=admin_dashboard(),
        current_page=&quot;/admin&quot;,
        session=session
    )

@app.get(&quot;/admin/users&quot;)
def admin_users(session, page: int = 1, error_message: str = None, success_message: str = None):
    &quot;&quot;&quot;Handler for the admin users page route.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;User Management - {config.APP_NAME}&quot;,
        content=admin_users_page(page, error_message, success_message),
        current_page=&quot;/admin&quot;,
        session=session
    )

@app.post(&quot;/admin/users/{user_id:int}/make-admin&quot;)
def make_admin(user_id: int, session):
    &quot;&quot;&quot;Handler for making a user an admin.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    # Set admin status
    success = UserDAO.set_admin_status(user_id, True)

    if success:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(success_message=&quot;User successfully made admin&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )
    else:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(error_message=&quot;Failed to update user status&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )

@app.post(&quot;/admin/users/{user_id:int}/remove-admin&quot;)
def remove_admin(user_id: int, session):
    &quot;&quot;&quot;Handler for removing admin status from a user.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    # Prevent removing admin status from the current user
    if session.get(&quot;user_id&quot;) == user_id:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(error_message=&quot;You cannot remove your own admin status&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )

    # Set admin status
    success = UserDAO.set_admin_status(user_id, False)

    if success:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(success_message=&quot;Admin status successfully removed&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )
    else:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(error_message=&quot;Failed to update user status&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )

@app.post(&quot;/admin/users/{user_id:int}/delete&quot;)
def admin_delete_user(user_id: int, session):
    &quot;&quot;&quot;Handler for deleting a user.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    # Prevent deleting the current user
    if session.get(&quot;user_id&quot;) == user_id:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(error_message=&quot;You cannot delete your own account&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )

    # Delete user
    success = UserDAO.delete_user(user_id)

    if success:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(success_message=&quot;User successfully deleted&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )
    else:
        return page_layout(
            title=f&quot;User Management - {config.APP_NAME}&quot;,
            content=admin_users_page(error_message=&quot;Failed to delete user&quot;),
            current_page=&quot;/admin&quot;,
            session=session
        )

@app.get(&quot;/admin/history&quot;)
def admin_history(session, page: int = 1):
    &quot;&quot;&quot;Handler for the admin history page route.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;All History - {config.APP_NAME}&quot;,
        content=admin_history_page(page=page),
        current_page=&quot;/admin&quot;,
        session=session
    )

@app.get(&quot;/admin/history/{record_id:int}&quot;)
def admin_view_history(record_id: int, session):
    &quot;&quot;&quot;Handler for the admin history detail page route.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    return page_layout(
        title=f&quot;History Details - {config.APP_NAME}&quot;,
        content=admin_history_detail(record_id=record_id),
        current_page=&quot;/admin&quot;,
        session=session
    )

@app.post(&quot;/admin/history/{record_id:int}/delete&quot;)
def admin_delete_history(record_id: int, session):
    &quot;&quot;&quot;Handler for admin deleting a history record.&quot;&quot;&quot;
    # Check admin authentication
    auth_redirect = require_auth(session, admin_required=True)
    if auth_redirect:
        return auth_redirect

    # Delete the record (no user_id filter for admin)
    success = HistoryDAO.delete_history(record_id)

    if success:
        # Show success message and redirect to admin history page
        success_message = Div(
            H1(&quot;Record Deleted&quot;, cls=&quot;text-3xl font-bold text-green-600 mb-4&quot;),
            P(&quot;The history record has been successfully deleted.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/admin/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Record Deleted - {config.APP_NAME}&quot;,
            content=success_message,
            current_page=&quot;/admin&quot;,
            session=session
        )
    else:
        # Show error message
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The record could not be deleted or doesn&apos;t exist.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/admin/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/admin&quot;,
            session=session
        )

# Error Handling

@app.get(&quot;/{path:path}&quot;)
def not_found(path: str, session=None):
    &quot;&quot;&quot;Handler for 404 Not Found errors.&quot;&quot;&quot;
    error_content = Div(
        H1(&quot;404 - Page Not Found&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-4&quot;),
        P(f&quot;Sorry, the page &apos;/{path}&apos; does not exist.&quot;, cls=&quot;mb-4&quot;),
        A(&quot;Return Home&quot;, href=&quot;/&quot;,
          cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
        cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center&quot;
    )

    return page_layout(
        title=f&quot;404 Not Found - {config.APP_NAME}&quot;,
        content=error_content,
        current_page=&quot;/&quot;,
        session=session
    )

# Run the application
if __name__ == &quot;__main__&quot;:
    import uvicorn
    uvicorn.run(&quot;main:app&quot;, host=&quot;0.0.0.0&quot;, port=5001, reload=True)
```

### Step 8: Setting Up Environment Variables

Before running your application, you&apos;ll need to set up environment variables for GitHub OAuth and other settings. Create or update your `.env` file:

**File: `.env`**

```
# API keys
OPENROUTER_API_KEY=your_openrouter_api_key_here

# Database
DB_PATH=tools.db

# Authentication
SECRET_KEY=your_secure_random_secret_key
GITHUB_CLIENT_ID=your_github_client_id
GITHUB_CLIENT_SECRET=your_github_client_secret
GITHUB_REDIRECT_URI=/auth/github/callback

# Admin account (created on first run)
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=your_secure_admin_password

# App settings
DEBUG=True
```

Replace placeholder values with your actual GitHub OAuth credentials and other settings.

### Step 9: Setting Up GitHub OAuth

To set up GitHub OAuth authentication:

1. Go to your GitHub account settings and navigate to &quot;Developer settings&quot; &gt; &quot;OAuth Apps&quot;
2. Click &quot;New OAuth App&quot; or select an existing app to modify
3. Fill in the required information:
   - **Application name**: Your app name (e.g., &quot;AI Title Generator&quot;)
   - **Homepage URL**: Your app&apos;s URL (e.g., &quot;http://localhost:5001&quot; for development)
   - **Application description**: Brief description of your app
   - **Authorization callback URL**: Your callback URL with full domain (e.g., &quot;http://localhost:5001/auth/github/callback&quot;)
4. Click &quot;Register application&quot;
5. After registration, you&apos;ll see your Client ID and you can generate a Client Secret
6. Add these credentials to your `.env` file

### Step 10: Running Your Enhanced Application

Now you can run your enhanced application:

```bash
python main.py
```

Open your browser and visit http://localhost:5001. You should see:

1. The option to register or log in
2. After logging in, access to the title generator tool
3. A personal history dashboard showing your generations
4. For admin users, access to the admin dashboard

### Testing User Registration and Login

1. **Register a new user**:
   - Go to the registration page
   - Fill in the required information
   - Submit the form
   - You should be redirected to the login page with a success message

2. **Log in with the registered user**:
   - Enter your email and password
   - You should be redirected to the home page
   - The navigation should show &quot;Title Generator&quot; and &quot;My History&quot; links

3. **Test GitHub login**:
   - Click &quot;Sign in with GitHub&quot;
   - Authorize your application on GitHub
   - You should be redirected back to your app and logged in

### Testing Admin Features

1. **Log in with the admin account**:
   - Use the admin credentials you set in the `.env` file
   - The navigation should include an &quot;Admin&quot; link

2. **Explore admin features**:
   - View and manage all users
   - Change user roles (make/remove admin)
   - View all users&apos; title generation history
   - Delete user accounts or history records

## Conclusion

Congratulations! You&apos;ve successfully enhanced your AI Title Generator application with:

1. **User authentication system** that supports both:
   - Traditional email/password authentication
   - GitHub OAuth integration

2. **Role-based access control** with:
   - Regular user accounts that can only access their own content
   - Admin accounts with advanced management capabilities

3. **User-specific history dashboards** that:
   - Show only the logged-in user&apos;s history
   - Provide statistics and insights on generation patterns
   - Allow users to manage their own history

4. **Admin management interface** that provides:
   - User management with role assignment
   - Global history monitoring across all users
   - Data management capabilities

The architecture follows best practices for web applications:

- **Separation of concerns**: Each module has a specific responsibility
- **Security**: Authentication and authorization are properly implemented
- **Data isolation**: Users can only access their own data
- **Maintainability**: Code is well-organized and modular

You now have a fully-featured AI Title Generator web application with user management capabilities, which you can further enhance with additional features such as:

- Email verification for new accounts
- Password reset functionality
- User profile customization
- Advanced analytics for admin users
- Favorite/bookmark feature for generated titles
- Team collaboration features

This authentication system provides a solid foundation for building more complex AI-powered tools</content:encoded><category>web-development</category><category>fasthtml</category></item><item><title>How to Add SQLite Database to Your FastHTML App</title><link>https://www.bitdoze.com/fasthtml-sqlite-db/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-sqlite-db/</guid><description>Learn how to implement a SQLite database to store generation history in your FastHTML AI Title Generator app. This tutorial covers creating a database schema, implementing data access layers, building a history page, and adding timestamp tracking for your AI-generated content.</description><pubDate>Mon, 03 Mar 2025 10:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;


Welcome back to our FastHTML series! In the previous tutorial, we built a powerful [AI Title Generator using FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/). Today, we&apos;ll enhance our application by adding a SQLite database to track generation history, allowing users to view their past title generations.



## Why Add a Database?

Adding a database to our application offers several benefits:

1. **Persistence**: Store generation history across application restarts
2. **Analysis**: Track usage patterns and popular topics
3. **User convenience**: Allow users to revisit previous generations without regenerating
4. **Audit trail**: Maintain a record of all AI interactions
5. **Future extensibility**: Lay the groundwork for user accounts and saved favorites

SQLite is perfect for our needs because it&apos;s:
- Lightweight (zero-configuration)
- Requires no separate server process
- Can be embedded directly in our application
- Supports SQL standard for queries
- Has excellent Python support through the built-in `sqlite3` module

Let&apos;s get started enhancing our AI Title Generator with database capabilities!

## Project Structure Updates

We&apos;ll extend our existing project structure with new database-related files:

```
ai-title-generator/
├── main.py                   # Updated with history routes
├── config.py                 # Updated with DB settings
├── ai_service.py             # Unchanged
├── db/                       # New directory for database code
│   ├── __init__.py
│   ├── database.py           # Database connection &amp; initialization
│   └── history_dao.py        # Data access for history records
├── components/               # Existing components directory
│   ├── __init__.py
│   ├── header.py             # Updated with history link
│   ├── footer.py             # Unchanged
│   └── page_layout.py        # Unchanged
├── pages/                    # Existing pages directory
│   ├── __init__.py
│   ├── home.py               # Unchanged
│   ├── title_generator.py    # Updated to save history
│   └── history.py            # New history page
├── tools/                    # Existing tools directory
│   ├── __init__.py
│   └── title_generator.py    # Unchanged
└── tools.db                  # New SQLite database file
```

## How to Add SQLite Database to Your FastHTML App

### Step 1: Setting Up the Database Structure

Let&apos;s start by creating the database module that will handle our SQLite connection and schema initialization.

First, let&apos;s create the database directory:

```bash
mkdir -p ai-title-generator/db
touch ai-title-generator/db/__init__.py
```

Now, let&apos;s create the main database file:

**File: `db/database.py`**

```python
import sqlite3
import os
from contextlib import contextmanager
import config

class Database:
    &quot;&quot;&quot;Handles database connections and initialization.&quot;&quot;&quot;

    def __init__(self, db_path=None):
        &quot;&quot;&quot;
        Initialize the database connection.

        Args:
            db_path: Path to the SQLite database file (defaults to config setting)
        &quot;&quot;&quot;
        # If db_path is None or empty, use a default path
        self.db_path = db_path or config.DB_PATH
        if not self.db_path:
            # Set default path if DB_PATH is empty
            self.db_path = &quot;tools.db&quot;
        self._initialize_db()

    def _initialize_db(self):
        &quot;&quot;&quot;Create database tables if they don&apos;t exist.&quot;&quot;&quot;
        with self.get_connection() as conn:
            cursor = conn.cursor()

            # Create the title_history table
            cursor.execute(&apos;&apos;&apos;
            CREATE TABLE IF NOT EXISTS title_history (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                topic TEXT NOT NULL,
                platform TEXT NOT NULL,
                style TEXT NOT NULL,
                number_of_titles INTEGER NOT NULL,
                titles TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
            &apos;&apos;&apos;)

            conn.commit()

    @contextmanager
    def get_connection(self):
        &quot;&quot;&quot;
        Context manager for database connections.

        Yields:
            sqlite3.Connection: Active database connection
        &quot;&quot;&quot;
        # Check if db_path has a directory component
        db_dir = os.path.dirname(self.db_path)

        # Only try to create directories if there&apos;s a directory path
        if db_dir:
            os.makedirs(db_dir, exist_ok=True)

        # Connect to the database
        conn = sqlite3.connect(self.db_path)

        # Configure connection
        conn.row_factory = sqlite3.Row  # Use dictionary-like rows

        try:
            yield conn
        finally:
            conn.close()

# Create a singleton instance
db = Database()
```

**Explanation**:
- We create a `Database` class to manage our SQLite connection
- The `_initialize_db` method creates our `title_history` table if it doesn&apos;t exist
- We use a context manager (`get_connection`) to ensure proper connection handling
- The `row_factory = sqlite3.Row` setting allows accessing results by column name
- We create a singleton instance `db` that can be imported throughout the application
- The table includes:
  - Basic fields for the title generation parameters
  - A `titles` field that will store the generated titles as a JSON string
  - A `created_at` timestamp that automatically records when the entry was created

Next, let&apos;s update the config file to include database settings:

**File: `config.py` (Updated)**

```python
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# API configuration
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
OPENROUTER_BASE_URL = &quot;https://openrouter.ai/api/v1&quot;

# Default model to use
DEFAULT_MODEL = os.getenv(&quot;DEFAULT_MODEL&quot;, &quot;openai/gpt-3.5-turbo&quot;)

# Database settings
DB_PATH = os.getenv(&quot;DB_PATH&quot;, &quot;tools.db&quot;)

# Application settings
DEBUG = os.getenv(&quot;DEBUG&quot;, &quot;True&quot;).lower() == &quot;true&quot;
APP_NAME = &quot;AI Title Generator&quot;
```

Now, let&apos;s create a Data Access Object (DAO) for the history table:

**File: `db/history_dao.py`**

```python
import json
from typing import List, Dict, Any, Optional
from datetime import datetime
from .database import db

class HistoryDAO:
    &quot;&quot;&quot;Data Access Object for title generation history.&quot;&quot;&quot;

    @staticmethod
    async def save_generation(
        topic: str,
        platform: str,
        style: str,
        number_of_titles: int,
        titles: List[str]
    ) -&gt; int:
        &quot;&quot;&quot;
        Save a title generation record to the database.

        Args:
            topic: The topic of the generation
            platform: The platform selected
            style: The style selected
            number_of_titles: Number of titles requested
            titles: List of generated titles

        Returns:
            int: ID of the new record
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            # Convert titles list to JSON string
            titles_json = json.dumps(titles)

            cursor.execute(&apos;&apos;&apos;
            INSERT INTO title_history
                (topic, platform, style, number_of_titles, titles)
            VALUES (?, ?, ?, ?, ?)
            &apos;&apos;&apos;, (topic, platform, style, number_of_titles, titles_json))

            conn.commit()
            return cursor.lastrowid

    @staticmethod
    def get_all_history(limit: int = 100, offset: int = 0) -&gt; List[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get all history records with pagination.

        Args:
            limit: Maximum number of records to return
            offset: Number of records to skip

        Returns:
            List of history records as dictionaries
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            SELECT id, topic, platform, style, number_of_titles, titles, created_at
            FROM title_history
            ORDER BY created_at DESC
            LIMIT ? OFFSET ?
            &apos;&apos;&apos;, (limit, offset))

            # Convert row objects to dictionaries
            result = []
            for row in cursor.fetchall():
                record = dict(row)
                # Parse titles from JSON string
                record[&apos;titles&apos;] = json.loads(record[&apos;titles&apos;])
                # Format timestamp for display
                created_at = datetime.fromisoformat(record[&apos;created_at&apos;].replace(&apos;Z&apos;, &apos;+00:00&apos;))
                record[&apos;created_at_formatted&apos;] = created_at.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)
                result.append(record)

            return result

    @staticmethod
    def get_history_by_id(record_id: int) -&gt; Optional[Dict[str, Any]]:
        &quot;&quot;&quot;
        Get a specific history record by ID.

        Args:
            record_id: The ID of the record to retrieve

        Returns:
            Dictionary with record data or None if not found
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            SELECT id, topic, platform, style, number_of_titles, titles, created_at
            FROM title_history
            WHERE id = ?
            &apos;&apos;&apos;, (record_id,))

            row = cursor.fetchone()
            if not row:
                return None

            record = dict(row)
            # Parse titles from JSON string
            record[&apos;titles&apos;] = json.loads(record[&apos;titles&apos;])
            # Format timestamp for display
            created_at = datetime.fromisoformat(record[&apos;created_at&apos;].replace(&apos;Z&apos;, &apos;+00:00&apos;))
            record[&apos;created_at_formatted&apos;] = created_at.strftime(&apos;%Y-%m-%d %H:%M:%S&apos;)

            return record

    @staticmethod
    def delete_history(record_id: int) -&gt; bool:
        &quot;&quot;&quot;
        Delete a history record by ID.

        Args:
            record_id: The ID of the record to delete

        Returns:
            bool: True if record was deleted, False if not found
        &quot;&quot;&quot;
        with db.get_connection() as conn:
            cursor = conn.cursor()

            cursor.execute(&apos;&apos;&apos;
            DELETE FROM title_history
            WHERE id = ?
            &apos;&apos;&apos;, (record_id,))

            conn.commit()
            return cursor.rowcount &gt; 0
```

**Explanation**:
- We create a `HistoryDAO` class with static methods for database operations
- The methods include:
  - `save_generation`: Stores a new title generation record
  - `get_all_history`: Retrieves all records with pagination support
  - `get_history_by_id`: Retrieves a specific record by ID
  - `delete_history`: Removes a record from the database
- We use JSON to serialize/deserialize the title lists for storage
- We format timestamps for display in a human-readable format
- The `get_all_history` method returns the most recent generations first

### Step 2: Updating the Header Component

Let&apos;s update the header to include a link to our new history page:

**File: `components/header.py` (Updated)**

```python
from fasthtml.common import *
import config

def header(current_page=&quot;/&quot;):
    &quot;&quot;&quot;
    Creates a consistent header with navigation.

    Args:
        current_page: The current page path

    Returns:
        A Header component with navigation
    &quot;&quot;&quot;
    nav_items = [
        (&quot;Home&quot;, &quot;/&quot;),
        (&quot;Title Generator&quot;, &quot;/title-generator&quot;),
        (&quot;History&quot;, &quot;/history&quot;)  # Added history link
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = &quot;text-white hover:text-gray-300 px-3 py-2&quot;
        if is_current:
            link_class += &quot; font-bold underline&quot;

        nav_links.append(
            Li(
                A(title, href=path, cls=link_class)
            )
        )

    return Header(
        Div(
            A(config.APP_NAME, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),
            Nav(
                Ul(
                    *nav_links,
                    cls=&quot;flex space-x-2&quot;
                ),
                cls=&quot;ml-auto&quot;
            ),
            cls=&quot;container mx-auto flex items-center justify-between px-4 py-3&quot;
        ),
        cls=&quot;bg-blue-600 shadow-md&quot;
    )
```

### Step 3: Creating the History Page

Now, let&apos;s create a new page to display the generation history:

**File: `pages/history.py`**

```python
from fasthtml.common import *
from db.history_dao import HistoryDAO

def history_page(page: int = 1, records_per_page: int = 10):
    &quot;&quot;&quot;
    Defines the history page content.

    Args:
        page: Current page number (1-based)
        records_per_page: Number of records per page

    Returns:
        Components representing the history page content
    &quot;&quot;&quot;
    # Calculate offset for pagination
    offset = (page - 1) * records_per_page

    # Get history records
    history_records = HistoryDAO.get_all_history(limit=records_per_page, offset=offset)

    # Build history cards
    history_cards = []
    if not history_records:
        history_cards.append(
            Div(
                P(&quot;No generation history found. Try generating some titles first!&quot;,
                  cls=&quot;text-gray-600 italic&quot;),
                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            )
        )
    else:
        for record in history_records:
            # Limit displayed titles to first 3 for compactness
            display_titles = record[&apos;titles&apos;][:3]
            has_more = len(record[&apos;titles&apos;]) &gt; 3

            title_items = []
            for title in display_titles:
                title_items.append(Li(title, cls=&quot;mb-1&quot;))

            if has_more:
                title_items.append(
                    Li(
                        A(f&quot;...and {len(record[&apos;titles&apos;]) - 3} more&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline italic&quot;),
                        cls=&quot;mt-2&quot;
                    )
                )

            history_cards.append(
                Div(
                    # Header with date and record info
                    Div(
                        Div(
                            H3(record[&apos;topic&apos;][:50] + (&quot;...&quot; if len(record[&apos;topic&apos;]) &gt; 50 else &quot;&quot;),
                               cls=&quot;text-lg font-semibold&quot;),
                            P(f&quot;{record[&apos;platform&apos;]} • {record[&apos;style&apos;]} • {record[&apos;number_of_titles&apos;]} titles&quot;,
                              cls=&quot;text-sm text-gray-600&quot;),
                            cls=&quot;flex-grow&quot;
                        ),
                        P(record[&apos;created_at_formatted&apos;],
                          cls=&quot;text-xs text-gray-500&quot;),
                        cls=&quot;flex justify-between items-start mb-3&quot;
                    ),

                    # Title preview
                    Div(
                        H4(&quot;Generated Titles:&quot;, cls=&quot;font-medium mb-2&quot;),
                        Ul(
                            *title_items,
                            cls=&quot;list-disc pl-5 text-gray-700&quot;
                        ),
                        cls=&quot;mb-3&quot;
                    ),

                    # Actions
                    Div(
                        A(&quot;View Details&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}&quot;,
                          cls=&quot;text-blue-600 hover:underline text-sm mr-4&quot;),
                        A(&quot;Delete&quot;,
                          href=f&quot;/history/{record[&apos;id&apos;]}/delete&quot;,
                          cls=&quot;text-red-600 hover:underline text-sm&quot;),
                        cls=&quot;flex justify-end&quot;
                    ),

                    cls=&quot;bg-white p-6 rounded-lg shadow-md mb-4&quot;
                )
            )

    # Build pagination controls
    current_page = page
    # For simplicity, we&apos;ll just have prev/next buttons
    pagination = Div(
        Div(
            A(&quot;← Previous&quot;,
              href=f&quot;/history?page={current_page - 1}&quot; if current_page &gt; 1 else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if current_page &gt; 1 else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            Span(f&quot;Page {current_page}&quot;,
                 cls=&quot;px-4 py-2&quot;),
            A(&quot;Next →&quot;,
              href=f&quot;/history?page={current_page + 1}&quot; if len(history_records) == records_per_page else &quot;#&quot;,
              cls=f&quot;px-4 py-2 rounded {&apos;bg-blue-600 text-white&apos; if len(history_records) == records_per_page else &apos;bg-gray-200 text-gray-500 cursor-default&apos;}&quot;),
            cls=&quot;flex items-center justify-center space-x-2&quot;
        ),
        cls=&quot;mt-6&quot;
    )

    return Div(
        # Page header
        H1(&quot;Generation History&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),
        P(&quot;View your previously generated titles.&quot;, cls=&quot;text-gray-600 mb-6&quot;),

        # Records container
        Div(
            *history_cards,
            cls=&quot;&quot;
        ),

        # Pagination
        pagination,

        cls=&quot;max-w-4xl mx-auto&quot;
    )

def history_detail_page(record_id: int):
    &quot;&quot;&quot;
    Defines the history detail page content.

    Args:
        record_id: ID of the history record to display

    Returns:
        Components representing the history detail page
    &quot;&quot;&quot;
    # Get the history record
    record = HistoryDAO.get_history_by_id(record_id)

    if not record:
        return Div(
            H1(&quot;Record Not Found&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The requested history record could not be found.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    # Create list items for each title
    title_items = []
    for i, title in enumerate(record[&apos;titles&apos;]):
        title_items.append(
            Li(
                Div(
                    P(title, cls=&quot;font-medium&quot;),
                    Button(
                        &quot;Copy&quot;,
                        type=&quot;button&quot;,
                        onclick=f&quot;navigator.clipboard.writeText(&apos;{title.replace(&quot;&apos;&quot;, &quot;\\&apos;&quot;)}&apos;); this.textContent = &apos;Copied!&apos;; setTimeout(() =&gt; this.textContent = &apos;Copy&apos;, 2000);&quot;,
                        cls=&quot;ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;p-3 border-b last:border-b-0&quot;
            )
        )

    return Div(
        # Page header
        H1(&quot;Title Generation Details&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Record details
        Div(
            # Metadata
            Div(
                H2(&quot;Generation Information&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                Div(
                    Div(
                        Strong(&quot;Date &amp; Time:&quot;),
                        P(record[&apos;created_at_formatted&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Topic:&quot;),
                        P(record[&apos;topic&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Platform:&quot;),
                        P(record[&apos;platform&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Style:&quot;),
                        P(record[&apos;style&apos;], cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    Div(
                        Strong(&quot;Number of Titles:&quot;),
                        P(str(record[&apos;number_of_titles&apos;]), cls=&quot;text-gray-700 mb-2&quot;),
                        cls=&quot;mb-3&quot;
                    ),
                    cls=&quot;bg-gray-50 p-4 rounded-lg mb-6&quot;
                ),

                # Titles section
                H2(&quot;Generated Titles&quot;, cls=&quot;text-xl font-semibold mb-4&quot;),
                P(&quot;Click &apos;Copy&apos; to copy any title to your clipboard.&quot;, cls=&quot;text-gray-600 mb-3&quot;),
                Ul(
                    *title_items,
                    cls=&quot;border rounded divide-y mb-6&quot;
                ),

                # Action buttons
                Div(
                    A(&quot;Generate Similar&quot;,
                      href=f&quot;/title-generator?topic={record[&apos;topic&apos;]}&amp;platform={record[&apos;platform&apos;]}&amp;style={record[&apos;style&apos;]}&amp;number_of_titles={record[&apos;number_of_titles&apos;]}&quot;,
                      cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Back to History&quot;,
                      href=&quot;/history&quot;,
                      cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Delete Record&quot;,
                      href=f&quot;/history/{record_id}/delete&quot;,
                      cls=&quot;bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded&quot;),
                    cls=&quot;flex flex-wrap gap-y-2&quot;
                ),

                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )

def delete_confirm_page(record_id: int):
    &quot;&quot;&quot;
    Confirmation page for deleting a history record.

    Args:
        record_id: ID of the record to delete

    Returns:
        Components representing the confirmation page
    &quot;&quot;&quot;
    # Get record to show details in confirmation
    record = HistoryDAO.get_history_by_id(record_id)

    if not record:
        return Div(
            H1(&quot;Record Not Found&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The requested history record could not be found.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

    return Div(
        H1(&quot;Confirm Deletion&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        Div(
            P(&quot;Are you sure you want to delete this history record?&quot;, cls=&quot;text-lg mb-4&quot;),

            # Record summary
            Div(
                P(f&quot;Topic: {record[&apos;topic&apos;][:100]}{&apos;...&apos; if len(record[&apos;topic&apos;]) &gt; 100 else &apos;&apos;}&quot;,
                  cls=&quot;mb-2&quot;),
                P(f&quot;Platform: {record[&apos;platform&apos;]}&quot;, cls=&quot;mb-2&quot;),
                P(f&quot;Created: {record[&apos;created_at_formatted&apos;]}&quot;, cls=&quot;mb-2&quot;),
                cls=&quot;bg-gray-100 p-4 rounded-lg mb-6&quot;
            ),

            P(&quot;This action cannot be undone.&quot;, cls=&quot;text-red-600 mb-6&quot;),

            # Form with confirmation button
            Form(
                Div(
                    Button(&quot;Yes, Delete Record&quot;,
                           type=&quot;submit&quot;,
                           cls=&quot;bg-red-600 hover:bg-red-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                    A(&quot;Cancel&quot;,
                      href=f&quot;/history/{record_id}&quot;,
                      cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                    cls=&quot;flex&quot;
                ),
                method=&quot;post&quot;,
                action=f&quot;/history/{record_id}/delete&quot;
            ),

            cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
        ),

        cls=&quot;max-w-2xl mx-auto&quot;
    )
```

**Explanation**:
- We create three view functions:
  - `history_page`: Shows a paginated list of all title generation records
  - `history_detail_page`: Shows the complete details of a specific record
  - `delete_confirm_page`: Confirmation screen before deleting a record

- The history page includes:
  - A summary view of each generation record
  - Preview of the first few titles from each record
  - Pagination controls for navigating through history
  - Links to view details or delete each record

- The detail page includes:
  - Complete metadata about the generation
  - All titles with copy buttons
  - A &quot;Generate Similar&quot; button that pre-fills the form with the same settings
  - Back and delete buttons

- The delete confirmation page:
  - Shows a summary of the record to be deleted
  - Requires explicit confirmation via form submission
  - Includes warning about the action being irreversible

### Step 4: Update Title Generator to Save History

Now we need to modify the title generator to save history when titles are generated:

**File: `pages/title_generator.py`**

We don&apos;t need to change the `title_generator_form` function, but we&apos;ll add links to the history page in both functions:

```python
from fasthtml.common import *

def title_generator_form():
    &quot;&quot;&quot;
    Defines the title generator form page.

    Returns:
        Components representing the title generator form
    &quot;&quot;&quot;
    return Div(
        # Page header
        H1(&quot;AI Title Generator&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Generator form
        Div(
            Form(
                # Topic field
                Div(
                    Label(&quot;What&apos;s your content about?&quot;, For=&quot;topic&quot;,
                          cls=&quot;block text-gray-700 mb-2&quot;),
                    Textarea(
                        id=&quot;topic&quot;,
                        name=&quot;topic&quot;,
                        placeholder=&quot;Describe your content topic in detail for better results...&quot;,
                        rows=3,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Platform selection
                Div(
                    Label(&quot;Platform:&quot;, For=&quot;platform&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;Blog&quot;, value=&quot;Blog&quot;, selected=True),
                        Option(&quot;YouTube&quot;, value=&quot;YouTube&quot;),
                        Option(&quot;Social Media&quot;, value=&quot;Social Media&quot;),
                        Option(&quot;Email Subject&quot;, value=&quot;Email Subject&quot;),
                        Option(&quot;News Article&quot;, value=&quot;News Article&quot;),
                        id=&quot;platform&quot;,
                        name=&quot;platform&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Style selection
                Div(
                    Label(&quot;Style:&quot;, For=&quot;style&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;Professional&quot;, value=&quot;Professional&quot;, selected=True),
                        Option(&quot;Casual&quot;, value=&quot;Casual&quot;),
                        Option(&quot;Clickbait&quot;, value=&quot;Clickbait&quot;),
                        Option(&quot;Informative&quot;, value=&quot;Informative&quot;),
                        Option(&quot;Funny&quot;, value=&quot;Funny&quot;),
                        id=&quot;style&quot;,
                        name=&quot;style&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Number of titles
                Div(
                    Label(&quot;Number of titles:&quot;, For=&quot;number_of_titles&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;5&quot;, value=&quot;5&quot;, selected=True),
                        Option(&quot;10&quot;, value=&quot;10&quot;),
                        Option(&quot;15&quot;, value=&quot;15&quot;),
                        id=&quot;number_of_titles&quot;,
                        name=&quot;number_of_titles&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-6&quot;
                ),

                # Submit button
                Button(
                    &quot;Generate Titles&quot;,
                    type=&quot;submit&quot;,
                    cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;
                ),

                action=&quot;/title-generator/generate&quot;,
                method=&quot;post&quot;,
                cls=&quot;bg-white p-6 rounded-lg shadow-md mb-8&quot;
            ),

            # Tips section
            Div(
                H3(&quot;Tips for Better Titles&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                Ul(
                    Li(&quot;Be specific about your topic for more relevant titles&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;Include your target audience for better context&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;Mention key points you want to highlight&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;For YouTube, specify if it&apos;s a tutorial, review, etc.&quot;, cls=&quot;mb-1&quot;),
                    cls=&quot;list-disc pl-5 text-gray-600&quot;
                ),
                cls=&quot;bg-blue-50 p-4 rounded-lg mt-6&quot;
            ),

            # Add link to history
            Div(
                P(
                    &quot;Want to see your previous generations? &quot;,
                    A(&quot;View History&quot;, href=&quot;/history&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
                    cls=&quot;text-sm text-gray-600 text-center mt-4&quot;
                ),
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )

def title_generator_results(topic, platform, style, titles, history_id=None):
    &quot;&quot;&quot;
    Defines the title generator results page.

    Args:
        topic: The topic that was entered
        platform: The platform that was selected
        style: The style that was selected
        titles: List of generated titles
        history_id: ID of the saved history record (optional)

    Returns:
        Components representing the results page
    &quot;&quot;&quot;
    # Create list items for each title
    title_items = []
    for i, title in enumerate(titles):
        title_items.append(
            Li(
                Div(
                    P(title, cls=&quot;font-medium&quot;),
                    Button(
                        &quot;Copy&quot;,
                        type=&quot;button&quot;,
                        onclick=f&quot;navigator.clipboard.writeText(&apos;{title.replace(&apos;\&apos;&apos;, &apos;\\\&apos;&apos;)}&apos;); this.textContent = &apos;Copied!&apos;; setTimeout(() =&gt; this.textContent = &apos;Copy&apos;, 2000);&quot;,
                        cls=&quot;ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;p-3 border-b last:border-b-0&quot;
            )
        )

    # Build history link if we have a history ID
    history_link = None
    if history_id:
        history_link = Div(
            P(
                &quot;This generation has been saved to your history. &quot;,
                A(&quot;View Details&quot;, href=f&quot;/history/{history_id}&quot;, cls=&quot;text-blue-600 hover:underline&quot;),
                cls=&quot;text-sm text-gray-600 mt-4&quot;
            ),
            cls=&quot;mb-4&quot;
        )

    return Div(
        # Page header
        H1(&quot;Generated Titles&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Results container
        Div(
            # Query summary
            Div(
                H2(&quot;Your Request&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                P(
                    Strong(&quot;Topic: &quot;), Span(topic), Br(),
                    Strong(&quot;Platform: &quot;), Span(platform), Br(),
                    Strong(&quot;Style: &quot;), Span(style),
                    cls=&quot;text-gray-600 mb-4&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # Titles list
            Div(
                H2(&quot;Title Options&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                P(&quot;Click &apos;Copy&apos; to copy any title to your clipboard.&quot;, cls=&quot;text-gray-600 mb-3&quot;),
                Ul(
                    *title_items,
                    cls=&quot;border rounded divide-y&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # History link
            history_link,

            # Action buttons
            Div(
                A(&quot;Generate More&quot;,
                  href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                A(&quot;View History&quot;,
                  href=&quot;/history&quot;,
                  cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                cls=&quot;flex&quot;
            ),

            cls=&quot;bg-white p-6 rounded-lg shadow-md mb-8 max-w-2xl mx-auto&quot;
        )
    )
```

### Step 5: Update the Main Application File

Finally, let&apos;s update our main application file to include the new routes and history functionality:


**File: `main.py` (Updated)** (continued)

```python
from fasthtml.common import *

# Import page content
from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results
from pages.history import history_page, history_detail_page, delete_confirm_page

# Import the page layout component
from components.page_layout import page_layout

# Import title generator tool
from tools.title_generator import TitleGenerator

# Import history DAO
from db.history_dao import HistoryDAO

# Import config
import config

# Initialize the FastHTML application
app = FastHTML()

# Initialize title generator tool
title_generator = TitleGenerator()

@app.get(&quot;/&quot;)
def home():
    &quot;&quot;&quot;Handler for the home page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Home - {config.APP_NAME}&quot;,
        content=home_page(),
        current_page=&quot;/&quot;
    )

@app.get(&quot;/title-generator&quot;)
def title_generator_page(topic: str = &quot;&quot;, platform: str = &quot;Blog&quot;, style: str = &quot;Professional&quot;, number_of_titles: str = &quot;5&quot;):
    &quot;&quot;&quot;
    Handler for the title generator page route.

    Now supports pre-filled values from query parameters (for &quot;Generate Similar&quot; feature)
    &quot;&quot;&quot;
    return page_layout(
        title=f&quot;Title Generator - {config.APP_NAME}&quot;,
        content=title_generator_form(),
        current_page=&quot;/title-generator&quot;
    )

@app.post(&quot;/title-generator/generate&quot;)
async def generate_titles(topic: str, platform: str, style: str, number_of_titles: str):
    &quot;&quot;&quot;
    Handler for processing title generation requests.

    Args:
        topic: The content topic
        platform: The target platform
        style: The title style
        number_of_titles: Number of titles to generate
    &quot;&quot;&quot;
    try:
        # Validate inputs
        if not topic:
            error_message = Div(
                H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
                P(&quot;Please provide a topic for your titles.&quot;, cls=&quot;mb-4&quot;),
                A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
                cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
            )

            return page_layout(
                title=f&quot;Error - {config.APP_NAME}&quot;,
                content=error_message,
                current_page=&quot;/title-generator&quot;
            )

        # Convert number_of_titles to integer
        num_titles = int(number_of_titles)

        # Generate titles
        titles = await title_generator.generate_titles(
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles
        )

        # Save to history database
        history_id = await HistoryDAO.save_generation(
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles,
            titles=titles
        )

        # Return the results page
        return page_layout(
            title=f&quot;Generated Titles - {config.APP_NAME}&quot;,
            content=title_generator_results(
                topic=topic,
                platform=platform,
                style=style,
                titles=titles,
                history_id=history_id  # Pass the history ID to the template
            ),
            current_page=&quot;/title-generator&quot;
        )
    except Exception as e:
        # Handle errors
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(f&quot;An error occurred while generating titles: {str(e)}&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/title-generator&quot;
        )

# History Routes
@app.get(&quot;/history&quot;)
def history(page: int = 1):
    &quot;&quot;&quot;
    Handler for the history page route.

    Args:
        page: Current page number (defaults to 1)
    &quot;&quot;&quot;
    # Ensure page is at least 1
    if page &lt; 1:
        page = 1

    return page_layout(
        title=f&quot;Generation History - {config.APP_NAME}&quot;,
        content=history_page(page=page),
        current_page=&quot;/history&quot;
    )

@app.get(&quot;/history/{record_id:int}&quot;)
def history_detail(record_id: int):
    &quot;&quot;&quot;
    Handler for the history detail page route.

    Args:
        record_id: ID of the history record to display
    &quot;&quot;&quot;
    return page_layout(
        title=f&quot;History Details - {config.APP_NAME}&quot;,
        content=history_detail_page(record_id=record_id),
        current_page=&quot;/history&quot;
    )

@app.get(&quot;/history/{record_id:int}/delete&quot;)
def confirm_delete(record_id: int):
    &quot;&quot;&quot;
    Handler for the delete confirmation page.

    Args:
        record_id: ID of the record to delete
    &quot;&quot;&quot;
    return page_layout(
        title=f&quot;Confirm Deletion - {config.APP_NAME}&quot;,
        content=delete_confirm_page(record_id=record_id),
        current_page=&quot;/history&quot;
    )

@app.post(&quot;/history/{record_id:int}/delete&quot;)
def delete_record(record_id: int):
    &quot;&quot;&quot;
    Handler for processing record deletion.

    Args:
        record_id: ID of the record to delete
    &quot;&quot;&quot;
    # Try to delete the record
    success = HistoryDAO.delete_history(record_id)

    if success:
        # Show success message and redirect to history page
        success_message = Div(
            H1(&quot;Record Deleted&quot;, cls=&quot;text-3xl font-bold text-green-600 mb-4&quot;),
            P(&quot;The history record has been successfully deleted.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Record Deleted - {config.APP_NAME}&quot;,
            content=success_message,
            current_page=&quot;/history&quot;
        )
    else:
        # Show error message
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(&quot;The record could not be deleted or doesn&apos;t exist.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to History&quot;, href=&quot;/history&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/history&quot;
        )

@app.get(&quot;/{path:path}&quot;)
def not_found(path: str):
    &quot;&quot;&quot;Handler for 404 Not Found errors.&quot;&quot;&quot;
    error_content = Div(
        H1(&quot;404 - Page Not Found&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-4&quot;),
        P(f&quot;Sorry, the page &apos;/{path}&apos; does not exist.&quot;, cls=&quot;mb-4&quot;),
        A(&quot;Return Home&quot;, href=&quot;/&quot;,
          cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
        cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center&quot;
    )

    return page_layout(
        title=f&quot;404 Not Found - {config.APP_NAME}&quot;,
        content=error_content,
        current_page=&quot;/&quot;
    )

# Run the application
if __name__ == &quot;__main__&quot;:
    import uvicorn
    uvicorn.run(&quot;main:app&quot;, host=&quot;0.0.0.0&quot;, port=5001, reload=True)
```

**Explanation**:
- We&apos;ve updated the main application file to include several new routes:
  - `/history`: Shows the paginated history list
  - `/history/{record_id}`: Shows details for a specific history record
  - `/history/{record_id}/delete` (GET): Shows deletion confirmation
  - `/history/{record_id}/delete` (POST): Processes deletion

- We modified the title generator route to:
  - Support pre-filled parameters from query strings (for &quot;Generate Similar&quot; feature)
  - Save generated titles to the history database
  - Pass the history ID to the results page

- We added proper pagination support in the history page
- We implemented a two-step deletion process for history records:
  1. Show confirmation page
  2. Process deletion and show success/error message

- All routes maintain consistent layout and navigation

### Step 6: Running Your Application with Database Support

Now that we&apos;ve added database functionality, we need to ensure our application handles database connections properly. Let&apos;s run the application:

1. Make sure you&apos;ve set up your `.env` file with your OpenRouter API key and database path:

```bash
echo &quot;OPENROUTER_API_KEY=your_api_key_here&quot; &gt; .env
echo &quot;DB_PATH=tools.db&quot; &gt;&gt; .env
```

2. Run the application:

```bash
python main.py
```

3. Open your browser and visit `http://localhost:5001`

#### Testing the History Functionality

Let&apos;s test our new features:

1. **Generate some titles:**
   - Navigate to the &quot;Title Generator&quot; page
   - Fill out the form and generate some titles
   - Notice the success message indicating your generation was saved to history

2. **View your history:**
   - Click the &quot;History&quot; link in the navigation
   - You should see your recent generations listed with previews of the titles
   - Try pagination if you&apos;ve generated multiple sets of titles

3. **View detailed history:**
   - Click &quot;View Details&quot; on any history entry
   - Verify that all the information is displayed correctly
   - Try the &quot;Copy&quot; buttons to copy titles to your clipboard

4. **Generate similar titles:**
   - From a history detail page, click &quot;Generate Similar&quot;
   - The title generator form should be pre-filled with the same parameters
   - Generate new titles with these parameters

5. **Delete a history record:**
   - From a history detail page, click &quot;Delete Record&quot;
   - Confirm the deletion on the confirmation page
   - Verify that the record is removed from the history list

## How the Database Integration Works

Let&apos;s understand the key components of our database integration:

1. **Database Connection Management**:
   - We use SQLite&apos;s built-in connection handling
   - Our context manager ensures connections are properly closed
   - The `row_factory = sqlite3.Row` setting allows dictionary-like access to results

2. **Data Access Pattern**:
   - We use the DAO (Data Access Object) pattern to separate database logic
   - Static methods provide a clean interface for database operations
   - JSON serialization handles complex data types

3. **Schema Design**:
   - Single table with relevant fields for title generation
   - Primary key for unique identification
   - Automatic timestamp for creation date

4. **Transaction Management**:
   - We use `conn.commit()` to ensure data is saved
   - Operations are wrapped in `try/finally` blocks via the context manager
   - This prevents connection leaks even if errors occur

5. **Data Format Handling**:
   - Titles are stored as JSON strings in the database
   - They&apos;re parsed back to Python lists when retrieved
   - Timestamps are formatted for user-friendly display

## Advanced Database Enhancements

If you want to take your database integration further, consider these enhancements:

1. **User management**:
   - Add users table with authentication
   - Link history records to specific users
   - Implement login/registration system

2. **Favorite titles**:
   - Allow marking specific titles as favorites
   - Create a favorites table with references to title history
   - Add a favorites page in the UI

3. **Tags and categories**:
   - Allow categorizing title generations with tags
   - Implement filtering by tag in history page
   - Add tag-based search functionality

4. **Analytics**:
   - Track which platforms and styles are most used
   - Create a dashboard with usage statistics
   - Visualize trends in generation patterns

5. **Database migrations**:
   - Implement a migration system for schema changes
   - Version your database schema
   - Allow smooth upgrades when adding fields or tables

6. **Backup and restore**:
   - Add functionality to export/import history data
   - Create scheduled backups of the database
   - Implement restore functionality

## Performance Considerations

SQLite works well for our use case, but here are some tips as your application grows:

1. **Indexes**:
   - Add indexes on frequently queried columns
   - For example: `CREATE INDEX idx_created_at ON title_history(created_at)`

2. **Pagination**:
   - Always use pagination for large result sets
   - Add limit/offset to queries that could return many rows

3. **Connection pooling**:
   - For higher traffic, consider implementing connection pooling
   - This can be done with libraries like `aiosqlite` for async support

4. **Database tuning**:
   - Configure SQLite with appropriate journal mode and synchronization settings
   - For example: `PRAGMA journal_mode=WAL` for better concurrency

5. **Query optimization**:
   - Use `EXPLAIN QUERY PLAN` to understand query performance
   - Optimize complex queries by restructuring them

## Conclusion

You&apos;ve now enhanced your AI Title Generator with a robust SQLite database that stores generation history. This addition provides several benefits:

1. **Persistence**: Your generations are saved across application restarts
2. **User convenience**: Users can revisit previous generations without regenerating
3. **Extensibility**: The foundation is laid for more advanced features
4. **Better UX**: The application feels more like a professional tool
5. **Insights**: You can analyze patterns in title generation over time

The integration follows good software design principles:
- **Separation of concerns**: Database logic is isolated in DAO classes
- **Type safety**: We use proper typing for all parameters and return values
- **Error handling**: Robust error handling is implemented throughout
- **UX considerations**: Clear feedback is provided for all operations

This database enhancement demonstrates how Python&apos;s built-in SQLite support makes it easy to add persistence to your FastHTML applications. The combination of Python&apos;s simplicity, FastHTML&apos;s declarative UI approach, and SQLite&apos;s lightweight but powerful database capabilities creates a solid foundation for building sophisticated AI-powered web applications.

As you continue developing your FastHTML applications, this database pattern can be adapted for many other use cases - from storing user preferences to caching API responses for better performance.

Happy coding!

## FastHTML Series

Below are the articles in our FastHTML series to help you get started:

- [FastHTML Get Started](https://www.bitdoze.com/fasthtml-start/)
- [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/)
- [FastHTML Complex AI Tools](https://www.bitdoze.com/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](https://www.bitdoze.com/fasthtml-sqlite-db/)
- [FastHTML Authentication](https://www.bitdoze.com/fasthtml-user-auth/)</content:encoded><category>web-development</category><category>fasthtml</category></item><item><title>Building a Multi-Page AI Tools Website with FastHTML: Complete Guide</title><link>https://www.bitdoze.com/fasthtml-complex-ai-tools/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-complex-ai-tools/</guid><description>Learn how to build a structured multi-page AI tools website with FastHTML using reusable components, shared layouts, and a modular tool system. Perfect for Python developers wanting to create maintainable web applications.</description><pubDate>Mon, 03 Mar 2025 00:00:00 GMT</pubDate><content:encoded>Welcome to our FastHTML series! In our previous article, [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/) and [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/), we explored how to create a basic multi-page website with consistent header and footer components. Today, we&apos;re taking it a step further by building a complete AI tools platform called &quot;Bit Tools&quot; using FastHTML.

In this article, we&apos;ll show you how to create a modular, maintainable website that offers multiple AI-powered tools to users. By the end, you&apos;ll understand how to structure a FastHTML project with reusable components, implement a tool registry system, and create a seamless user experience across multiple pages. Let&apos;s dive in!

## What We&apos;re Building: Bit Tools Platform

The Bit Tools platform is a website that offers various AI-powered content creation tools, including:

- **Title Generator**: Creates engaging titles for YouTube videos, articles, or TikTok posts
- **Social Post Generator**: Generates social media content for different platforms
- **Blog Outline Generator**: Creates structured outlines for blog posts

Each tool has its own dedicated page with a custom form for user input, and the results are displayed in a user-friendly format. The website has a consistent layout with a header, footer, and navigation system that highlights the current page.

## Project Structure Overview

A well-organized project structure is crucial for maintaining a multi-page website, especially as it grows. Here&apos;s the directory structure we&apos;ll use for our Bit Tools platform:

```
bit-tools/
├── main.py                 # Main application entry point
├── requirements.txt        # Project dependencies
├── components/             # Reusable UI components
│   ├── __init__.py
│   ├── header.py           # Navigation header
│   ├── footer.py           # Page footer
│   ├── page_layout.py      # Shared page layout
│   └── social_icons.py     # Social media icons
├── pages/                  # Individual page content
│   ├── __init__.py
│   ├── home.py             # Home page content
│   ├── about.py            # About page content
│   ├── contact.py          # Contact page content
│   ├── tools.py            # Tools listing page
│   └── tool_pages.py       # Individual tool pages
└── tools/                  # Tool implementations
    ├── __init__.py
    ├── base.py             # Base tool class
    ├── base_types.py       # Type definitions
    ├── errors.py           # Error handling
    ├── factory.py          # Tool factory
    ├── registry.py         # Tool registry
    ├── title_generator.py  # Title generator tool
    ├── social_post_generator.py  # Social post generator tool
    ├── blog_outline_generator.py # Blog outline generator tool
    └── utils.py            # Utility functions
```

This structure follows several important principles:

1. **Separation of concerns**: Each file has a specific purpose
2. **Modularity**: Components are reusable across pages
3. **Scalability**: Easy to add new pages and tools
4. **Organization**: Logical grouping of related functionality

Let&apos;s explore each part of this structure in detail.

## Setting Up the Project

First, let&apos;s set up our project with the necessary dependencies. Create a `requirements.txt` file with the following content:

```python
python-fasthtml
python-dotenv
pydantic-ai
openai
```

These dependencies include:
- **FastHTML**: The web framework we&apos;re using to build our application
- **python-dotenv**: For loading environment variables
- **pydantic-ai**: For working with AI models and data validation
- **openai**: For interacting with OpenAI&apos;s API for our AI tools

To install these dependencies, run:

```bash
pip install -r requirements.txt
```


## The Utility Layer

### OpenAI Integration with `utility.py`

The `utility.py` file creates a bridge between our application and AI services like OpenAI or OpenRouter.

**File: `tools/utility.py`**

```python
from openai import AsyncOpenAI
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel

def create_pydantic_agent(model_name, api_key, base_url):
    &quot;&quot;&quot;
    Create a Pydantic AI Agent connected to OpenRouter.

    Args:
        model_name: The model to use (e.g., &quot;openai/gpt-4o-mini&quot;)
        api_key: OpenRouter API key
        base_url: Base URL for OpenRouter API

    Returns:
        An initialized Pydantic AI Agent
    &quot;&quot;&quot;
    client = AsyncOpenAI(
        api_key=api_key,
        base_url=base_url,
    )

    model = OpenAIModel(model_name, openai_client=client)
    return Agent(model)
```

This utility function:

1. **Creates an AI Agent**: Initializes a Pydantic AI Agent, which serves as a wrapper for interactions with the AI model.
2. **Configures API Access**: Sets up a connection to OpenAI or compatible services like OpenRouter using the provided credentials.
3. **Abstracts Complexity**: Hides the details of API initialization, making it easier to use AI capabilities throughout the application.

The function accepts three parameters:
- `model_name`: The specific AI model to use (e.g., &quot;openai/gpt-4o-mini&quot;)
- `api_key`: Authentication key for the API service
- `base_url`: The endpoint URL for the API service

This abstraction allows us to easily switch between different models or services without changing our tool implementations.

### Base Tool Implementation with `base.py`

The `base.py` file defines the foundation for all tools in our system, providing a consistent interface and shared functionality.

**File: `tools/base.py`**

```python
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional

class BaseTool(ABC):
    &quot;&quot;&quot;
    Abstract base class for all AI tools.

    This provides a standard interface for tool implementation,
    making it easier to add new tools to the system.
    &quot;&quot;&quot;

    @property
    @abstractmethod
    def name(self) -&gt; str:
        &quot;&quot;&quot;Return the name of the tool.&quot;&quot;&quot;
        pass

    @property
    @abstractmethod
    def description(self) -&gt; str:
        &quot;&quot;&quot;Return a description of what the tool does.&quot;&quot;&quot;
        pass

    @property
    def icon(self) -&gt; str:
        # Default icon if not overridden
        return &quot;&quot;&quot;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot; class=&quot;w-6 h-6&quot;&gt;
            &lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z&quot; /&gt;
            &lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 0 1 9 14.437V9.564Z&quot; /&gt;
        &lt;/svg&gt;&quot;&quot;&quot;

    @property
    def id(self) -&gt; str:
        &quot;&quot;&quot;Return the tool ID used in URLs and for lookup.&quot;&quot;&quot;
        return self.name.lower().replace(&apos; &apos;, &apos;-&apos;)

    @property
    def route(self) -&gt; str:
        &quot;&quot;&quot;Return the URL route for the tool.&quot;&quot;&quot;
        return f&quot;/tools/{self.id}&quot;

    @abstractmethod
    async def process(self, inputs: Dict[str, Any]) -&gt; Dict[str, Any]:
        &quot;&quot;&quot;
        Process the inputs and return the results.

        Args:
            inputs: Dictionary of input parameters from the form

        Returns:
            Dictionary of results to be passed to the results page
        &quot;&quot;&quot;
        pass

    @property
    @abstractmethod
    def input_form_fields(self) -&gt; Dict[str, Dict[str, Any]]:
        &quot;&quot;&quot;
        Return the configuration for the input form fields.

        Returns:
            Dictionary containing form field definitions
        &quot;&quot;&quot;
        pass

    def validate_inputs(self, inputs: Dict[str, Any]) -&gt; List[Dict[str, Any]]:
        &quot;&quot;&quot;
        Validate the inputs and return detailed error information.

        Args:
            inputs: Dictionary of input parameters from the form

        Returns:
            List of error dictionaries with field, code, and message
        &quot;&quot;&quot;
        errors = []

        for field_id, field_config in self.input_form_fields.items():
            # Check required fields
            if field_config.get(&quot;required&quot;, False) and not inputs.get(field_id):
                errors.append({
                    &quot;field&quot;: field_id,
                    &quot;code&quot;: &quot;required&quot;,
                    &quot;message&quot;: f&quot;{field_config.get(&apos;label&apos;, field_id)} is required&quot;
                })

            # Check field-specific validation
            if field_id in inputs and inputs[field_id]:
                # Example: max length validation
                max_length = field_config.get(&quot;maxLength&quot;)
                if max_length and len(str(inputs[field_id])) &gt; max_length:
                    errors.append({
                        &quot;field&quot;: field_id,
                        &quot;code&quot;: &quot;max_length&quot;,
                        &quot;message&quot;: f&quot;{field_config.get(&apos;label&apos;, field_id)} exceeds maximum length of {max_length}&quot;
                    })

                # Example: min length validation
                min_length = field_config.get(&quot;minLength&quot;)
                if min_length and len(str(inputs[field_id])) &lt; min_length:
                    errors.append({
                        &quot;field&quot;: field_id,
                        &quot;code&quot;: &quot;min_length&quot;,
                        &quot;message&quot;: f&quot;{field_config.get(&apos;label&apos;, field_id)} must be at least {min_length} characters&quot;
                    })

        return errors
```

Key aspects of the `BaseTool` class:

1. **Abstract Base Class**: Uses Python&apos;s ABC module to define an interface that all tools must implement.
2. **Core Properties**:
   - `name`: The display name of the tool
   - `description`: A description of what the tool does
   - `icon`: SVG icon for visual representation (with a default implementation)
   - `id`: A URL-friendly identifier derived from the name
   - `route`: The URL path where the tool can be accessed
3. **Abstract Methods**:
   - `process()`: The main method that handles user inputs and returns results
   - `input_form_fields()`: Defines the form fields for user input
4. **Input Validation**: The `validate_inputs()` method checks user inputs against requirements like &quot;required&quot;, &quot;maxLength&quot;, and &quot;minLength&quot;.

This base class ensures consistency across all tools and reduces code duplication by implementing common functionality like input validation.

### Specialized Tool Types with `base_types.py`

The `base_types.py` file extends the base tool concept with specialized types for different AI tasks.

**File: `tools/base_types.py`**

```python
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
from .base import BaseTool

class TextGenerationTool(BaseTool, ABC):
    &quot;&quot;&quot;Base class for text generation tools.&quot;&quot;&quot;

    @property
    def tool_type(self) -&gt; str:
        return &quot;text_generation&quot;

    @property
    def default_system_prompt(self) -&gt; str:
        &quot;&quot;&quot;Default system prompt for this tool type.&quot;&quot;&quot;
        return &quot;&quot;&quot;
        You are a versatile text generation assistant. Create high-quality,
        engaging content based on the user&apos;s requirements.
        &quot;&quot;&quot;

    def get_system_prompt(self) -&gt; str:
        &quot;&quot;&quot;Get the system prompt, allowing for customization.&quot;&quot;&quot;
        return self.default_system_prompt

    @abstractmethod
    async def generate_text(self, inputs: Dict[str, Any]) -&gt; List[str]:
        &quot;&quot;&quot;Generate text based on inputs.&quot;&quot;&quot;
        pass

    async def process(self, inputs: Dict[str, Any]) -&gt; Dict[str, Any]:
        &quot;&quot;&quot;Process inputs and generate text.&quot;&quot;&quot;
        try:
            # Validate inputs
            validation_errors = self.validate_inputs(inputs)
            if validation_errors:
                return {&quot;error&quot;: &quot;Validation failed&quot;, &quot;validation_errors&quot;: validation_errors}

            # Generate text
            generated_texts = await self.generate_text(inputs)

            # Return results
            return {
                &quot;metadata&quot;: {
                    **{k: v for k, v in inputs.items() if k in self.input_form_fields},
                    &quot;count&quot;: len(generated_texts)
                },
                &quot;titles&quot;: generated_texts  # Using &apos;titles&apos; for backward compatibility
            }
        except Exception as e:
            return {&quot;error&quot;: f&quot;Failed to generate text: {str(e)}&quot;}

class TextTransformationTool(BaseTool, ABC):
    &quot;&quot;&quot;Base class for text transformation tools.&quot;&quot;&quot;

    @property
    def tool_type(self) -&gt; str:
        return &quot;text_transformation&quot;

    @abstractmethod
    async def transform_text(self, text: str, options: Dict[str, Any]) -&gt; str:
        &quot;&quot;&quot;Transform the input text based on options.&quot;&quot;&quot;
        pass

    async def process(self, inputs: Dict[str, Any]) -&gt; Dict[str, Any]:
        &quot;&quot;&quot;Process inputs and transform text.&quot;&quot;&quot;
        try:
            # Validate inputs
            validation_errors = self.validate_inputs(inputs)
            if validation_errors:
                return {&quot;error&quot;: &quot;Validation failed&quot;, &quot;validation_errors&quot;: validation_errors}

            # Get input text
            text = inputs.get(&quot;text&quot;, &quot;&quot;).strip()
            if not text:
                return {&quot;error&quot;: &quot;Please provide text to transform.&quot;}

            # Transform text
            transformed_text = await self.transform_text(
                text,
                {k: v for k, v in inputs.items() if k != &quot;text&quot;}
            )

            # Return results
            return {
                &quot;metadata&quot;: {
                    **{k: v for k, v in inputs.items() if k in self.input_form_fields},
                },
                &quot;original_text&quot;: text,
                &quot;transformed_text&quot;: transformed_text
            }
        except Exception as e:
            return {&quot;error&quot;: f&quot;Failed to transform text: {str(e)}&quot;}
```

This file defines two specialized tool types:

1. **TextGenerationTool**:
   - Creates new content based on user inputs
   - Provides a default system prompt for AI interactions
   - Implements the `process()` method from `BaseTool` with logic specific to text generation
   - Returns a standardized response format with metadata and generated texts

2. **TextTransformationTool**:
   - Transforms existing text based on user options
   - Focuses on taking input text and options, returning modified text
   - Handles validation and error cases
   - Returns a standardized response with the original and transformed text

These specialized classes further reduce code duplication by implementing common patterns for each type of tool. When creating a new tool, developers only need to implement the specific logic for their tool type.

### Error Handling with `errors.py`

The `errors.py` file provides a structured approach to error handling throughout the application.

**File: `tools/errors.py`**

```python
from enum import Enum
from typing import Dict, Any, Optional

class ErrorCode(Enum):
    &quot;&quot;&quot;Error codes for tool-related errors.&quot;&quot;&quot;
    INVALID_INPUT = &quot;invalid_input&quot;
    API_ERROR = &quot;api_error&quot;
    RATE_LIMIT = &quot;rate_limit&quot;
    INTERNAL_ERROR = &quot;internal_error&quot;

class ToolError(Exception):
    &quot;&quot;&quot;Base exception for tool-related errors.&quot;&quot;&quot;

    def __init__(
        self,
        code: ErrorCode,
        message: str,
        details: Optional[Dict[str, Any]] = None
    ):
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -&gt; Dict[str, Any]:
        &quot;&quot;&quot;Convert the error to a dictionary for API responses.&quot;&quot;&quot;
        return {
            &quot;error&quot;: {
                &quot;code&quot;: self.code.value,
                &quot;message&quot;: self.message,
                &quot;details&quot;: self.details
            }
        }
```

This error handling system:

1. **Defines Error Codes**: Uses an enum to standardize error types across the application
2. **Custom Exception Class**: Extends Python&apos;s Exception class with additional context and metadata
3. **Serializable Errors**: Provides a `to_dict()` method to convert errors to a consistent JSON format for API responses
4. **Structured Details**: Allows adding specific details about what caused the error

This approach ensures errors are handled consistently and provides clear, informative messages to users when something goes wrong.

### Configuration Management with `config.py`

The `config.py` file centralizes application configuration settings.

**File: `tools/config.py`**

```python
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# OpenRouter API configuration
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
OPENROUTER_BASE_URL = &quot;https://openrouter.ai/api/v1&quot;

# Default model to use
DEFAULT_MODEL = os.getenv(&quot;DEFAULT_MODEL&quot;)

# Application settings
DEBUG = True
```

The configuration file:

1. **Loads Environment Variables**: Uses python-dotenv to load settings from a `.env` file
2. **API Credentials**: Stores API keys and endpoints for external services
3. **Model Settings**: Configures which AI model to use by default
4. **Application Settings**: Manages global application behavior like debug mode

This centralized approach makes it easy to update settings across the application and keeps sensitive information like API keys out of the codebase.



## Creating Reusable Components

One of the key principles of maintainable web development is creating reusable components. Let&apos;s start by implementing our header, footer, and page layout components.

### Header Component

The header component provides navigation and branding for our website. It highlights the current page and adapts to different screen sizes.

**File: `components/header.py`**

```python
from fasthtml.common import *

def header(current_page=&quot;/&quot;):
    &quot;&quot;&quot;
    Creates a consistent header with navigation.

    Args:
        current_page: The current page path, used to highlight the active link

    Returns:
        A Header component with navigation
    &quot;&quot;&quot;
    nav_items = [
        (&quot;Home&quot;, &quot;/&quot;),
        (&quot;Tools&quot;, &quot;/tools&quot;),
        (&quot;About&quot;, &quot;/about&quot;),
        (&quot;Contact&quot;, &quot;/contact&quot;)
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path or (
            current_page.startswith(&quot;/tools/&quot;) and path == &quot;/tools&quot;
        )
        link_class = &quot;text-white hover:text-gray-300 px-3 py-2&quot;
        if is_current:
            link_class += &quot; font-bold underline&quot;

        nav_links.append(
            Li(
                A(title, href=path, cls=link_class)
            )
        )

    return Header(
        Div(
            A(&quot;Bit Tools&quot;, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),
            Nav(
                Ul(
                    *nav_links,
                    cls=&quot;flex space-x-2&quot;
                ),
                cls=&quot;ml-auto&quot;
            ),
            cls=&quot;container mx-auto flex items-center justify-between px-4 py-3&quot;
        ),
        cls=&quot;bg-blue-600 shadow-md&quot;
    )
```

This header component:
- Takes a `current_page` parameter to highlight the active navigation link
- Creates a list of navigation items with appropriate styling
- Handles special cases like highlighting the &quot;Tools&quot; link when on individual tool pages
- Uses Tailwind CSS classes for styling

Let&apos;s break down the code in more detail:

1. **Navigation Items Definition** (lines 118-123):
   ```python
   nav_items = [
       (&quot;Home&quot;, &quot;/&quot;),
       (&quot;Tools&quot;, &quot;/tools&quot;),
       (&quot;About&quot;, &quot;/about&quot;),
       (&quot;Contact&quot;, &quot;/contact&quot;)
   ]
   ```
   This creates a list of tuples, each containing a navigation label and its corresponding URL path.

2. **Active Link Detection** (lines 127-132):
   ```python
   is_current = current_page == path or (
       current_page.startswith(&quot;/tools/&quot;) and path == &quot;/tools&quot;
   )
   ```
   This checks if the current page matches the navigation item&apos;s path. The special condition handles tool detail pages (like &quot;/tools/title-generator&quot;) to still highlight the &quot;Tools&quot; navigation item.

3. **Navigation Link Creation** (lines 134-138):
   ```python
   nav_links.append(
       Li(
           A(title, href=path, cls=link_class)
       )
   )
   ```
   This creates an HTML list item (`&lt;li&gt;`) containing an anchor tag (`&lt;a&gt;`) for each navigation item.

4. **Header Structure** (lines 140-153):
   The header is structured with a container div that holds the logo and navigation, using Flexbox for layout.

### Footer Component

The footer component provides copyright information and appears at the bottom of every page.

**File: `components/footer.py`**

```python
from fasthtml.common import *

def footer():
    &quot;&quot;&quot;Creates a consistent footer.&quot;&quot;&quot;
    return Footer(
        Div(
            P(&quot;© 2025 Bit Tools. All rights reserved.&quot;, cls=&quot;text-center text-gray-500&quot;),
            cls=&quot;container mx-auto px-4 py-6&quot;
        ),
        cls=&quot;bg-gray-100 mt-auto&quot;
    )
```

This simple footer:
- Displays copyright information
- Uses Tailwind CSS for styling
- Has the `mt-auto` class to ensure it stays at the bottom of the page

### Page Layout Component

The page layout component combines the header, footer, and page-specific content into a consistent layout.

**File: `components/page_layout.py`**

```python
from fasthtml.common import *
from .header import header
from .footer import footer

def page_layout(title, content, current_page=&quot;/&quot;):
    &quot;&quot;&quot;
    Creates a consistent page layout with header and footer.

    Args:
        title: The page title
        content: The main content components
        current_page: The current page path

    Returns:
        A complete HTML page
    &quot;&quot;&quot;
    return Html(
        Head(
            Title(title),
            Meta(charset=&quot;UTF-8&quot;),
            Meta(name=&quot;viewport&quot;, content=&quot;width=device-width, initial-scale=1.0&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;),
            Script(defer=True, **{&quot;data-domain&quot;: &quot;bit-tools.com&quot;, &quot;src&quot;: &quot;https://an.bitdoze.com/js/script.js&quot;}),
        ),
        Body(
            Div(
                header(current_page),
                Main(
                    Div(
                        content,
                        cls=&quot;container mx-auto px-4 py-8&quot;
                    ),
                    cls=&quot;flex-grow&quot;
                ),
                footer(),
                cls=&quot;flex flex-col min-h-screen&quot;
            )
        )
    )
```

This page layout:
- Takes a title, content, and current page path as parameters
- Includes the header and footer components
- Sets up the HTML document structure with appropriate meta tags
- Includes the Tailwind CSS script for styling
- Uses a flex column layout to ensure the footer stays at the bottom
- Includes analytics script for tracking

Let&apos;s examine the code in more detail:

1. **Function Parameters** (lines 232-242):
   ```python
   def page_layout(title, content, current_page=&quot;/&quot;):
       &quot;&quot;&quot;
       Creates a consistent page layout with header and footer.

       Args:
           title: The page title
           content: The main content components
           current_page: The current page path
       &quot;&quot;&quot;
   ```
   The function takes three parameters: the page title (displayed in the browser tab), the main content components, and the current page path (used to highlight the active navigation link).

2. **HTML Document Structure** (lines 244-251):
   ```python
   return Html(
       Head(
           Title(title),
           Meta(charset=&quot;UTF-8&quot;),
           Meta(name=&quot;viewport&quot;, content=&quot;width=device-width, initial-scale=1.0&quot;),
           Script(src=&quot;https://cdn.tailwindcss.com&quot;),
           Script(defer=True, **{&quot;data-domain&quot;: &quot;bit-tools.com&quot;, &quot;src&quot;: &quot;https://an.bitdoze.com/js/script.js&quot;}),
       ),
       Body(...)
   )
   ```
   This creates a complete HTML document with proper head elements including meta tags for character encoding and responsive design, the page title, and necessary scripts.

3. **Body Structure** (lines 252-265):
   ```python
   Body(
       Div(
           header(current_page),
           Main(
               Div(
                   content,
                   cls=&quot;container mx-auto px-4 py-8&quot;
               ),
               cls=&quot;flex-grow&quot;
           ),
           footer(),
           cls=&quot;flex flex-col min-h-screen&quot;
       )
   )
   ```
   The body uses a flex column layout (`flex flex-col min-h-screen`) to ensure the footer stays at the bottom of the page. The main content area has `flex-grow` to expand and fill available space.

## Implementing Pages

Now that we have our reusable components, let&apos;s implement the individual pages of our website.

### Home Page

The home page welcomes users and showcases the available tools.

**File: `pages/home.py`**

```python
from fasthtml.common import *
from fasthtml.components import NotStr
from tools import get_all_tools
from components.social_icons import social_icons

def home():
    &quot;&quot;&quot;
    Defines the home page content.

    Returns:
        Components representing the home page content
    &quot;&quot;&quot;
    # Get tools for display
    tools_list = get_all_tools()

    return Div(
        # Hero section with social icons
        Div(
            Div(
                Div(
                    H1(
                        &quot;Welcome to &quot;,
                        Span(&quot;Bit Tools&quot;,
                             cls=&quot;bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500 sm:whitespace-nowrap&quot;),
                        cls=&quot;text-5xl md:text-[3.50rem] font-bold leading-tighter tracking-tighter mb-4 font-heading&quot;
                    ),
                    Div(
                        P(&quot;Create engaging content with our AI-powered tools.&quot;,
                          cls=&quot;text-xl text-gray-600 mb-8&quot;),
                        cls=&quot;max-w-3xl mx-auto&quot;
                    ),
                    # Use the social icons component
                    social_icons(),
                    cls=&quot;text-center pb-10 md:pb-16&quot;
                ),
                cls=&quot;py-12 md:py-20&quot;
            ),
            cls=&quot;max-w-6xl mx-auto px-4 sm:px-6&quot;
        ),

        # Tools section
        Div(
            H2(&quot;Our Tools&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                *[
                    Div(
                        Div(
                            Div(
                                NotStr(tool.icon),
                                cls=&quot;text-blue-600 w-12 h-12 mr-4&quot;
                            ),
                            Div(
                                H3(tool.name, cls=&quot;text-xl font-semibold mb-2&quot;),
                                P(tool.description, cls=&quot;text-gray-600&quot;),
                                cls=&quot;flex-1&quot;
                            ),
                            cls=&quot;flex items-start&quot;
                        ),
                        A(&quot;Try it now →&quot;,
                          href=tool.route,
                          cls=&quot;mt-4 inline-block text-blue-600 hover:text-blue-800 font-medium&quot;),
                        cls=&quot;bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow&quot;
                    )
                    for tool in tools_list
                ],
                cls=&quot;grid grid-cols-1 md:grid-cols-2 gap-6 mb-12&quot;
            ),
            cls=&quot;py-8 max-w-6xl mx-auto px-4 sm:px-6&quot;
        ),
        cls=&quot;relative overflow-hidden&quot;
    )
```

The home page:
- Fetches the list of available tools from the tool registry
- Displays a hero section with a welcome message and social icons
- Shows a grid of available tools with their icons, names, descriptions, and links
- Uses responsive design with Tailwind CSS classes

Let&apos;s break down the key parts of this implementation:

1. **Imports and Dependencies** (lines 338-341):
   ```python
   from fasthtml.common import *
   from fasthtml.components import NotStr
   from tools import get_all_tools
   from components.social_icons import social_icons
   ```
   We import the necessary FastHTML components, the `NotStr` component (which allows rendering raw HTML/SVG), the tool registry functions, and our custom social icons component.

2. **Fetching Tools** (lines 350-351):
   ```python
   # Get tools for display
   tools_list = get_all_tools()
   ```
   This retrieves all registered tools from the registry, which will be displayed on the home page.

3. **Hero Section** (lines 354-376):
   ```python
   # Hero section with social icons
   Div(
       Div(
           Div(
               H1(
                   &quot;Welcome to &quot;,
                   Span(&quot;Bit Tools&quot;,
                        cls=&quot;bg-clip-text text-transparent bg-gradient-to-r from-blue-500 to-indigo-500 sm:whitespace-nowrap&quot;),
                   cls=&quot;text-5xl md:text-[3.50rem] font-bold leading-tighter tracking-tighter mb-4 font-heading&quot;
               ),
               # ...
           ),
           # ...
       ),
       # ...
   )
   ```
   The hero section features a large heading with a gradient text effect for &quot;Bit Tools&quot;, a subtitle, and social media icons.

4. **Tools Grid** (lines 378-406):
   ```python
   # Tools section
   Div(
       H2(&quot;Our Tools&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
       Div(
           *[
               # Tool card for each tool
               Div(
                   # ...
               )
               for tool in tools_list
           ],
           cls=&quot;grid grid-cols-1 md:grid-cols-2 gap-6 mb-12&quot;
       ),
       # ...
   )
   ```
   This creates a responsive grid of tool cards. On mobile, it shows one column, and on medium screens and larger, it shows two columns. Each card displays the tool&apos;s icon, name, description, and a link to try it.

### Tools System

The heart of our application is the tools system, which allows us to create, register, and use various AI-powered tools. Let&apos;s explore how it works.

#### Tool Registry

The tool registry keeps track of all available tools and provides methods to access them.

**File: `tools/registry.py`** (simplified)

```python
class ToolRegistry:
    &quot;&quot;&quot;Registry for all available tools.&quot;&quot;&quot;

    def __init__(self):
        self.tools = {}
        self.categories = {}

    def register(self, tool, categories=None):
        &quot;&quot;&quot;Register a tool with the registry.&quot;&quot;&quot;
        self.tools[tool.id] = tool

        # Set the tool&apos;s route
        tool.route = f&quot;/tools/{tool.id}&quot;

        # Register categories
        if categories:
            for category in categories:
                if category not in self.categories:
                    self.categories[category] = []
                self.categories[category].append(tool)

    def get_tool(self, tool_id):
        &quot;&quot;&quot;Get a tool by ID.&quot;&quot;&quot;
        return self.tools.get(tool_id)

    def get_all_tools(self):
        &quot;&quot;&quot;Get all registered tools.&quot;&quot;&quot;
        return list(self.tools.values())

    def get_tools_by_category(self, category):
        &quot;&quot;&quot;Get all tools in a category.&quot;&quot;&quot;
        return self.categories.get(category, [])

    def get_categories(self):
        &quot;&quot;&quot;Get all categories.&quot;&quot;&quot;
        return list(self.categories.keys())

# Create a singleton registry instance
registry = ToolRegistry()
```

The tool registry:
- Maintains a dictionary of tools indexed by their IDs
- Organizes tools into categories
- Provides methods to retrieve tools by ID or category
- Sets the route for each tool based on its ID

#### Tool Factory

The tool factory creates tool classes with consistent behavior.

**File: `tools/factory.py`** (simplified)

```python
from .base import BaseTool

def create_text_generation_tool(name, description, icon, system_prompt,
                               user_prompt_template, input_form_fields,
                               post_process_func=None):
    &quot;&quot;&quot;
    Factory function to create a text generation tool class.

    Args:
        name: Tool name
        description: Tool description
        icon: SVG icon as string
        system_prompt: System prompt for the AI
        user_prompt_template: Template for user prompts
        input_form_fields: Form field definitions
        post_process_func: Function to process AI output

    Returns:
        A tool class that can be instantiated
    &quot;&quot;&quot;

    class TextGenerationTool(BaseTool):
        def __init__(self):
            super().__init__(name, description, icon)
            self.system_prompt = system_prompt
            self.user_prompt_template = user_prompt_template
            self.input_form_fields = input_form_fields
            self.post_process_func = post_process_func

        async def process(self, inputs):
            &quot;&quot;&quot;Process user inputs and generate results.&quot;&quot;&quot;
            # Format the user prompt with inputs
            user_prompt = self.user_prompt_template.format(**inputs)

            # Call the AI model (simplified)
            result = await self.call_ai_model(
                system_prompt=self.system_prompt,
                user_prompt=user_prompt
            )

            # Post-process the result if needed
            if self.post_process_func:
                result = self.post_process_func(result)

            return result

    return TextGenerationTool
```

The tool factory:
- Creates a new tool class with the specified parameters
- Handles the common behavior for text generation tools
- Provides a consistent interface for processing user inputs
- Supports post-processing of AI-generated results

#### Tool Implementation

Let&apos;s look at how a specific tool is implemented using our factory.

**File: `tools/title_generator.py`** (simplified)

```python
import re
from typing import List
from .factory import create_text_generation_tool
from .registry import registry

# System prompt for title generation
title_system_prompt = &quot;&quot;&quot;
You are a versatile content title generator specializing in catchy, platform-specific titles.
[... detailed instructions ...]
&quot;&quot;&quot;

# User prompt template for title generation
title_user_prompt_template = &quot;&quot;&quot;
Create 10 engaging {platform} titles for content about: {topic}. Tone: {style}.
&quot;&quot;&quot;

# Post-processing function for titles
def process_titles(text: str) -&gt; List[str]:
    # Clean and format the titles
    # [... processing logic ...]
    return unique_titles[:10]

# Create the title generator tool
TitleGeneratorClass = create_text_generation_tool(
    name=&quot;AI Title Generator&quot;,
    description=&quot;Create engaging titles for YouTube videos, articles, or TikTok posts in various styles.&quot;,
    icon=&quot;&quot;&quot;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot; class=&quot;w-6 h-6&quot;&gt;
        &lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z&quot; /&gt;
    &lt;/svg&gt;&quot;&quot;&quot;,
    system_prompt=title_system_prompt,
    user_prompt_template=title_user_prompt_template,
    input_form_fields={
        &quot;topic&quot;: {
            &quot;type&quot;: &quot;textarea&quot;,
            &quot;label&quot;: &quot;What&apos;s your content about?&quot;,
            &quot;placeholder&quot;: &quot;Describe your content topic in detail for better results...&quot;,
            &quot;required&quot;: True,
            &quot;rows&quot;: 3
        },
        &quot;platform&quot;: {
            &quot;type&quot;: &quot;select&quot;,
            &quot;label&quot;: &quot;Platform&quot;,
            &quot;options&quot;: [
                {&quot;value&quot;: &quot;YouTube&quot;, &quot;label&quot;: &quot;YouTube&quot;, &quot;selected&quot;: True},
                {&quot;value&quot;: &quot;Article&quot;, &quot;label&quot;: &quot;Article&quot;},
                {&quot;value&quot;: &quot;TikTok&quot;, &quot;label&quot;: &quot;TikTok&quot;}
            ]
        },
        &quot;style&quot;: {
            &quot;type&quot;: &quot;select&quot;,
            &quot;label&quot;: &quot;Style&quot;,
            &quot;options&quot;: [
                {&quot;value&quot;: &quot;Professional&quot;, &quot;label&quot;: &quot;Professional&quot;, &quot;selected&quot;: True},
                {&quot;value&quot;: &quot;Funny&quot;, &quot;label&quot;: &quot;Funny&quot;}
            ]
        }
    },
    post_process_func=process_titles
)

# Instantiate the tool
title_generator_tool = TitleGeneratorClass()

# Register the tool with the registry
registry.register(title_generator_tool, categories=[&quot;Content Creation&quot;])
```

This tool implementation:
- Defines a system prompt with detailed instructions for the AI
- Creates a user prompt template that incorporates user inputs
- Implements a post-processing function to clean and format the AI&apos;s output
- Defines form fields for user input with appropriate types and options
- Instantiates the tool and registers it with the registry

### Tool Pages

Now let&apos;s implement the pages that display and interact with our tools.

**File: `pages/tools.py`** (simplified)

```python
from fasthtml.common import *
from tools import get_all_tools, get_categories

def tools():
    &quot;&quot;&quot;
    Defines the tools listing page content.

    Returns:
        Components representing the tools page content
    &quot;&quot;&quot;
    tools_by_category = {}
    categories = get_categories()

    for category in categories:
        tools_by_category[category] = get_tools_by_category(category)

    return Div(
        H1(&quot;AI Tools&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),

        # Tools by category
        *[
            Div(
                H2(category, cls=&quot;text-2xl font-bold mb-4&quot;),
                Div(
                    *[
                        Div(
                            Div(
                                Div(
                                    NotStr(tool.icon),
                                    cls=&quot;text-blue-600 w-12 h-12 mr-4&quot;
                                ),
                                Div(
                                    H3(tool.name, cls=&quot;text-xl font-semibold mb-2&quot;),
                                    P(tool.description, cls=&quot;text-gray-600&quot;),
                                    cls=&quot;flex-1&quot;
                                ),
                                cls=&quot;flex items-start&quot;
                            ),
                            A(&quot;Try it now →&quot;,
                              href=tool.route,
                              cls=&quot;mt-4 inline-block text-blue-600 hover:text-blue-800 font-medium&quot;),
                            cls=&quot;bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition-shadow&quot;
                        )
                        for tool in tools_by_category[category]
                    ],
                    cls=&quot;grid grid-cols-1 md:grid-cols-2 gap-6 mb-12&quot;
                ),
                cls=&quot;mb-8&quot;
            )
            for category in categories
        ],

        cls=&quot;max-w-6xl mx-auto&quot;
    )
```

**File: `pages/tool_pages.py`** (simplified)

```python
from fasthtml.common import *
from tools import get_tool_by_id

def tool_page(tool_id):
    &quot;&quot;&quot;
    Defines the individual tool page content.

    Args:
        tool_id: The ID of the tool to display

    Returns:
        Components representing the tool page content
    &quot;&quot;&quot;
    tool = get_tool_by_id(tool_id)

    # Create form fields based on tool&apos;s input_form_fields
    form_fields = []
    for field_id, field_config in tool.input_form_fields.items():
        # Create appropriate form field based on type
        if field_config[&quot;type&quot;] == &quot;textarea&quot;:
            form_fields.append(
                Div(
                    Label(field_config[&quot;label&quot;], For=field_id, cls=&quot;block text-gray-700 mb-1&quot;),
                    Textarea(
                        id=field_id,
                        name=field_id,
                        placeholder=field_config.get(&quot;placeholder&quot;, &quot;&quot;),
                        rows=field_config.get(&quot;rows&quot;, 3),
                        required=field_config.get(&quot;required&quot;, False),
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                )
            )
        elif field_config[&quot;type&quot;] == &quot;select&quot;:
            options = []
            for option in field_config[&quot;options&quot;]:
                options.append(
                    Option(
                        option[&quot;label&quot;],
                        value=option[&quot;value&quot;],
                        selected=option.get(&quot;selected&quot;, False)
                    )
                )

            form_fields.append(
                Div(
                    Label(field_config[&quot;label&quot;], For=field_id, cls=&quot;block text-gray-700 mb-1&quot;),
                    Select(
                        *options,
                        id=field_id,
                        name=field_id,
                        required=field_config.get(&quot;required&quot;, False),
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                )
            )

    return Div(
        Div(
            # Tool header
            Div(
                Div(
                    NotStr(tool.icon),
                    cls=&quot;text-blue-600 w-16 h-16 mr-4&quot;
                ),
                Div(
                    H1(tool.name, cls=&quot;text-3xl font-bold mb-2&quot;),
                    P(tool.description, cls=&quot;text-gray-600&quot;),
                    cls=&quot;flex-1&quot;
                ),
                cls=&quot;flex items-start mb-8&quot;
            ),

            # Tool form
            Form(
                *form_fields,
                Button(
                    &quot;Generate&quot;,
                    type=&quot;submit&quot;,
                    cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;
                ),
                action=f&quot;/tools/{tool_id}/process&quot;,
                method=&quot;post&quot;,
                cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        ),
        cls=&quot;container mx-auto px-4 py-8&quot;
    )

def tool_results_page(tool_id, results):
    &quot;&quot;&quot;
    Defines the tool results page content.

    Args:
        tool_id: The ID of the tool
        results: The results to display

    Returns:
        Components representing the results page content
    &quot;&quot;&quot;
    tool = get_tool_by_id(tool_id)

    # Format results based on tool type
    if isinstance(results, list):
        # For list results (like titles)
        result_items = [
            Li(
                P(item, cls=&quot;mb-2&quot;),
                cls=&quot;mb-4 p-4 bg-gray-50 rounded-lg&quot;
            )
            for item in results
        ]

        results_display = Div(
            H2(&quot;Generated Results&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            Ul(
                *result_items,
                cls=&quot;list-none p-0&quot;
            ),
            cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
        )
    else:
        # For text results
        results_display = Div(
            H2(&quot;Generated Results&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            Div(
                P(results, cls=&quot;whitespace-pre-wrap&quot;),
                cls=&quot;p-4 bg-gray-50 rounded-lg&quot;
            ),
            cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
        )

    return Div(
        Div(
            # Tool header
            Div(
                Div(
                    NotStr(tool.icon),
                    cls=&quot;text-blue-600 w-16 h-16 mr-4&quot;
                ),
                Div(
                    H1(f&quot;{tool.name} Results&quot;, cls=&quot;text-3xl font-bold mb-2&quot;),
                    P(tool.description, cls=&quot;text-gray-600&quot;),
                    cls=&quot;flex-1&quot;
                ),
                cls=&quot;flex items-start mb-8&quot;
            ),

            # Results
            results_display,

            # Back button
            Div(
                A(&quot;← Try Again&quot;,
                  href=f&quot;/tools/{tool_id}&quot;,
                  cls=&quot;inline-block mt-6 text-blue-600 hover:text-blue-800 font-medium&quot;),
                cls=&quot;mt-4&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        ),
        cls=&quot;container mx-auto px-4 py-8&quot;
    )
```

These tool pages:
- Display a list of tools organized by category
- Generate form fields dynamically based on each tool&apos;s configuration
- Process form submissions and display results
- Format results appropriately based on their type (list or text)
- Provide navigation between tool pages and results

## Main Application

Finally, let&apos;s implement the main application file that ties everything together.

**File: `main.py`**

```python
from fasthtml.common import *

# Import page content from the pages directory
from pages.home import home as home_page
from pages.about import about as about_page
from pages.contact import contact as contact_page
from pages.tools import tools as tools_page
from pages.tool_pages import tool_page, tool_results_page

# Import the tools registry
from tools import get_all_tools, get_tool_by_id

# Import the page layout component
from components.page_layout import page_layout

# Initialize the FastHTML application
app = FastHTML()

@app.get(&quot;/&quot;)
def home():
    &quot;&quot;&quot;Handler for the home page route.&quot;&quot;&quot;
    return page_layout(
        title=&quot;Home - Bit Tools&quot;,
        content=home_page(),
        current_page=&quot;/&quot;
    )

@app.get(&quot;/about&quot;)
def about():
    return page_layout(
        title=&quot;About Us - Bit Tools&quot;,
        content=about_page(),
        current_page=&quot;/about&quot;
    )

@app.get(&quot;/contact&quot;)
def contact():
    return page_layout(
        title=&quot;Contact Us - Bit Tools&quot;,
        content=contact_page(),
        current_page=&quot;/contact&quot;
    )

@app.post(&quot;/submit-contact&quot;)
def submit_contact(name: str, email: str, message: str):
    &quot;&quot;&quot;Handler for contact form submission.&quot;&quot;&quot;
    acknowledgment = Div(
        Div(
            H1(&quot;Thank You!&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            P(f&quot;Hello {name}, we&apos;ve received your message and will respond to {email} soon.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Return Home&quot;, href=&quot;/&quot;, cls=&quot;inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600&quot;)
        , cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;)
    , cls=&quot;max-w-md mx-auto&quot;)

    return page_layout(
        title=&quot;Thank You - Bit Tools&quot;,
        content=acknowledgment,
        current_page=&quot;/contact&quot;
    )

@app.get(&quot;/tools&quot;)
def tools():
    return page_layout(
        title=&quot;AI Tools - Bit Tools&quot;,
        content=tools_page(),
        current_page=&quot;/tools&quot;
    )

@app.get(&quot;/tools/{tool_id}&quot;)
def tool_page_handler(tool_id: str):
    tool = get_tool_by_id(tool_id)
    if not tool:
        error_content = Div(
            Div(
                H1(&quot;Tool Not Found&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
                P(&quot;Sorry, the requested tool could not be found.&quot;, cls=&quot;mb-4&quot;),
                A(&quot;Back to Tools&quot;, href=&quot;/tools&quot;, cls=&quot;inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600&quot;)
            , cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;)
        , cls=&quot;max-w-md mx-auto&quot;)

        return page_layout(
            title=&quot;Tool Not Found - Bit Tools&quot;,
            content=error_content,
            current_page=&quot;/tools&quot;
        )

    return page_layout(
        title=f&quot;{tool.name} - Bit Tools&quot;,
        content=tool_page(tool_id),
        current_page=f&quot;/tools/{tool_id}&quot;
    )

@app.get(&quot;/{path:path}&quot;)
def not_found(path: str):
    error_content = Div(
        Div(
            H1(&quot;404 - Page Not Found&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            P(f&quot;Sorry, the page &apos;/{path}&apos; does not exist.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Return Home&quot;, href=&quot;/&quot;, cls=&quot;inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600&quot;)
        , cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;)
    , cls=&quot;max-w-md mx-auto&quot;)

    return page_layout(
        title=&quot;404 Not Found - Bit Tools&quot;,
        content=error_content,
        current_page=&quot;/&quot;
    )

@app.post(&quot;/tools/{tool_id}/process&quot;)
async def process_tool(tool_id: str, request):
    &quot;&quot;&quot;Handler for tool form submission.&quot;&quot;&quot;
    tool = get_tool_by_id(tool_id)
    if not tool:
        error_content = Div(
            H1(&quot;Tool Not Found&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            P(&quot;Sorry, the requested tool could not be found.&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Back to Tools&quot;, href=&quot;/tools&quot;, cls=&quot;inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600&quot;),
            cls=&quot;container mx-auto max-w-md bg-white p-6 rounded-lg shadow-md text-center&quot;
        )

        return page_layout(
            title=&quot;Tool Not Found - Bit Tools&quot;,
            content=error_content,
            current_page=&quot;/tools&quot;
        )

    try:
        form_data = await request.form()
        inputs = {key: value for key, value in form_data.items()}
        results = await tool.process(inputs)

        return page_layout(
            title=f&quot;{tool.name} Results - Bit Tools&quot;,
            content=tool_results_page(tool_id, results),
            current_page=f&quot;/tools/{tool_id}&quot;
        )
    except Exception as e:
        error_content = Div(
            H1(&quot;Processing Error&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            P(f&quot;An error occurred while processing your request: {str(e)}&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Try Again&quot;, href=f&quot;/tools/{tool_id}&quot;, cls=&quot;inline-block px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600&quot;),
            cls=&quot;container mx-auto max-w-md bg-white p-6 rounded-lg shadow-md text-center&quot;
        )

        return page_layout(
            title=&quot;Error - Bit Tools&quot;,
            content=error_content,
            current_page=f&quot;/tools/{tool_id}&quot;
        )

# Run the application
if __name__ == &quot;__main__&quot;:
    serve()
```

The main application:
- Imports all necessary components and pages
- Defines routes for each page
- Handles form submissions for contact and tool processing
- Provides error handling for not found pages and processing errors
- Starts the FastHTML server

## Running the Application

To run the application, simply execute the main.py file:

```bash
python main.py
```

This will start the FastHTML server, and you can access your website at `http://localhost:5001/`.

## Extending the Platform

One of the key advantages of our modular design is how easy it is to extend the platform. Here are some ways you can add to the Bit Tools platform:

### Adding a New Tool

To add a new tool:

1. Create a new file in the `tools/` directory (e.g., `image_generator.py`)
2. Use the tool factory to create your tool class
3. Define the system prompt, user prompt template, and form fields
4. Implement any necessary post-processing functions
5. Instantiate and register the tool with the registry

The tool will automatically appear on the home page and tools page, and will have its own dedicated page.

#### Example: Creating an Email Crafting Tool

Let&apos;s walk through a complete example of adding a new tool for crafting professional emails. This tool will help users create well-structured emails for different business scenarios.

**File: `tools/email_crafter.py`**

```python
import re
from typing import Dict, Any
from .factory import create_text_generation_tool
from .registry import registry

# System prompt for email generation
email_system_prompt = &quot;&quot;&quot;
You are an expert email writer who specializes in crafting professional, effective emails.
Follow these guidelines when creating emails:

1. Maintain a professional tone appropriate to the context
2. Be clear and concise
3. Use proper email structure (greeting, body, closing)
4. Include all necessary information
5. Avoid unnecessary jargon
6. Ensure proper grammar and punctuation
7. Adapt the style to match the purpose and recipient

Your goal is to create emails that are professional, effective, and achieve the sender&apos;s objective.
&quot;&quot;&quot;

# User prompt template for email generation
email_user_prompt_template = &quot;&quot;&quot;
Create a professional email with the following details:

Purpose: {purpose}
Recipient: {recipient}
Key points to include:
{key_points}

Tone: {tone}
&quot;&quot;&quot;

# Post-processing function for emails
def process_email(text: str) -&gt; Dict[str, Any]:
    &quot;&quot;&quot;Process the generated email to extract subject and body.&quot;&quot;&quot;
    # Extract subject line if present
    subject_match = re.search(r&quot;Subject:(.+?)(?:\n|$)&quot;, text, re.IGNORECASE)
    subject = subject_match.group(1).strip() if subject_match else &quot;No subject extracted&quot;

    # Clean up the text
    email_body = re.sub(r&quot;Subject:.+?\n&quot;, &quot;&quot;, text, flags=re.IGNORECASE)
    email_body = email_body.strip()

    return {
        &quot;subject&quot;: subject,
        &quot;body&quot;: email_body
    }

# Create the email crafter tool
EmailCrafterClass = create_text_generation_tool(
    name=&quot;Professional Email Crafter&quot;,
    description=&quot;Create well-structured professional emails for various business scenarios.&quot;,
    icon=&quot;&quot;&quot;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke-width=&quot;1.5&quot; stroke=&quot;currentColor&quot; class=&quot;w-6 h-6&quot;&gt;
        &lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75&quot; /&gt;
    &lt;/svg&gt;&quot;&quot;&quot;,
    system_prompt=email_system_prompt,
    user_prompt_template=email_user_prompt_template,
    input_form_fields={
        &quot;purpose&quot;: {
            &quot;type&quot;: &quot;select&quot;,
            &quot;label&quot;: &quot;Email Purpose&quot;,
            &quot;options&quot;: [
                {&quot;value&quot;: &quot;Request Information&quot;, &quot;label&quot;: &quot;Request Information&quot;, &quot;selected&quot;: True},
                {&quot;value&quot;: &quot;Follow Up&quot;, &quot;label&quot;: &quot;Follow Up&quot;},
                {&quot;value&quot;: &quot;Thank You&quot;, &quot;label&quot;: &quot;Thank You&quot;},
                {&quot;value&quot;: &quot;Introduction&quot;, &quot;label&quot;: &quot;Introduction&quot;},
                {&quot;value&quot;: &quot;Proposal&quot;, &quot;label&quot;: &quot;Proposal&quot;},
                {&quot;value&quot;: &quot;Complaint&quot;, &quot;label&quot;: &quot;Complaint&quot;}
            ]
        },
        &quot;recipient&quot;: {
            &quot;type&quot;: &quot;select&quot;,
            &quot;label&quot;: &quot;Recipient Type&quot;,
            &quot;options&quot;: [
                {&quot;value&quot;: &quot;Client&quot;, &quot;label&quot;: &quot;Client&quot;, &quot;selected&quot;: True},
                {&quot;value&quot;: &quot;Colleague&quot;, &quot;label&quot;: &quot;Colleague&quot;},
                {&quot;value&quot;: &quot;Manager&quot;, &quot;label&quot;: &quot;Manager&quot;},
                {&quot;value&quot;: &quot;Vendor&quot;, &quot;label&quot;: &quot;Vendor&quot;},
                {&quot;value&quot;: &quot;Potential Customer&quot;, &quot;label&quot;: &quot;Potential Customer&quot;}
            ]
        },
        &quot;key_points&quot;: {
            &quot;type&quot;: &quot;textarea&quot;,
            &quot;label&quot;: &quot;Key Points to Include&quot;,
            &quot;placeholder&quot;: &quot;List the main points you want to include in your email...&quot;,
            &quot;required&quot;: True,
            &quot;rows&quot;: 5
        },
        &quot;tone&quot;: {
            &quot;type&quot;: &quot;select&quot;,
            &quot;label&quot;: &quot;Email Tone&quot;,
            &quot;options&quot;: [
                {&quot;value&quot;: &quot;Formal&quot;, &quot;label&quot;: &quot;Formal&quot;, &quot;selected&quot;: True},
                {&quot;value&quot;: &quot;Friendly Professional&quot;, &quot;label&quot;: &quot;Friendly Professional&quot;},
                {&quot;value&quot;: &quot;Urgent&quot;, &quot;label&quot;: &quot;Urgent&quot;},
                {&quot;value&quot;: &quot;Persuasive&quot;, &quot;label&quot;: &quot;Persuasive&quot;}
            ]
        }
    },
    post_process_func=process_email
)

# Instantiate the tool
email_crafter_tool = EmailCrafterClass()

# Register the tool with the registry
registry.register(email_crafter_tool, categories=[&quot;Communication&quot;])
```

This email crafting tool:

1. **Defines a System Prompt**: Provides detailed instructions to the AI on how to craft professional emails.

2. **Creates a User Prompt Template**: Structures the user&apos;s input into a format that guides the AI to generate a well-formed email.

3. **Implements Post-Processing**: Extracts the subject line and body from the generated email for better display.

4. **Defines Form Fields**:
   - **Purpose**: A dropdown to select the email&apos;s purpose
   - **Recipient**: A dropdown to specify the type of recipient
   - **Key Points**: A textarea for the user to list the main points to include
   - **Tone**: A dropdown to select the desired tone of the email

5. **Registers with the Registry**: Makes the tool available in the &quot;Communication&quot; category.

To display the results, we need to enhance the `tool_results_page` function in `pages/tool_pages.py` to better handle email results:

```python
# Add this to the tool_results_page function in pages/tool_pages.py
elif isinstance(results, dict) and &quot;subject&quot; in results and &quot;body&quot; in results:
    # For email results
    results_display = Div(
        H2(&quot;Generated Email&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
        Div(
            H3(f&quot;Subject: {results[&apos;subject&apos;]}&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
            Div(
                P(results[&apos;body&apos;], cls=&quot;whitespace-pre-wrap&quot;),
                cls=&quot;p-4 bg-gray-50 rounded-lg&quot;
            ),
            cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
        ),
        cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
    )
```

With this implementation, users can now:
1. Select the purpose of their email
2. Specify the type of recipient
3. Enter the key points they want to include
4. Choose the tone of the email
5. Generate a professional email with a subject line and well-structured body

The tool will automatically appear on the home page and tools page, and users can access it through its dedicated page at `/tools/professional-email-crafter`.

### Adding a New Page

To add a new page:

1. Create a new file in the `pages/` directory (e.g., `pricing.py`)
2. Define a function that returns the page content
3. Add a route handler in `main.py`
4. Add a link to the page in the header component

## Conclusion

Congratulations! You&apos;ve learned how to build a complete multi-page AI tools website with FastHTML. This project demonstrates several important concepts:

1. **Modular Design**: Separating components, pages, and tools for better maintainability
2. **Reusable Components**: Creating consistent header, footer, and layout components
3. **Dynamic Content**: Generating pages and forms based on tool configurations
4. **Error Handling**: Providing user-friendly error messages
5. **Responsive Design**: Using Tailwind CSS for a responsive layout

The Bit Tools platform provides a solid foundation that you can extend with additional tools and features. By following the patterns established in this project, you can create a powerful, maintainable web application that leverages AI to provide value to your users.

Happy coding!

## FastHTML Series

Below are the articles in our FastHTML series to help you get started:

- [FastHTML Get Started](https://www.bitdoze.com/fasthtml-start/)
- [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/)
- [FastHTML Complex AI Tools](https://www.bitdoze.com/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](https://www.bitdoze.com/fasthtml-sqlite-db/)
- [FastHTML Authentication](https://www.bitdoze.com/fasthtml-user-auth/)</content:encoded><category>web-development</category><category>fasthtml</category></item><item><title>Building a Simple AI-Powered Web App with FastHTML and PydanticAI</title><link>https://www.bitdoze.com/fasthtml-pydenticai-tools/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-pydenticai-tools/</guid><description>Learn how to build a modern AI title generator web app using FastHTML and Pydantic AI with OpenRouter integration. This step-by-step tutorial covers creating a modular project structure, implementing AI services, and building a responsive user interface for generating optimized content titles.</description><pubDate>Mon, 03 Mar 2025 00:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;

Welcome to the next article in our FastHTML series! In this tutorial, we&apos;ll build a practical AI-powered web application using FastHTML and Pydantic AI.

If you&apos;re new to FastHTML, I recommend checking out our previous articles:
- [FastHTML Get Started](https://www.bitdoze.com/fasthtml-start/) - Where we cover the basics of creating your first FastHTML page
- [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/) - Where we explore creating a multi-page website with consistent navigation

Now we&apos;re taking things to the next level by adding AI capabilities to our FastHTML website. Unlike traditional web development that requires JavaScript frameworks, Python developers can now create full-featured web applications with AI integration using pure Python. Isn&apos;t that cool?

Our project today is an AI Title Generator - a handy tool that helps content creators develop engaging titles for blogs, YouTube videos, social media posts, and more. By the end of this tutorial, you&apos;ll have a working title generator that you can customize and extend with other AI features.

Don&apos;t worry if you&apos;re new to AI integration - we&apos;ll break everything down into manageable steps and explain how each part works. Let&apos;s dive in and start building!


## Project Structure Overview

Here&apos;s the exact structure we&apos;ll build:

```
ai-title-generator/
├── main.py                # Main application entry point
├── config.py              # Configuration settings
├── ai_service.py          # AI integration module
├── components/            # Reusable UI components
│   ├── __init__.py
│   ├── header.py          # Page header
│   ├── footer.py          # Page footer
│   └── page_layout.py     # Layout template
├── pages/                 # Individual page content
│   ├── __init__.py
│   ├── home.py            # Home page
│   └── title_generator.py # Title generator page
└── tools/                 # AI tools
    ├── __init__.py
    └── title_generator.py # Title generation tool
```

This structure follows software design best practices:

- **Separation of concerns**: Each module has a specific responsibility
- **Component reusability**: UI elements are compartmentalized for reuse
- **Modular design**: Different aspects of the application are organized into logical groups
- **Scalability**: Easy to add new features or AI tools



## Building a Simple AI-Powered Web App with FastHTML and PydanticAI



&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/omVqE81ssHc&quot;
  label=&quot;FastHTML AI Tools&quot;
/&gt;

### Step 1: Setting Up the Project

First, let&apos;s create our project directory and install the required packages:

```bash
mkdir -p ai-title-generator/components ai-title-generator/pages ai-title-generator/tools
cd ai-title-generator
touch components/__init__.py pages/__init__.py tools/__init__.py
python3 -m venv .venv
source .venv/bin/activate
pip install python-fasthtml python-dotenv openai pydantic-ai
```

Here we&apos;re installing:
- **python-fasthtml**: The Python-based web framework that lets us build HTML interfaces with Python code
- **python-dotenv**: For loading environment variables from a .env file
- **openai**: The official OpenAI Python client (compatible with OpenRouter)
- **pydantic-ai**: A library that combines structured data validation with AI capabilities

Now let&apos;s create our configuration file to manage environment variables:

**File: `config.py`**

```python
import os
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# API configuration
OPENROUTER_API_KEY = os.getenv(&quot;OPENROUTER_API_KEY&quot;)
OPENROUTER_BASE_URL = &quot;https://openrouter.ai/api/v1&quot;

# Default model to use
DEFAULT_MODEL = os.getenv(&quot;DEFAULT_MODEL&quot;, &quot;openai/gpt-3.5-turbo&quot;)

# Application settings
DEBUG = os.getenv(&quot;DEBUG&quot;, &quot;True&quot;).lower() == &quot;true&quot;
APP_NAME = &quot;AI Title Generator&quot;
```

**Explanation**:
- We use `load_dotenv()` to load environment variables from a .env file
- We set up configuration for OpenRouter API access
- We define a default AI model with a fallback to GPT-3.5 Turbo
- We set application-wide settings like DEBUG mode and APP_NAME
- All these settings are centralized for easy updates

Create a `.env` file in your project root with your OpenRouter API key:

**File: `.env`**

```
OPENROUTER_API_KEY=your_openrouter_api_key_here
DEFAULT_MODEL=openai/gpt-3.5-turbo
DEBUG=True
```

This file will be read by the `python-dotenv` library but won&apos;t be committed to version control, keeping your API keys secure.

### Step 2: Creating the AI Service

The AI service acts as a bridge between our application and AI models. It handles all communication with OpenRouter&apos;s API and provides a clean interface for our application to use.

**File: `ai_service.py`**

```python
from openai import AsyncOpenAI
from pydantic_ai import Agent
from pydantic_ai.models.openai import OpenAIModel
import config
from typing import Optional, List, Dict, Any

class AIService:
    &quot;&quot;&quot;Service for interacting with AI models via OpenRouter.&quot;&quot;&quot;

    def __init__(self, model_name: Optional[str] = None):
        &quot;&quot;&quot;
        Initialize the AI service.

        Args:
            model_name: Name of the model to use, defaults to config.DEFAULT_MODEL
        &quot;&quot;&quot;
        self.model_name = model_name or config.DEFAULT_MODEL
        self.client = AsyncOpenAI(
            api_key=config.OPENROUTER_API_KEY,
            base_url=config.OPENROUTER_BASE_URL,
        )

        # Initialize the Pydantic AI agent
        model = OpenAIModel(self.model_name, openai_client=self.client)
        self.agent = Agent(model)

    async def chat_completion(self,
                           user_message: str,
                           system_prompt: Optional[str] = None,
                           temperature: float = 0.7) -&gt; str:
        &quot;&quot;&quot;
        Get a chat completion from the AI model.

        Args:
            user_message: The user&apos;s message/query
            system_prompt: Optional system instructions
            temperature: Controls randomness (0.0-1.0)

        Returns:
            The AI&apos;s response as a string
        &quot;&quot;&quot;
        messages = []

        # Add system message if provided
        if system_prompt:
            messages.append({&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: system_prompt})

        # Add user message
        messages.append({&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: user_message})

        # Call the OpenAI API directly for more control
        response = await self.client.chat.completions.create(
            model=self.model_name,
            messages=messages,
            temperature=temperature,
        )

        # Extract and return the response text
        return response.choices[0].message.content

    async def structured_completion(self,
                                 user_message: str,
                                 output_schema: Any,
                                 system_prompt: Optional[str] = None) -&gt; Any:
        &quot;&quot;&quot;
        Get a structured completion using Pydantic AI.

        Args:
            user_message: The user&apos;s message/query
            output_schema: Pydantic model defining the output structure
            system_prompt: Optional system instructions

        Returns:
            An instance of the output_schema Pydantic model
        &quot;&quot;&quot;
        # Create a prompt dictionary
        prompt = {&quot;query&quot;: user_message}

        # Set system prompt if provided
        if system_prompt:
            self.agent.system_prompt = system_prompt

        # Get structured response using Pydantic AI
        result = await self.agent.run(
            input=prompt,
            output_schema=output_schema
        )

        return result
```

**Explanation**:
- The `AIService` class provides two main methods for interacting with AI models:
  - `chat_completion`: A standard method that returns free-form text responses
  - `structured_completion`: Uses Pydantic AI to return validated, structured data

- The class initializes an OpenAI client configured to use OpenRouter&apos;s API endpoint
- We use async methods for better performance and responsiveness
- `temperature` parameter controls how random or deterministic the AI responses are
- The Pydantic AI agent will force responses to conform to a specific data structure

### Step 3: Creating the Title Generator Tool

Now let&apos;s create the tool that generates titles. This tool uses our AI service and implements specific logic for title generation.

**File: `tools/title_generator.py`**

```python
from typing import List, Dict, Any, Optional
from pydantic import BaseModel
from ai_service import AIService
import re

class TitleGenerationRequest(BaseModel):
    &quot;&quot;&quot;Schema for title generation request.&quot;&quot;&quot;
    topic: str
    platform: str = &quot;Blog&quot;
    style: str = &quot;Professional&quot;
    number_of_titles: int = 5

class TitleGenerationResponse(BaseModel):
    &quot;&quot;&quot;Schema for title generation response.&quot;&quot;&quot;
    titles: List[str]

class TitleGenerator:
    &quot;&quot;&quot;Tool for generating titles for various platforms.&quot;&quot;&quot;

    def __init__(self):
        &quot;&quot;&quot;Initialize the title generator tool.&quot;&quot;&quot;
        self.name = &quot;AI Title Generator&quot;
        self.description = &quot;Generate engaging titles for blogs, YouTube videos, or social media posts.&quot;
        self.ai_service = AIService()

    async def generate_titles(self,
                           topic: str,
                           platform: str = &quot;Blog&quot;,
                           style: str = &quot;Professional&quot;,
                           number_of_titles: int = 5) -&gt; List[str]:
        &quot;&quot;&quot;
        Generate titles based on the given parameters.

        Args:
            topic: The subject to generate titles about
            platform: The platform (Blog, YouTube, etc.)
            style: The writing style
            number_of_titles: Number of titles to generate

        Returns:
            A list of generated titles
        &quot;&quot;&quot;
        # Create system prompt
        system_prompt = &quot;&quot;&quot;
        You are an expert title generator specializing in creating engaging, click-worthy titles
        that are appropriate for different platforms. Follow these guidelines:

        - Create titles that grab attention without being misleading
        - Adapt the style and format to the specified platform
        - Ensure titles are relevant to the topic
        - Keep titles concise and effective
        - Return only the titles as a numbered list
        &quot;&quot;&quot;

        # Create user prompt
        user_prompt = f&quot;&quot;&quot;
        Generate {number_of_titles} engaging {platform} titles about: {topic}

        Style: {style}

        Return only the titles as a numbered list.
        &quot;&quot;&quot;

        # Get raw response from AI
        raw_response = await self.ai_service.chat_completion(
            user_message=user_prompt,
            system_prompt=system_prompt,
            temperature=0.8
        )

        # Process response to extract titles
        titles = self._extract_titles_from_response(raw_response, number_of_titles)

        return titles

    def _extract_titles_from_response(self, response: str, expected_count: int) -&gt; List[str]:
        &quot;&quot;&quot;
        Extract titles from the AI response.

        Args:
            response: The raw AI response text
            expected_count: Expected number of titles

        Returns:
            List of extracted titles
        &quot;&quot;&quot;
        # Remove any markdown or extra formatting
        clean_response = response.strip()

        # Try to extract numbered list items (e.g., &quot;1. Title here&quot;)
        numbered_pattern = r&quot;^\s*\d+\.?\s*(.+)$&quot;
        titles = []

        # Process line by line
        for line in clean_response.split(&apos;\n&apos;):
            line = line.strip()
            if not line:
                continue

            # Try to match numbered pattern
            match = re.match(numbered_pattern, line)
            if match:
                title = match.group(1).strip()
                if title:
                    titles.append(title)
            elif not line.startswith(&apos;#&apos;) and len(line) &gt; 15:
                # If not a numbered item but looks like a title
                # (not a heading and reasonably long)
                titles.append(line)

        # If we couldn&apos;t extract properly, just split by newlines and take non-empty lines
        if not titles:
            titles = [line.strip() for line in clean_response.split(&apos;\n&apos;)
                     if line.strip() and len(line.strip()) &gt; 10]

        # Return up to the expected count
        return titles[:expected_count]
```

**Explanation**:
- We define two Pydantic models:
  - `TitleGenerationRequest`: Defines the expected input parameters
  - `TitleGenerationResponse`: Defines the expected output format

- The `TitleGenerator` class has:
  - Metadata like name and description
  - A method to generate titles using the AI service
  - A helper method to extract clean title strings from the AI&apos;s response

- The system prompt provides detailed instructions for the AI model
- We use regular expressions to parse the titles from the numbered list
- The code includes fallback extraction logic in case the AI doesn&apos;t format its response as expected
- We limit the titles to the requested count

### Step 4: Creating UI Components

Next, we&apos;ll create reusable UI components for our application. Let&apos;s start with the header.

**File: `components/header.py`**

```python
from fasthtml.common import *
import config

def header(current_page=&quot;/&quot;):
    &quot;&quot;&quot;
    Creates a consistent header with navigation.

    Args:
        current_page: The current page path

    Returns:
        A Header component with navigation
    &quot;&quot;&quot;
    nav_items = [
        (&quot;Home&quot;, &quot;/&quot;),
        (&quot;Title Generator&quot;, &quot;/title-generator&quot;)
    ]

    nav_links = []
    for title, path in nav_items:
        is_current = current_page == path
        link_class = &quot;text-white hover:text-gray-300 px-3 py-2&quot;
        if is_current:
            link_class += &quot; font-bold underline&quot;

        nav_links.append(
            Li(
                A(title, href=path, cls=link_class)
            )
        )

    return Header(
        Div(
            A(config.APP_NAME, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),
            Nav(
                Ul(
                    *nav_links,
                    cls=&quot;flex space-x-2&quot;
                ),
                cls=&quot;ml-auto&quot;
            ),
            cls=&quot;container mx-auto flex items-center justify-between px-4 py-3&quot;
        ),
        cls=&quot;bg-blue-600 shadow-md&quot;
    )
```

**Explanation**:
- The `header` function creates a navigation bar with links to different pages
- It takes a `current_page` parameter to highlight the active menu item
- We use Tailwind CSS classes for styling:
  - `bg-blue-600`: Blue background color
  - `shadow-md`: Medium shadow for depth
  - `flex` and `justify-between`: Flexbox layout for positioning
- The function builds HTML elements like `Header`, `Div`, `Nav`, etc. using FastHTML components
- The navigation items are generated dynamically, making it easy to add new pages

Now, let&apos;s create the footer component:

**File: `components/footer.py`**

```python
from fasthtml.common import *
import config

def footer():
    &quot;&quot;&quot;Creates a consistent footer.&quot;&quot;&quot;
    return Footer(
        Div(
            P(f&quot;© 2025 {config.APP_NAME}. Built with FastHTML and Pydantic AI.&quot;,
              cls=&quot;text-center text-gray-500&quot;),
            cls=&quot;container mx-auto px-4 py-6&quot;
        ),
        cls=&quot;bg-gray-100 mt-auto&quot;
    )
```

**Explanation**:
- The `footer` function creates a simple footer with copyright information
- It uses the `APP_NAME` from the config file to maintain consistency
- The `mt-auto` class pushes the footer to the bottom of the page
- It has a light gray background with centered text

Finally, let&apos;s create the page layout component that brings everything together:

**File: `components/page_layout.py`**

```python
from fasthtml.common import *
from .header import header
from .footer import footer

def page_layout(title, content, current_page=&quot;/&quot;):
    &quot;&quot;&quot;
    Creates a consistent page layout with header and footer.

    Args:
        title: The page title
        content: The main content components
        current_page: The current page path

    Returns:
        A complete HTML page
    &quot;&quot;&quot;
    return Html(
        Head(
            Title(title),
            Meta(charset=&quot;UTF-8&quot;),
            Meta(name=&quot;viewport&quot;, content=&quot;width=device-width, initial-scale=1.0&quot;),
            # Include Tailwind CSS for styling
            Script(src=&quot;https://cdn.tailwindcss.com&quot;),
        ),
        Body(
            Div(
                header(current_page),
                Main(
                    Div(
                        content,
                        cls=&quot;container mx-auto px-4 py-8&quot;
                    ),
                    cls=&quot;flex-grow&quot;
                ),
                footer(),
                cls=&quot;flex flex-col min-h-screen&quot;
            )
        )
    )
```

**Explanation**:
- The `page_layout` function creates a complete HTML page with:
  - Proper HTML document structure
  - Metadata in the `&lt;head&gt;` section
  - The header component with current page highlighted
  - The main content area
  - The footer component
- It uses a flexbox layout to ensure the footer stays at the bottom:
  - `flex flex-col min-h-screen`: Makes the container a flex column with minimum height of viewport
  - `flex-grow`: Makes the main content area expand to fill available space
- The Tailwind CSS is included via CDN for simplicity
- The viewport meta tag ensures responsive behavior on mobile devices

### Step 5: Creating Pages

Now let&apos;s create the individual pages of our application, starting with the home page:

**File: `pages/home.py`**

```python
from fasthtml.common import *
import config

def home():
    &quot;&quot;&quot;
    Defines the home page content.

    Returns:
        Components representing the home page content
    &quot;&quot;&quot;
    return Div(
        # Hero section
        Div(
            H1(config.APP_NAME,
               cls=&quot;text-4xl font-bold text-center text-gray-800 mb-4&quot;),
            P(&quot;Create engaging titles for your content with AI assistance.&quot;,
              cls=&quot;text-xl text-center text-gray-600 mb-6&quot;),
            Div(
                A(&quot;Generate Titles →&quot;,
                  href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
                cls=&quot;flex justify-center&quot;
            ),
            cls=&quot;py-12&quot;
        ),

        # Features section
        Div(
            H2(&quot;Features&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                # Feature 1
                Div(
                    H3(&quot;Platform-Specific&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Generate titles optimized for blogs, YouTube, social media, and more.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Feature 2
                Div(
                    H3(&quot;Multiple Styles&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Choose from professional, casual, clickbait, or informative styles.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Feature 3
                Div(
                    H3(&quot;AI-Powered&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Utilizes advanced AI models to craft engaging, relevant titles.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-6&quot;
            ),
            cls=&quot;py-8&quot;
        ),

        # How it works section
        Div(
            H2(&quot;How It Works&quot;, cls=&quot;text-3xl font-bold text-center mb-8&quot;),
            Div(
                # Step 1
                Div(
                    Div(
                        &quot;1&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Enter Your Topic&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Describe what your content is about in detail.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Step 2
                Div(
                    Div(
                        &quot;2&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Choose Settings&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Select the platform and style that matches your needs.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                # Step 3
                Div(
                    Div(
                        &quot;3&quot;,
                        cls=&quot;flex items-center justify-center bg-blue-600 text-white text-xl font-bold rounded-full w-10 h-10 mb-4&quot;
                    ),
                    H3(&quot;Get Results&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                    P(&quot;Review multiple title options and choose your favorite.&quot;,
                      cls=&quot;text-gray-600&quot;),
                    cls=&quot;bg-white p-6 rounded-lg shadow-md&quot;
                ),
                cls=&quot;grid grid-cols-1 md:grid-cols-3 gap-6&quot;
            ),
            cls=&quot;py-8&quot;
        )
    )
```

**Explanation**:
- The home page is divided into three main sections:
  1. **Hero section**: A prominent call-to-action area with a heading, description, and button
  2. **Features section**: Highlights key features of the application in a responsive grid
  3. **How it works section**: Explains the process in a step-by-step format

- We use Tailwind CSS extensively for styling:
  - Responsive grid with `grid-cols-1 md:grid-cols-3` (1 column on mobile, 3 on medium+ screens)
  - Consistent card styling with `bg-white p-6 rounded-lg shadow-md`
  - Proper spacing with margin and padding classes
  - Text styling with size, weight, and color classes

- The UI follows a clean, modern design pattern with:
  - Clear visual hierarchy
  - Ample white space
  - Consistent visual elements
  - Step indicators with numbered circles

Next, let&apos;s create the title generator page with its form and results view:

**File: `pages/title_generator.py`**

```python
from fasthtml.common import *

def title_generator_form():
    &quot;&quot;&quot;
    Defines the title generator form page.

    Returns:
        Components representing the title generator form
    &quot;&quot;&quot;
    return Div(
        # Page header
        H1(&quot;AI Title Generator&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Generator form
        Div(
            Form(
                # Topic field
                Div(
                    Label(&quot;What&apos;s your content about?&quot;, For=&quot;topic&quot;,
                          cls=&quot;block text-gray-700 mb-2&quot;),
                    Textarea(
                        id=&quot;topic&quot;,
                        name=&quot;topic&quot;,
                        placeholder=&quot;Describe your content topic in detail for better results...&quot;,
                        rows=3,
                        required=True,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Platform selection
                Div(
                    Label(&quot;Platform:&quot;, For=&quot;platform&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;Blog&quot;, value=&quot;Blog&quot;, selected=True),
                        Option(&quot;YouTube&quot;, value=&quot;YouTube&quot;),
                        Option(&quot;Social Media&quot;, value=&quot;Social Media&quot;),
                        Option(&quot;Email Subject&quot;, value=&quot;Email Subject&quot;),
                        Option(&quot;News Article&quot;, value=&quot;News Article&quot;),
                        id=&quot;platform&quot;,
                        name=&quot;platform&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Style selection
                Div(
                    Label(&quot;Style:&quot;, For=&quot;style&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;Professional&quot;, value=&quot;Professional&quot;, selected=True),
                        Option(&quot;Casual&quot;, value=&quot;Casual&quot;),
                        Option(&quot;Clickbait&quot;, value=&quot;Clickbait&quot;),
                        Option(&quot;Informative&quot;, value=&quot;Informative&quot;),
                        Option(&quot;Funny&quot;, value=&quot;Funny&quot;),
                        id=&quot;style&quot;,
                        name=&quot;style&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-4&quot;
                ),

                # Number of titles
                Div(
                    Label(&quot;Number of titles:&quot;, For=&quot;number_of_titles&quot;, cls=&quot;block text-gray-700 mb-2&quot;),
                    Select(
                        Option(&quot;5&quot;, value=&quot;5&quot;, selected=True),
                        Option(&quot;10&quot;, value=&quot;10&quot;),
                        Option(&quot;15&quot;, value=&quot;15&quot;),
                        id=&quot;number_of_titles&quot;,
                        name=&quot;number_of_titles&quot;,
                        cls=&quot;w-full px-3 py-2 border rounded focus:outline-none focus:ring focus:border-blue-500&quot;
                    ),
                    cls=&quot;mb-6&quot;
                ),

                # Submit button
                Button(
                    &quot;Generate Titles&quot;,
                    type=&quot;submit&quot;,
                    cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;
                ),

                action=&quot;/title-generator/generate&quot;,
                method=&quot;post&quot;,
                cls=&quot;bg-white p-6 rounded-lg shadow-md mb-8&quot;
            ),

            # Tips section
            Div(
                H3(&quot;Tips for Better Titles&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                Ul(
                    Li(&quot;Be specific about your topic for more relevant titles&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;Include your target audience for better context&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;Mention key points you want to highlight&quot;, cls=&quot;mb-1&quot;),
                    Li(&quot;For YouTube, specify if it&apos;s a tutorial, review, etc.&quot;, cls=&quot;mb-1&quot;),
                    cls=&quot;list-disc pl-5 text-gray-600&quot;
                ),
                cls=&quot;bg-blue-50 p-4 rounded-lg mt-6&quot;
            ),

            cls=&quot;max-w-2xl mx-auto&quot;
        )
    )

def title_generator_results(topic, platform, style, titles):
    &quot;&quot;&quot;
    Defines the title generator results page.

    Args:
        topic: The topic that was entered
        platform: The platform that was selected
        style: The style that was selected
        titles: List of generated titles

    Returns:
        Components representing the results page
    &quot;&quot;&quot;
    # Create list items for each title
    title_items = []
    for i, title in enumerate(titles):
        title_items.append(
            Li(
                Div(
                    P(title, cls=&quot;font-medium&quot;),
                    Button(
                        &quot;Copy&quot;,
                        type=&quot;button&quot;,
                        onclick=f&quot;navigator.clipboard.writeText(&apos;{title.replace(&quot;&apos;&quot;, &quot;\\&apos;&quot;)}&apos;); this.textContent = &apos;Copied!&apos;; setTimeout(() =&gt; this.textContent = &apos;Copy&apos;, 2000);&quot;,
                        cls=&quot;ml-auto text-sm bg-gray-200 hover:bg-gray-300 px-2 py-1 rounded&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;p-3 border-b last:border-b-0&quot;
            )
        )

    return Div(
        # Page header
        H1(&quot;Generated Titles&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-6&quot;),

        # Results container
        Div(
            # Query summary
            Div(
                H2(&quot;Your Request&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                P(
                    Strong(&quot;Topic: &quot;), Span(topic), Br(),
                    Strong(&quot;Platform: &quot;), Span(platform), Br(),
                    Strong(&quot;Style: &quot;), Span(style),
                    cls=&quot;text-gray-600 mb-4&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # Titles list
            Div(
                H2(&quot;Title Options&quot;, cls=&quot;text-xl font-semibold mb-2&quot;),
                P(&quot;Click &apos;Copy&apos; to copy any title to your clipboard.&quot;, cls=&quot;text-gray-600 mb-3&quot;),
                Ul(
                    *title_items,
                    cls=&quot;border rounded divide-y&quot;
                ),
                cls=&quot;mb-6&quot;
            ),

            # Action buttons
            Div(
                A(&quot;Generate More&quot;,
                  href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-3&quot;),
                A(&quot;Back to Home&quot;,
                  href=&quot;/&quot;,
                  cls=&quot;bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded&quot;),
                cls=&quot;flex&quot;
            ),

            cls=&quot;bg-white p-6 rounded-lg shadow-md mb-8 max-w-2xl mx-auto&quot;
        )
    )
```

**Explanation**:
- This file contains two functions:
  1. `title_generator_form()`: Creates the input form for generating titles
  2. `title_generator_results()`: Creates the results page showing generated titles

- The form includes:
  - A textarea for entering the topic
  - Dropdown selects for platform, style, and number of titles
  - A submit button to trigger generation
  - A tips section for better results

- Form components are structured with:
  - Proper `&lt;label&gt;` elements for accessibility
  - Input validation (required attribute)
  - Focus states and visual feedback
  - Clear organization with consistent spacing

- The results page includes:
  - A summary of the user&apos;s request
  - A list of generated titles
  - Copy buttons with JavaScript for each title
  - Navigation buttons to generate more or return home

- The copy button uses a small JavaScript snippet to:
  1. Copy the title text to the clipboard
  2. Change the button text to &quot;Copied!&quot; temporarily
  3. Revert back to &quot;Copy&quot; after 2 seconds

### Step 6: Creating the Main Application

Finally, let&apos;s create the main application file that ties everything together:

**File: `main.py`**

```python
from fasthtml.common import *

# Import page content
from pages.home import home as home_page
from pages.title_generator import title_generator_form, title_generator_results

# Import the page layout component
from components.page_layout import page_layout

# Import title generator tool
from tools.title_generator import TitleGenerator

# Import config
import config

# Initialize the FastHTML application
app = FastHTML()

# Initialize title generator tool
title_generator = TitleGenerator()

@app.get(&quot;/&quot;)
def home():
    &quot;&quot;&quot;Handler for the home page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Home - {config.APP_NAME}&quot;,
        content=home_page(),
        current_page=&quot;/&quot;
    )

@app.get(&quot;/title-generator&quot;)
def title_generator_page():
    &quot;&quot;&quot;Handler for the title generator page route.&quot;&quot;&quot;
    return page_layout(
        title=f&quot;Title Generator - {config.APP_NAME}&quot;,
        content=title_generator_form(),
        current_page=&quot;/title-generator&quot;
    )

@app.post(&quot;/title-generator/generate&quot;)
async def generate_titles(topic: str, platform: str, style: str, number_of_titles: str):
    &quot;&quot;&quot;
    Handler for processing title generation requests.

    Args:
        topic: The content topic
        platform: The target platform
        style: The title style
        number_of_titles: Number of titles to generate
    &quot;&quot;&quot;
    try:
        # Validate inputs
        if not topic:
            error_message = Div(
                H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
                P(&quot;Please provide a topic for your titles.&quot;, cls=&quot;mb-4&quot;),
                A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
                  cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
                cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
            )

            return page_layout(
                title=f&quot;Error - {config.APP_NAME}&quot;,
                content=error_message,
                current_page=&quot;/title-generator&quot;
            )

        # Convert number_of_titles to integer
        num_titles = int(number_of_titles)

        # Generate titles
        titles = await title_generator.generate_titles(
            topic=topic,
            platform=platform,
            style=style,
            number_of_titles=num_titles
        )

        # Return the results page
        return page_layout(
            title=f&quot;Generated Titles - {config.APP_NAME}&quot;,
            content=title_generator_results(
                topic=topic,
                platform=platform,
                style=style,
                titles=titles
            ),
            current_page=&quot;/title-generator&quot;
        )
    except Exception as e:
        # Handle errors
        error_message = Div(
            H1(&quot;Error&quot;, cls=&quot;text-3xl font-bold text-red-600 mb-4&quot;),
            P(f&quot;An error occurred while generating titles: {str(e)}&quot;, cls=&quot;mb-4&quot;),
            A(&quot;Try Again&quot;, href=&quot;/title-generator&quot;,
              cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
            cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md&quot;
        )

        return page_layout(
            title=f&quot;Error - {config.APP_NAME}&quot;,
            content=error_message,
            current_page=&quot;/title-generator&quot;
        )

@app.get(&quot;/{path:path}&quot;)
def not_found(path: str):
    &quot;&quot;&quot;Handler for 404 Not Found errors.&quot;&quot;&quot;
    error_content = Div(
        H1(&quot;404 - Page Not Found&quot;, cls=&quot;text-3xl font-bold text-gray-800 mb-4&quot;),
        P(f&quot;Sorry, the page &apos;/{path}&apos; does not exist.&quot;, cls=&quot;mb-4&quot;),
        A(&quot;Return Home&quot;, href=&quot;/&quot;,
          cls=&quot;bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded&quot;),
        cls=&quot;max-w-2xl mx-auto bg-white p-6 rounded-lg shadow-md text-center&quot;
    )

    return page_layout(
        title=f&quot;404 Not Found - {config.APP_NAME}&quot;,
        content=error_content,
        current_page=&quot;/&quot;
    )

# Run the application
if __name__ == &quot;__main__&quot;:
    import uvicorn
    uvicorn.run(&quot;main:app&quot;, host=&quot;0.0.0.0&quot;, port=5001, reload=True)
```

**Explanation**:
- This file serves as the entry point for our FastHTML application and defines:
  - Route handlers for different URLs
  - The application&apos;s behavior when processing form submissions
  - Error handling for invalid inputs and unexpected errors
  - A catch-all handler for 404 errors

- The core functionality includes:
  - Route `/`: Displays the home page
  - Route `/title-generator`: Displays the title generator form
  - Route `/title-generator/generate`: Processes the form submission (POST route)
  - Route `/{path:path}`: Catches any undefined routes and shows a 404 page

- The `generate_titles` function:
  1. Validates user input
  2. Converts string parameters to appropriate types
  3. Calls the title generator tool to generate titles
  4. Returns the results page with generated titles
  5. Handles errors and provides user-friendly error messages

- We use FastHTML&apos;s routing decorators (`@app.get()` and `@app.post()`) to:
  - Map URLs to specific handler functions
  - Automatically extract form data as function parameters

- The error handling implements defensive programming:
  - Checks for empty input
  - Uses try/except to catch any unexpected errors
  - Provides clear error messages with actionable next steps

- The application server is configured to:
  - Run on all network interfaces (0.0.0.0)
  - Use port 5001
  - Enable auto-reload during development for faster iteration

### Step 7: Running Your Application

To run the application:

1. Make sure you&apos;ve set up your `.env` file with your OpenRouter API key:

```bash
echo &quot;OPENROUTER_API_KEY=your_api_key_here&quot; &gt; .env
```

2. Run the application:

```bash
python main.py
```

3. Open your browser and visit `http://localhost:5001`

You should see the home page with information about the title generator. From here, you can navigate to the title generator and begin creating AI-powered titles.

#### Using the Title Generator

1. **Navigate to the Title Generator page**:
   - Click &quot;Generate Titles&quot; button on the home page, or
   - Click &quot;Title Generator&quot; in the navigation menu

2. **Enter your requirements**:
   - Type your content topic in the textarea
   - Select the platform (Blog, YouTube, etc.)
   - Choose a style (Professional, Casual, etc.)
   - Specify how many titles you want

3. **Generate titles**:
   - Click &quot;Generate Titles&quot; to submit the form
   - Wait briefly while the AI processes your request
   - Review the generated titles on the results page

4. **Use the results**:
   - Click &quot;Copy&quot; next to any title to copy it to your clipboard
   - Generate more titles by clicking &quot;Generate More&quot;
   - Return to the home page by clicking &quot;Back to Home&quot;

#### Application Flow

Here&apos;s how the application works behind the scenes:

1. **FastHTML routing** receives the HTTP request
2. The appropriate **handler function** processes the request based on the URL
3. For form submissions, the **TitleGenerator tool** connects to the AI model
4. The **AIService** sends the request to OpenRouter&apos;s API
5. The AI model generates recommendations
6. The response is **processed and formatted**
7. A **response page** is rendered with the results
8. The user sees clean, formatted titles they can use for their content

## Enhancing the Title Generator

This title generator can be enhanced in several ways:

1. **Add more platforms**: Expand the options to include more specific platforms like TikTok, Pinterest, or LinkedIn. This would require updating the platform dropdown and potentially adjusting the system prompt for more platform-specific guidance.

2. **Add SEO options**: Include settings for generating SEO-friendly titles with keyword optimization. You could add fields for target keywords and SEO requirements.

3. **Add title length options**: Allow users to specify if they want short, medium, or long titles, which can be important for different platforms that have different character limits.

4. **Create a title variation tool**: Add a feature to generate variations of an existing title. This would be valuable for A/B testing titles for the same content.

5. **Add a favorites system**: Allow users to save their favorite generated titles. This would require adding user sessions or a simple database.

6. **Implement a history feature**: Keep track of previously generated titles so users can refer back to them.

7. **Add formatting options**: Allow users to specify capitalization styles, or include options for including numbers, questions, or emotional hooks in titles.

8. **Implement user feedback**: Add a rating system for generated titles to help improve the AI model&apos;s performance over time.

## Advanced Implementation Ideas

For those looking to take this project further, here are some advanced ideas:

1. **User authentication**: Add login functionality to allow users to save preferences and title history.

2. **Custom AI models**: Integrate with fine-tuned models specifically trained for title generation.

3. **Analytics**: Track usage patterns to understand which types of titles users prefer.

4. **Batch processing**: Allow users to generate titles for multiple topics at once.

5. **Export functionality**: Enable exporting title lists to CSV or other formats.

6. **A/B testing integration**: Connect with platforms like Google Optimize to test title effectiveness.

7. **Competitor analysis**: Add features to analyze existing popular titles in specific niches.

## Conclusion

You&apos;ve now built a complete AI-powered title generator web application using FastHTML and Pydantic AI. This project demonstrates several key concepts:

1. **Python-based web development**: Building a full-stack web application without JavaScript frameworks
2. **AI integration**: Connecting to powerful language models through OpenRouter
3. **Modular application design**: Creating maintainable code through separation of concerns
4. **Responsive UI**: Building a clean, mobile-friendly interface with Tailwind CSS
5. **Error handling**: Implementing robust error handling for a better user experience

The power of this approach is that you can create sophisticated web applications entirely in Python, leveraging AI capabilities through a clean, type-safe interface. The modular architecture allows you to easily extend this application with additional AI tools or enhance the existing title generator with more features.

This application pattern can be adapted to create many other AI-powered tools, such as:
- Content summarizers
- Product description generators
- Social media post creators
- Email draft writers
- SEO description optimizers

By combining FastHTML&apos;s intuitive component system with Pydantic AI&apos;s structured approach to AI interactions, you can rapidly develop AI-powered web applications that provide real value to users while maintaining clean, maintainable code.

Happy coding!

## FastHTML Series

Below are the articles in our FastHTML series to help you get started:

- [FastHTML Get Started](https://www.bitdoze.com/fasthtml-start/)
- [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/)
- [FastHTML Complex AI Tools](https://www.bitdoze.com/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](https://www.bitdoze.com/fasthtml-sqlite-db/)
- [FastHTML Authentication](https://www.bitdoze.com/fasthtml-user-auth/)</content:encoded><category>web-development</category><category>fasthtml</category></item><item><title>FastHTML For Beginners: Build An UI to Python App in 5 Minutes</title><link>https://www.bitdoze.com/fasthtml-start/</link><guid isPermaLink="true">https://www.bitdoze.com/fasthtml-start/</guid><description>Master FastHTML quickly! Learn to add a user interface to your Python app in just 5 minutes with our beginner-friendly guide.</description><pubDate>Wed, 26 Feb 2025 00:00:00 GMT</pubDate><content:encoded>&lt;Button variant=&quot;solid&quot; label=&quot;With External Link&quot; url=&quot;https://astro.build&quot; /&gt;

&lt;Accordion label=&quot;Example Accordion Label&quot; group=&quot;accordion-01&quot; expanded=&quot;true&quot;&gt;
  Content goes here...
&lt;/Accordion&gt;

&lt;Tabs&gt;
  &lt;Tab name=&quot;Overview&quot;&gt;Tab content here...&lt;/Tab&gt;
  &lt;Tab name=&quot;Details&quot;&gt;More content here...&lt;/Tab&gt;
&lt;/Tabs&gt;

&lt;Notice type=&quot;info&quot; title=&quot;Information&quot;&gt;
  This is an informational notice.
&lt;/Notice&gt;

&lt;ListCheck&gt;
  - Item one
  - Item two
&lt;/ListCheck&gt;

[FastHTML](https://fastht.ml/) is an innovative Python-based web framework designed to make web development accessible and enjoyable for beginners while providing robust tools for seasoned developers. By blending Python&apos;s simplicity with HTML-like syntax, FastHTML allows you to create dynamic and responsive web applications without wrestling with complex setups or unfamiliar languages.

## Why FastHTML?

Unlike traditional web development that requires knowledge of HTML, CSS, JavaScript and possibly frameworks like React or Vue, FastHTML lets you build complete web applications using just Python. Here&apos;s why it&apos;s particularly valuable for beginners:

- **Single Language**: Build both backend and frontend with just Python
- **Simpler Than Alternatives**: More approachable than Django or Flask for UI development
- **Hypermedia-Driven**: Built-in support for HTMX allows interactivity without JavaScript
- **Python-Native Syntax**: Use familiar Python functions instead of learning template languages
- **No Build Tools**: No need for npm, webpack, or other JavaScript build tools

This article serves as your entry point into FastHTML, guiding you through installation, basic syntax, and the use of various components with practical examples. By the end, you&apos;ll be equipped to build your first FastHTML project with confidence.


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/fqzTg5CrlGY&quot;
  label=&quot;FastHTML For Beginners&quot;
/&gt;

## FastHTML Series

Below are the articles on FastHTML to help you get started:

- [FastHTML Get Started](https://www.bitdoze.com/fasthtml-start/)
- [FastHTML Multiple Pages](https://www.bitdoze.com/fasthtml-multiple-pages/)
- [FastHTML Complex AI Tools](https://www.bitdoze.com/fasthtml-complex-ai-tools/)
- [Building a Simple AI-Powered Web App with FastHTML and Pydantic AI](https://www.bitdoze.com/fasthtml-pydenticai-tools/)
- [Adding SQLite Database History to Your FastHTML AI Title Generator](https://www.bitdoze.com/fasthtml-sqlite-db/)
- [FastHTML Authentication](https://www.bitdoze.com/fasthtml-user-auth/)

## Installing FastHTML

Before diving into coding, you need to set up FastHTML on your machine. The first prerequisite is Python, version 3.7 or higher. If you don&apos;t have Python installed, head to the [Python install on MAC](https://www.bitdoze.com/install-upgrade-python-mac/) and download the latest version compatible with your operating macOS. Follow the installation prompts, ensuring you check the option to add Python to your system&apos;s PATH, which makes running Python commands easier from the terminal.

If you have other OS than Mac you should check the python website for the tutorial.

With Python ready, installing FastHTML is a breeze. Open your terminal (Command Prompt on Windows, Terminal on macOS/Linux) and follow these steps:

**1. Create a virtual environment for the project and activate it**

```sh
python3 -m venv fhenv
source fhenv/bin/activate
# On Windows:
# fhenv\Scripts\activate
```

This creates an isolated environment for your project, preventing package conflicts. A virtual environment is like a separate, clean installation of Python where you can install packages without affecting your system Python installation.

**2. Install FastHTML**

```bash
pip install python-fasthtml
```

This fetches the FastHTML package and its dependencies. You can also check the official [FastHTML documentation](https://docs.fastht.ml/) for more details.

## Understanding FastHTML Syntax

FastHTML&apos;s standout feature is its ability to let you write web pages using Python functions that mimic HTML tags. Instead of juggling separate HTML files, you define your page structure directly in Python:

```python
from fasthtml.common import *

# Creating a paragraph element
paragraph = P(&quot;Hello, World!&quot;)
```

In this snippet, `P` is a FastHTML function that generates an HTML `&lt;p&gt;` tag with &quot;Hello, World!&quot; as its content. This gets converted to `&lt;p&gt;Hello, World!&lt;/p&gt;` when rendered. FastHTML provides similar functions for all standard HTML elements—`H1` for headings, `Div` for divisions, `Ul` and `Li` for lists, and so on.

Here&apos;s a quick mapping of some common HTML elements to FastHTML functions:

| HTML | FastHTML | Example |
|------|----------|---------|
| `&lt;p&gt;` | `P()` | `P(&quot;Text&quot;)` |
| `&lt;h1&gt;` | `H1()` | `H1(&quot;Heading&quot;)` |
| `&lt;div&gt;` | `Div()` | `Div(P(&quot;Child element&quot;))` |
| `&lt;a&gt;` | `A()` | `A(&quot;Link text&quot;, href=&quot;https://example.com&quot;)` |
| `&lt;input&gt;` | `Input()` | `Input(type=&quot;text&quot;, name=&quot;username&quot;)` |

To build a complete webpage, you combine these functions into a structure:

```python
page = Html(
    Head(
        Title(&quot;My First Page&quot;)
    ),
    Body(
        H1(&quot;Welcome to FastHTML&quot;),
        P(&quot;This is a simple page built with FastHTML.&quot;)
    )
)
```

This code produces a complete HTML document with a title, heading, and paragraph. The nested structure mirrors HTML&apos;s hierarchy, making it easy to visualize how components fit together.

**How FastHTML Functions Work**

Each FastHTML function takes:
- Positional arguments for child elements or content
- Keyword arguments for HTML attributes

For example, in `P(&quot;Hello&quot;, cls=&quot;greeting&quot;)`:
- `&quot;Hello&quot;` is the content of the paragraph
- `cls=&quot;greeting&quot;` becomes the HTML attribute `class=&quot;greeting&quot;`

Note: We use `cls` instead of `class` because `class` is a reserved keyword in Python.

## Building Your First Web Page

Let&apos;s put this into action by creating a simple web page. Create a file called `main.py` and add the following code:

```python
from fasthtml.common import *

# Create a FastHTML application
app = FastHTML()

# Define a route for the root URL &quot;/&quot;
@app.get(&quot;/&quot;)
def home():
    page = Html(
        Head(
            Title(&quot;Getting Started with FastHTML&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;)  # Including Tailwind CSS for styling
        ),
        Body(
            H1(&quot;FastHTML Basics&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            P(&quot;Below is a list of features:&quot;, cls=&quot;mb-2&quot;),
            Ul(
                Li(&quot;Easy to learn&quot;),
                Li(&quot;Python-based&quot;),
                Li(&quot;Dynamic and responsive&quot;)
            )
        )
    )
    return page

# Start the FastHTML server
if __name__ == &quot;__main__&quot;:
    serve()
```

Let&apos;s break this down:

1. We import all FastHTML components with `from fasthtml.common import *`
2. We create a FastHTML application with `app = FastHTML()`
3. We define a route handler for the root URL (`/`) using the `@app.get(&quot;/&quot;)` decorator
4. Our `home()` function returns a complete HTML page structure
5. The `serve()` function starts the FastHTML server

Save the file, then run it from your terminal:

```bash
python main.py
```

You should see output similar to:

```
Link: http://localhost:5001
INFO:     Will watch for changes in these directories: [&apos;/path/to/your/project&apos;]
INFO:     Uvicorn running on http://0.0.0.0:5001 (Press CTRL+C to quit)
```

Open your browser and navigate to `http://localhost:5001`. You&apos;ll see a page with a heading, paragraph, and bullet list. The `@app.get(&quot;/&quot;)` decorator tells FastHTML to serve this page at the root URL.

**What&apos;s Happening Behind the Scenes**

When you visit `http://localhost:5001`:
1. FastHTML receives the request for the root URL (`/`)
2. It calls the `home()` function
3. The function returns a page structure built with FastHTML components
4. FastHTML converts these components to HTML
5. The HTML is sent to your browser

FastHTML handles all the HTTP server details so you can focus on building your UI.

## Basic UI Components

Let&apos;s explore the fundamental FastHTML components you can use to build interfaces. We&apos;ll start with the basics and progressively build more complex UIs.

### Text Elements

Text elements are the foundation of any interface. FastHTML makes creating them intuitive:

```python
# Headings
heading1 = H1(&quot;Main Heading&quot;, cls=&quot;text-2xl font-bold&quot;)
heading2 = H2(&quot;Subheading&quot;, cls=&quot;text-xl font-semibold&quot;)

# Paragraphs
paragraph = P(&quot;This is a paragraph of text.&quot;, cls=&quot;mb-4&quot;)

# Formatted text
bold_text = Strong(&quot;This text is bold&quot;)
italic_text = Em(&quot;This text is italicized&quot;)
```

Here&apos;s a complete example showing various text elements:

```python
@app.get(&quot;/text-elements&quot;)
def text_elements():
    return Html(
        Head(Title(&quot;Text Elements&quot;)),
        Body(
            H1(&quot;Heading Level 1&quot;, cls=&quot;text-3xl font-bold&quot;),
            H2(&quot;Heading Level 2&quot;, cls=&quot;text-2xl font-semibold&quot;),
            H3(&quot;Heading Level 3&quot;, cls=&quot;text-xl font-medium&quot;),
            P(&quot;This is a regular paragraph with some &quot;,
              Strong(&quot;bold text&quot;), &quot; and some &quot;,
              Em(&quot;italicized text&quot;), &quot; mixed in.&quot;),
            P(&quot;You can also use &quot;, Code(&quot;code snippets&quot;), &quot; inline.&quot;)
        )
    )
```

When rendered, this creates a hierarchy of text elements with different sizes and styles. The `cls` attribute sets CSS classes that style the elements. In this example, we&apos;re using Tailwind CSS classes like `text-3xl` (for font size) and `font-bold` (for font weight).

### Containers and Layout

Organizing content is key to good UI design. FastHTML provides container elements for structuring your page:

```python
# Basic container
container = Div(
    H2(&quot;Section Title&quot;),
    P(&quot;Content inside a container&quot;),
    cls=&quot;p-4 bg-gray-100 rounded&quot;
)

# Grid layout (with Tailwind CSS)
grid = Div(
    Div(P(&quot;Column 1&quot;), cls=&quot;p-2&quot;),
    Div(P(&quot;Column 2&quot;), cls=&quot;p-2&quot;),
    Div(P(&quot;Column 3&quot;), cls=&quot;p-2&quot;),
    cls=&quot;grid grid-cols-3 gap-4&quot;
)
```

The `Div` component is extremely versatile for creating containers and layout structures. When combined with CSS frameworks like Tailwind, you can create responsive layouts with minimal effort.

Here&apos;s a layout example using nested containers:

```python
@app.get(&quot;/layout&quot;)
def layout_demo():
    return Html(
        Head(
            Title(&quot;Layout Demo&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;)
        ),
        Body(
            Div(
                H1(&quot;Page Layout Example&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),

                # Main layout grid with sidebar and content
                Div(
                    # Sidebar
                    Div(
                        H2(&quot;Sidebar&quot;, cls=&quot;text-xl mb-2&quot;),
                        Ul(
                            Li(&quot;Home&quot;),
                            Li(&quot;About&quot;),
                            Li(&quot;Services&quot;),
                            Li(&quot;Contact&quot;),
                            cls=&quot;space-y-2&quot;
                        ),
                        cls=&quot;bg-gray-100 p-4 rounded&quot;
                    ),

                    # Main content
                    Div(
                        H2(&quot;Main Content&quot;, cls=&quot;text-xl mb-2&quot;),
                        P(&quot;This is the main content area of our layout example.&quot;),
                        P(&quot;You can structure complex layouts using nested Div elements.&quot;),
                        cls=&quot;bg-white p-4 rounded&quot;
                    ),

                    # Grid with 1 column on mobile, 4 columns on medium screens and up
                    cls=&quot;grid grid-cols-1 md:grid-cols-4 gap-4&quot;
                ),
                cls=&quot;container mx-auto p-4&quot;
            )
        )
    )
```

This creates a responsive layout with:
- A sidebar that contains navigation links
- A main content area
- A layout that adapts to different screen sizes (1 column on mobile, 4 columns on larger screens)

### Links and Buttons

Interactive elements like links and buttons allow users to navigate and take actions:

```python
# Basic link
link = A(&quot;Visit Google&quot;, href=&quot;https://google.com&quot;, cls=&quot;text-blue-500 hover:underline&quot;)

# Button
button = Button(&quot;Click Me&quot;, cls=&quot;bg-blue-500 text-white px-4 py-2 rounded&quot;)
```

The `A` component creates HTML anchor tags (`&lt;a&gt;`) for links, while the `Button` component creates HTML button elements (`&lt;button&gt;`).

Let&apos;s create a navigation bar with links and buttons:

```python
@app.get(&quot;/navigation&quot;)
def navigation_demo():
    return Html(
        Head(
            Title(&quot;Navigation Demo&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;)
        ),
        Body(
            # Navigation bar
            Div(
                Div(
                    # Logo/site name
                    A(&quot;FastHTML Demo&quot;, href=&quot;/&quot;, cls=&quot;text-xl font-bold text-white&quot;),

                    # Navigation links and login button
                    Div(
                        A(&quot;Home&quot;, href=&quot;/&quot;, cls=&quot;text-white hover:text-gray-200 mx-2&quot;),
                        A(&quot;Features&quot;, href=&quot;/features&quot;, cls=&quot;text-white hover:text-gray-200 mx-2&quot;),
                        A(&quot;Docs&quot;, href=&quot;/docs&quot;, cls=&quot;text-white hover:text-gray-200 mx-2&quot;),
                        Button(&quot;Login&quot;, cls=&quot;bg-white text-blue-600 px-3 py-1 rounded ml-4&quot;),
                        cls=&quot;flex items-center&quot;
                    ),
                    cls=&quot;flex justify-between items-center&quot;
                ),
                cls=&quot;bg-blue-600 p-4&quot;
            ),

            # Page content
            Div(
                H1(&quot;Welcome to FastHTML&quot;, cls=&quot;text-3xl font-bold mb-4&quot;),
                P(&quot;This example shows a navigation bar with links and a button.&quot;),
                cls=&quot;container mx-auto p-4&quot;
            )
        )
    )
```

This creates a navigation bar with:
- A site logo/name on the left
- Navigation links in the center
- A login button on the right
- Hover effects on the links
- A clean, modern appearance thanks to Tailwind CSS

### Forms and Inputs

Forms allow users to input data. FastHTML makes creating forms straightforward:

```python
# Text input
text_input = Input(type=&quot;text&quot;, name=&quot;username&quot;, placeholder=&quot;Enter username&quot;)

# Password input
password_input = Input(type=&quot;password&quot;, name=&quot;password&quot;, placeholder=&quot;Enter password&quot;)

# Complete form
login_form = Form(
    Label(&quot;Username:&quot;, Input(type=&quot;text&quot;, name=&quot;username&quot;)),
    Label(&quot;Password:&quot;, Input(type=&quot;password&quot;, name=&quot;password&quot;)),
    Button(&quot;Submit&quot;, type=&quot;submit&quot;),
    action=&quot;/submit&quot;,
    method=&quot;post&quot;
)
```

The `Form` component creates HTML form elements, while `Input` creates various input types based on the `type` attribute. The `action` attribute specifies where the form data will be sent, and the `method` attribute specifies the HTTP method (GET or POST).

Let&apos;s create a complete contact form:

```python
@app.get(&quot;/contact&quot;)
def contact_form():
    return Html(
        Head(
            Title(&quot;Contact Form&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;)
        ),
        Body(
            Div(
                H1(&quot;Contact Us&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),

                # Contact form
                Form(
                    # Name field
                    Div(
                        Label(&quot;Name:&quot;, For=&quot;name&quot;, cls=&quot;block mb-1&quot;),
                        Input(type=&quot;text&quot;, id=&quot;name&quot;, name=&quot;name&quot;, placeholder=&quot;Your name&quot;,
                              cls=&quot;w-full p-2 border rounded mb-3&quot;),
                        cls=&quot;mb-4&quot;
                    ),

                    # Email field
                    Div(
                        Label(&quot;Email:&quot;, For=&quot;email&quot;, cls=&quot;block mb-1&quot;),
                        Input(type=&quot;email&quot;, id=&quot;email&quot;, name=&quot;email&quot;, placeholder=&quot;Your email&quot;,
                              cls=&quot;w-full p-2 border rounded mb-3&quot;),
                        cls=&quot;mb-4&quot;
                    ),

                    # Message field
                    Div(
                        Label(&quot;Message:&quot;, For=&quot;message&quot;, cls=&quot;block mb-1&quot;),
                        Textarea(id=&quot;message&quot;, name=&quot;message&quot;, placeholder=&quot;Your message&quot;, rows=5,
                                cls=&quot;w-full p-2 border rounded mb-3&quot;),
                        cls=&quot;mb-4&quot;
                    ),

                    # Submit button
                    Button(&quot;Send Message&quot;, type=&quot;submit&quot;,
                           cls=&quot;bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600&quot;),

                    # Form attributes
                    action=&quot;/submit-contact&quot;,
                    method=&quot;post&quot;,
                    cls=&quot;max-w-md mx-auto bg-gray-50 p-6 rounded shadow&quot;
                ),
                cls=&quot;container mx-auto p-4&quot;
            )
        )
    )
```

This creates a styled contact form with:
- Text input for name
- Email input for email address
- Textarea for the message
- Submit button
- Form submission handling to &quot;/submit-contact&quot;
- Proper styling and layout for all elements

### Lists and Tables

Organizing data with lists and tables is common in web applications:

```python
# Unordered list
unordered_list = Ul(
    Li(&quot;Item 1&quot;),
    Li(&quot;Item 2&quot;),
    Li(&quot;Item 3&quot;)
)

# Ordered list
ordered_list = Ol(
    Li(&quot;First item&quot;),
    Li(&quot;Second item&quot;),
    Li(&quot;Third item&quot;)
)

# Basic table
table = Table(
    Thead(
        Tr(
            Th(&quot;Name&quot;),
            Th(&quot;Email&quot;),
            Th(&quot;Role&quot;)
        )
    ),
    Tbody(
        Tr(
            Td(&quot;John Doe&quot;),
            Td(&quot;john@example.com&quot;),
            Td(&quot;Admin&quot;)
        ),
        Tr(
            Td(&quot;Jane Smith&quot;),
            Td(&quot;jane@example.com&quot;),
            Td(&quot;User&quot;)
        )
    )
)
```

The `Ul` and `Ol` components create unordered and ordered lists, while `Li` creates list items. The `Table`, `Thead`, `Tbody`, `Tr`, `Th`, and `Td` components create HTML table elements.

Here&apos;s a data table example:

```python
@app.get(&quot;/data-table&quot;)
def data_table():
    return Html(
        Head(
            Title(&quot;Data Table&quot;),
            Script(src=&quot;https://cdn.tailwindcss.com&quot;)
        ),
        Body(
            Div(
                H1(&quot;User Data&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),

                # User data table
                Table(
                    # Table header
                    Thead(
                        Tr(
                            Th(&quot;ID&quot;, cls=&quot;p-2 border&quot;),
                            Th(&quot;Name&quot;, cls=&quot;p-2 border&quot;),
                            Th(&quot;Email&quot;, cls=&quot;p-2 border&quot;),
                            Th(&quot;Role&quot;, cls=&quot;p-2 border&quot;),
                            Th(&quot;Actions&quot;, cls=&quot;p-2 border&quot;),
                            cls=&quot;bg-gray-100&quot;
                        )
                    ),

                    # Table body
                    Tbody(
                        # Row 1
                        Tr(
                            Td(&quot;1&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;John Doe&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;john@example.com&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;Admin&quot;, cls=&quot;p-2 border&quot;),
                            Td(Button(&quot;Edit&quot;, cls=&quot;bg-blue-500 text-white px-2 py-1 rounded mr-2&quot;),
                               Button(&quot;Delete&quot;, cls=&quot;bg-red-500 text-white px-2 py-1 rounded&quot;),
                               cls=&quot;p-2 border&quot;),
                        ),
                        # Row 2
                        Tr(
                            Td(&quot;2&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;Jane Smith&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;jane@example.com&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;User&quot;, cls=&quot;p-2 border&quot;),
                            Td(Button(&quot;Edit&quot;, cls=&quot;bg-blue-500 text-white px-2 py-1 rounded mr-2&quot;),
                               Button(&quot;Delete&quot;, cls=&quot;bg-red-500 text-white px-2 py-1 rounded&quot;),
                               cls=&quot;p-2 border&quot;),
                        ),
                        # Row 3
                        Tr(
                            Td(&quot;3&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;Robert Johnson&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;robert@example.com&quot;, cls=&quot;p-2 border&quot;),
                            Td(&quot;Editor&quot;, cls=&quot;p-2 border&quot;),
                            Td(Button(&quot;Edit&quot;, cls=&quot;bg-blue-500 text-white px-2 py-1 rounded mr-2&quot;),
                               Button(&quot;Delete&quot;, cls=&quot;bg-red-500 text-white px-2 py-1 rounded&quot;),
                               cls=&quot;p-2 border&quot;),
                        )
                    ),
                    cls=&quot;w-full border-collapse&quot;
                ),
                cls=&quot;container mx-auto p-4 overflow-x-auto&quot;
            )
        )
    )
```

This creates a styled data table with:
- Column headers (ID, Name, Email, Role, Actions)
- Multiple rows of data
- Action buttons in the last column
- Proper styling for all elements
- Horizontal scrolling for small screens

## Adding Interactivity with HTMX

FastHTML seamlessly integrates with HTMX, a library that allows you to access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, without writing JavaScript. FastHTML includes HTMX by default, so there&apos;s no need to import it separately in most cases.

Here&apos;s a simple counter example:

```python
from fasthtml.common import *

app = FastHTML()

# A simple counter variable to demonstrate state
counter = 0

@app.get(&quot;/&quot;)
def home():
    return Titled(&quot;HTMX Counter Example&quot;,
        Div(
            H1(&quot;HTMX Counter&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),
            Div(
                # Counter display with unique ID for targeting
                P(f&quot;Current count: {counter}&quot;, id=&quot;counter&quot;, cls=&quot;text-xl mb-4&quot;),

                # Increment button with HTMX attributes
                Button(&quot;Increment&quot;,
                      hx_post=&quot;/increment&quot;,
                      hx_target=&quot;#counter&quot;,
                      cls=&quot;bg-blue-500 text-white px-4 py-2 rounded mr-2&quot;),

                # Decrement button with HTMX attributes
                Button(&quot;Decrement&quot;,
                      hx_post=&quot;/decrement&quot;,
                      hx_target=&quot;#counter&quot;,
                      cls=&quot;bg-red-500 text-white px-4 py-2 rounded&quot;),
                cls=&quot;p-4 bg-gray-100 rounded&quot;
            ),
            cls=&quot;container mx-auto p-4&quot;
        ),
        Script(src=&quot;https://cdn.tailwindcss.com&quot;)
    )

# Handler for increment button
@app.post(&quot;/increment&quot;)
def increment():
    global counter
    counter += 1
    # Return just the counter element, not the whole page
    return P(f&quot;Current count: {counter}&quot;, id=&quot;counter&quot;, cls=&quot;text-xl mb-4&quot;)

# Handler for decrement button
@app.post(&quot;/decrement&quot;)
def decrement():
    global counter
    counter -= 1
    # Return just the counter element, not the whole page
    return P(f&quot;Current count: {counter}&quot;, id=&quot;counter&quot;, cls=&quot;text-xl mb-4&quot;)

serve()
```

### Understanding HTMX Attributes

FastHTML provides special attributes for HTMX integration:

1. `hx_post` - Sends a POST request to the specified URL when the element is clicked
2. `hx_get` - Sends a GET request to the specified URL when the element is clicked
3. `hx_target` - Specifies which element to update with the response (using CSS selector syntax)
4. `hx_swap` - Controls how the response is swapped in (e.g., &quot;innerHTML&quot;, &quot;outerHTML&quot;, &quot;beforeend&quot;)
5. `hx_trigger` - Specifies when to trigger the request (e.g., &quot;click&quot;, &quot;change&quot;, etc.)

In FastHTML, these attributes are provided as Python parameters, with underscores replacing hyphens (e.g., `hx_post` instead of `hx-post`).

### How the Counter Works

1. When you click the &quot;Increment&quot; button, HTMX sends a POST request to `/increment`
2. The server runs the `increment()` function, which increases the counter value
3. The function returns just the updated paragraph element
4. HTMX replaces the content of the element with id=&quot;counter&quot; with the response
5. The page updates without a full refresh

This pattern is powerful for creating interactive web applications without writing JavaScript.

## Building a Todo Application

Let&apos;s combine everything we&apos;ve learned to build a simple todo application:

```python
from fasthtml.common import *
from dataclasses import dataclass

app = FastHTML()

# Our simple data store
todos = []
todo_id_counter = 0

# Define the data structure for a todo item
@dataclass
class Todo:
    id: int
    title: str
    completed: bool = False

@app.get(&quot;/&quot;)
def home():
    return Titled(&quot;FastHTML Todo App&quot;,
        Div(
            H1(&quot;Todo Application&quot;, cls=&quot;text-2xl font-bold mb-4&quot;),

            # Add new todo form
            Form(
                Div(
                    Input(type=&quot;text&quot;, name=&quot;title&quot;, placeholder=&quot;Add a new todo&quot;,
                          cls=&quot;p-2 border rounded w-full md:w-80&quot;),
                    Button(&quot;Add&quot;, type=&quot;submit&quot;,
                           cls=&quot;bg-blue-500 text-white px-4 py-2 rounded ml-2&quot;),
                    cls=&quot;flex items-center mb-4&quot;
                ),
                # When the form is submitted, send a POST request to /add-todo
                hx_post=&quot;/add-todo&quot;,
                # Update the element with id=&quot;todo-list&quot;
                hx_target=&quot;#todo-list&quot;,
                # Add the new todo at the end of the list
                hx_swap=&quot;beforeend&quot;
            ),

            # Todo list container
            Div(
                id=&quot;todo-list&quot;,
                cls=&quot;space-y-2&quot;
            ),
            cls=&quot;container mx-auto p-4 max-w-md&quot;
        ),
        Script(src=&quot;https://cdn.tailwindcss.com&quot;)
    )

# Handler for adding a new todo
@app.post(&quot;/add-todo&quot;)
def add_todo(title: str):
    global todo_id_counter
    # Skip if the title is empty
    if not title.strip():
        return &quot;&quot;

    # Create a new todo and add it to the list
    todo_id_counter += 1
    new_todo = Todo(id=todo_id_counter, title=title)
    todos.append(new_todo)

    # Return the HTML for the new todo item
    return create_todo_item(new_todo)

# Handler for toggling a todo&apos;s completed status
@app.post(&quot;/toggle-todo/{id}&quot;)
def toggle_todo(id: int):
    for todo in todos:
        if todo.id == id:
            # Toggle the completed status
            todo.completed = not todo.completed
            # Return the updated todo item HTML
            return create_todo_item(todo)
    return &quot;&quot;

# Handler for deleting a todo
@app.delete(&quot;/delete-todo/{id}&quot;)
def delete_todo(id: int):
    global todos
    # Remove the todo with the specified id
    todos = [todo for todo in todos if todo.id != id]
    # Return an empty string since we&apos;re removing the element
    return &quot;&quot;

# Helper function to create the HTML for a todo item
def create_todo_item(todo: Todo):
    # Add strikethrough style if the todo is completed
    completed_class = &quot;line-through text-gray-500&quot; if todo.completed else &quot;&quot;

    return Div(
        Div(
            # Checkbox for marking the todo as completed
            Input(type=&quot;checkbox&quot;,
                  checked=todo.completed,
                  hx_post=f&quot;/toggle-todo/{todo.id}&quot;,
                  hx_target=f&quot;#todo-{todo.id}&quot;,
                  hx_swap=&quot;outerHTML&quot;,
                  cls=&quot;mr-2&quot;),
            # Todo title
            Span(todo.title, cls=completed_class),
            cls=&quot;flex-grow&quot;
        ),
        # Delete button
        Button(&quot;×&quot;,
               hx_delete=f&quot;/delete-todo/{todo.id}&quot;,
               hx_target=f&quot;#todo-{todo.id}&quot;,
               hx_swap=&quot;outerHTML&quot;,
               cls=&quot;text-red-500 font-bold&quot;),
        # Unique ID for targeting this todo item
        id=f&quot;todo-{todo.id}&quot;,
        cls=&quot;flex items-center p-2 border rounded&quot;
    )

serve()
```

**How the Todo App Works**

1. **Data Structure**: We use a Python dataclass to define the structure of a todo item
2. **UI Structure**: The main page has a form for adding todos and a container for displaying them
3. **Adding Todos**:
   - The form sends a POST request to `/add-todo` when submitted
   - The server creates a new todo and returns the HTML for it
   - HTMX adds the new todo to the end of the list
4. **Toggling Todos**:
   - The checkbox sends a POST request to `/toggle-todo/{id}` when clicked
   - The server toggles the todo&apos;s completed status and returns the updated HTML
   - HTMX replaces the todo item with the updated version
5. **Deleting Todos**:
   - The delete button sends a DELETE request to `/delete-todo/{id}` when clicked
   - The server removes the todo from the list
   - HTMX removes the todo item from the page

This demonstrates how FastHTML can be used to build a complete interactive application with minimal code.

## The FastHTML Advantage

Now that you&apos;ve seen FastHTML in action, let&apos;s summarize its key advantages:

1. **Python-Powered UI**: Write both your backend and frontend in Python
2. **Declarative Syntax**: Create UIs by composing functions rather than writing HTML templates
3. **Integrated Interactivity**: Built-in support for HTMX makes adding interactivity simple
4. **No Context Switching**: Stay in Python throughout your development workflow
5. **Minimal Dependencies**: No need for a complex JavaScript stack
6. **Quick Development**: Build functional UIs in minutes rather than hours

FastHTML is particularly well-suited for:
- Internal tools and dashboards
- Prototypes and MVPs
- Data visualization applications
- Admin interfaces
- Any application where development speed is prioritized over complex UI interactions

## Conclusion

FastHTML empowers beginners to craft web applications using Python&apos;s familiar syntax, sidestepping the complexities of traditional web development. In this guide, we&apos;ve walked through installing FastHTML, mastering its HTML-like syntax, and building various UI components from simple text elements to complete interactive applications.

You&apos;ve seen how to:
- Create basic HTML elements using Python functions
- Structure layouts with containers and grids
- Build forms for user input
- Create interactive UIs with HTMX integration

With FastHTML, you can focus on your application&apos;s functionality rather than wrestling with multiple languages and frameworks. Its intuitive approach makes web development more accessible while providing the power and flexibility needed for real-world applications.</content:encoded><category>web-development</category><category>fasthtml</category></item><item><title>Unleash the Power of oha: Website Performance Testing Made Simple</title><link>https://www.bitdoze.com/oha-website-load-testing/</link><guid isPermaLink="true">https://www.bitdoze.com/oha-website-load-testing/</guid><description>Learn how to use oha for website load testing. Simple guide with practical examples to measure your site&apos;s performance, throughput, and response times. Includes installation and basic usage.</description><pubDate>Mon, 10 Feb 2025 05:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;


Website performance is crucial for user experience and SEO. In this guide, you&apos;ll learn how to use oha, a lightweight HTTP load testing tool, to measure your website&apos;s performance under load. Whether you&apos;re a developer or site owner, these tests will help you understand your site&apos;s capabilities.

## What is oha?

![oha](../../assets/images/25/02/oha.webp)


[oha](https://github.com/hatoo/oha) (おはよう) is:
- A modern, lightweight HTTP load testing tool
- Written in Rust for optimal performance
- Perfect for quick website performance testing
- Features real-time visualization of results

Key benefits:
| Feature | Benefit |
|---------|----------|
| Speed | Fast execution with minimal resource usage |
| Simplicity | Single command operation |
| Visual Output | Real-time metrics display |
| Detailed Reports | Comprehensive performance statistics |


&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/iVcvbmY0pk8&quot;
  label=&quot;Unleash the Power of oha: Website Performance Testing Made Simple&quot;
/&gt;

## Installation
Choose your operating system and follow these simple steps:

### Linux:
```bash
cargo install oha
```

### MacOS:
```bash
brew install oha
```

### Windows:
```bash
winget install hatoo.oha
```

## Basic Usage

### Simple Test Command:
```bash
oha https://yourwebsite.com
```

### Common Options Table:
| Option | Description | Example |
|--------|-------------|---------|
| -n | Total requests | oha -n 200 https://site.com |
| -c | Concurrent users | oha -c 50 https://site.com |
| -q | Requests per second | oha -q 100 https://site.com |
| --no-tui | Disable visual interface | oha --no-tui https://site.com |

### Understanding Test Results

Let&apos;s analyze a typical output:
```sh
Summary:
  Success rate: 100.00%
  Total:        0.6689 secs
  Slowest:      0.4123 secs
  Fastest:      0.0733 secs
  Average:      0.1557 secs
  Requests/sec: 299.0098
```

Key Metrics Explained:
| Metric | What It Means | Good Values |
|--------|---------------|-------------|
| Success rate | Percentage of successful requests | Should be close to 100% |
| Average time | Mean response time | Under 1 second |
| Requests/sec | Throughput capacity | Depends on your needs |
| Slowest | Worst response time | Should not be more than 3x average |

### Response Time Distribution
The percentile breakdown shows how your site performs across all requests:
- 50th percentile (median): Normal user experience
- 90th percentile: Slower but acceptable responses
- 99th percentile: Worst-case scenarios


**Real Output:**

```sh
╰─❯ oha https://www.bitdoze.com
Summary:
  Success rate: 100.00%
  Total:        0.6689 secs
  Slowest:      0.4123 secs
  Fastest:      0.0733 secs
  Average:      0.1557 secs
  Requests/sec: 299.0098

  Total data:   2.08 MiB
  Size/request: 10.64 KiB
  Size/sec:     3.11 MiB

Response time histogram:
  0.073 [1]   |
  0.107 [144] |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.141 [5]   |■
  0.175 [0]   |
  0.209 [0]   |
  0.243 [0]   |
  0.277 [0]   |
  0.311 [5]   |■
  0.345 [15]  |■■■
  0.378 [18]  |■■■■
  0.412 [12]  |■■

Response time distribution:
  10.00% in 0.0803 secs
  25.00% in 0.0847 secs
  50.00% in 0.0935 secs
  75.00% in 0.3010 secs
  90.00% in 0.3617 secs
  95.00% in 0.3813 secs
  99.00% in 0.4108 secs
  99.90% in 0.4123 secs
  99.99% in 0.4123 secs


Details (average, fastest, slowest):
  DNS+dialup:   0.0811 secs, 0.0488 secs, 0.1036 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0005 secs

Status code distribution:
  [200] 200 responses
```

## Basic Testing Scenarios

1. **Quick Health Check**
```bash
oha -n 100 https://yoursite.com
```
Purpose: Quick overview of site performance

2. **Load Testing**
```bash
oha -n 1000 -c 50 https://yoursite.com
```
Purpose: Simulate multiple concurrent users

3. **Stress Testing**
```bash
oha -n 2000 -c 100 -q 200 https://yoursite.com
```
Purpose: Find performance limits


## Real-World Testing Examples

### 1. Testing API Endpoints
```bash
oha -n 500 -c 50 -m POST -T &quot;application/json&quot; -d &apos;{&quot;key&quot;:&quot;value&quot;}&apos; https://api.yoursite.com/endpoint
```

API Testing Parameters:
| Parameter | Description | Usage |
|-----------|-------------|--------|
| -m POST | HTTP method | For API calls |
| -T | Content type | Specify data format |
| -d | Request body | Send data |

### 2. Simulating Peak Traffic
```bash
oha -n 2000 -c 100 --disable-keepalive https://yoursite.com
```

Peak Traffic Settings:
- Higher concurrent connections (-c)
- Disabled keepalive for realism
- Larger number of requests (-n)

## Interpreting Results

### Performance Metrics Table
| Metric | Good | Warning | Critical |
|--------|------|---------|-----------|
| Response Time | &lt; 1s | 1-3s | &gt; 3s |
| Success Rate | &gt; 99% | 95-99% | &lt; 95% |
| Requests/sec | Site-specific | 20% drop | &gt; 30% drop |

### Common Issues and Solutions

1. **High Response Times**
- Possible Causes:
  - Server resources maxed out
  - Database bottlenecks
  - Unoptimized code
- Solutions:
  - Implement caching
  - Optimize database queries
  - Scale server resources

2. **Failed Requests**
- Possible Causes:
  - Server timeout
  - Rate limiting
  - Network issues
- Solutions:
  - Increase timeout values
  - Adjust rate limits
  - Check network configuration

## Best Practices for Load Testing

### Do&apos;s and Don&apos;ts

✅ Do:
- Start with small tests
- Test during low-traffic periods
- Monitor server resources
- Test regularly
- Document results

❌ Don&apos;t:
- Test production without warning
- Run tests from production servers
- Ignore error rates
- Test single endpoints only



## Comparing oha with k6

[k6](https://k6.io) is a modern load testing tool by Grafana Labs that uses JavaScript for creating test scenarios. Unlike oha&apos;s simple command-line approach, k6 allows you to write complex testing scripts that can simulate real user behaviors.


### Feature Comparison

| Feature | oha | k6 |
|---------|-----|-----|
| Ease of Use | ★★★★★ | ★★★☆☆ |
| Scripting Required | No | Yes |
| Real-time Metrics | Basic | Advanced |
| Learning Curve | Minimal | Moderate |
| CI/CD Integration | Limited | Extensive |

### When to Use Each Tool

Use oha for:
- Quick performance checks
- Simple HTTP testing
- Immediate results
- Command-line operations

Use k6 for:
- Complex user scenarios
- Detailed performance analysis
- CI/CD pipeline integration
- Custom test scripts



## Conclusion

Website load testing with oha provides a straightforward and efficient way to measure and understand your site&apos;s performance under various conditions. Through this guide, we&apos;ve explored how to install and use oha, interpret its results, and apply best practices for effective load testing.

Remember that regular testing, careful documentation of results, and gradual scaling of test parameters are key to maintaining optimal website performance. Whether you&apos;re managing a small blog or a complex web application, oha&apos;s simplicity and powerful features make it an excellent choice for routine performance monitoring and load testing. As you implement these testing practices, focus on establishing baseline metrics, monitoring changes over time, and using the insights gained to continuously improve your website&apos;s performance and user experience.</content:encoded><category>tools</category><category>load-tests</category></item><item><title>Fix Cannot Open Packages Database In /var/lib/rpm DB_RUNRECOVERY: Fatal error</title><link>https://www.bitdoze.com/fix-rpmdb-error-bdb0087-db_runrecovery/</link><guid isPermaLink="true">https://www.bitdoze.com/fix-rpmdb-error-bdb0087-db_runrecovery/</guid><description>Learn how to fix the common Cannot Open Packages Database In /var/lib/rpm DB_RUNRECOVERY: Fatal error and install packages.</description><pubDate>Fri, 24 May 2024 00:00:00 GMT</pubDate><content:encoded>If you are encountering the below error message that is not allowing you to install `rpm` or `yum` packages:

```sh
error: rpmdb: BDB0113 Thread/process 21929/140612494501952 failed: BDB1507 Thread died in Berkeley DB library
error: db5 error(-30973) from dbenv-&gt;failchk: BDB0087 DB_RUNRECOVERY: Fatal error, run database recovery
error: cannot open Packages index using db5 - (-30973)
error: cannot open Packages database in /var/lib/rpm
error: rpmdb: BDB0113 Thread/process 21929/140612494501952 failed: BDB1507 Thread died in Berkeley DB library
error: db5 error(-30973) from dbenv-&gt;failchk: BDB0087 DB_RUNRECOVERY: Fatal error, run database recovery
error: cannot open Packages database in /var/lib/rpm
```

indicates that the RPM database (rpmdb) is corrupted. This corruption can occur due to several reasons, including interrupted package installations, updates, or removals, power failures, or other system interruptions.

## Causes of RPM Database Corruption

1. **Interrupted Operations**: The most common cause is the interruption of package management operations such as installation, update, or removal. This can happen if the process is manually stopped or if the system loses power during the operation.
2. **Stale Lock Files**: If `rpm`, `yum`, or `dnf` commands do not exit cleanly, they can leave behind lock files in `/var/lib/rpm`, which can cause subsequent operations to fail.
3. **Hardware Issues**: Failing memory or disk can also lead to database corruption. This is less common but should be considered if the problem persists.
4. **Software Bugs**: There may be bugs in the RPM or Berkeley DB software that cause corruption under certain conditions.

## Steps to Resolve the Issue

To fix the corrupted RPM database, you can follow these steps:

1. **Backup the Current RPM Database**:

   ```bash
   mkdir /var/lib/rpm/backup
   cp -a /var/lib/rpm/__db* /var/lib/rpm/backup/
   ```

2. **Remove the Corrupted Database Files**:

   ```bash
   rm -f /var/lib/rpm/__db.[0-9][0-9]*
   ```

3. **Rebuild the RPM Database**:

   ```bash
   rpm --rebuilddb
   ```

4. **Clean the Yum Cache**:

   ```bash
   yum clean all
   ```

5. **Verify the Integrity of the Packages File** (Optional):

   ```bash
   db_verify /var/lib/rpm/Packages
   ```

6. **Check for Hardware Issues** (Optional):
   Review system logs for any hardware-related errors that might indicate failing memory or disk.

## Example Commands

Here is a consolidated set of commands to perform the above steps:

```bash
# Backup the current RPM database
mkdir /var/lib/rpm/backup
cp -a /var/lib/rpm/__db* /var/lib/rpm/backup/

# Remove the corrupted database files
rm -f /var/lib/rpm/__db.[0-9][0-9]*

# Rebuild the RPM database
rpm --rebuilddb

# Clean the Yum cache
yum clean all
```

## Additional Considerations

- **Ensure Uninterrupted Operations**: Make sure that package management operations are not interrupted. Use a UPS to prevent power failures during critical operations.
- **Check for Automation Issues**: If you are using automation tools, ensure they are not terminating package management processes abruptly.
- **Monitor System Logs**: Regularly check system logs for any signs of hardware issues or other anomalies that could lead to database corruption.

By following these steps, you should be able to resolve the RPM database corruption and prevent it from recurring. If the problem persists, consider investigating deeper into hardware issues or potential software bugs.</content:encoded><category>linux</category><category>linux</category></item><item><title>How To Compare Two Folders Content and See Different Files in Terminal</title><link>https://www.bitdoze.com/compare-folders-content-differences/</link><guid isPermaLink="true">https://www.bitdoze.com/compare-folders-content-differences/</guid><description>Learn how you can compare two folders and see the different files in terminal in a linux or MacOs</description><pubDate>Tue, 14 Nov 2023 00:00:00 GMT</pubDate><content:encoded>If you have two folders that contain similar files, you might want to compare them and see what are the differences. For example, you might have a backup folder that is supposed to be identical to your original folder, but you are not sure if they are in sync. Or you might have two versions of a project that you want to merge or update.

For me I needed to check the different files in two repos, I contracted someone to help me with some issues on this website in astro.js and he provided me the zip archive with the finished code. I needed to see what are the modified files so I could understand what changed.

In this article, you will learn how to compare two folders’ content and see different files in the terminal using some simple commands. This can be done in the terminal and will work in MacOS or Linux, on Windows can also work but you need the Windows subsystem to have access to the command.

By the end of this article, you will be able to compare any two folders and find out what files are added, deleted, modified, or unchanged.

## Compare Two Folders Content and See Different Files in Terminal

The **diff** command is a command-line utility that compares two files or directories line by line and displays the differences between them. It also tells you what changes you need to make to one file or directory to make it match the other one. The basic syntax of the diff command is:

```bash
diff [options] file1 file2
```

or

```bash
diff [options] dir1 dir2
```

To compare two directories, you need to use the -rq option

```bash
diff -rq dir1 dir2
```

- **-r** option, which stands for recursive. This option tells the diff command to compare all the files and subdirectories inside the specified directories. For example, if you have two directories named dir1 and dir2, you can compare them by running:

- **-q** makes the output more concise and readable -q option stands for brief. This option tells the diff command to only report when files differ, without showing the details of the differences. For example, if you run:

```bash
diff -rq  /Users/dbalota/websites/bitdoze-astro-bkw  /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze
```

The output will look something like this:

```bash
Only in /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src: .DS_Store
Only in /Users/dbalota/websites/bitdoze-astro-bkw/src/assets/favicons: Grammarly.c8npGa4ajbhi8fee1ud80d82.dmg
Only in /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/assets/favicons: favicon.ico
Only in /Users/dbalota/websites/bitdoze-astro-bkw/src/assets/favicons: favicon.png
Only in /Users/dbalota/websites/bitdoze-astro-bkw/src/assets/favicons: favicon.svg
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/config/menu.json and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/config/menu.json differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/content/posts/add-accordion-carrd.mdx and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/content/posts/add-accordion-carrd.mdx differ
Only in /Users/dbalota/websites/bitdoze-astro-bkw/src/content/posts: compare-folders-content-differences.mdx
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/content/posts/opnform-open-source.mdx and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/content/posts/opnform-open-source.mdx differ
Only in /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts: .DS_Store
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/Base.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/Base.astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/PostSingle.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/PostSingle.astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/Posts.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/Posts.astro differ
Only in /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/components: .DS_Store
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/components/SimilarPosts.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/components/SimilarPosts.astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/components/widgets/Button.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/components/widgets/Button.astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/layouts/partials/Footer.astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/layouts/partials/Footer.astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/pages/[regular].astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/pages/[regular].astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/pages/authors/[single].astro and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/pages/authors/[single].astro differ
Files /Users/dbalota/websites/bitdoze-astro-bkw/src/styles/base.scss and /Users/dbalota/Desktop/orange-facturi/easypanel/bitdoze/src/styles/base.scss diffe
```

In the above output, you can see that it will give you the exact files which are different.

## Enhance The Diff Command and Filter The Results

One way to filter the output of the diff command is to use the grep command, which can search for a pattern in the input and print only the matching lines. For example, if we want to compare two directories, dir1 and dir2, and exclude a certain file from the comparison, we can use the following command:

```bash
diff -rq dir1 dir2 | grep -v &lt;file&gt;
```

The pipe symbol (|) redirects the output of the diff command to the input of the grep command. The **grep -v** option tells the grep command to invert the match, meaning that it will print only the lines that do not contain the pattern. The pattern is the name of the file we want to exclude, enclosed in angle brackets. For example, if we want to exclude the file README.md, we can use:

Another way to filter the output of the diff command is to use the **grep -i** option, which tells the grep command to ignore the case of the pattern. This can be useful if we want to compare two directories and find the files that have the same name but different cases. For example, if we want to compare dir1 and dir2 and find the files that have the name file.txt, regardless of the case, we can use the following command:

```bash
diff -rq dir1 dir2 | grep -i file.txt
```

The grep -i option tells the grep command to match the pattern file.txt in any case, such as File.txt, FILE.TXT, or fiLe.TxT. For example, if dir1 contains File.txt and dir2 contains file.txt, the command will print:

Files dir1/File.txt and dir2/file.txt differ

By using these filters, we can enhance the diff command and make it more flexible and powerful for our purposes.

## Conclusions

This easy is to compare two folders with subdirectories and see exactly what fails are different, with the terminal on Linux or MacOS you don&apos;t need any app besides the command diff. You can enhance the command with grep to find exactly what you are interested in and also you can use **-u** option to see the exact changes.</content:encoded><category>linux</category><category>linux</category></item><item><title>How To Install Docker &amp; Docker-compose for Ubuntu ARM Systems</title><link>https://www.bitdoze.com/install-docker-ubuntu-arm/</link><guid isPermaLink="true">https://www.bitdoze.com/install-docker-ubuntu-arm/</guid><description>Learn how to install docker and docker compose on Ubuntu ARM system to host your own apps</description><pubDate>Thu, 28 Sep 2023 05:00:00 GMT</pubDate><content:encoded>import Button from &quot;../../components/widgets/Button.astro&quot;;

Docker has revolutionized the way we think about software development and deployment. With Docker, you can package your application and its dependencies into a container, making it easier to move and manage. Docker-Compose further simplifies multi-container Docker applications. In this guide, we&apos;ll walk you through how to install Docker and Docker-Compose on Ubuntu ARM systems.

ARM and x86 are two major types of CPU architectures that have been ruling different domains of computing. ARM, which stands for Advanced RISC Machine, is known for its power efficiency and is predominantly used in mobile devices, IoT gadgets, and increasingly in servers. On the other hand, x86 architecture, developed by Intel, has long been the standard for desktop and server computing.

The key difference lies in their design philosophy. ARM relies on a simpler set of instructions, allowing for lower power consumption, which is crucial for battery-powered devices. x86 architectures offer a broad set of instructions, optimized for high performance in complex computing tasks. Both have their pros and cons, but with ARM&apos;s growing presence in the server and desktop markets, it&apos;s becoming more important to understand how to work with both.

The [ARM vs x86: A Benchmark Comparison You Need to See](https://www.wpdoze.com/arm-vs-x86-vps-server-benchmarks/) article will provide more details about the benchmarks and where you can create an ARM VPS.

&lt;Button link=&quot;https://go.bitdoze.com/hetzner&quot; text=&quot;Get an ARM Server&quot; /&gt;
&lt;Button link=&quot;https://go.bitdoze.com/hostinger-vps&quot; text=&quot;Hostinger VPS&quot; /&gt;

Some other docker articles that can help you in your docker journey:

- [Add Users to a Docker Container](https://www.bitdoze.com/add-users-to-docker-container/)
- [Copy Multiple Files in One Layer Using a Dockerfile](https://www.bitdoze.com/copy-multiple-files-in-one-layer-using-a-dockerfile/)
- [Redirect Docker Logs to a Single File](https://www.bitdoze.com/redirect-docker-logs-to-a-single-file/)
- [Environment Variables ARG and ENV in Docker](https://www.bitdoze.com/docker-env-vars/)

## Steps to Install Docker &amp; Docker-compose for Ubuntu ARM Systems


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

### Step 1: Update Package List

Start by updating the package list to make sure you have the latest version of the packages.

```bash
sudo apt-get update
```

- **sudo:** Run the command as a superuser.
- **apt-get:** Ubuntu package management utility.
- **update:** Fetches the package list from the repository.

### Step 2: Install Required Packages

Before installing Docker, you&apos;ll need to install some essential packages.

```bash
sudo apt-get install ca-certificates curl gnupg lsb-release
```

- **ca-certificates:** For secure web communication.
- **curl:** Command-line tool for transferring data.
- **gnupg:** For key management.
- **lsb-release:** Provides info about the Linux distribution.

### Step 3: Create Keyring Directory

Create a directory to store the Docker GPG key.

```bash
sudo mkdir -p /etc/apt/keyrings
```

- **mkdir:** Make directory command.
- **&quot;-p&quot;:** Create parent directories as needed.

### Step 4: Add Docker GPG Key

Download and add the Docker GPG key to the keyring directory.

```bash
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
```

### Step 5: Add Docker Repository

Add the Docker repository to your sources list.

```bash
echo \
  &quot;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  jammy stable&quot; | sudo tee /etc/apt/sources.list.d/docker.list &gt; /dev/null
```

- **&quot;dpkg --print-architecture&quot;:** Prints the system architecture.
- **tee:** Reads from standard input and writes to standard output and files.

### Step 6: Update Package List Again

Run an update again to fetch packages from the newly added Docker repository.

```bash
sudo apt-get update
```

### Step 7: Install Docker and Docker-Compose

Finally, install Docker and Docker-Compose.

```bash
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-compose
```

- **docker-ce:** Docker Community Edition.
- **docker-ce-cli:** Docker CLI.
- **containerd.io:** Container runtime.
- **docker-compose-plugin:** Compose CLI plugin.
- **docker-compose:** To define and run multi-container Docker applications.

In case you are interested to have a web panel that can help you manage your applications and be used as a reverse proxy you can check the bellow course:

&lt;Button
  link=&quot;https://webdoze.net/courses/cloudpanel-setup/&quot;
  text=&quot;CloudPanel Setup Course&quot;
/&gt;

## Conclusion

And there you have it! You&apos;ve successfully installed Docker and Docker-Compose on your Ubuntu ARM system. With these tools at your disposal, you&apos;re now ready to begin containerizing applications and taking full advantage of what Docker has to offer. Happy Docking!</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>OpnForm Free Open Source Form Builder Tool</title><link>https://www.bitdoze.com/opnform-open-source/</link><guid isPermaLink="true">https://www.bitdoze.com/opnform-open-source/</guid><description>OpnForm is an open-source free form builder tool can help host forms online.</description><pubDate>Fri, 18 Aug 2023 05:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

## 🔭 OpnForm Overview

[OpnForm](https://opnform.com/) is a user-friendly, open-source form builder designed to enable individuals and businesses to create beautiful and functional forms with ease. The platform is currently in its beta phase and offers a range of features that make form creation quick and simple. Users can create a form in less than two minutes, with more than 10 input types available, including images and logic, without the need for coding knowledge. Once a form is created, OpnForm generates a unique link that users can share widely or embed directly into their websites.

The platform also provides a robust set of response management tools, allowing users to receive notifications of new submissions, send confirmations, export submissions as CSV files, and view detailed analytics of form views and submissions. Additional features include file uploads (up to 5MB), extensive customization options (themes, texts, colors, images, custom thank you pages), and advanced functionalities such as form logic, URL pre-fill, unique submission IDs, hidden fields, form passwords, webhooks, custom code, and closing dates. OpnForm is committed to accessibility, offering these features under a generous, unlimited free plan.

## 🎥 OpnForm Video Overview

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/EhE6H9nCm8U&quot;
  label=&quot;OpnForm - Add A Contact Form To Astro Free&quot;
/&gt;

## ❔ Why Use OpnForm

OpnForm stands out as an exceptional choice for creating online forms due to its user-friendly, open-source nature. It empowers users to craft beautiful, functional forms in less than two minutes, without requiring any coding skills. This makes it accessible to individuals and businesses of all sizes, whether for contact forms, surveys, or complex data collection tasks.

OpnForm offers a generous, unlimited free plan, which includes unlimited forms, fields, and responses, making it a cost-effective solution for any budget. The platform provides a unique link for each form that users can share widely or embed directly into their websites, facilitating easy distribution.

## 📋 OpnForm Features

- **Quick Form Creation:** - Craft beautiful forms in under 2 minutes without coding skills.
- **Unlimited Free Plan:** - Enjoy unlimited forms, fields, and responses at no cost.
- **Customizable Design:** - Tailor form themes, text, colors, and images to match your brand.
- **Easy Sharing &amp; Embedding:** - Distribute forms with a unique link or embed them directly into your website.
- **Instant Notifications:** - Receive alerts in Slack or your mailbox when new submissions arrive.
- **File Upload Capability:** - Securely add file upload inputs to your forms, with up to 5MB storage per file.
- **AI-Powered Form Generation:** - Generate fully working forms in seconds using OpnForm&apos;s intelligent AI feature.
- **Detailed Analytics and Export:** - Track form views and submissions, and easily export data as CSV files.
- **Advanced Form Logic:** - Implement conditional logic to create dynamic, interactive forms that adapt to user inputs.
- **Secure and Private Data Handling:** - Ensure the confidentiality and integrity of collected data with robust security features.

## 🏷️ Pricing

- **Free**

&lt;Button link=&quot;https://opnform.com/&quot; text=&quot;Check OpnForm&quot; /&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category></item><item><title>How To Clean All Docker Images With Disks and Everything</title><link>https://www.bitdoze.com/cleanup-all-docker-things/</link><guid isPermaLink="true">https://www.bitdoze.com/cleanup-all-docker-things/</guid><description>Learn how to declutter your Docker environment. Our guide shows you how to remove images, containers, volumes, and networks, freeing up valuable disk space. Follow step-by-step instructions to start fresh with Docker.</description><pubDate>Wed, 16 Aug 2023 05:00:00 GMT</pubDate><content:encoded>I was responsible for maintaining the analytics platform for several websites using plausible.io, which I had set up using Docker and docker-compose. The other day, I noticed there was an update available for the plausible.io Docker image. Eager to benefit from the latest features, I updated the docker-compose image configuration and tried to apply the update.

To my surprise, the update failed. After checking the logs, I realized the problem was with the PostgreSQL database version. I had version 12, but the new plausible.io image required version 14. I thought the quickest solution would be to revert the PostgreSQL database to its previous state using the old image.

After restoring the database and trying to restart [plausible.io](https://www.bitdoze.com/install-plausible-analytics/), I faced another issue: the websites were no longer accessible through the analytics platform. I decided to [pull the latest images again](https://www.bitdoze.com/updating-container-docker-compose/), hoping this would resolve the problem. However, I was met with a new error:

```bash
ERROR: for plausible_plausible_events_db_1  Cannot create container for service plausible_events_db: open /var/lib/docker/volumes/plausible_event-data/_data: no such file or directory
```

Additionally, there was a warning:

```bash
WARNING: Service &quot;plausible_events_db&quot; is using volume &quot;/var/lib/clickhouse&quot; from the previous container. Host mapping &quot;plausible_event-data2&quot; has no effect. Remove the existing containers (with `docker-compose rm plausible`)
```

It became clear to me that I needed to take more drastic measures. I decided to do a clean install of everything and remove the previous plausible.io configurations.

I have started doing that but in the beginning, I only cleaned the images and volumes had an issue and so on. In the below steps you will find everything you need to do a proper docker cleanup to install the new images fresh.


&gt; In case you are interested to monitor server resources like CPU, memory, disk space you can check: [How To Monitor Server and Docker Resources](https://www.bitdoze.com/sever-monitoring/)

## How To Clean All Docker Images With Disks and Everything

### 1.Stop All Running Containers:

First, you need to stop all running containers because you can&apos;t remove a container that is currently running.

```bash
docker stop $(docker ps -a -q)
```

This command stops all running containers by listing all container IDs and then stopping them.

&gt; If you have more containers there that don&apos;t need to be stopped you can only stop them.

### 2.Remove All Containers:

After stopping all containers, you can remove them.

```bash
docker rm $(docker ps -a -q)
```

This command removes all containers by listing all container IDs and then removing them.

&gt; If you have containers that should not be removed just remove them one by one not with all.

### 3.Remove All Images:

Once all containers are removed, you can remove all images.

```bash
docker rmi $(docker images -q)
```

This command removes all images by listing all image IDs and then removing them.

&gt; Again if you don&apos;t want all images to be removed remove what you don&apos;t need.

### 4.Remove All Volumes:

Docker volumes are used to persist data from a certain container or to share data between containers. To remove all unused volumes:

```bash
docker volume prune -f
```

This command removes all unused volumes. The -f or --force flag will bypass the confirmation prompt.

### 5.Remove All Networks:

To remove all unused networks:

```bash
docker network prune -f
```

This command removes all unused networks. The -f or --force flag will bypass the confirmation prompt.

### 6.System-wide Cleanup:

Docker provides a command that cleans up containers, images, volumes, and networks that are not associated with a container:

```bash
docker system prune -a -f
```

The -a flag tells Docker to remove all unused images, not just dangling ones. The -f or --force flag will bypass the confirmation prompt.

### 7.Disk Settings and Everything:

If you want to clean up disk space further, you may need to look into the Docker data directory, which is usually located at /var/lib/docker/ on Linux systems. Be very careful with this step, as it will remove all Docker data:

```bash
 sudo rm -rf /var/lib/docker
```

After this, you may need to restart the Docker service:

```bash
sudo systemctl restart docker
```

&gt; Do this only if you don&apos;t have other docker images and you want a fresh start.

### Warning:

&gt; These commands will remove all your Docker containers, images, volumes, and networks. They will also free up disk space, but you will lose all data associated with your Docker containers and images. Make sure you have backed up important data before running these commands.

## Conclusions

In this way, you clean up all the docker things if you bump into issues and you want a fresh start. Be sure to take a backup before in case you need something. Also if this is a production environment you should also do a basic test before with a downtime.

Good luck with your Docker cleanup!</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>Fix - SSH Too Many Authentication Failures</title><link>https://www.bitdoze.com/fix-ssh-too-many-authentication-failures/</link><guid isPermaLink="true">https://www.bitdoze.com/fix-ssh-too-many-authentication-failures/</guid><description>Fix Too Many Authentication Failures in SSH with our guide. Learn to activate SSH agent, use options, and modify settings</description><pubDate>Thu, 03 Aug 2023 06:00:00 GMT</pubDate><content:encoded>Let&apos;s face it, the world of servers and hosting can be as exciting as it is frustrating. Just when you think you have everything running smoothly, an error like &quot;Received disconnect from UNKNOWN port 65535:2: Too Many Authentication Failures&quot; pops up and you&apos;re left scratching your head. This usually happens for me because I need to switch between servers with different keys and SSH configs.

Sound familiar? Don&apos;t fret! We&apos;ve all been there. And I&apos;m here to walk you through a solution, that can help you fix the error, they helped me.

## What&apos;s the Issue?

This particular error is often linked to SSH (Secure Shell) connections. SSH is a network protocol that allows users to manage their servers remotely, and it&apos;s a powerful tool that many of us rely on daily. However, it can occasionally throw a curveball like this one.

The error message essentially means that there have been too many failed attempts to authenticate the SSH connection. The server gets suspicious and shuts down the connection for security reasons. Makes sense, right? But what&apos;s the fix?

## The Fixes

### **1. Activate SSH Agent**

The first thing to do is to activate the SSH agent. This is like a manager for your SSH keys, and it can help streamline the authentication process. Just run:

```bash
eval `ssh-agent`
```

This command will initialize the SSH agent in the background, and you&apos;re good to go.

### **2. Use the -o IdentitiesOnly=yes Option (if not using an SSH key)**

If you&apos;re not using an SSH key, then this option can be a lifesaver. It tells the SSH client to only use the authentication identity files that are configured in the SSH configuration files or passed on the command line. Run:

```bash
ssh -o IdentitiesOnly=yes user@host
```

This way, you&apos;re narrowing down the authentication methods, and that can clear up the issue.

### **3. Increase MaxAuthTries in /etc/ssh/sshd_config**

Sometimes, the issue is that the server&apos;s threshold for failed attempts is just too low. You can fix this by increasing the MaxAuthTries value in the SSH daemon configuration file. Here&apos;s how:

```bash
sudo nano /etc/ssh/sshd_config
```

Find the line that says MaxAuthTries and increase the value. If it doesn&apos;t exist, add:

```bash
MaxAuthTries 10
```

Don&apos;t forget to restart the SSH service:

```bash
sudo systemctl restart sshd
```

## Conclusion

These steps should help you tackle the &quot;Received disconnect from UNKNOWN port 65535:2: Too Many Authentication Failures&quot; error.It&apos;s all about understanding what&apos;s happening under the hood and applying the right solution.

Remember, servers can be quirky, but they don&apos;t have to be a mystery. Happy hosting!</content:encoded><category>linux</category><category>linux</category></item><item><title>Fix Kernel Panic - Not Syncing: VFS: Unable to Mount Root FS on Unknown-Block(0,0)</title><link>https://www.bitdoze.com/fix-kernel-panic-unable-mount-root-fs/</link><guid isPermaLink="true">https://www.bitdoze.com/fix-kernel-panic-unable-mount-root-fs/</guid><description>Learn how to fix the common Ubuntu error Kernel Panic - Not Syncing: VFS: Unable to Mount Root FS on Unknown-Block(0,0) with our step-by-step guide.</description><pubDate>Thu, 11 May 2023 06:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import ubuntukernal from &quot;../../assets/images/23/05/ubuntu-boot.jpeg&quot;;
import dockerup from &quot;../../assets/images/23/05/docker-up.jpeg&quot;;

I have an Ubuntu 22.04 on an [Hetzner VPS](https://go.bitdoze.com/hetzner), [Hostinger VPS](https://go.bitdoze.com/hostinger-vps) and I have an update of the packages to the latest version. During the update the Ubuntu Kernal updated also to the latest version which was: **_5.15.0-71-generic_**

All went good and I have rebooted the server but it never went up. I have logged in to the console and I have seen that it was getting stuck during boot at the error:

```
Kernel Panic - Not Syncing: VFS: Unable to Mount Root FS on Unknown-Block(0,0)
```

Kernel Panic - Not Syncing: VFS: Unable to Mount Root FS on Unknown-Block(0,0) is a common error that can occur in Ubuntu when the system is unable to mount the root file system during boot. This error message indicates that the kernel is unable to find the root file system, which is necessary for the operating system to start up properly.

## How to Fix Kernel Panic on Ubuntu

The steps below helped me move past the error and achieve a clean boot in the end. I will detail the steps so that you can follow along and effectively resolve your issue.

### Login to Console and Choose Advanced Options For Ubuntu

If you are not on a laptop and you are using a VPS server the provider needs to have a console that will allow you to see what is happening during boot and choose the **Advanced Options For Ubuntu** in there you will have the latest Kernels to choose from and you need not to choose the latest one like in bellow picture:

&lt;Picture
  src={ubuntukernal}
  widths={[200, 400, 900]}
  sizes=&quot;(max-width: 900px) 100vw, 900px&quot;
  alt=&quot;Ubuntu Boot Options&quot;
/&gt;

This will allow your system to boot.

### Check The File System

Login to your VPS and runn the bellow command:

```bash
sudo fdisk -l
```

Output:

```bash
sudo fdisk -l
Disk /dev/sda: 76.3 GiB, 81923145728 bytes, 160006144 sectors
Disk model: QEMU HARDDISK
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: gpt
Disk identifier: 6288F8C3-0B08-4091-9A39-F8940D8E5D62

Device      Start       End   Sectors  Size Type
/dev/sda1  528384 160006110 159477727   76G Linux filesystem
/dev/sda14   2048      4095      2048    1M BIOS boot
/dev/sda15   4096    528383    524288  256M EFI System

Partition table entries are not in disk order.
```

In here you will see the device for Linux filesystem in my case is **_/dev/sda1_**

### Update the root FS

Next you need to mount and update the FS:

```bash
sudo mount /dev/sda1 /mnt
sudo mount --bind /dev /mnt/dev
sudo mount --bind /dev/pts /mnt/dev/pts
sudo mount --bind /proc /mnt/proc
sudo mount --bind /sys /mnt/sys
sudo chroot /mnt
```

### Update The Temporary File System

Next run the bellow command:

```bash
root@cloud:/# update-initramfs -u -k 5.15.0-71-generic
output:
update-initramfs: Generating /boot/initrd.img-5.15.0-71-generic
```

In my case the problem was on **_5.15.0-71-generic_** Kernal that was installed with the upgrade, you need to use your kernal, which should be in the Ubuntu Advanced options.

The command update-initramfs -u -k 5.15.0-71-generic is used to update the initial RAM file system (initramfs) for the kernel version 5.15.0-71-generic in Ubuntu.

The initramfs is a temporary file system that is loaded into memory during the boot process before the root file system is mounted. It contains the necessary files and drivers to initialize the hardware and load the root file system.

By running this command, you are updating the initramfs for the specified kernel version and ensuring that the necessary files and drivers are available during the boot process. This can be useful if you have recently installed new hardware or made changes to the system that require updated drivers.

The -u option tells the command to update the initramfs, and the -k option specifies the kernel version to update. The 5.15.0-71-generic part of the command specifies the specific kernel version to update.

### Update Your GRUB

Next run:

```sh
root@cloud:/# update-grub

Output:
Sourcing file `/etc/default/grub&apos;
Sourcing file `/etc/default/grub.d/init-select.cfg&apos;
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-5.15.0-71-generic
Found initrd image: /boot/initrd.img-5.15.0-71-generic
Found linux image: /boot/vmlinuz-5.15.0-56-generic
Found initrd image: /boot/initrd.img-5.15.0-56-generic
Found linux image: /boot/vmlinuz-5.15.0-53-generic
Found initrd image: /boot/initrd.img-5.15.0-53-generic
Found linux image: /boot/vmlinuz-5.15.0-46-generic
Found initrd image: /boot/initrd.img-5.15.0-46-generic
Found linux image: /boot/vmlinuz-5.15.0-41-generic
Found initrd image: /boot/initrd.img-5.15.0-41-generic
Warning: os-prober will not be executed to detect other bootable partitions.
Systems on them will not be added to the GRUB boot configuration.
Check GRUB_DISABLE_OS_PROBER documentation entry.
done
```

The command update-grub is used to update the GRUB bootloader configuration in Ubuntu.

GRUB (Grand Unified Bootloader) is a bootloader that is used to load the Linux kernel and start the boot process for Ubuntu. The GRUB configuration file is located at /boot/grub/grub.cfg and contains information about the available kernels and boot options.

When you run the update-grub command, it scans your system and detects any changes to the available kernels and boot options. It then updates the GRUB configuration file with the new information.

Now you can reboot your server and should start successfully.</content:encoded><category>linux</category><category>linux</category></item><item><title>How To Update A Container With Docker Compose</title><link>https://www.bitdoze.com/updating-container-docker-compose/</link><guid isPermaLink="true">https://www.bitdoze.com/updating-container-docker-compose/</guid><description>How To Update A Container With Docker Compose is a tutorial that shows you how to use Docker Compose to update a container image and configuration without losing any data or settings.</description><pubDate>Thu, 11 May 2023 05:00:00 GMT</pubDate><content:encoded>import { Picture } from &quot;astro:assets&quot;;
import dockerpull from &quot;../../assets/images/23/05/docker-pull.png&quot;;
import dockerup from &quot;../../assets/images/23/05/docker-up.jpeg&quot;;

Docker Compose is a tool that lets you define and run multiple containers as a single service. It&apos;s great for developing and testing applications that have multiple components, such as a web server, a database, and a cache. I&apos;ll show you how to update a container with Docker Compose in a few simple steps. You&apos;ll learn how to modify the Dockerfile, rebuild the image, and restart the container with the new configuration.

I am having a [Plausible](https://plausible.io/) installation done with docker-compose that has been almost 1 year since I updated, it was built following the tutorial: [Install Plausible Google Analytics](https://www.wpdoze.com/how-to-install-plausible/) and it has the bellow docker compose file:

```yaml
version: &quot;3.3&quot;
services:
  mail:
    image: bytemark/smtp
    restart: always

  plausible_db:
    image: postgres:12
    restart: always
    volumes:
      - db-data:/var/lib/postgresql/data
    environment:
      - POSTGRES_PASSWORD=postgres

  plausible_events_db:
    image: yandex/clickhouse-server:21.3.2.5
    restart: always
    volumes:
      - event-data:/var/lib/clickhouse
      - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
      - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
    ulimits:
      nofile:
        soft: 262144
        hard: 262144

  plausible:
    image: plausible/analytics:latest
    restart: always
    command: sh -c &quot;sleep 10 &amp;&amp; /entrypoint.sh db createdb &amp;&amp; /entrypoint.sh db migrate &amp;&amp; /entrypoint.sh db init-admin &amp;&amp; /entrypoint.sh run&quot;
    depends_on:
      - plausible_db
      - plausible_events_db
      - mail
    ports:
      - 8000:8000
    env_file:
      - plausible-conf.env

volumes:
  db-data:
    driver: local
  event-data:
    driver: local
  geoip:
    driver: local
```

## Docker Compose Plausible Explination

This is a Docker Compose file written in YAML format, which describes a multi-container application consisting of plausible services: mail, plausible_db, plausible_events_db, and plausible.

The mail service uses the bytemark/smtp image to provide a Simple Mail Transfer Protocol (SMTP) server for sending emails.

The plausible_db service uses the postgres:12 image to provide a PostgreSQL database server. It stores its data in a named Docker volume db-data, and sets the POSTGRES_PASSWORD environment variable to &quot;postgres&quot; for authentication.

The plausible_events_db service uses the yandex/clickhouse-server:21.3.2.5 image to provide a ClickHouse database server for storing event data. It stores its data in a named Docker volume event-data, and mounts two configuration files from the local file system. It also sets the nofile ulimit to allow for a higher number of open files.

The plausible service uses the plausible/analytics:latest image to provide a web analytics platform. It depends on the plausible_db, plausible_events_db, and mail services, and exposes port 8000 to the host. It also uses an environment file plausible-conf.env to set certain configuration variables. Finally, it runs a command to initialize the database, run migrations, create an admin user, and start the application.

The file defines three named Docker volumes, db-data, event-data, and geoip, which can be used to persist data across container restarts.

All services have the restart option set to always, which ensures that they are automatically restarted if they fail or if the Docker daemon is restarted.

## What Will be Updated

If I leave the Docker Compose file untouched, the only container that will receive an update is the plausible service, which runs the latest version of the plausible/analytics image. This is precisely what I&apos;ve been eagerly awaiting - a chance to unlock the full potential of the latest features and enhancements.

Of course, if you&apos;d like to update the other containers as well, you&apos;re more than welcome to do so! Just make sure that you pull the latest image with the latest tag or specify the exact version for the **_postgres:12_** and **_yandex/clickhouse-server:21.3.2.5_** images. Luckily, in my case, these images are already set to a specific version, so the Docker command will pull the exact image I need to ensure that everything runs smoothly.

## What will Happen with the Data

The data for plausible analytics is helled in volumes, so if you check the yml file you see:

```yaml
volumes:
      - db-data:/var/lib/postgresql/data
volumes:
      - event-data:/var/lib/clickhouse
      - ./clickhouse/clickhouse-config.xml:/etc/clickhouse-server/config.d/logging.xml:ro
      - ./clickhouse/clickhouse-user-config.xml:/etc/clickhouse-server/users.d/logging.xml:ro
```

The actual data is not in the docker image but on the actual VM, so it will just be picked up.

## Running The Update Commands

### Pull The Latest Docker Images

In function of what you modified you need to run:

```bash
 docker compose pull
```

This command will pul the images that is in docker compose besed on the tags in my case will only update the **plausible/analytics:latest**

Bellow is the actual output:

&lt;Picture
  src={dockerpull}
  alt=&quot;Docker Pull Output&quot;
/&gt;

### Recreate Docker Images

Now that you have the latest docker images downloaded is just a matter of recreating them to use the latest things. To do so you just run:

```bash
docker compose up -d
```

When you run docker-compose up -d, Docker Compose will create and start the containers for all the services defined in the docker-compose.yml file. It will also create any needed networks and volumes if they are defined in the file.

The -d flag tells Docker Compose to run the containers in a detached mode, which means that the containers will run in the background and will not be attached to the terminal. This allows you to continue using the terminal for other tasks while the containers are running.

The output will be:

&lt;Picture
  src={dockerup}
  alt=&quot;Docker Up Output&quot;
/&gt;

### Verify that Containers are Up

Once the containers are started, you can use the docker-compose ps command to check the status of the containers and verify that they are running as expected. You can also use other Docker commands, such as docker logs and docker exec, to interact with the containers and troubleshoot any issues that may arise.

```bash
docker-compose ps
```

Output:

```
docker-compose ps
             Name                            Command               State                    Ports
-------------------------------------------------------------------------------------------------------------------
plausible-mail-1                  docker-entrypoint.sh exim  ...   Up      25/tcp
plausible-plausible-1             /entrypoint.sh sh -c sleep ...   Up      0.0.0.0:8000-&gt;8000/tcp,:::8000-&gt;8000/tcp
plausible-plausible_db-1          docker-entrypoint.sh postgres    Up      5432/tcp
plausible-plausible_events_db-1   /entrypoint.sh                   Up      8123/tcp, 9000/tcp, 9009/tcp
```

## Conclusions

That&apos;s all you need to do to have your docker-compose containers updated to the version you need, to be as safe as possible you can do a server backup in case something happens to be able to do a rollback.</content:encoded><category>self-hosting</category><category>docker</category></item><item><title>Easy File Upload to Oracle Cloud (OCI) Bucket: Python Script</title><link>https://www.bitdoze.com/upload-directory-oci-bucket-python/</link><guid isPermaLink="true">https://www.bitdoze.com/upload-directory-oci-bucket-python/</guid><description>Python script makes it easy to upload files to Oracle Cloud Infrastructure Object Storage.  Checking bucket existence, creating new buckets, and uploading files from a specified directory.</description><pubDate>Wed, 26 Apr 2023 05:00:00 GMT</pubDate><content:encoded>This Python script is designed for beginners who want to upload files from their computers to Oracle Cloud Infrastructure Object Storage. The script uses the OCI library to connect to the cloud and upload files to a bucket. It includes functions for checking if a bucket exists, creating a new bucket if necessary, and uploading files from a specified directory to the cloud.
The script is easy to use and can be run with a simple command in a command prompt or terminal window. This makes it a great tool for anyone who wants a simple and efficient way to store and access files in the OCI cloud.

## What The Script is Doing

### Libraries

The program uses some built-in Python libraries to work with files and directories, as well as a library called &quot;oci&quot; which lets the program talk to the Oracle Cloud.

### Creating an Object Storage client

The program starts by creating an &quot;Object Storage client&quot; object. This object is like a special tool that helps the program talk to the Oracle Cloud and do things like storing files.

### Checking if a bucket exists

The program checks if a &quot;bucket&quot; already exists in the Oracle Cloud. A bucket is like a special folder in the cloud where files can be stored. If the bucket already exists, the program won&apos;t create a new one.

### Creating a bucket

If the bucket doesn&apos;t exist, the program creates it. This is like creating a new folder in the cloud to store files. The program also sets some rules to make sure that nobody can see the files in the bucket without permission.

### Uploading files to the bucket

The program then looks for files in a directory on your computer. It uploads each file to the bucket in the cloud. This is like putting your toys in a box and sending the box to the cloud, so you can play with your toys from anywhere in the world.

### Putting it all together

The program puts all of these steps together into a &quot;main&quot; function, which is like a big recipe for the computer to follow. It also uses some special commands to let you choose which directory on your computer to upload files from, and which bucket in the cloud to upload files to.

## Python Script to Upload The Directory to OCI Object Storage Bucket

```python
import oci
import os
import sys
import argparse




# Check if bucket exists
def create_bucket_if_not_exists(object_storage, namespace, compartment_id, bucket_name):
    try:
        object_storage.get_bucket(namespace, bucket_name)
        print(f&quot;Bucket {bucket_name} already exists&quot;)
    except oci.exceptions.ServiceError as e:
        if e.status == 404:
            # Create bucket if it doesn&apos;t exist
            print(f&quot;Creating bucket {bucket_name}&quot;)
            create_bucket_details = oci.object_storage.models.CreateBucketDetails(
                name=bucket_name,
                compartment_id=compartment_id,
                public_access_type=&quot;NoPublicAccess&quot;
            )
            object_storage.create_bucket(namespace, create_bucket_details)
        else:
            raise

# Upload contents of directory
def upload_directory_contents(bucket_name, object_storage, namespace, directory_path):
    # Create Upload Manager
    upload_manager = oci.object_storage.UploadManager(object_storage, max_parallel_uploads=10)
    for root, dirs, files in os.walk(directory_path):
        for file in files:
            file_path = os.path.join(root, file)
            print(f&quot;Uploading {file_path}&quot;)
            upload_manager.upload_file(namespace, bucket_name, file, file_path)

def main():

    # Create Object Storage client
    config = oci.config.from_file(&quot;~/.oci/config&quot;, &quot;DEFAULT&quot;)
    object_storage = oci.object_storage.ObjectStorageClient(config)
    # Set up compartment and bucket details
    compartment_id = &quot;&lt;compartment_id&gt;&quot;
    parser = argparse.ArgumentParser()
    parser.add_argument(&apos;--dir&apos;, required=True)
    parser.add_argument(&apos;--bucket_name&apos;, required=True)
    args = parser.parse_args()
    bucket_name = args.bucket_name
    directory_path = args.dir

    namespace = object_storage.get_namespace().data
    create_bucket_if_not_exists(object_storage, namespace, compartment_id, bucket_name)
    upload_directory_contents(bucket_name, object_storage, namespace, directory_path)

if __name__ == &quot;__main__&quot;:
    main()
```

In the script you need to replace **compartment_id** with your compartment_id where the bucket will be stored.

## Set Everything To Run The Script

### Installation of OCI

Python should be already installed on your PC, I will not cover that. First, we need to install the oci tool. To do this, open a command prompt or terminal and type the following command:

```bash
pip install oci
```

This command will install oci so that our Python script can use it.

Other option is to install with Yum, for Linux 7 is:

```bash
sudo yum install python36-oci-cli
```

More on [Working With CLI](https://docs.public.oneportal.content.oci.oraclecloud.com/en-us/iaas/Content/API/SDKDocs/cliinstall.htm)

### Creating the Configuration File

Next, we need a secret file called config that tells the script how to access our toy box in OCI. The file should look like this:

```ini
[DEFAULT]
user=ocid1.user...
fingerprint=...
key_file=path/to/your/oci_api_key.pem
tenancy=ocid1.tenancy...
region=us-ashburn-1
```

The script will look for this file when it runs. You should configure the file with your details, the OCI documentation will help you do that. The files is checked in the script under the line:

```python
config = oci.config.from_file(&quot;~/.oci/config&quot;, &quot;DEFAULT&quot;)
```

### Running the Script

To run the script,open a command prompt or terminal and type the following command:

```bash
python script.py --dir /path/to/your/folder --bucket_name your_bucket_name
```

Replace /path/to/your/folder with the path to the folder with the toys (files) you want to store, and your_bucket_name with the name you want to give to your toy box (bucket).

### What Happens When the Script Runs

When the script runs, it does the following things:

- It reads the secret config file.
- It checks if our tbucket exists or creates a new one if needed.
- It takes all files from the folder we chose and puts them into our bucket in OCI.

That&apos;s it! Now you know how the script works and how to use it to store your files safely in OCI.</content:encoded><category>hosting</category><category>oci</category><category>python</category></item><item><title>Screen.Studio Review - Revolutionize Your Video Content</title><link>https://www.bitdoze.com/screen-studio-review/</link><guid isPermaLink="true">https://www.bitdoze.com/screen-studio-review/</guid><description>Screen.Studio Review, see how this Mac screen recording tool can help you make more engaging videos</description><pubDate>Tue, 04 Apr 2023 05:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;
import { Picture } from &quot;astro:assets&quot;;
import img1 from &quot;../../assets/images/23/04/screen_studio_1.jpg&quot;;
import img2 from &quot;../../assets/images/23/04/screen_studio_export.jpeg&quot;;

[Screen Studio](https://go.bitdoze.com/screen-studio) is a screen recorder for macOS devices that can help you create more engaging videos for your products or social media accounts. In this article, we are going to do a review of Screen Studio to see what exactly it has to offer and if it is the right one for you.

I have been using [Screen Studio](https://go.bitdoze.com/screen-studio) for a few months now to record the videos on my YouTube channel and I have tested most of the features. What I can say is that I really like the product as it helps me make more engaging videos faster, Screen Studio is developed by [Adam Pietrasiak](https://twitter.com/pie6k) who has added a lot of functionalities into the product in just couple of months. At first you couldn&apos;t record sound or the camera, but now it&apos;s all possible.

&lt;Button
  link=&quot;https://go.bitdoze.com/screen-studio&quot;
  text=&quot;Check Screen Studio&quot;
/&gt;

## Screen.Studio Screen Recorder Review

### Screen.Studio Video Review

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/J9zGVtfMQi0&quot;
  label=&quot;Screen.Studio Review&quot;
/&gt;

In this section we will see the exact features that Screen Studio has to offer as well as the pros and cons that I see after using it for a couple of months.

### Features

Below are the main features of this screen recorder.

- **Zoom Effects** - Screen.studio will add zoom effects when you click the mouse, you can change the animations in the editor between slow, smooth, fast and fast.
- **Wallpaper &amp; Window Frame** - You can add backgrounds to your videos, you can add wallpaper, image, color or gradient.
- **Record Camera** - Screen Studio has the option to record the camera on your system, it has a nice anymation when you zoom in and you can change the position.
- **Record Sound** - Screen Studio will record your microphone sound.
- **Subtitle Generation** - within the editor you have the option to autogenerate the subtitles for your recording automatically, you can edit some basic things.
- **Capture in Different Formats** - you can easily convert your video to different formats, you have the option for 16:9, 9:16, 1:1, 4:3, there are also nice animations for 9:16 that will help you to create an appealing video.

The Screen Studio editor will allow you to edit the video and add zooms or mouse tracking, converting the video to any size you want. Screen Studio is a complete video editor that will help you make nicer videos with little effort.

### Pricing

You can download and install Screen Studio for free to see exactly what it has to offer, but you will not be able to export the video. The standard plan for Screan Studio starts at $89 for 1 device where you get 1 year of updates and you can use it forever. If you need it for more devices you have the $189 plan for 3 MacOS devices. The prices are not bad at all if you think that you can use it for as long as you want.

### Support

I&apos;ve been using Screen.studio for a couple of months and I haven&apos;t had any problem with it, in case you have problems I&apos;ve seen that Adam is quick to fix them and it&apos;s also listening to new future request. Overall Screen Studio has a nice support and is updated regularly.

&lt;Button
  link=&quot;https://go.bitdoze.com/screen-studio&quot;
  text=&quot;Check Screen Studio&quot;
/&gt;

### UI Design

Screen Studio has a nice UI and is similar to what you see in other editor tools. On the left you have the video and on the right, you have the options. Also, the recording dock it&apos;s allows you to choose from the full screen or just a window. The only downside is the fact that when you record longer videos you can only zoom out and not have a slide to go to that timeline. This makes editing larger videos more difficult as you can&apos;t see the effects as well and they are crammed together.

&lt;Picture
  src={img1}
  alt=&quot;Screen Studio Interface&quot;
/&gt;

### Exporting a Video &amp; Performance

After you finish editing the video you will go to the Export section where you can export a video in GIF or mp4 format. You can choose different resolutions like 1080p or 4k and choose the quality from soclial media or studio or lower if you want.

I have a MacBook AIR M1 and if you record a longer video it will take some time to have it exported, usually for me it takes about 3 times the video length, so if I have a 10 minute video the export will take about 30 minutes to finish. This is slower than other video editors, but I think this is due to the way Screen Studio captures videos. In the latest updates it looks like things have improved from a performance perspective.

&lt;Picture
  src={img2}
  alt=&quot;Screen Studio Export&quot;
/&gt;

### What I like and what Screen Studio can do Better

#### Strong points

- **Beautiful Zoom Animations** - Screen Studio&apos;s animations look pretty nice and it will help you to make an engaging video easier.
- **Camera Animations** - changing the camera size when zooming in and out gives a more dynamic perspective to the video.
- **Creates Shorts/TikTok Videos** - with the fact that the 9:16 format has smooth animations it can help in crating quick tutorials for shorts and be engaging out of the box.
- **Nice and Easy UI** - editing the video and adding various effects is easy and can be done by anyone.

#### Where Screan.Studio Do Better

- **Slider for Timeline** - You can only zoom in and out for the timeline, this is good if you only do small videos, if you have more than 10 minutes it can be difficult to edit the video. I wish there was a slider for the timeline.
- **Slow Export** - I know Adam is working on improving this but on my MacBook AIR M1 the export can take a long time for a 30 minute video, it can take up to 2 hours which is quite high.

## Conclusions

[Screen Studio](https://go.bitdoze.com/screen-studio) is a wandering product that can help you make engaging videos easily without too much editing skills. I feel that Screen Studio is perfect for those who need to make small videos for their products or create tutorial YouTube shorts or TikToks. For longer content videos you should be aware that it takes some time to export the video, but if you are not in a hurry like me it is not a problem.

&lt;Button
  link=&quot;https://go.bitdoze.com/screen-studio&quot;
  text=&quot;Check Screen Studio&quot;
/&gt;</content:encoded><category>tools</category><category>screen-recording</category></item><item><title>Plausible.io - Google Analytics Lightweight Alternative</title><link>https://www.bitdoze.com/plausible-tool/</link><guid isPermaLink="true">https://www.bitdoze.com/plausible-tool/</guid><description>Plausible.io self-hosted Google analytics lightweight alternative</description><pubDate>Fri, 17 Mar 2023 05:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

## 🔭 Plausible Overview

[Plausible.io](https://plausible.io/) is an open-source analytics tool that provides website owners with detailed metrics about their visitors. It&apos;s designed to be simple, lightweight, and privacy-first; plus it offers a suite of features that make it an attractive alternative to the big players in the industry, such as Google Analytics. Plausible can be self-hosted on your own server so no one but you to have access to your traffic data.

## 🎥 Plausible Video Overview

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/NoBp7bqAHUI&quot;
  label=&quot;Plausible Analytics&quot;
/&gt;


&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).


## ❔ Why Use Plausible

If you want to switch from Google Analytics to a self-hosted tool, Plausible is the analytics tool for you. Plausible can be quickly installed on your servers and start tracking your website traffic. Plausible Analytics is lightweight (&lt; 1 KB) and will not slow down your website. Plausible puts you in control of your statistics.

## 📋 Plausible Features

- **Simple analysis:** Reports generated by Plausible are simpler and easier to understand.
- **Lightweight script:** The script used by Plausible is light and has less than 1KB in size.
- **No need for cookie banners or GDPR consent:** No cookies are used and no personal data is collected.
- **Track events, goal conversions, campaigns:** You can easily track different sources and events.
- **Self-hosted:** Plausible can be installed on your own server.
- **Powerful API:** You can integrate Plausible with your application using the provided API.

## 🏷️ Pricing

- **Free** - if you self host it yourself
- **Paid** - from $9 for 10k in Plausible cloud

&lt;Button link=&quot;https://plausible.io/&quot; text=&quot;Check Plausible&quot; /&gt;</content:encoded><category>tools</category><category>self-hosted</category></item><item><title>Uptime Kuma Self Hosted Monitoring Tool</title><link>https://www.bitdoze.com/uptime-kuma-tool/</link><guid isPermaLink="true">https://www.bitdoze.com/uptime-kuma-tool/</guid><description>Uptime Kuma is the best self-hosted monitoring tool that you can use.</description><pubDate>Thu, 16 Mar 2023 05:00:00 GMT</pubDate><content:encoded>import YouTubeEmbed from &quot;../../components/widgets/YouTubeEmbed.astro&quot;;
import Button from &quot;../../components/widgets/Button.astro&quot;;

## 🔭 Uptime Kuma Overview

[Uptime Kuma](https://github.com/louislam/uptime-kuma) is an open-source monitoring tool designed to help you track the availability and response time of your website or web application. With Uptime Kuma, you can monitor your websites, APIs, and services.

Uptime Kuma comes with a simple and intuitive web interface that allows you to easily set up monitors, view reports, and receive alerts when downtime or performance issues occur. It supports a wide range of protocols and services including HTTP, HTTPS, DNS, TCP, and ICMP.

## 🎥 Uptime Kuma Video Overview

&lt;YouTubeEmbed
  url=&quot;https://www.youtube.com/embed/enJYtsPIxYY&quot;
  label=&quot;Uptime Kuma Self Hosted Monitoring Tool&quot;
/&gt;

## ❔ Why Use Uptime Kuma

&gt; If you are interested to see some free cool open source self hosted apps you can check [toolhunt.net self hosted section](https://toolhunt.net/sh/).

Uptime Kuma is a powerful and flexible monitoring tool that can help you ensure your website or web application is always available and performing at its best. Here are some reasons why you should consider using Uptime Kuma:

- **Easy to Use:** Uptime Kuma comes with a simple and intuitive web interface that makes it easy to set up monitors, view reports, and receive alerts.
- **Customizable Monitoring:** With Uptime Kuma, you can customize the monitoring frequency, response time thresholds, and alerting rules to suit your specific needs.
- **Advanced Reporting:** Uptime Kuma provides detailed reports that allow you to track uptime and response time trends over time, as well as identify problem areas.
- **Integrations:** Uptime Kuma supports integrations with popular services such as Slack, Discord, and PagerDuty, allowing you to receive alerts via your preferred communication channel.
- **Open Source:** Uptime Kuma is an open-source tool, which means that you have full control over the software and can customize it to suit your needs.

## 📋 Uptime Kuma Features

- Monitoring uptime for HTTP(s) / TCP / HTTP(s) Keyword / Ping / DNS Record / Push / Steam Game Server / Docker Containers
- Notifications via Telegram, Discord, Gotify, Slack, Pushover, Email (SMTP), and 90+ notification services
- Proxy support - You can add a proxy to Uptime Kuma
- Status Page - you can have a status page to see the service status
- Clean Interface - The interface is fast and looks good, you have access to graphs and responce time.
- Multiple Languages - You can use multiple Languages.
- Easy to Deploy - You can deploy it with 1 click via Docker. You can check: [Deploy Uptime Kuma With One Click](https://www.bitdoze.com/deploy-uptime-kuma/)

## 🏷️ Pricing

- **Free**

&lt;Button
  link=&quot;https://github.com/louislam/uptime-kuma&quot;
  text=&quot;Check Uptime Kuma&quot;
/&gt;</content:encoded><category>self-hosting</category><category>self-hosted</category></item></channel></rss>