<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: stale_orbit</title>
    <description>The latest articles on DEV Community by stale_orbit (@stale_orbit).</description>
    <link>https://dev.to/stale_orbit</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4039118%2F6b92b9c0-8f08-43de-818d-c827926fb8b1.jpg</url>
      <title>DEV Community: stale_orbit</title>
      <link>https://dev.to/stale_orbit</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/stale_orbit"/>
    <language>en</language>
    <item>
      <title>NocoBase in Docker: a fine place to run, a poor place to build</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Mon, 10 Aug 2026 01:57:31 +0000</pubDate>
      <link>https://dev.to/stale_orbit/nocobase-in-docker-a-fine-place-to-run-a-poor-place-to-build-c69</link>
      <guid>https://dev.to/stale_orbit/nocobase-in-docker-a-fine-place-to-run-a-poor-place-to-build-c69</guid>
      <description>&lt;p&gt;The NocoBase plugin development docs assume one of two starting points: a project created with &lt;code&gt;create-nocobase-app&lt;/code&gt;, or an installation from the Git source. Neither is what most people actually have in front of them. What you have is the container you spun up with &lt;code&gt;docker compose up -d&lt;/code&gt; a week ago, database already populated, and building a plugin &lt;em&gt;there&lt;/em&gt; would obviously be quicker.&lt;/p&gt;

&lt;p&gt;Someone tried exactly that and posted about it on the official forum (&lt;a href="https://forum.nocobase.com/t/topic/12732" rel="noopener noreferrer"&gt;t/12732&lt;/a&gt;, May 2026): compilation failed in the Docker environment because &lt;code&gt;@nocobase/devtools&lt;/code&gt; wasn't installed. The thread ends with one reply asking for a full tutorial, and nothing after that. The report is also from the 2.0 line.&lt;/p&gt;

&lt;p&gt;So I ran it end to end on 2.1.23. You can make it work — but &lt;strong&gt;you shouldn't&lt;/strong&gt;, and the reason isn't the two missing dependencies. It's what happens afterwards.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt; NocoBase 2.1.23 (official Docker image &lt;code&gt;nocobase/nocobase:2.1.23&lt;/code&gt;) + PostgreSQL 16. Everything done through the CLI inside the container (&lt;code&gt;yarn pm&lt;/code&gt;, &lt;code&gt;yarn build&lt;/code&gt;), with the implementation in &lt;code&gt;@nocobase/cli-v1&lt;/code&gt; read where behavior needed explaining.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Obstacle 1: scaffolding reports success and produces nothing
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn pm create @scope/plugin-hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It ends with &lt;code&gt;Done in 5.57s.&lt;/code&gt; and &lt;strong&gt;exit code 0&lt;/strong&gt;. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /app/nocobase/packages/plugins/
.gitkeep     &lt;span class="c"&gt;# nothing new&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scrolling back through the output, there's an error buried in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot find module '@umijs/utils'
Require stack:
- /app/nocobase/node_modules/@nocobase/cli-v1/src/plugin-generator.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generator module can't resolve a dependency and dies, &lt;strong&gt;while the command as a whole still exits successfully&lt;/strong&gt;. If you don't read the log you're left hunting for a directory that was never created. I hunted for a while.&lt;/p&gt;

&lt;p&gt;The fix is what it looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn add @umijs/utils &lt;span class="nt"&gt;-W&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that the scaffold appears, including the V2 page entry points:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;packages/plugins/@scope/plugin-hello/
├── package.json
├── src/index.ts
├── client.js / client.d.ts
├── client-v2.js / client-v2.d.ts
└── server.js / server.d.ts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Obstacle 2: the build asks for "all dependencies"
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn build @scope/plugin-hello
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Please install all dependencies
$ yarn install
error Command failed with exit code 1.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one fails honestly — exit code 1 — but the message doesn't say &lt;em&gt;which&lt;/em&gt; dependency. The check turns out to be a single condition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @nocobase/cli-v1, util.js&lt;/span&gt;
&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hasTsNode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isPackageValid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ts-node/dist/bin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodeCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasTsNode&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Please install all dependencies&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only looks for &lt;code&gt;ts-node&lt;/code&gt;. What brings &lt;code&gt;ts-node&lt;/code&gt; along is &lt;code&gt;@nocobase/devtools&lt;/code&gt; — the package named in the forum report.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;yarn add @nocobase/devtools@2.1.23 &lt;span class="nt"&gt;-W&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt;    &lt;span class="c"&gt;# match your app version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the build runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rspack 1.7.8 compiled successfully in 38 ms
@scope/plugin-hello: build plugin server source
@scope/plugin-hello: build declaration
Done in 8.99s.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;yarn pm enable @scope/plugin-hello&lt;/code&gt; reports success. At this point the forum report is confirmed: add two dependencies and you can build plugins in the Docker image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The interesting part starts here.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Obstacle 3: recreate the container and it's all gone
&lt;/h2&gt;

&lt;p&gt;Sooner or later you bump an image tag or change a setting, which means &lt;code&gt;docker compose down &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt;. Afterwards:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the plugin           : No such file or directory
@nocobase/devtools   : No such file or directory
@umijs/utils         : No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Obvious in hindsight — all of it lives in the container's own filesystem layer. The only thing that survives is whatever a volume is mounted onto, which in a standard setup is exactly one path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;volume nocobase-storage -&amp;gt; /app/nocobase/storage      # the only survivor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plugins are scaffolded into &lt;code&gt;packages/plugins/&lt;/code&gt;, which is outside that. (&lt;code&gt;storage/plugins/&lt;/code&gt; did still hold its contents after the recreate, which matters for the recommendation below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  And then it runs while broken
&lt;/h2&gt;

&lt;p&gt;The files are gone. The database still says the plugin is installed and enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;installed&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"applicationPlugins"&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'@scope/plugin-hello'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
 &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="k"&gt;scope&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;plugin&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;hello&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So every startup logs this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Cannot find plugin '@scope/plugin-hello'
  at PluginManager.getPackageJson (...)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the application &lt;strong&gt;returns HTTP 200 and serves traffic normally&lt;/strong&gt;. It doesn't crash, which is precisely the problem: without reading the logs you have no signal at all. I ran into the same "boots fine, internally broken" shape once before, when testing a version downgrade — NocoBase does not stop for this class of inconsistency, so you need your own way of noticing.&lt;/p&gt;

&lt;p&gt;Cleanup is one statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="nv"&gt;"applicationPlugins"&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'@scope/plugin-hello'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What to do instead
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build somewhere else.&lt;/strong&gt; The reason the docs assume &lt;code&gt;create-nocobase-app&lt;/code&gt; or a Git checkout is that those give you an environment where the tooling is present and your work doesn't evaporate. The Docker image is built for running: its &lt;code&gt;devDependencies&lt;/code&gt; are empty, which is a deliberate choice rather than an oversight. Fighting that costs more than it saves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you insist on Docker, give the output somewhere to live.&lt;/strong&gt; Mount &lt;code&gt;packages/&lt;/code&gt; from a volume or a bind mount and at least the plugin survives. The dependencies won't — they land in &lt;code&gt;node_modules&lt;/code&gt; — so you're reinstalling them after every container recreate. Automating that is more work than standing up a proper development environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Let the Docker instance be the thing that runs.&lt;/strong&gt; Build elsewhere and bring the result in through &lt;code&gt;storage/plugins/&lt;/code&gt;, which is volume-backed. Which is to say: separate building from running, the way you would with anything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  One more thing, if you're behind a corporate proxy
&lt;/h2&gt;

&lt;p&gt;On a network with TLS inspection, &lt;code&gt;yarn add&lt;/code&gt; inside the container fails with &lt;code&gt;self-signed certificate in certificate chain&lt;/code&gt;. Copy your CA bundle in with &lt;code&gt;docker cp&lt;/code&gt; and point both &lt;code&gt;NODE_EXTRA_CA_CERTS&lt;/code&gt; and yarn's &lt;code&gt;cafile&lt;/code&gt; at the copy.&lt;/p&gt;

&lt;p&gt;Copy it — don't bind-mount the host's system CA file into the container. That's a good way to damage the host's certificate store, which I mention because I have done it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Stepping outside the documented prerequisites costs &lt;strong&gt;two missing dependencies&lt;/strong&gt;: &lt;code&gt;@umijs/utils&lt;/code&gt; for scaffolding, &lt;code&gt;@nocobase/devtools&lt;/code&gt; (for &lt;code&gt;ts-node&lt;/code&gt;) for building.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pm create&lt;/code&gt; exits 0 even when it fails.&lt;/strong&gt; Read the log; don't trust the status code.&lt;/li&gt;
&lt;li&gt;Those fixes work — &lt;strong&gt;until you recreate the container, which erases the plugin and the dependencies alike.&lt;/strong&gt; Only volume-mounted paths survive.&lt;/li&gt;
&lt;li&gt;Afterwards the database still lists the plugin as enabled, so the app &lt;strong&gt;logs an error on every boot and returns HTTP 200 regardless&lt;/strong&gt;. Nothing crashes, so nothing tells you.&lt;/li&gt;
&lt;li&gt;Use the Docker image to run, and build in the environment the docs assume.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The instinct to use the environment already in front of you is a reasonable one. In this case the cost of stepping outside the assumptions came back in full. My guess is the forum report stopped at "add the dependency and it compiles" simply because nobody carried it as far as recreating the container.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Measured on 2.1.23 / PostgreSQL 16. Behavior may change in future versions.)&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/development" rel="noopener noreferrer"&gt;Plugin development prerequisites (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/get-started/installation/docker" rel="noopener noreferrer"&gt;Installing with Docker (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12732" rel="noopener noreferrer"&gt;Forum: developing plugins in a Docker environment (Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Previously: &lt;a href="https://dev.to/stale_orbit/nocobase-api-keys-tight-permissions-loose-key-management-2o9d"&gt;NocoBase API keys: tight permissions, loose key management&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>docker</category>
      <category>opensource</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>NocoBase API keys: tight permissions, loose key management</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Wed, 05 Aug 2026 03:33:41 +0000</pubDate>
      <link>https://dev.to/stale_orbit/nocobase-api-keys-tight-permissions-loose-key-management-2o9d</link>
      <guid>https://dev.to/stale_orbit/nocobase-api-keys-tight-permissions-loose-key-management-2o9d</guid>
      <description>&lt;p&gt;In a &lt;a href="https://dev.to/stale_orbit/nocobase-says-the-free-edition-builds-unlimited-applications-heres-what-that-actually-means-5661"&gt;previous post&lt;/a&gt; about where the Community edition's limits actually sit, I mentioned a workaround for the webhook trigger being a paid feature: create a role whose write permission is scoped to a single table, issue an API key for it, and have the external system POST to the REST API directly. Someone on the official forum runs exactly that in production, receiving pushed data from a bank.&lt;/p&gt;

&lt;p&gt;Recommending something without testing it sat badly with me, so I built it and hammered on it. The good news: &lt;strong&gt;the permission model holds up exactly as advertised.&lt;/strong&gt; The less good news: &lt;strong&gt;three operational behaviors caught me out&lt;/strong&gt;, and two of them let you end up with keys that look alive in the list while being dead in practice.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt; NocoBase 2.1.23 (official Docker image) + PostgreSQL 16, driven entirely through the REST API. Where behavior was surprising I read the implementation in &lt;code&gt;@nocobase/plugin-api-keys&lt;/code&gt; and &lt;code&gt;@nocobase/acl&lt;/code&gt; to confirm the mechanism.&lt;/p&gt;

&lt;p&gt;Most of what follows bites when you &lt;strong&gt;automate key issuance through the API&lt;/strong&gt;. Creating keys by hand in the admin UI avoids all but one of them — and that one is the most consequential. There's a routes-vs-pitfalls table near the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  An API key is a role, wearing a token
&lt;/h2&gt;

&lt;p&gt;A NocoBase API key carries no permissions of its own. It's a JWT that encodes &lt;em&gt;which user, acting as which role&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"userId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"roleName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integration"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1785120716&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;33342720716&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to notice. First, the key's authority is &lt;strong&gt;exactly the role's authority&lt;/strong&gt; — nothing more, nothing less. Second, there's &lt;strong&gt;no &lt;code&gt;jti&lt;/code&gt;&lt;/strong&gt; — no per-key identifier. That absence causes the third surprise later.&lt;/p&gt;

&lt;p&gt;(An "unlimited" expiry still writes an &lt;code&gt;exp&lt;/code&gt;; the implementation just substitutes a date far in the future.)&lt;/p&gt;

&lt;h3&gt;
  
  
  Scoping works as advertised
&lt;/h3&gt;

&lt;p&gt;I built a role allowed to do exactly one thing — &lt;code&gt;create&lt;/code&gt; on an &lt;code&gt;inbound_orders&lt;/code&gt; table — and issued a key for it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request with that key&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;create&lt;/code&gt; on the permitted &lt;code&gt;inbound_orders&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;create&lt;/code&gt; on a different table&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;list&lt;/code&gt; on that same &lt;code&gt;inbound_orders&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;list&lt;/code&gt; on &lt;code&gt;users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note the third row: &lt;strong&gt;same table, but read access is refused&lt;/strong&gt; because only &lt;code&gt;create&lt;/code&gt; was granted. The granularity is per-action, not per-table. As a credential to hand an external system, that's tight enough to be comfortable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Role edits take effect immediately, without reissuing
&lt;/h3&gt;

&lt;p&gt;I left the key untouched and changed the role instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grant internal_memos:create to the role  → same key, create → 200
revoke it again                          → same key, create → 403
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No redistribution needed when requirements change. The flip side is worth stating plainly: &lt;strong&gt;widening a role widens every key already issued for it.&lt;/strong&gt; Use one role per integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting regular users issue keys — and the ACL rule that blocks it
&lt;/h2&gt;

&lt;p&gt;By default only administrators can create keys — a regular account gets &lt;code&gt;403 No permissions&lt;/code&gt;. To open it up, grant the role the &lt;code&gt;pm.api-keys.configuration&lt;/code&gt; snippet. That's where I lost some time.&lt;/p&gt;

&lt;p&gt;The stock role carries &lt;code&gt;["!pm", "!pm.*", "!ui.*"]&lt;/code&gt;. Adding the specific grant on top of that does nothing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Snippet configuration&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;!pm&lt;/code&gt;, &lt;code&gt;!pm.*&lt;/code&gt;, &lt;code&gt;!ui.*&lt;/code&gt;, &lt;code&gt;pm.api-keys.configuration&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pm.api-keys.configuration&lt;/code&gt;, &lt;code&gt;!pm&lt;/code&gt;, &lt;code&gt;!pm.*&lt;/code&gt;, &lt;code&gt;!ui.*&lt;/code&gt; (order reversed)&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;!pm&lt;/code&gt;, &lt;code&gt;!ui.*&lt;/code&gt;, &lt;code&gt;pm.api-keys.configuration&lt;/code&gt; (drop &lt;code&gt;!pm.*&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The ACL implementation collects allows and denies separately, then folds them like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// from @nocobase/acl, effectiveSnippets()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;effectiveSnippets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;allowedSnippets&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rejectedSnippets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Anything matched by a deny is dropped, no matter how specifically it was allowed — and order is irrelevant.&lt;/strong&gt; If your instinct comes from IAM or CSS, where specificity or ordering decides, that instinct is wrong here. You can't carve an exception out of a wildcard deny; you have to narrow the deny itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 1: omit the role and you get a cheerful 200 with nothing behind it
&lt;/h2&gt;

&lt;p&gt;This one is specific to the API — the UI has a mandatory role selector.&lt;/p&gt;

&lt;p&gt;Leave &lt;code&gt;role&lt;/code&gt; out of the create request and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /api/apiKeys:create  {"name": "my-key", "expiresIn": "never"}
→ HTTP 200
→ {}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Success status, no key, no error. The implementation explains it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// @nocobase/plugin-api-keys, create&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;values&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// returns without a word&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your provisioning script treats &lt;code&gt;200&lt;/code&gt; as "key created", it will sail past this. &lt;strong&gt;Check that the response body contains &lt;code&gt;token&lt;/code&gt;&lt;/strong&gt;, not just the status code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 2: nobody can inventory the keys — not even root
&lt;/h2&gt;

&lt;p&gt;Of the three, this is the one that applies whichever route you use, and it has the largest operational consequence.&lt;/p&gt;

&lt;p&gt;Keys issued by a regular user simply don't appear in an administrator's list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;the user's own list        : ['integration-key', 'clean-key', 'gov-key', ...]
the administrator's list   : ['admin_api_key']        ← only their own
rows actually in the DB    : 14
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first thought was that I'd tested with an underpowered account. I hadn't: it held &lt;code&gt;root&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt; and &lt;code&gt;member&lt;/code&gt;, and &lt;strong&gt;passing &lt;code&gt;X-Role: root&lt;/code&gt; explicitly returns the same single key.&lt;/strong&gt; The reason is a server-side middleware that rewrites the request unconditionally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// applied to list / destroy&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mergeParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;createdById&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No role check anywhere, which means no amount of privilege routes around it — and because the admin UI calls the same REST endpoint, &lt;strong&gt;using the UI doesn't help either&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;As "you can't read other people's credentials", this is sound design. As operations, it means &lt;strong&gt;there is no way to answer "how many keys are live right now, and who holds them?" from inside the product.&lt;/strong&gt; If you need that answer, query the &lt;code&gt;apiKeys&lt;/code&gt; table directly (it carries &lt;code&gt;createdById&lt;/code&gt;). And if you're considering opening key issuance to regular users, weigh it against this: you'd be handing out credentials you cannot subsequently enumerate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surprise 3: keys issued in the same second are the same key
&lt;/h2&gt;

&lt;p&gt;Also an automation-only problem in practice — issuing two keys within one second by hand is difficult. It's still the one that fooled me longest.&lt;/p&gt;

&lt;p&gt;I issued two keys back to back for the same user, role and expiry, then compared the token strings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;token(twin-a) == token(twin-b)  →  True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look again at the payload: &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;roleName&lt;/code&gt;, &lt;code&gt;iat&lt;/code&gt;, &lt;code&gt;exp&lt;/code&gt;, and &lt;strong&gt;no &lt;code&gt;jti&lt;/code&gt;&lt;/strong&gt;. Since &lt;code&gt;iat&lt;/code&gt; has one-second resolution, two keys created inside the same second with the same parameters serialize and sign to a byte-identical string. Space them more than a second apart and they differ.&lt;/p&gt;

&lt;p&gt;Because both carry the same role, this isn't privilege escalation. The damage shows up at revocation time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;call the API with twin-b   → 200 (alive)
revoke twin-a              → 200
call the API with twin-b   → 401 ← never touched it
is twin-b still listed?    → True ← still looks alive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Revocation works by adding the token string to a blocklist, so a second record holding the same string dies with it. &lt;strong&gt;Issue keys one at a time, with a gap.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The same asymmetry, from the admin side
&lt;/h3&gt;

&lt;p&gt;An administrator can't see another user's keys, but the &lt;code&gt;id&lt;/code&gt; is readable straight from the &lt;code&gt;apiKeys&lt;/code&gt; table. Point the revoke endpoint at that &lt;code&gt;id&lt;/code&gt; and:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Row still in the database&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Still listed for its owner&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Key still usable&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;401&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Revocation happens in two steps, and they disagree about ownership. The first — blocking the token — looks the row up with a raw &lt;code&gt;findById&lt;/code&gt; that skips the ownership filter. The second — deleting the row — goes through the filtered action from Surprise 2. So the credential dies while its record survives.&lt;/p&gt;

&lt;p&gt;The operational takeaway is short: &lt;strong&gt;once a key starts returning 401, stop trusting the list.&lt;/strong&gt; A row being there doesn't mean the credential works. Reissue and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which of these actually apply to you
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behavior&lt;/th&gt;
&lt;th&gt;Issuing via API&lt;/th&gt;
&lt;th&gt;Issuing via UI&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Silent 200 with no key&lt;/td&gt;
&lt;td&gt;applies&lt;/td&gt;
&lt;td&gt;not applicable&lt;/td&gt;
&lt;td&gt;the UI requires a role selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Keys can't be inventoried&lt;/td&gt;
&lt;td&gt;applies&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;applies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;enforced server-side; the UI uses the same endpoint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. Same-second token collision&lt;/td&gt;
&lt;td&gt;applies&lt;/td&gt;
&lt;td&gt;effectively not&lt;/td&gt;
&lt;td&gt;issuing twice within a second by hand is impractical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3b. Revoked-but-still-listed&lt;/td&gt;
&lt;td&gt;applies&lt;/td&gt;
&lt;td&gt;not applicable&lt;/td&gt;
&lt;td&gt;admins can't see others' keys to target an &lt;code&gt;id&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;If you provision keys by hand, only the second one is your problem&lt;/strong&gt; — but it's a real one, because it quietly invalidates the assumption that the admin console shows you what's out there. &lt;strong&gt;Start automating issuance and the rest arrive together.&lt;/strong&gt; That transition is exactly when this post is worth rereading.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One role per integration.&lt;/strong&gt; Widening a role widens every key already issued under it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope by action, not just by table.&lt;/strong&gt; A receive-only integration should not be granted &lt;code&gt;list&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't try to except your way out of a wildcard deny&lt;/strong&gt; — deny wins, regardless of order.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;When provisioning via API, always send &lt;code&gt;role&lt;/code&gt;, and verify &lt;code&gt;token&lt;/code&gt; is in the response.&lt;/strong&gt; A 200 alone proves nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Issue keys one at a time, spaced more than a second apart.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track issued keys outside the product.&lt;/strong&gt; The admin console cannot enumerate them, so keep a register — or query the database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;em&gt;(Measured on 2.1.23 / PostgreSQL 16. Behavior may change in future versions.)&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.nocobase.com/handbook/api-keys" rel="noopener noreferrer"&gt;API keys (official docs)&lt;/a&gt; / &lt;a href="https://docs.nocobase.com/handbook/acl" rel="noopener noreferrer"&gt;ACL (official docs)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12606" rel="noopener noreferrer"&gt;Forum: opening API key creation to regular users (Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/13337" rel="noopener noreferrer"&gt;Forum: receiving data from external services with a scoped key (Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Previously: &lt;a href="https://dev.to/stale_orbit/nocobase-says-the-free-edition-builds-unlimited-applications-heres-what-that-actually-means-5661"&gt;Where the free edition's limits actually sit&lt;/a&gt; / &lt;a href="https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m"&gt;Are NocoBase 2.x workflows really "sequential"?&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>security</category>
      <category>api</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>NocoBase says the free edition builds "unlimited applications" — here's what that actually means</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Tue, 28 Jul 2026 02:23:12 +0000</pubDate>
      <link>https://dev.to/stale_orbit/nocobase-says-the-free-edition-builds-unlimited-applications-heres-what-that-actually-means-5661</link>
      <guid>https://dev.to/stale_orbit/nocobase-says-the-free-edition-builds-unlimited-applications-heres-what-that-actually-means-5661</guid>
      <description>&lt;p&gt;If you're evaluating NocoBase, you hit this question early: &lt;strong&gt;how far does the free Community edition actually go?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.nocobase.com/en/commercial" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; says the Community edition lets you:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Build unlimited applications with no limits on users or data&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sounds generous. Then you open the docs, look up anything multi-app related, and every page carries a paid-edition badge. So can you build "unlimited applications" for free or not?&lt;/p&gt;

&lt;p&gt;It turns out someone asked the official forum exactly this, and a staff member settled it. This post maps the Community/paid boundary using only primary sources — the pricing page and official staff answers on the forum (several of them in the Chinese-language category, which is by far the most active and where a lot of these answers live).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Everything below reflects the pricing page and forum answers as of July 2026. Editions and prices change; check the pricing page before making a purchase decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What "unlimited applications" actually means
&lt;/h2&gt;

&lt;p&gt;The forum question (&lt;a href="https://forum.nocobase.com/t/topic/13365" rel="noopener noreferrer"&gt;t/13365&lt;/a&gt;, July 2026, Chinese) was word-for-word the confusion above: the pricing page says unlimited apps, the multi-app docs all say paid edition — which is it?&lt;/p&gt;

&lt;p&gt;The staff answer (translated):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Community edition lets you deploy and develop multiple mutually independent application &lt;em&gt;instances&lt;/em&gt;. But "multi-app" refers to the &lt;em&gt;feature&lt;/em&gt; of creating and managing multiple isolated applications inside a single NocoBase. Using that feature in a newly installed environment requires the Professional edition.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Unlimited applications" = you can stand up as many NocoBase instances as you want.&lt;/strong&gt; Ten Docker containers running ten systems is fine — it's open source, after all.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The multi-app &lt;em&gt;feature&lt;/em&gt; — managing multiple isolated apps inside one NocoBase — is paid.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you read "build unlimited applications" and expected the multi-app feature, you'd be disappointed — and judging by the forum, plenty of people read it exactly that way. I'd argue that's on the wording, not the readers.&lt;/p&gt;

&lt;p&gt;One more wrinkle worth knowing: &lt;strong&gt;the pricing page lists multi-application architecture under Enterprise, while the forum answer above says Professional.&lt;/strong&gt; If multi-app is the reason you're buying, confirm the required edition with the team directly before you pay.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "wait, that's paid?" list
&lt;/h2&gt;

&lt;p&gt;Pulling from the edition comparison on the pricing page, here's where the boundary actually sits. These are the ones most likely to surprise you:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Edition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Rebranding (logo / product name)&lt;/td&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;External database connections&lt;/strong&gt; (supported DBs vary by edition — see below)&lt;/td&gt;
&lt;td&gt;Standard and up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk Excel import/export&lt;/td&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Approval workflows&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subflows&lt;/td&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Webhook trigger&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSO / record edit history&lt;/td&gt;
&lt;td&gt;Professional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit log (user action logging)&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-app / multi-space&lt;/td&gt;
&lt;td&gt;Enterprise (forum says Professional — see above)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cluster deployment&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these bite hardest in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Approval workflows.&lt;/strong&gt; Request-then-approve is the bread and butter of internal tools, and the Approval plugin is Professional. To be precise: the Community edition &lt;em&gt;does&lt;/em&gt; have a generic Manual Handling node (pause until a human acts). What's paid is Approval — the dedicated plugin with request lists, approval screens, and the rest of the purpose-built UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhook trigger.&lt;/strong&gt; The official path for receiving data from external systems sits on the paid side. There's a solid workaround, though — next section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External databases.&lt;/strong&gt; Layering NocoBase on top of an existing business DB starts at Standard ($800), and the supported engines depend on the edition: MySQL / PostgreSQL / MariaDB at Standard, MSSQL and REST API data sources at Professional, Oracle / ClickHouse / Doris at Enterprise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  No webhooks? You can still receive data
&lt;/h2&gt;

&lt;p&gt;In another thread (&lt;a href="https://forum.nocobase.com/t/topic/13337" rel="noopener noreferrer"&gt;t/13337&lt;/a&gt;, July 2026), someone needed to receive pushed data from an external service. After a reply pointed out that webhooks require Professional, a practitioner shared what they run in production (translated):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Create an API key, and give it a role whose write permission is restricted to just the table you're storing into. The external service writes directly through the REST API. This is how we currently receive pushed data from a bank.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every NocoBase collection automatically gets a REST API, so &lt;strong&gt;"receive via webhook" becomes "have the sender write via REST"&lt;/strong&gt; — fully within the Community edition. The key detail is the locked-down role: API keys inherit the role's permissions, so a key scoped to one table limits the blast radius if it ever leaks.&lt;/p&gt;

&lt;p&gt;(If you needed the webhook specifically to &lt;em&gt;trigger a workflow&lt;/em&gt; on arrival, bind a collection event to the target table instead — with the caveat that collection events have their own quirks, like &lt;a href="https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m"&gt;not firing on bulk operations&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  You can't buy plugins individually anymore
&lt;/h2&gt;

&lt;p&gt;One more boundary fact that trips up budgeting. There was a period when commercial plugins could be purchased one by one. Asked about buying just the report-printing and subflow plugins (&lt;a href="https://forum.nocobase.com/t/topic/12377" rel="noopener noreferrer"&gt;t/12377&lt;/a&gt;, May 2026), staff answered (translated):&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That's no longer supported. You need to purchase a license, and the license includes the corresponding plugins.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So the current model is: &lt;strong&gt;buy an edition license, get that edition's plugin set.&lt;/strong&gt; If the one feature you need lives in Professional, your unit of purchase is Professional ($8,000, one-time) — there's no à-la-carte option. Plan budgets around editions, not features.&lt;/p&gt;

&lt;h2&gt;
  
  
  To be fair: the free side has been growing
&lt;/h2&gt;

&lt;p&gt;Lest this read as "the free edition is bait" — the movement in 2026 has been in the other direction. In February, &lt;a href="https://www.nocobase.com/en/blog/pricing-adjustment-202602" rel="noopener noreferrer"&gt;the core license changed from AGPL-3.0 to Apache-2.0&lt;/a&gt;, and &lt;strong&gt;16 previously-commercial plugins were open-sourced&lt;/strong&gt; in the same move.&lt;/p&gt;

&lt;p&gt;The realistic summary: &lt;strong&gt;a single internal business system, without approval flows, is entirely buildable on the Community edition.&lt;/strong&gt; The paid line is drawn around approvals, the official external-integration path, unified multi-app management, and enterprise operations (clustering, audit logging).&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluation checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Unlimited apps" means unlimited &lt;em&gt;instances&lt;/em&gt;.&lt;/strong&gt; Managing multiple apps inside one NocoBase is paid — and the required edition (Professional vs Enterprise) is stated inconsistently, so confirm before buying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask "do we need approval workflows?" first.&lt;/strong&gt; It's the most common late surprise. If yes, budget for Professional from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No webhooks needed for inbound data&lt;/strong&gt; — a write-scoped API key plus the auto-generated REST API covers it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No per-plugin purchases.&lt;/strong&gt; Find which edition contains your must-have feature and price that edition.&lt;/li&gt;
&lt;li&gt;Editions and prices move; &lt;strong&gt;re-check the pricing page (and ask the team directly for edge cases) before deciding.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.nocobase.com/en/commercial" rel="noopener noreferrer"&gt;Pricing / edition comparison (official)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/13365" rel="noopener noreferrer"&gt;Forum: can the Community edition create multi-apps? (official answer, Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/13337" rel="noopener noreferrer"&gt;Forum: receiving data from external services (API-key pattern, Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12377" rel="noopener noreferrer"&gt;Forum: individual plugin purchases discontinued (official answer, Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.nocobase.com/en/blog/pricing-adjustment-202602" rel="noopener noreferrer"&gt;License change announcement: AGPL-3.0 → Apache-2.0, Feb 2026 (official)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Previously: &lt;a href="https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m"&gt;Are NocoBase 2.x workflows really "sequential"?&lt;/a&gt; / &lt;a href="https://dev.to/stale_orbit/nocobase-and-the-mystery-of-the-shifted-timestamps-mysql-vs-postgresql-measured-4o4h"&gt;The mystery of the shifted timestamps&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>opensource</category>
      <category>lowcode</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>NocoBase and the mystery of the shifted timestamps: MySQL vs PostgreSQL, measured</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:50:56 +0000</pubDate>
      <link>https://dev.to/stale_orbit/nocobase-and-the-mystery-of-the-shifted-timestamps-mysql-vs-postgresql-measured-4o4h</link>
      <guid>https://dev.to/stale_orbit/nocobase-and-the-mystery-of-the-shifted-timestamps-mysql-vs-postgresql-measured-4o4h</guid>
      <description>&lt;p&gt;There's a class of bug reports that keeps coming back in the NocoBase community, especially in the Chinese-language forum: "all my times are off by 8 hours" or "dates show up as the day before." China is UTC+8, so the shift is 8 hours there. I run my instances at UTC+9, and sure enough — my shift is 9 hours. Whatever your offset is, that's the size of your shift.&lt;/p&gt;

&lt;p&gt;That pattern is a strong hint that this isn't random corruption. It's a mechanism. I set up NocoBase 2.x against both PostgreSQL and MySQL and measured what actually gets stored and how it gets reinterpreted, until the mystery had a concrete answer.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt; NocoBase 2.0.51 and 2.1.23 (official Docker images) × PostgreSQL 16 and MySQL 8.4. All data written and read through the REST API, with the server timezone controlled via the container's &lt;code&gt;TZ&lt;/code&gt; environment variable. I'm deliberately ignoring the browser-side rendering here — this is about what the &lt;em&gt;server&lt;/em&gt; stores and how it interprets it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Background: 2.x has four datetime field types
&lt;/h2&gt;

&lt;p&gt;NocoBase 2.x collections offer four datetime-ish field types (&lt;a href="https://docs.nocobase.com/handbook/data-modeling/collection-fields/datetime/" rel="noopener noreferrer"&gt;official list&lt;/a&gt; — though several of the per-type detail pages still say "To be added", which is exactly why I measured instead):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Datetime (with time zone)&lt;/td&gt;
&lt;td&gt;Absolute instants — event start times, logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datetime (without time zone)&lt;/td&gt;
&lt;td&gt;Wall-clock times you want preserved as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date only&lt;/td&gt;
&lt;td&gt;Birthdays, due dates, anniversaries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unix timestamp&lt;/td&gt;
&lt;td&gt;System integration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Measurement 1: what each type actually stores
&lt;/h2&gt;

&lt;p&gt;I imported "2026-07-12 09:00" via xlsx and looked at the raw values in each database (identical on 2.0.51 and 2.1.23):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field type&lt;/th&gt;
&lt;th&gt;PostgreSQL&lt;/th&gt;
&lt;th&gt;MySQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Datetime (with TZ)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;timestamptz&lt;/code&gt; → &lt;code&gt;2026-07-12 09:00:00+09&lt;/code&gt; (&lt;strong&gt;an absolute instant, offset included&lt;/strong&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DATETIME&lt;/code&gt; → &lt;code&gt;2026-07-12 09:00:00&lt;/code&gt; (&lt;strong&gt;wall clock only — no offset information&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Datetime (without TZ)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;timestamp&lt;/code&gt; → &lt;code&gt;09:00:00&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DATETIME&lt;/code&gt; → &lt;code&gt;09:00:00&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Date only&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;date&lt;/code&gt; → &lt;code&gt;2026-07-12&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;date&lt;/code&gt; → &lt;code&gt;2026-07-12&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row is the whole story. The &lt;em&gt;same&lt;/em&gt; field type — "Datetime (with time zone)" — is stored as an absolute instant on PostgreSQL, but as a bare wall-clock value on MySQL. MySQL's &lt;code&gt;DATETIME&lt;/code&gt; simply has nowhere to put the offset. Keep that in mind for the next experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measurement 2: change the server TZ, and your stored data changes meaning (MySQL)
&lt;/h2&gt;

&lt;p&gt;On the MySQL setup, I wrote "2026-07-12 09:00" while the server ran with &lt;code&gt;TZ=UTC&lt;/code&gt;. Then I changed the app container to &lt;code&gt;TZ=Asia/Tokyo&lt;/code&gt; and restarted. &lt;strong&gt;Nothing else.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Raw value in MySQL (not a single byte changed)
dt_tz: 2026-07-12 09:00:00

# What the API returns
with TZ=UTC        → "2026-07-12T09:00:00.000Z"   (= 18:00 at UTC+9)
with TZ=Asia/Tokyo → "2026-07-12T00:00:00.000Z"   (= 09:00 at UTC+9)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The data on disk is byte-for-byte identical, but its meaning as an absolute instant moved by 9 hours.&lt;/strong&gt; Because MySQL's &lt;code&gt;DATETIME&lt;/code&gt; doesn't record &lt;em&gt;which&lt;/em&gt; timezone's 09:00 this is, NocoBase has to interpret it through the server's &lt;code&gt;TZ&lt;/code&gt; on every read and write. Change &lt;code&gt;TZ&lt;/code&gt; after the fact, and every stored datetime in your database silently shifts meaning at once.&lt;/p&gt;

&lt;p&gt;This mechanism explains most of the recurring "8-hour shift" reports: an instance starts life with &lt;code&gt;TZ&lt;/code&gt; unset (= UTC), someone later sets it to local time — or staging and production simply disagree on &lt;code&gt;TZ&lt;/code&gt;. No data was harmed; every interpretation was.&lt;/p&gt;

&lt;p&gt;And PostgreSQL? &lt;strong&gt;I ran the same experiment and the values didn't move.&lt;/strong&gt; &lt;code&gt;timestamptz&lt;/code&gt; stored the offset, so there's nothing left to reinterpret.&lt;/p&gt;

&lt;p&gt;Two rules fall straight out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pick the server &lt;code&gt;TZ&lt;/code&gt; on day one and never change it&lt;/strong&gt; — especially on MySQL. Pin it explicitly in your compose file, and keep it identical across environments (dev, staging, prod).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;If you get to choose the database, PostgreSQL is structurally immune to this entire class of accident.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A separate thing: the import date bug (real, and fixed)
&lt;/h2&gt;

&lt;p&gt;Not every shifted date is this mechanism. There was also a genuine bug in the 2.x era: &lt;strong&gt;on v2.0.44–2.0.51, CSV/Excel imports could store dates as one day earlier&lt;/strong&gt;, even with database, app, and server timezones all aligned. It was confirmed as a defect and fixed in mid-May 2026 (forum threads &lt;a href="https://forum.nocobase.com/t/topic/12426" rel="noopener noreferrer"&gt;t/12426&lt;/a&gt;, &lt;a href="https://forum.nocobase.com/t/topic/12494" rel="noopener noreferrer"&gt;t/12494&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;For what it's worth, I could not reproduce it on 2.0.51 importing xlsx/CSV through the API — both databases stored the dates correctly. The reports involved the UI path, so another route or environmental factor was likely involved. Either way, current versions have the fix.&lt;/p&gt;

&lt;p&gt;The practical lesson: when dates shift, &lt;strong&gt;separate "is this the mechanism?" from "is this a known bug on my version?"&lt;/strong&gt; Understanding measurement 1 and 2 covers the first question; a quick forum search (including the Chinese category — it's the most active by far) covers the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Match the field type to the meaning.&lt;/strong&gt; Birthdays and due dates belong in "Date only". Absolute instants belong in "Datetime (with TZ)". Using "Datetime (with TZ)" for date-only data is the classic way to get dates that render one day off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the server &lt;code&gt;TZ&lt;/code&gt; in your compose file on day one&lt;/strong&gt; and never touch it afterwards (MySQL especially). UTC or your local zone — either works; just pick one and use it everywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After any import, open one record and check the datetime.&lt;/strong&gt; These shifts hit every row uniformly — one record tells you everything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If something is off: check your version, then search the forum&lt;/strong&gt; — including the Chinese-language category, where most of the real-world reports live.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NocoBase 2.x "Datetime (with time zone)" is an absolute instant on PostgreSQL but &lt;strong&gt;a wall-clock value + &lt;code&gt;TZ&lt;/code&gt;-based interpretation on MySQL&lt;/strong&gt; (measured).&lt;/li&gt;
&lt;li&gt;Which is why changing the server &lt;code&gt;TZ&lt;/code&gt; later shifts the meaning of &lt;em&gt;all&lt;/em&gt; stored datetimes on MySQL — the origin of the recurring "8-hour shift" (or 9, or whatever your offset is).&lt;/li&gt;
&lt;li&gt;The import date-off-by-one bug was real but is fixed; distinguish mechanism from known bugs and the diagnosis gets fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;(Measured on 2.0.51 / 2.1.23 × PostgreSQL 16 / MySQL 8.4. Behavior may change in future versions.)&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/handbook/data-modeling/collection-fields/datetime/" rel="noopener noreferrer"&gt;DateTime field types (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12426" rel="noopener noreferrer"&gt;Forum: CSV import stores previous day (Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12494" rel="noopener noreferrer"&gt;Forum: dates one day earlier after 2.0.44 (Chinese)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Previously: &lt;a href="https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m"&gt;Are NocoBase 2.x workflows really "sequential"? I measured it&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>mysql</category>
      <category>postgres</category>
      <category>lowcode</category>
    </item>
    <item>
      <title>Are NocoBase 2.x workflows really "sequential"? I measured it, and everything ran in parallel</title>
      <dc:creator>stale_orbit</dc:creator>
      <pubDate>Tue, 21 Jul 2026 09:33:23 +0000</pubDate>
      <link>https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m</link>
      <guid>https://dev.to/stale_orbit/are-nocobase-2x-workflows-really-sequential-i-measured-it-and-everything-ran-in-parallel-1d5m</guid>
      <description>&lt;p&gt;If you read the NocoBase workflow docs the way I did, you come away thinking workflows can't run concurrently. Two sentences do most of the damage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Workflows can be triggered concurrently, but they are executed &lt;strong&gt;sequentially in a queue&lt;/strong&gt;. Even if multiple workflows are triggered at the same time, they will be executed &lt;strong&gt;one by one, not in parallel&lt;/strong&gt;.&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/advanced/executions" rel="noopener noreferrer"&gt;Executions&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Regardless of the mode, each branch will be executed &lt;strong&gt;in order from left to right&lt;/strong&gt;.&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/nodes/parallel" rel="noopener noreferrer"&gt;Parallel branch&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Taken at face value, that reads as "workflows have no throughput parallelism, and even parallel branches run one after another." If it were true, it would be a serious constraint on how you design anything with real load.&lt;/p&gt;

&lt;p&gt;So I built the three scenarios on my own instance and measured them. My prediction was wrong three times in a row. The short version: &lt;strong&gt;everything runs in parallel, cleanly.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Test setup:&lt;/strong&gt; NocoBase 2.1.23 (official Docker image) + PostgreSQL 16, on a 12-core machine. Workflows were created, triggered, and measured entirely through the REST API. Elapsed time is the difference between &lt;code&gt;createdAt&lt;/code&gt; and &lt;code&gt;updatedAt&lt;/code&gt; on rows in the &lt;code&gt;executions&lt;/code&gt; table — not a stopwatch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Test 1: two "wait 5 seconds" branches under a parallel node
&lt;/h2&gt;

&lt;p&gt;I put a Parallel branch node in front of two branches, each with a single 5-second delay node.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;10 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;5 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = 1 (success) / elapsed 5.17 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;5 seconds.&lt;/strong&gt; The two waits overlapped in real time. Looking at the job records, the two delay nodes started 11 ms apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parallel branch   created = 06:25:01.894
delay A (5s)      created = 06:25:01.904
delay B (5s)      created = 06:25:01.915   &amp;lt;- did not wait for branch A to finish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So "executed in order from left to right" is about the order branches are &lt;em&gt;entered&lt;/em&gt;, not the order they &lt;em&gt;finish&lt;/em&gt;. The moment branch A parks itself on a wait, the engine hands control back and branch B starts. The waits stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: surely CPU work runs sequentially?
&lt;/h2&gt;

&lt;p&gt;Fine, waits overlap. But Node.js is single-threaded, so &lt;strong&gt;CPU-bound&lt;/strong&gt; work shouldn't overlap — that was my next assumption. I put a JavaScript node running a 3-second busy loop on each of two branches.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;  &lt;span class="c1"&gt;// hog the CPU for 3 seconds&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;6 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;3 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;status = 1 (success) / elapsed 3.18 s
both branches returned result = 'done'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3 seconds again.&lt;/strong&gt; Even CPU work overlapped.&lt;/p&gt;

&lt;p&gt;The trick is in the plugin. The JavaScript node doesn't run on the main event loop — it runs in a &lt;strong&gt;&lt;code&gt;worker_threads&lt;/code&gt; worker&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// from @nocobase/plugin-workflow-javascript&lt;/span&gt;
&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;import_node_worker_threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:worker_threads&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it runs on a separate thread, a CPU-bound script doesn't block the main loop. "It's Node, so it's single-threaded" was exactly the wrong intuition here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 3: fire four executions at the same time
&lt;/h2&gt;

&lt;p&gt;This is the one the docs speak to most directly: "even if multiple workflows are triggered at the same time, they will be executed one by one."&lt;/p&gt;

&lt;p&gt;I triggered &lt;strong&gt;the same 3-second-CPU workflow four times simultaneously.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sequential → &lt;strong&gt;12 s&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Parallel → &lt;strong&gt;3 s&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; id   | status |   started    |   finished   | elapsed
------+--------+--------------+--------------+---------
 ...2 |      1 | 15:38:53.341 | 15:38:56.834 |  3.493
 ...3 |      1 | 15:38:53.395 | 15:38:56.782 |  3.387
 ...4 |      1 | 15:38:53.404 | 15:38:56.811 |  3.407
 ...5 |      1 | 15:38:53.408 | 15:38:56.823 |  3.415

wall-clock for all four: 3.49 s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;All four ran fully in parallel.&lt;/strong&gt; They started within ~70 ms of each other and finished within ~50 ms of each other. (Three 5-second delay executions fired together came in at 5.1 s wall-clock, same story.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So are the docs wrong?
&lt;/h2&gt;

&lt;p&gt;No — and this is the part worth slowing down on. The same Executions page also says, in effect: a "Running" execution that is parked on a waiting node is &lt;em&gt;not&lt;/em&gt; holding the dispatch slot. Other queued executions can start while it waits.&lt;/p&gt;

&lt;p&gt;In other words, "sequential" describes &lt;strong&gt;the order the dispatcher pulls from the queue&lt;/strong&gt;, not "only one runs at a time." As soon as an execution hits a node that yields — a delay, a manual approval, an HTTP request — the next one starts. And JavaScript nodes escape to a worker thread entirely.&lt;/p&gt;

&lt;p&gt;But I'll say this plainly: the English phrasing &lt;strong&gt;"one by one, not in parallel"&lt;/strong&gt; reads as a throughput claim, and it made me design around a limit that isn't there. I misread it completely. Worth knowing: the Chinese-forum reports about "the queue getting stuck" (e.g. &lt;a href="https://forum.nocobase.com/t/topic/10331" rel="noopener noreferrer"&gt;t/10331&lt;/a&gt;) are &lt;strong&gt;all from the 1.x era&lt;/strong&gt;. In 2.x the dispatcher was reworked — &lt;a href="https://github.com/nocobase/nocobase/pull/9673" rel="noopener noreferrer"&gt;PR #9673&lt;/a&gt; fixed duplicate dispatch, &lt;a href="https://github.com/nocobase/nocobase/pull/9953" rel="noopener noreferrer"&gt;PR #9953&lt;/a&gt; eased lock contention — and it just runs concurrently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bottom line: you don't need to worry about 2.x workflow concurrency.&lt;/strong&gt; Waits overlap, CPU work overlaps, simultaneous triggers overlap.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real traps are somewhere else
&lt;/h2&gt;

&lt;p&gt;Concurrency turned out to be a non-issue. But there are constraints that &lt;em&gt;do&lt;/em&gt; bite in production — and the docs state them outright. These matter far more than the parallelism question.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Bulk operations don't fire events
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"Collection events do not currently support triggering by &lt;strong&gt;bulk data operations&lt;/strong&gt;."&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/triggers/collection" rel="noopener noreferrer"&gt;Collection event&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"using the &lt;strong&gt;SQL action node&lt;/strong&gt; to operate on the database is equivalent to direct database operations and &lt;strong&gt;will not trigger collection events&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;A workflow you thought you wired up silently does nothing&lt;/strong&gt; depending on the path that changed the data. CSV bulk import or an update via the SQL node won't trigger it. If you put business logic in workflows, this is the biggest footgun.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Missed schedules never recover
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;"if ... the entire NocoBase application service is in an inactive or shutdown state, the scheduled task ... will be &lt;strong&gt;missed&lt;/strong&gt;. Moreover, after the service is restarted, the &lt;strong&gt;missed tasks will not be triggered again&lt;/strong&gt;."&lt;br&gt;
— &lt;a href="https://docs.nocobase.com/workflow/triggers/schedule" rel="noopener noreferrer"&gt;Schedule event&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Any scheduled run that lands during a deploy or upgrade restart is silently dropped&lt;/strong&gt; — and never replayed. If a daily batch lives in a workflow, mind your restart windows.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Loop limit defaults to unlimited
&lt;/h3&gt;

&lt;p&gt;You can cap it with the &lt;code&gt;WORKFLOW_LOOP_LIMIT&lt;/code&gt; env var, but &lt;strong&gt;the default is unlimited&lt;/strong&gt; (&lt;a href="https://docs.nocobase.com/workflow/nodes/loop" rel="noopener noreferrer"&gt;Loop node&lt;/a&gt;). Get a loop condition wrong and nothing stops it. Set this in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. No failure notifications
&lt;/h3&gt;

&lt;p&gt;To a forum question — "my scheduled workflow errors out sometimes, there's no alert, and I only find out by digging through the history" — the official reply was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"你好，目前没有" (currently, there is none)&lt;br&gt;
— &lt;a href="https://forum.nocobase.com/t/topic/12040" rel="noopener noreferrer"&gt;forum t/12040&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Put something critical in a workflow and it can fail quietly.&lt;/strong&gt; You have to build the monitoring yourself.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. The transaction node arrived in 2.2.0
&lt;/h3&gt;

&lt;p&gt;The DB transaction node — the one that rolls back a group of operations together — was &lt;strong&gt;added in 2.2.0&lt;/strong&gt; (&lt;a href="https://docs.nocobase.com/workflow/nodes/transaction" rel="noopener noreferrer"&gt;Transaction node&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;2.0 / 2.1 workflows have no all-or-nothing guarantee.&lt;/strong&gt; Take a workflow that "① decrements stock → ② writes a shipment record." If ② fails, &lt;strong&gt;① stays applied.&lt;/strong&gt; Stock went down, no record exists — data left in a half-broken state. If you're running anything where integrity is the whole point (money, inventory) on 2.1 or earlier, keep this in mind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency was never the problem&lt;/strong&gt; (measured on 2.1.23). Parallel branches, and multiple simultaneously-triggered executions, overlap cleanly — for both waits and CPU work. JavaScript nodes run on &lt;code&gt;worker_threads&lt;/code&gt;, so the single-thread intuition doesn't apply.&lt;/li&gt;
&lt;li&gt;The official "sequentially in a queue / one by one, not in parallel" is about &lt;strong&gt;dispatch order&lt;/strong&gt;. Read as a throughput claim, it misleads. (It misled me.)&lt;/li&gt;
&lt;li&gt;The Chinese-forum "queue gets stuck" reports are &lt;strong&gt;all 1.x&lt;/strong&gt;. The 2.x dispatcher was rebuilt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real traps are "won't fire / won't notice / breaks halfway"&lt;/strong&gt; — bulk ops and the SQL node skip events, schedules are dropped on restart, failures are silent, and pre-2.2 has no transaction node so a mid-workflow failure leaves data inconsistent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Reading one sentence of docs and designing around it cost me more than spinning up Docker and measuring would have. The environment is one &lt;code&gt;docker compose up&lt;/code&gt; away — if something about your workflow design worries you, measure it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Measured on 2.1.23 / PostgreSQL 16 / 12 cores. Worker-thread count tracks CPU cores, so on a small box the CPU-parallelism will be lower.)&lt;/em&gt;&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/advanced/executions" rel="noopener noreferrer"&gt;Executions (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/nodes/parallel" rel="noopener noreferrer"&gt;Parallel branch node (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/triggers/collection" rel="noopener noreferrer"&gt;Collection event trigger (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/triggers/schedule" rel="noopener noreferrer"&gt;Schedule event trigger (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.nocobase.com/workflow/nodes/transaction" rel="noopener noreferrer"&gt;Transaction node (official docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/nocobase/nocobase/pull/9673" rel="noopener noreferrer"&gt;PR #9673: fix duplicate dispatch&lt;/a&gt; / &lt;a href="https://github.com/nocobase/nocobase/pull/9953" rel="noopener noreferrer"&gt;PR #9953: ease lock contention&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://forum.nocobase.com/t/topic/12040" rel="noopener noreferrer"&gt;Forum: no failure notifications yet&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nocobase</category>
      <category>lowcode</category>
      <category>workflow</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
