If you run Directus 12 in CI on an Open Innovation Grant licence, you will meet this error within a week: 403 Activation Limit Exceeded. The grant includes five activations, meant to cover the environments of one project — local, dev, staging, production. But an ephemeral pipeline that tears down Directus and its database and rebuilds them from scratch registers as a brand-new instance on every run, burns one activation each time, and after the fifth run the API refuses to boot at all. People are hitting this and staying on v11 because of it.
The advice floating around — a stable PUBLIC_URL, a seeded project ID — does not work, and half of it has been guessed rather than tested. So we read the licence manager's source and then verified everything below against a live licensing service: Directus 12.3.0, a real OIG key, fourteen container boots, both recipes to exhaustion. Every claim in this post was executed, not inferred. Here is what actually holds.
Why every CI run costs an activation
An activation binds three things: the licence key, the project ID, and the configured PUBLIC_URL. The part everyone misses is that the project ID lives in the database — it is minted when the instance first bootstraps. A fresh Postgres container means a fresh project_id, which means a new binding, which costs an activation. Your PUBLIC_URL can be identical on every run; it makes no difference on its own.
What happens at boot is a decision the licence manager takes by comparing the LICENSE_KEY environment variable against what is stored in directus_settings:
| Env key | Key in DB | Token in DB | What Directus does |
|---|---|---|---|
| set | absent | — | Activate — burns a slot |
| set | different | — | Update — burns a slot |
| set | same | present | Verify + refresh — free |
| set | same | absent | Silently drops to Core — free, and broken |
| absent | present | present | Verify + refresh — free |
Two rows in that table are the whole story. The third row is the escape hatch: if the booting instance already looks activated — same key, valid token, existing project ID — Directus refreshes instead of activating, and no slot is spent. The fourth row is the trap next to it: seed the key but forget the token and nothing errors, nothing activates, and your instance quietly runs the free Core tier while your licensed-feature tests fail for no visible reason.
That gives you two working strategies, and they are mutually exclusive.
Recipe one: carry the activated state into every run
Activate once, on your machine. Then make every CI container boot looking like that already-activated instance. No CI run ever contacts the activation endpoint again.
First, boot a local instance with your key in the environment and the exact PUBLIC_URL your CI will use, and let it activate once. Then pull the three values out of directus_settings — raw, straight from Postgres:
select project_id, license_key, license_token
from directus_settings;The key and token come back as long encrypted blobs. That is correct — do not decrypt them, and do not substitute the plaintext key. The column is encrypted with your SECRET, and Directus compares the decrypted value against the env var at boot. Seed the plaintext and decryption produces garbage, the comparison fails, and you are in the "update" row of the table, burning a slot per run — which is precisely the failure mode people have been reporting. The encrypted blobs only decrypt if CI uses the same SECRET as the instance that produced them, so pin it.
Then add a custom migration that seeds those values into every fresh database. Note the insert-or-update shape — on a brand-new database the settings row may not exist yet, and a bare update would silently do nothing:
export async function up(knex) {
const state = {
project_id: '01a0…', // from your dump
license_key: '1||scrypt||…', // encrypted blob, verbatim
license_token: '1||scrypt||…', // encrypted blob, verbatim
};
const existing = await knex('directus_settings').first('id');
if (existing) {
await knex('directus_settings')
.where('id', existing.id).update(state);
} else {
await knex('directus_settings').insert({ id: 1, ...state });
}
}CI then boots with five things held constant: the migration, LICENSE_KEY in the env, and the same SECRET, PUBLIC_URL and key as the local activation. Migrations run before the licence check, so the manager wakes up, finds env key equal to database key, verifies the token, refreshes — and never activates.
We ran six consecutive fully-ephemeral boots this way — container and database destroyed and recreated each time. All six came up with the grant active. Zero activations consumed. On a five-activation key, six clean runs is not a suggestive result; it is proof.
One more time, because it is the recipe's only real trap: the token is not optional. Seed the key without it and the boot does not activate or error — it downgrades to Core silently. We ran that variant too: 200 on every endpoint, licence reads "Core", no log line hints at why.
Recipe two: activate on boot, deactivate on teardown
The official docs say to deactivate a licence before destroying the instance it is bound to. What they do not spell out is that this composes into a perfectly good CI lifecycle: activate at the start of the run, deactivate at the end, and the slot comes back.
The catch that will burn an hour if you don't know it: a licence supplied through LICENSE_KEY can never be managed through the API. Not with any flag — the code forbids it outright, and you get a 403 with "You cannot manage license for the current license." So for this recipe the env var must go. Boot on the Core tier with management enabled:
LICENSE_KEY_MANAGEMENT_ENABLED=true # and no LICENSE_KEYthen activate as an admin at the start of the run:
curl -X POST "$DIRECTUS_URL/license" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"license_key":"'$OIG_KEY'"}' # → 204and release it at the end, in a step your pipeline runs even when tests fail — if: always() in GitHub Actions, after_script in GitLab:
curl -X DELETE "$DIRECTUS_URL/license" \
-H "Authorization: Bearer $ADMIN_TOKEN" # → 204The question nobody had answered in public is whether that DELETE actually frees the slot on the licensing server, or just cleans your database. So we answered it the only way that counts: we ran five full activate/deactivate cycles on a key that allows five activations — each cycle on a fresh database, meaning a fresh project ID, meaning a genuinely new binding every time. If deactivation did not free slots, the fifth cycle would have been the sixth activation and failed. It returned 204 and came up licensed. Deactivation frees the slot. Tested, not assumed.
The residual risk is a run that dies before teardown — a cancelled pipeline, a crashed runner. That binding stays consumed until you release it, so keep the teardown step unconditional and treat leaked bindings as something to sweep up (see the FAQ below).
Which recipe to use
Use recipe one when you want hermetic runs: no admin credentials in the pipeline, no dependency on the licensing service being reachable mid-run, nothing to leak when a runner dies. The price is a seeding step and the care around SECRET and encrypted values.
Use recipe two when you want the officially-sanctioned shape and simpler state: no seeded blobs, just two API calls. The price is admin credentials in CI, a live licensing service on every run, and the leaked-binding sweep when pipelines die ungracefully.
What you cannot do is mix them: recipe one requires the key in the environment, and a key in the environment is exactly what locks recipe two out.
Two small traps on the way
/server/health returns 403 unauthenticated in v12. If your compose healthcheck or CI wait-loop polls it without a token, the instance looks permanently unhealthy while working fine. Poll /server/info instead, or authenticate the health probe.
Compose overrides don't unset environment variables the way you might expect. Setting LICENSE_KEY: null in an override file left the variable defined in our tests, which silently turned a recipe-two boot back into an env-sourced licence with the API locked. If a service must boot without the key, write it a compose file that never mentions it.
What we tested, exactly
Everything above ran on 19 August 2026 against Directus 12.3.0 from Docker Hub, a real Open Innovation Grant key, and the live licensing service: one baseline activation, six ephemeral seeded boots (zero activations), five activate/deactivate cycles to prove slot-freeing, the missing-token downgrade, the plaintext-seeding failure, and the env-key API lockout. The behaviour matches the licence manager's source at the commit we read. Directus's team has said publicly that the ephemeral story is a known gap they are working on, so expect this to improve — and check the date on this post before trusting it forever. For the wider licensing picture — tiers, thresholds, what the grant actually requires — see our plain-English guide to the Directus licence and what v12 changed for SSO.
Common questions
Why does every CI run use up a Directus 12 activation?
An activation binds the licence key, the project ID and the configured PUBLIC_URL — and the project ID is minted in the database at first bootstrap. An ephemeral pipeline that recreates the database gets a new project ID every run, so each run registers as a new instance regardless of the URL. The fix is either to seed the activated state (project_id, encrypted license_key and license_token, with the same SECRET) into every fresh database, or to deactivate the licence before teardown so the slot is returned.
Does deactivating a Directus licence actually free the activation slot?
Yes — we proved it rather than assumed it. We ran five consecutive activate/deactivate cycles on a key that allows five activations, each against a fresh database and therefore a genuinely new binding. If deactivation only cleaned the local database, the fifth cycle would have exceeded the limit and failed; it activated cleanly. DELETE /license returns the slot on the licensing server.
Can I activate or deactivate the licence via the API if LICENSE_KEY is set in the environment?
No. An env-sourced licence is never manageable through the API — the licence manager forbids it outright, independent of LICENSE_KEY_MANAGEMENT_ENABLED, and every attempt returns a 403. For the activate/deactivate lifecycle the key must not be in the environment: boot on Core with LICENSE_KEY_MANAGEMENT_ENABLED=true and activate through POST /license instead.
What happens if I seed the licence key but not the licence token?
Directus boots without error and silently runs the free Core tier. It does not attempt an activation and does not log a warning — the licence endpoint simply reports Core and the licensed features are absent. We reproduced this live. The token is not an optimisation; the refresh path requires it, so always seed project_id, license_key and license_token together.
I already burned through my activations — what now?
Email support@directus.io and ask for the stale bindings to be removed; the team has confirmed they can do this. If you still control an instance holding a binding, you can also free it yourself by booting it and calling DELETE /license as an admin — that returns the slot immediately. And the OIG documentation notes you can ask Directus for more activations if your project genuinely needs more environments.
Upgrading to Directus 12 and hitting licensing walls?
We build on Directus full time — we read the licence manager's source and burned a real key to its limit so you don't have to. Whether it's the activation model in your pipeline, the MSCL thresholds, or whether the grant fits your project at all: tell us how you run Directus and we'll tell you where you stand.
Get in touch →