Self-hosted headless CMS with a REST API, an MCP server for AI agents and Studio, in one image
3.7K
What changed in each release of the gabilungu/bloko image. A release that can break a website or an agent built on an earlier one says so first, under Breaking, with what to change. Every release notes whether it changes a database: updating to such a release migrates the databases when Bloko starts, and going back to an earlier version then means restoring a backup taken before the update (see the README, "Updates and backups").
Existing website values, typed relations and media URLs keep their behavior. Adoption is explicit: partial/BC dates require config.partial: true, and Markdown link tracking requires config.node_references: true. Websites must support the new date forms before opting in. New reference fields resolve to objects; tracked references add deletion protection. Full pages gain an additive references map and images gain nullable focal_point metadata.
Database changes: migrations 021_image_focal_point, 022_content_dates and 023_content_references add image focal points, indexed date intervals, reference identities and backlink indexes. Existing rows, values and files are preserved. Date keys and target identities are backfilled; existing Markdown remains untracked. Large projects may take longer to upgrade while indexes are built. Going back follows the normal pre-upgrade backup/restore procedure.
node reference fields with optional allowed node types, including repeater rows and custom/legacy stack locations. Studio has a searchable picker and a Referenced by panel. Markdown tracking parses actual links, ignores code examples, and validates adoption without rewriting text.update_image accept a point or null to clear it; omission preserves it. Caption, credit and focal-point changes save together.object-fit: cover and object-position, including proportional resizes, cached metadata and the limits of a single point. Server resizing still preserves the full image.Existing website and agent APIs keep their response shapes, filters, ordering and complete-list defaults. No consumer changes are required. New pagination is opt-in.
Database changes: project migrations 018_content_lookup_index, 019_node_page_indexes and 020_delivery_page_indexes add indexes for content lookup, manual node pages, publication-date lists, relation batches and image owners. They preserve every row and value; no media files change. Index creation can extend the upgrade and block writes while it runs. Going back follows the normal pre-upgrade backup/restore procedure.
next_cursor until null, including after empty batches. Nested lists are fetched separately; page requests can opt out of automatic relation expansion with include_relations=0. Existing endpoints never silently truncate their responses.Database changes: none.
PATCH /nodes/{id} accepts existing-part patches in parts and the complete optional part_order alongside node fields.Breaking for websites and agents: shared metadata is nested. Delivery part template is now { code, title, data } or null; list templates contains these objects instead of codes. Delivery node_type is { code, title, data } on pages, lists, related nodes and sitemap entries. Relations remain grouped, with type: { code, title, reverse_title, data } and items: [{ id, data, node }] replacing nodes. The group title remains direction-aware. Update website consumers together with Bloko before deployment.
MCP node summaries also nest node_type and templates; part reads nest template with its management metadata. get_node.relations now uses nested type instead of the relation_type string, alongside connection data and related-node data. MCP titles remain multilingual. Write references and query filters remain strings; REST management ids keep their existing shapes.
Consumer examples:
// Before: part.template === 'contact'
part.template?.code === 'contact'
// Before: node.templates.includes('contact')
node.templates.some(template => template.code === 'contact')
// Before: child.node_type === 'article'
child.node_type.code === 'article'
// Before: group.type === 'authored-by'; group.nodes.map(node => node.path)
group.type.code === 'authored-by'; group.items.map(connection => connection.node.path)
Database changes: migration 017_entity_data adds object-only JSONB data columns to nodes, templates, node types, relation types and individual relations. Existing entities receive {} without changing existing content, ids or dates; no media files change. A node's data change moves updated_at, but saving equivalent JSON does not. Shared metadata changes do not move dependent node dates. Going back requires a pre-upgrade backup.
{} clears it. Parts have no separate data field: template data stays on the shared template.PATCH /projects/{slug}/relations/{id} and MCP update_relation edit connection data, respecting locks on both ends.Database changes: project databases get migration 016_custom_block_touch, which changes when a custom part's blocks count as a change of their node. No value changes.
updated_at on nodes whose custom parts don't use it: select blocks without a title in that language aren't rewritten, and a block saved unchanged doesn't count as a change.upsert_nodes, and name the part in part, as set_values does. parts.{index} is kept for issues about the part itself, such as an unknown template.blocks in their order, as /templates/{id}/blocks does.find_nodes (and every node summary) reads the templates of the listed nodes in one query instead of loading every part's values.create_node, delete_template, find_nodes (values, has, missing) and the list filters. A part prop's type change counts "parts or stack pieces".Breaking for websites and agents: a node's content is now an ordered list of parts instead of one template. Pages return parts instead of node.template and blocks; lists return templates and parts instead of template and values; values are written per part. A website or agent built on 0.13 has to change with this release: deploy the updated website together with Bloko. Everything below says what changes and how.
Database changes: project databases get the part migration. Each node with a template gets one part using it, with the template's code, and every value moves into it unchanged (same rows, ids and values); nodes with neither a template nor values get none. Stray values are preserved in additional parts for their own templates, including on nodes without a selected template. Stack props become part props (their stored scope stack becomes part), and the node's old template column is dropped once it has moved into its part. No node's dates move, and no image file is touched. Take a backup before updating: going back means restoring it.
concepts#parts): a node holds any number of template parts and custom parts, in the order the editor sets. The same template can be used several times on one node, each part with its own values: a hero, an introduction, two clinics with different addresses, a gallery. A custom part is one block that belongs to the node alone (a text, an image, a gallery, a group of fields), with no template to make first. Every part has an id and a code, unique in its node. Changing a template changes every part using it; removing a part deletes its values and any custom blocks it owns; shared templates, other parts and image files stay.component: "carousel" tells a website how to render a custom part.GET and POST /nodes/{id}/parts, GET, PATCH and DELETE /nodes/{id}/parts/{part} (id or code), POST /nodes/{id}/parts/reorder. MCP: add_part, update_part, remove_part, reorder_parts.Pages (/content/{language}/{path}): node.template and blocks are replaced by parts, each { id, code, template, props, blocks }, with blocks built as before. Before:
{ "node": { "template": "condition", "…": "…" }, "blocks": [{ "code": "summary", "type": "long_text", "value": "…" }] }
Now:
{ "node": { "…": "…" }, "parts": [{ "id": "…", "code": "condition", "template": "condition", "props": {}, "blocks": [{ "code": "summary", "type": "long_text", "value": "…" }] }] }
A site that read page.blocks finds the part it needs by template (page.parts.find((part) => part.template === 'condition')), not by position, and renders the other parts too (see websites#rendering).
Lists (children, root pages, search, related nodes): template becomes templates, the codes of the node's templates. With values=summary, values: { "summary": "…" } becomes parts: [{ "id": "…", "code": "condition", "template": "condition", "values": { "summary": "…" } }]: every part holding a summary, each with its own.
Filters template, value.{code}, has and missing match when any part of the node does, each filter on its own. missing=x needs a part that has a block x without a value (see websites#filters).
Tools (REST): GET and PATCH /nodes/{id}/content are gone: use /nodes/{id}/parts. POST /nodes takes parts: [{ "template": "article", "values": { … } }] instead of _template and values; nodes no longer have _template, and PATCH /nodes/{id} refuses it. Blocks have _part next to _template, one of them set. /stack-props is /part-props, and props have scope: "part".
Agents (MCP): create_node and upsert_nodes take parts instead of template and values (upsert_nodes matches parts by code, so it can still be run again); update_node no longer takes template. set_values names the part: before { "node": "…", "values": { "summary": { "en": "…" } } }, now { "node": "…", "part": "article", "values": { "summary": { "en": "…" } } }. get_node returns parts; get_values returns an array of parts, find_nodes returns templates. The block tools take node and part for a custom part's blocks, with a write token. describe_project lists part_props; create_stack_prop, update_stack_prop and delete_stack_prop are create_part_prop, update_part_prop and delete_part_prop. list_images names values as node.part.block.
Stacks are unchanged, and will be removed in a later release with their content converted: new pages are better built from parts.
Database changes: project databases get migration 014_node_dates: created_at becomes a column of nodes, filled with the moment each node was created (what it showed until now, read from the node's id). No other value changes, and no updated_at moves. Take a backup before updating.
created_at, updated_at and published_at, when creating or changing a node, in Studio (node details), REST (POST/PATCH /nodes) and MCP (create_node, update_node, upsert_nodes), so content moved from another site keeps its dates. Left alone they work as before: updated_at moves to now with every change of content, and a given one is kept until the next change that doesn't give it. See concepts#nodes.get_node shows created_at and updated_at next to published_at./browser/{language}/{node id}), so a node without a slug in the language, and the nodes under it, can be read like the others; a red mark says websites don't have that page in that language (a filled dot: the node has no slug there; a ring: a node above it has none). Websites are unchanged: such pages still don't exist for them.GET /nodes/{id}/page/{language} returns a node's page by id, for tools that browse content.Database changes: none.
Breaking for agents: set_values now answers with the values of the blocks you sent, not every value of the node. Read the rest with get_node. Nothing breaks for websites.
Database changes: project databases get migration 013_relation_ends_and_order: two new columns on relation types (from_node_types, to_node_types, empty) and a sort on relations, set to the order they were listed in. No relation changes. Take a backup before updating.
value.{code}=x on children, root pages and search, and values in MCP find_nodes: text contains it, select and boolean equal it, number and dates equal it or fall in a range (value.born=1950..1959, value.died=1934, value.starts=2026-03..). has= and missing= find nodes that have or lack a value, in the language for localized blocks. Node props compare the same way (prop.priority=1..3). See websites#filters.related.{relation type}={node id or path} keeps the nodes linked to a node, and relations=in-category adds each listed node's related nodes, so every category with its diseases is one request. MCP find_nodes takes related.from_node_types and to_node_types, checked when a relation is made; Studio's pickers only offer what fits. Existing relations stay.reorder_relations in MCP, POST /nodes/{id}/relations/reorder. Websites get them in that order.?drafts=1 on pages, lists, search and the sitemap includes drafts, for a staging site or a preview. Listed nodes have status. Never pass a visitor's query string on to Bloko.upsert_nodes creates or updates up to 50 nodes under a parent, matched by code, so an import can be run again; one invalid node doesn't stop the others.add_blocks adds several blocks to a template in one call, in order, each with the blocks inside it (containers, repeaters, stacks) — a whole template at once, or nothing when one block is invalid.get_values reads a node's values without the rest of the node: all of them, or only the blocks named.publish_nodes takes descendants: true to publish a section with everything under it, and answers with the nodes that were drafts.template, next to node_type.q searches subtitles as well as titles: in website lists and search, in GET /nodes, in MCP find_nodes and in Studio.prop.{code}=x on a text prop now matches when the text contains x (it had to equal it), like text blocks.woods-lamp, not wood-s-lamp. Existing slugs stay as they are.websites#search says that search without filters is the site's flat index (every page with title, subtitle, path and template, 500 per request); agents#tips says how to check a write without reading the node back.Breaking for websites: ?sort=sort is now ?sort=manual.
Database changes: project databases get migration 012_children_order: the sort_children_by column becomes children_order and its sort value becomes manual. Nothing else changes, and nodes keep their updated_at. Take a backup before updating.
original delivers the vector, and {width}.webp renders it at any width (no upper limit, since a vector has no natural size). An SVG carrying scripts, event handlers, embedded documents or links to other servers is refused at upload, and the ones Bloko keeps are served with Content-Security-Policy: sandbox and nosniff, so opening one can only draw.sort now offer the same options, under the same names. The node setting is children_order (was sort_children_by) and takes manual (was sort), title, subtitle, created_at, updated_at or published_at, each with a leading - for descending — the list a website can already ask for with ?sort=. Studio's Children order dropdown shows exactly these.subtitle and by -subtitle, and ask for manual explicitly. ?sort=sort is gone: children still default to the parent's children_order, root pages to manual and search to title.children_order replaces sort_children_by on nodes (create_node, update_node, node responses).Breaking for websites and agents: the content type rich_text is now markdown. A site that checks type === 'rich_text' has to check 'markdown'; agents send content_type: "markdown".
Database changes: project databases get migrations 010_stack (the stack content type and the stack props scope) and 011_markdown (blocks of content type rich_text become markdown; their values are unchanged). Take a backup before updating.
stack block holds a sequence of pieces, each one of its child blocks, in any order, so one template serves every article. A piece is a value block (markdown, image, images…), a group of fields (a container: a call to action with label and URL) or a repeater (a FAQ). In the node editor, + Add a piece and drag to reorder. Websites get [{ _key, block, value, props }] and item_blocks; agents write [{ "text": { "en": "…" } }, { "image": "<id>" }, { "cta": { "label": { "en": "…" }, "url": "…" } }]._props (wide on one image, say), with its own column under Props. REST: /projects/{p}/stack-props; MCP: create_stack_prop, update_stack_prop, delete_stack_prop, stack_props in describe_project and reorder_structure.The rich_text content type is now called markdown, everywhere: the database (migration 011 renames existing blocks), the REST API and MCP (send markdown when creating or changing a block; rich_text is refused), Studio and the docs (content-types#markdown). Values are the same markdown text as before.
The volume in docker-compose.prod.yml is now named bloko_data instead of being prefixed with the folder the file sits in (bloko_bloko_data). An install made with 0.9.0 or earlier keeps its data in the old volume: stop Bloko, copy it over once, then start again.
docker compose -f docker-compose.prod.yml --env-file .env.prod down
docker volume create bloko_data
docker run --rm -v bloko_bloko_data:/from -v bloko_data:/to alpine sh -c 'cp -a /from/. /to/'
docker compose -f docker-compose.prod.yml --env-file .env.prod up -d
Check the log and Studio, then docker volume rm bloko_bloko_data. Restoring a backup into an empty bloko_data works too.
docker-compose.prod.yml no longer ships a Caddy service: HTTPS is your own reverse proxy, which Bloko doesn't manage. Nothing changes for installs that already run one; if you used --profile https, run Caddy (or nginx, Traefik, a panel) yourself in front of BLOKO_LISTEN.
Database changes: project databases get migrations 008_props and 009_props_per_scope (a props table, and props columns on nodes and blocks). Nothing existing changes. Take a backup before updating.
Updating from 0.8: this is the last update to make by hand; 0.8 has no update button. Back up, then add the updater service and BLOKO_UPDATER_URL from the new docker-compose.prod.yml, set BLOKO_VERSION=latest in .env.prod, and run docker compose pull && docker compose up -d. Updates after this one are a button in Bloko settings → Instance. Leave the
Content type
Image
Digest
sha256:da08f4ce3…
Size
145.2 MB
Last updated
about 1 month ago
docker pull gabilungu/bloko