I asked for a Notion clone and ended up living in NocoBase

Why the database half of my Notion habit moved to a self-hosted NocoBase instead of a Notion look-alike, and what a day of building on it actually produced.

I asked for a Notion clone and ended up living in NocoBase

On the afternoon of 13 October I had two conversations about the same problem and didn't notice they were the same problem.

At 17:30 I asked my AI coding assistant which open-source tool was "exactly like" Notion, because I was already using Notion and wanted my notes on my own server. That conversation went the predictable way. AFFiNE and AppFlowy came out on top, I deployed both that evening, and the story of what it took to run them belongs in a different post.

Fifteen minutes earlier, in another session, I had typed something a lot less ambitious:

i have nocobase in self-hosted. i am lazy to configure it. what i can do with it? what do you suggest with it? what can you do with it?

NocoBase had been sitting in my compose repo since 7 October. It was running, it had a Traefik route and a tile on my Dashy page, and when the assistant listed its collections it found exactly two: roles and users. I had deployed it because it looked useful and then never opened it again, which is how a lot of my self-hosting goes. You spend an evening getting the container healthy, you feel productive, and the app sits there.

By the next morning it had become the place where I keep most of what Notion used to hold. This post is about why it won that job, what it actually is, what the first day produced, and what it costs to live with. The other posts in this batch go into how it was built: the Python provisioning, the Cloudflare problems, the workflow engine, the permissions incident, the headless-browser testing and the phone app.

What Notion was really doing for me

When I looked honestly at my Notion workspace, maybe a third of it was prose. Meeting notes, a few long documents, half-finished blog drafts. The rest was tables with a date column. Certificates with an expiry. Domains with a renewal. A list of pull requests I'd sent to other people's projects. A list of the services I run. A couple of budget pages that I'd abandoned twice.

The prose half is what the Notion look-alikes are built for. The table half is where they're weakest, and the comparison I got that afternoon said so without my asking. It put AFFiNE and AppFlowy at the top for "docs, databases and boards in one app", then listed the gaps: Notion's polish, its integrations, "synced blocks and advanced relations/rollups are still weaker or missing in the open-source tools". The same table put NocoDB and Baserow in a separate row as "the database side only", Airtable-style tools rather than Notion replacements.

That row was the one I needed and didn't read carefully at the time. What my tables wanted wasn't a nicer editor. They wanted things a document tool doesn't do. A date should turn orange on its own when it gets close. A list of things that are due should come to me instead of me going to it. A balance should recompute when I tick a transfer as done. Notion can fake some of that with formulas and a weekly habit of looking. I had the formulas. I didn't have the habit.

What NocoBase is, briefly

NocoBase isn't a document editor at all. It's a no-code app builder on top of a real database, Postgres in my case. You define collections, which are tables with typed fields and relations, in the UI. You assemble pages from blocks: tables, forms, detail views, kanban boards, calendars, charts. You add workflows, which are its automation engine: scheduled jobs, "when a record is created or changed, do X", HTTP requests, SQL, JavaScript, notifications. On top of that sit roles with per-collection permissions, and a REST API that covers all of it, including the parts most no-code tools keep to the UI, like creating menus and pages.

The version I got when I pulled nocobase/nocobase:latest that week was 2.2.22. The instance runs the classic interface at /admin. I mention the version because everything later in this series was built against it, and some of it is very version-specific.

The REST API is what made the difference, not the feature list. The assistant's first answer listed three things it could do with it, in decreasing order of confidence.

It could build the data model and fill it "through the REST API from scripts, so you don't click through the setup". That was the part it was sure of.

It could build reminders and automation the same way, "but building workflows that way is fiddly, so I'd likely have you check them in the UI".

Page layouts were "the weakest part for me. NocoBase stores pages as UI-schema JSON, which I can write, but I'd want you to check the result in your browser."

That turned out to be an accurate forecast of where the next two days would hurt. Collections were easy. Workflows were a long series of small surprises. Pages needed a headless browser to check every one of them, and still produced a crash I'll describe later.

What was deliberately left out

The most useful part of that first answer was a short list of things not to put in NocoBase. It said to skip rebuilding my other side projects inside it, because "they already have their own code, so NocoBase would only duplicate them". And when I later asked for every idea it had, the list ended with a section called "What I'd skip":

Notes, photos and files. Memos, SiYuan, AFFiNE, Immich and Nextcloud already handle those better.

So the split ended up being simple. Long-form writing goes to the Notion-style editors. Photos and files stay where they already were. Anything that looks like a row with a date, a status or an amount goes into NocoBase. It isn't the single-app Notion replacement I asked for at 17:30. It's two apps, and I've stopped minding.

I excluded one thing myself. The first suggestion list included a job and application pipeline, a kanban from "applied" to "offer". I said no to it in the very first message. Not everything needs a tracker, and that one would have made me feel watched.

Some alternatives never got a fair hearing, and I should say so. NocoDB and Baserow were in that comparison table and I didn't try either. I already had NocoBase running, and the deciding feature turned out to be the workflow engine plus an API that can create pages. I don't know how far the other two would have got with the same approach. Firefly III had been in my repo for personal finance, and it was removed in the same commit that added AFFiNE and AppFlowy ("Add AFFiNE and AppFlowy, drop Firefly, update Dashy links"). My notes from that week say why in one line: "Firefly (NocoBase finance is enough)". A dedicated finance app is better at finance. I wanted one place.

The deployment is boring, which is good

The stack is two containers. This is the compose file with my hostname and timezone swapped out:

services:
  app:
    image: nocobase/nocobase:latest          # resolved to 2.2.22 at build time; see below
    container_name: nocobase_app
    restart: unless-stopped
    networks: [frontend, internal]           # frontend = Traefik's network, internal = private to this stack
    environment:
      - TZ=Etc/UTC                           # mine is the local zone; schedules and "today" depend on it
      - APP_KEY=${NOCOBASE_APP_KEY:?}        # :? makes compose refuse to start if the .env lacks it
      - DB_DIALECT=postgres
      - DB_HOST=nocobase_db                  # the container name, not "db"
      - DB_PORT=5432
      - DB_DATABASE=nocobase
      - DB_USER=nocobase
      - DB_PASSWORD=${NOCOBASE_DB_PASSWORD:?}
      - INIT_APP_LANG=en-US
      - INIT_ROOT_EMAIL=${NOCOBASE_ROOT_EMAIL:?}
      - INIT_ROOT_USERNAME=${NOCOBASE_ROOT_USERNAME:-admin}
      - INIT_ROOT_PASSWORD=${NOCOBASE_ROOT_PASSWORD:?}
    volumes:
      - ./storage:/app/nocobase/storage      # uploads, plugin state, logs
    depends_on: [db]
    labels:
      - traefik.enable=true
      - traefik.http.routers.nocobase.rule=Host(`${NOCOBASE_HOST:-nocobase.example.com}`)
      - traefik.http.routers.nocobase.entrypoints=websecure
      - traefik.http.routers.nocobase.middlewares=default-security-headers@file,remove_identity@file
      - traefik.http.services.nocobase.loadbalancer.server.port=80
      - traefik.docker.network=frontend

  db:
    image: postgres:16-alpine                # major pinned on purpose
    container_name: nocobase_db
    restart: unless-stopped
    networks: [internal]                     # never on Traefik's network
    environment:
      - POSTGRES_USER=nocobase
      - POSTGRES_PASSWORD=${NOCOBASE_DB_PASSWORD:?}
      - POSTGRES_DB=nocobase
    volumes:
      - ./db:/var/lib/postgresql/data

networks:
  frontend:
    external: true
  # Private to this stack. Deliberately NOT the shared `backend` network:
  # several stacks have a service called `db`, and Docker DNS would resolve
  # that name to a database from the wrong stack.
  internal:
    internal: true

The comment on the internal network is the line I'd point other people at. It came out of a cleanup a week earlier, the commit "Give each stack a private db network; use explicit db hostnames". If several compose projects share one network and more than one of them has a service called db, that name resolves to whichever container Docker returns first, and an app can quietly talk to the wrong database. Each stack now gets its own internal: true network, which also means the database has no route out, and the app reaches it by container name.

The :? on every secret is a small thing I've come to rely on. Compose refuses to start if the .env is missing a value, instead of starting NocoBase with an empty APP_KEY that I'd discover weeks later.

TZ matters more than it looks. NocoBase's scheduled workflows run on the container's clock, and all my "days left" maths uses an expression that pins "today" to my local timezone in SQL: (now() AT TIME ZONE '<zone>')::date. Without both, a certificate expiring tomorrow could flip to overdue at the wrong hour of the evening.

The two middlewares come from my Traefik file provider and are shared by several apps. Nothing in them is specific to NocoBase. Shared header middlewares come up again in the headless-browser post, as a suspect for a header Chrome complained about on every page load.

From four trackers to 117 pages in about sixteen hours

My actual first request, typed at 17:26 with the API key pasted underneath, was small. A certifications tracker with provider, title, validity and expiry, and a coloured flag "if less than 3 months to renewal". A table of my open-source pull requests. A homelab inventory ("you can check dashy"). And a finance tracker: "customizable, where I can add accounts from multiple banks, I can track how much I borrowed from other banks or accounts, every last day of month it tells me where and how much I should return to which account, I can set recurring transfers and a checkbox to confirm I completed it... make it customizable in case I want to change something."

The first evening produced ten collections, thirteen pages and twelve workflows. All thirteen pages loaded without errors in a headless browser sweep. The finance logic was tested end to end with throwaway accounts, and the test printed what it expected next to what it got:

--- after accounts (expect 1000 / 500)
  acct TEST Current balance 1000.00
  acct TEST Savings balance 500.00
--- after loan (expect A 1300, B 200, outstanding 300)
  acct TEST Current balance 1300.00
  acct TEST Savings balance 200.00
  loan TEST borrow repaid 0.00 outstanding 300.00 active
  txn transfer_out 300.00 3 Lent: TEST borrow
  txn transfer_in 300.00 2 Borrowed: TEST borrow

The month-end logic was tested by faking the date. Five recurring rules (monthly on the last day, weekly on Saturday, quarterly, yearly, and a fixed-amount loan repayment) all produced a to-do dated the 31st. A separate run checked that an open borrowing generates its own month-end "Return" task, and that ticking it as done closes the loan and puts both balances back where they started. Then every test row was deleted and the counts were checked to be zero. The finance section has been empty since, on purpose.

Then I asked "what else can you suggest?", got a list, asked for "a lot" more, got fifty numbered ideas, and replied:

Okay do all of these. but keep each one in their section, make them customizable and allow notification for things due or nearing expiry or things like that

That second round added 63 collections and brought the total to 79 pages and 56 workflows, across sections for career, homelab operations, personal admin, a "life" section, projects and a couple more I'll keep to myself. The next morning I picked from a third list, and that round added auto-filled data: domain expiry and registrar from RDAP, SSL expiry from certificate-transparency logs, pull requests from the GitHub API, plus a calendar page, email digests and a rebrand. At the end of 14 October the instance had 117 pages and 85 workflows.

It hasn't stopped. By 16 October the assistant's chat integration was mapping 125 tables across the menu sections, and the newest additions (calendar events with reminders, recurring tasks) came in that week. The provision folder has grown from 11 Python files in its first commit to 54.

I didn't click any of it. Everything was created through the REST API by Python scripts, which is the subject of the next post.

One idea, repeated a hundred times

The build scaled because almost every tracker is the same idea. Something has a date. That date is fine, coming up, or past. Each record carries its own "warn me N days before" value, so a passport can warn 60 days out while a credential rotation warns at 14.

The scripts turn that into three generated fields on every tracker with a date: warn_days, days_left and a flag select with three values (OK in green, Due soon in orange, Overdue in red). One SQL statement per tracker recomputes them whenever a record is saved, and the same statements run together every night at 00:10. Each flagged tracker also contributes a SELECT to a union view called v_attention, which feeds a "Needs attention" table on the Home page and a morning digest at 08:00.

The digest has rules I'd still choose again. Overdue items appear every day. Items due within seven days appear every day. Everything inside its own warning window appears on Mondays only, so the weekly view is complete and the daily one is short. If one tracker has more than three items it collapses into a single line ("4 items need attention (see Home)"), because a digest that scrolls is a digest I stop reading.

The first useful thing the digest ever told me was about the API key I'd given the assistant for the build. It had gone into a "Credential rotation" tracker as a seed row, with its one-day expiry, and the first test digest contained:

🟠 🔑 Rotate: NocoBase API key "claude" (NocoBase) — 14 Oct 2026 (1 days)

The tool was nagging me about the credential that was building it. I took that as a sign the pattern worked.

How I actually use it

Day to day, very little of it happens inside NocoBase. The morning digest arrives as an in-app notification, and since 15 October also as a push on a separate ntfy topic at 08:10, with high priority when something is overdue. The in-app message links to Home, which shows the attention table, the next few events, upcoming public holidays and, as of this week, the newest RSS items. The calendar page is the one I open on purpose, because it overlays everything with a date: renewals, bills, trips, birthdays, holidays and a few auto-filled feeds.

Writing data happens more and more from my phone through the chat integration (which is its own series of posts). Every page in NocoBase has become a chat command named after its table, and quick-capture commands like adding a backlog item or a shopping-list entry go straight into the same tables. That only works because everything in NocoBase is reachable by API, which keeps coming back as the actual reason I'm using it.

The auto-filled tables are the ones I trust most, because I don't type them. Domain expiry dates come from RDAP every week. SSL expiry for my hostnames comes from Cert Spotter, after crt.sh returned 502s and timeouts on the first try. Service health is pinged every ten minutes, and on its very first run it flagged two services as down that simply had no DNS record. That was a configuration fact rather than an outage, but I hadn't known it.

The honest state in mid-October is mixed. Certificates, domains, services, SSL and pull requests are populated and update themselves. The finance section is empty on purpose, because I decided to start tracking money at the beginning of next month. A lot of the trackers from the "do all of these" round are empty shells waiting for me to care about them. A hundred-odd collections is more than one person fills in a week, and some of them never will be.

What it costs to keep

I said "customizable" and I meant it. Everything the scripts create is an ordinary NocoBase collection, page or workflow, so I can add a field or change a select option in the UI like anyone else. The scripts were changed early on to leave hand-made pages alone on a rebuild, after a scare I describe in the next post.

When I asked what the Python scripts were even for, the answer was blunt: "They aren't needed to run anything." The data and the configuration live in Postgres. If I deleted the folder, nothing would change. The scripts are a record of how it was built, a way to rebuild, and a way to do bulk changes ("add a warn-days field to every tracker" is a loop, not sixty edits). That's mostly true, with an exception I found later. The roles and the custom themes were set up with one-off API calls that never made it into the scripts, so a rebuild from scratch wouldn't recreate them.

Backups follow from the same fact. Everything that matters is in one Postgres database plus the storage folder for uploads. Secrets the workflows use (the email API key, ntfy and other app tokens) live in NocoBase's own environment-variable settings, inside that same database, not in any .env file in git. Restoring the database restores them, and nothing else does.

The maintenance worry is the image tag. The pages aren't built in NocoBase's editor. They're generated as raw UI-schema JSON matching the internals of the 2.2 classic UI, down to component names like TableV2.Column.Decorator and chart groups like ant-design-charts. A minor upgrade that renames any of that would break pages in ways the editor would never produce. It has already happened within a single version: the inline quick-edit cell crashes the 2.2 UI with Render Failed … reading 'length', and the scripts now refuse to generate it.

My own update policy, written a few days later in the repo's UPDATING.md, puts NocoBase in the row "Has a database schema or several images that must match", with the rule "pin an exact version, upgrade deliberately, back up first". It also notes that Renovate, which now opens image-update PRs for the repo, can only bump pinned tags, not :latest. As of today the NocoBase compose file still says latest. The policy is right and I haven't applied it to the one app where it matters most. I'm writing that down here so I stop pretending it's done.

What I'd do differently

I'd pin the image to the version the schemas were written against before building anything, and upgrade on purpose with a database dump first and a screenshot sweep after. The update script I use elsewhere already tags the old image for rollback. That's a one-line compose change I still haven't made.

I'd start with fewer sections. Generating 117 pages costs nothing when a script does it, but each one is a page I'm responsible for when something changes underneath it. The trackers I actually open are the ones from the first evening, the calendar and Home. The fifty-idea list was fun to say yes to. Half of it is furniture.

I'd write the roles into the scripts from the start, because permissions turned out to be the part where getting it wrong did real damage. That story has its own post.

And I'd read the "database side only" row of that first comparison table more carefully. I ended up in the right place, but by accident, because an app I'd forgotten about happened to be running. The Notion workspace is still there, by the way. I haven't decided what to do with it yet.