cancel
Showing results for 
Search instead for 
Did you mean: 
angelborroy
Community Manager Community Manager
Community Manager

Alfresco Community 26.2, released in July 2026, drops support for Apache Solr and introduces Alfresco Search Community, a search module that indexes the repository into a standard OpenSearch or Elasticsearch cluster. This post covers it at the level of detail you need in order to actually deploy it: every configuration property, where the artifacts come from, which Docker images to pin, how to migrate an existing repository, and what happens to the queries you already have.

Status: generally available. Alfresco Search Community ships with the 26.2 GA release and is intended for production use. It is distributed as binaries only, a Docker image and a JAR, and the final license text has not been published yet. Both points are covered at the end.

A note on names. The product is called Alfresco Search Community, but every technical identifier keeps the legacy vocabulary: the repository subsystem is elasticsearch, the shared-secret property is solr.sharedSecret, the text-extraction endpoint is /alfresco/service/api/solr/textContent, and the artifact is alfresco-elasticsearch-batch-indexing. That is deliberate and stable. It also means the word "elasticsearch" in a property name does not imply Elasticsearch the product: OpenSearch is configured through exactly the same properties.

Contents

What changed in Alfresco Community 26.2

Two things, and the second exists because of the first.

  • Apache Solr is no longer supported. Alfresco Search Services, the Solr 6 based engine that has shipped with Community since 2018, is not a supported option on 26.2.
  • Alfresco Search Community is the replacement. It supports OpenSearch or Elasticsearch out of the box, it reuses the same elasticsearch repository subsystem that Alfresco Search Enterprise uses, and it is distributed as binaries only. Source code is not published today; the stated intention is 27.1 or later.

Functionally it is the equivalent of Alfresco Search Enterprise, but the indexing mechanism is different: instead of consuming repository events, it polls the repository database for changed transactions. That distinction drives most of the operational detail in this post, and it gets its own section further down.

All three of the usual deployment routes already support it

Twenty years of Alfresco search engines

It helps to see where this sits historically, because the 2026 change is smaller than it looks in one respect and much bigger in another.

  • 2005, embedded Lucene. Index local to the repository. No query language of its own.
  • 2011, Solr 1. Search moves out of process. Asynchronous tracking by polling, every 15 seconds.
  • 2014, Solr 4. Basic sharding. ACL indexing.
  • 2018, Solr 6. Extended sharding. High availability through master/slave replication. SQLJ endpoint.
  • 2026, a standard engine (OpenSearch or Elasticsearch). Continuous polling, no events. One repository subsystem, two interchangeable engines.

Search Engines in Alfresco CommunitySearch Engines in Alfresco Community

Polling-based tracking is not new. Alfresco has pulled changes on a timer since 2011, and Alfresco Search Community keeps doing exactly that, just with a 30 second default instead of 15 and against a different target. However, for the first time the index lives in an engine that Alfresco does not ship, patch, or wrap. There is no Alfresco plugin on the search cluster. You run stock OpenSearch or stock Elasticsearch, and you can run it as a managed service.

If you want to stay on Apache Solr

There is a community answer, and it is worth knowing about even if you are moving to OpenSearch. jecicorp/AlfrescoSearchServices is a fork of Alfresco Search Services maintained by Jeci, modernising the project onto Apache Solr 9 and Java 17.

  • Apache Solr 9 out of the box.
  • Java 17.
  • Currently beta.
  • Maintained by the community, not by Hyland, and not supported on Alfresco Enterprise.

You can stand it up with alfresco-docker-installer if you want to compare the two engines side by side. There is a longer write-up of that project on this blog: Bring Alfresco Search to 2026: Vanilla Solr 9, Java 17, and an invitation to the Community.

Where it sits in the Alfresco Search family

Four products, two repository subsystems. The subsystem is what matters technically, because it determines which query languages and which fields are available.

  • Alfresco Search Services, subsystem solr6. Community Edition, from before 7.1 up to and including 23.2. Not an option on 26.2.
  • Alfresco Search and Insight Engine, subsystem solr6. Enterprise only, from before 7.1 up to 7.1+. Gone by 23.2.
  • Alfresco Search Community, subsystem elasticsearch. Community Edition, new in 26.2. This is the subject of this post.
  • Alfresco Search Enterprise, subsystem elasticsearch. Enterprise only, from 7.1 onwards, still current in 26.2.

Alfresco Search FamilyAlfresco Search Family

The practical consequence: if you are on Community 26.2 you are on the elasticsearch subsystem, which is the same subsystem Enterprise customers have been using since 7.1. Everything documented about the query behaviour of that subsystem applies to you now, including its limitations.

What Alfresco Search Community is

The official documentation splits it into two cooperating parts, and that split is the single most useful thing to internalise:

  • The search query path, which is part of Alfresco Content Services. Client applications send search requests to Content Services, which translates them into search-cluster queries, applies permission filtering, and returns results. Applications never talk to the search cluster directly.
  • The batch-indexing application, a standalone Spring Boot service that keeps the search cluster up to date. It reads changed nodes from the repository database, retrieves extracted text from Content Services, and writes metadata, content, and path documents to the search cluster.

Those two halves are deployed, configured, versioned, and monitored separately. Half the configuration properties in this post belong to the repository and half belong to the indexer, and mixing them up is the most common early mistake.

Supported platforms

  • Alfresco Content Services (Community Edition): 26.2 and later. Provides the search query path and the text-content endpoint used for content indexing.
  • OpenSearch: 2.11.1. Supported as an alternative to Elasticsearch.
  • Elasticsearch: 8.17.x. Use a patch release from the supported 8.17.x family.
  • Alfresco Transform Core (AIO): 5.4.x. Required for content transformation and text extraction.
  • Java runtime: 17. Required to run the batch-indexing application.

No Alfresco plugin is installed on the search cluster. That is what makes a managed OpenSearch or Elasticsearch service viable.

Supported databases

This is the correction most worth flagging, because the 26.2-preview announcement said PostgreSQL only and that is no longer accurate. The batch-indexing application reads node metadata directly from the repository database, and the supported list is:

  • PostgreSQL: JDBC driver bundled with the batch-indexing application.
  • MySQL: supply the JDBC driver at startup.
  • MariaDB: supply the JDBC driver at startup.

For anything other than PostgreSQL, drop the driver JAR into /opt/db-drivers. The Docker image declares that path as a volume and the application loads drivers from it at startup, through a Spring Boot PropertiesLauncher with Loader-Path: db-drivers/. Mount the directory read-only.

services:
  alfresco-elasticsearch-batch-indexing:
    image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
    volumes:
      - ./db-drivers:/opt/db-drivers:ro
    environment:
      SPRING_DATASOURCE_URL: jdbc:mariadb://mariadb:3306/alfresco

The read-only mount is not decoration. The indexer's own threat model calls it out: the driver directory is executable code loaded at startup, so it should not be writable by the running container.

Architecture: inside the batch indexer

The whole system, with the numbered hops you will see in the logs and in the metrics.

High Level ArchitectureHigh Level Architecture

 Some properties of this design that are easy to miss:

  • The database connection is read-only. The indexer never writes to the repository database, and the documentation is explicit that you should point it at a read-only user.
  • Content extraction goes through Content Services, not through the content store. The indexer calls /alfresco/service/api/solr/textContent over HTTP, authenticated with the X-Alfresco-Search-Secret header. It never touches alf_data.
  • It is Spring Boot plus Spring Batch, and the Spring Batch metadata lives in an in-memory HSQLDB (jdbc:hsqldb:mem:batch). Job history is therefore ephemeral and does not survive a restart, which is intentional but has an operational consequence covered later.

The three indexes

  • Main search index, default name alfresco. Holds the searchable documents: metadata, content, and path. This is the index Content Services queries.
  • State index, default name alfresco-reindex-state. Holds the indexing cursor, a single watermark document. Hidden.
  • Dead-letter index, default name alfresco-reindex-dead-letter. Records items that could not be indexed. Hidden.

Hidden is not access control. The state and dead-letter indexes are created with index.hidden: true so that they do not show up in index listings. That is a convenience, not a security boundary. Use search-cluster permissions to protect them.

Because they are hidden, the obvious command does not show them. You need expand_wildcards=all:

# shows only "alfresco"
GET _cat/indices?v&s=index

# shows all three
GET _cat/indices/alfresco*?v&s=index&expand_wildcards=all

# proves the hidden flag
GET alfresco*/_settings?expand_wildcards=all&filter_path=**.hidden

The archive index, and why it does not work

There is a fourth index name in play, belonging to the repository rather than to the indexer: elasticsearch.archive.indexName, defaulting to alfresco-archive, which the Configure documentation describes as the index used for deleted (archived) nodes.

Deleting a node in Alfresco does not erase it. The node moves from workspace://SpacesStore to archive://SpacesStore, the store behind the trashcan, and the repository picks an index from the store protocol of the search request. SearchRequestBuilderService.getElasticIndex() maps workspace to elasticsearch.indexName, archive to elasticsearch.archive.indexName, and throws on any other protocol. Through the v1 Search API you reach the second one by scoping a request to deleted-nodes.

That routing is the whole of the feature. Nothing writes to the index, and nothing creates it either:

  • The repository never writes to the search cluster at all. 
  • elasticsearch.createIndexIfNotExists only ever covers the main index. ElasticsearchInitialiser and ContentModelSynchronizer work on indexName and never on the archive name.
  • The batch indexer has no notion of it. The 5.7.1 image contains no reference to alfresco-archive, to archive.indexName, or to the archive store, only to the three indexes above.

So the property is a query route to an index no component fills. Left alone that is not an empty result but a failure, because nothing sets ignore_unavailable and the repository rethrows any search error that is not a highlighting error:

curl -s -u admin:admin -X POST \
  http://localhost:8080/alfresco/api/-default-/public/search/versions/1/search \
  -H 'Content-Type: application/json' \
  -d '{"query":{"query":"*"},"scope":{"locations":["deleted-nodes"]}}'

{"error":{"statusCode":500,
  "briefSummary":"Request failed: [index_not_found_exception] no such index [alfresco-archive]"}}

Do not reach for the obvious workaround. Creating alfresco-archive by hand does make the request succeed, with zero results, and that is worse than the error: an empty result set is indistinguishable from "no deleted nodes matched", so the caller cannot tell a missing feature from a genuine absence of hits. 

Two related facts. Deletion is handled correctly on the main index: a node moved to the trashcan is removed from alfresco within one indexing cycle, so trashcan content does not linger in ordinary results. And Solr 6 did index deleted nodes, into a second core declared for archive://SpacesStore. Indexed access to deleted content is therefore a capability lost in the move to Alfresco Search Community, not one merely left un-configured. Check whether anything depends on it before you switch.

What a document actually looks like

One non-obvious detail that will save you an afternoon. Alfresco property names are escaped in the index mapping: : becomes %3A and . becomes %2E. So cm:name is stored as the field cm%3Aname, and cm:content.mimetype is stored as cm%3Acontent%2Emimetype.

In a JSON request body you write the single-escaped form. In a URL path or query parameter you have to double-escape it, or you get an empty result with no error:

# in a body: single escape
GET alfresco/_search
{"query":{"term":{"cm%3Aname":"budget.xls"}}}

# in a URL: double escape, or you silently get nothing
GET alfresco/_search?q=cm%253Aname:budget.xls

Alongside the escaped property fields there is a set of uppercase system fields, around 32 of them in a stock index, including TYPE, ASPECT, PATH, UNPREFIXED_PATH, ANCESTOR, PARENT, PRIMARYPARENT, READER, DENIED, OWNER, ALIVE, SITE, TAG and PROPERTIES. A stock 26.2 index with the sample sites carries roughly 950 mapped fields and six custom analyzers (locale_text_index, locale_text_query, locale_content, path_emulator and two cross-locale variants).

One more subtlety on paths, which trips people up when they try to hand-write a subtree query: a match_phrase on PATH matches the node itself, not its descendants. Descendant queries use a prefix on UNPREFIXED_PATH. In practice you should let AFTS build these, but it explains why a hand-rolled PATH query returns 1 hit where you expected 69.

The continuous sync cycle

This is the loop that replaces Solr trackers. It runs inside ContinuousReindexingService, every pollingInterval (30 seconds by default), and the whole behaviour of the system falls out of it.

Sync CycleSync Cycle

 Four consequences worth stating explicitly, because each one turns into a support question:

  • The cursor only advances on success. A failed cycle leaves the watermark where it was, so the next cycle retries the same window. Nothing is lost by a transient failure, it is just deferred.
  • The window overlaps. Each cycle looks back overlap (10 minutes) further than strictly necessary, so a change committed right on a window boundary is not missed. It also means documents get rewritten harmlessly on every cycle they fall inside.
  • Metadata, content, and path are independent. Each can be enabled or disabled on its own, and a content-transform failure does not fail the cycle. The affected node's metadata and path still index, and the content is re-attempted later.
  • On a first run with no cursor, indexing starts at now - overlap. This is the single most important default in the whole product: pre-existing content is not indexed automatically. A fresh indexer pointed at a repository with ten years of history will index the last ten minutes and consider itself up to date. Getting the history in is a manual procedure, covered in the migration section.

Keeping the mapping current: content model sync

OpenSearch does not know about Alfresco content models, and the alfresco index is created with dynamic: false, meaning unmapped fields are silently ignored rather than auto-mapped. So something has to translate model changes into mapping updates. That something lives in the repository, not in the indexer.

Content Model SyncContent Model Sync

 Two things follow. First, adding a property to a custom model does not require a reindex; the mapping is extended and new documents pick it up. Second, elasticsearch.index.mapping.total_fields.limit (default 7500) is a real ceiling, and because a stock index already uses around 950 fields, a model with a few thousand properties can hit it. When it does, the failure comes from OpenSearch as a native mapping error, not from Alfresco.

Note that this synchronizer is part of the elasticsearch subsystem in the repository. It only runs when that subsystem is active, which matters for the migration ordering discussed later.

Custom models and the indexer prefix map

Model synchronization takes care of the mapping. It does not take care of the indexer, and that is where the sharpest edge in this module sits for anyone running a content model of their own.

The repository stores a property's identity as a namespace URI plus a local name. The prefix, cm or sys or your own, lives in the model definition the dictionary loads at runtime, not in the database. Index field names are built from the prefixed name, so something has to turn http://www.alfresco.org/model/content/1.0 into cm. The repository does that with its own NamespaceService. The batch indexer cannot: it reads nodes over JDBC, where the mapping does not exist. It uses a static JSON file instead, alfresco.reindex.prefixes-file, which defaults to classpath:reindex.prefixes-file.json inside the JAR and lists 60 namespaces, all of them Alfresco's.

A model in a namespace that file does not list fails in one of two ways, both silent. Measured on 26.2.0 with batch indexing 5.7.1:

  • A node whose own type comes from the custom model, say hr:contract, is not indexed at all. No document, and the dead-letter index stays empty: the node counts as filtered, so filterCount rises while readCount and writeCount look healthy.
  • An ordinary cm:content node that merely carries a custom aspect is indexed, but without the aspect's properties, and with the aspect itself missing from its ASPECT field.

The only signal is an ERROR in the indexer log:

o.a.r.processors.AlfrescoNodeProcessor : impossible to get prefixed name of contractNumber
o.a.r.processors.AlfrescoNodeProcessor : impossible to retrieve type name for node 874

Queries then return fewer results than they should, which is the mirror image of the silently dropped query conditions discussed later in this post, and just as quiet.

The file replaces the shipped map, it does not extend it. A file holding only your namespace removes Alfresco's 60, and the indexer can then no longer resolve sys:versionMajor while validating the repository schema. That failure is loud: validateDbSchemaStep fails with NumberFormatException: Cannot parse null string and nothing is indexed at all. Passing a single entry as a JVM system property, -DprefixUriMap[uri]=prefix, breaks in exactly the same way, because the system property takes precedence over the whole map rather than adding a key to it.

So the file has to be complete, and the repository is the only component that knows every deployed model. It will hand the whole map over. model-ns-prefix-mapping is a small Alfresco Labs addon, Apache-2.0 and ACS 7.0 or later, that adds one read-only WebScript returning exactly the JSON structure the indexer consumes, for every namespace in the dictionary including yours. It is not part of the product and carries no support, but it reads the dictionary and writes nothing.

Install the release JAR in the repository webapp and restart it. The Docker image carries an exploded webapp, so a bind mount is enough:

  alfresco:
    volumes:
      - ./model-ns-prefix-mapping-1.2.0.jar:/usr/local/tomcat/webapps/alfresco/WEB-INF/lib/model-ns-prefix-mapping-1.2.0.jar:ro

Deploy your model, then ask the repository for the map:

curl -s -u admin:admin http://localhost:8080/alfresco/s/model/ns-prefix-map > prefixes.json

The dictionary is read live, so no repository restart is needed between deploying a model and fetching a map that covers it. And what comes back is not the shipped file plus your namespace. On a stock Community 26.2 repository it holds 64 entries against the shipped file's 60, and differs in both directions: the shipped file carries four namespaces that only exist in Enterprise deployments (abs, devicesync, hwf, sync) and misses eight that Community registers, among them the IPTC and XMP metadata namespaces. Generating the map from the repository is what makes it match the repository being indexed.

Then mount the result and point the indexer at it:

  batch-indexer:
    environment:
      JAVA_OPTS: -Dalfresco.reindex.prefixes-file=file:/config/prefixes.json
    volumes:
      - ./prefixes.json:/config/prefixes.json:ro

It has to be JAVA_OPTS rather than an environment variable, because the property is read through a Spring @PropertySource that resolves before the relaxed binding which would turn ALFRESCO_REINDEX_PREFIXESFILE into a property name. The indexer never talks to the addon: once the file exists, the addon can stay for the next model you deploy or be unmounted again.

Correcting the file does not revisit nodes the indexer has already passed, because it walks forward through transaction commit times. Touch a handful of nodes to bring them back into the window, or, if the model was in use all along, reseed the cursor and reindex from the start of history as described in the migration section.

Catching it before your users do

Nothing outside the repository can find out that a model changed. There is no model event on the repository event stream and no marker in the database. POST /api/solr/modelsdiff looks like the answer and is not: under the elasticsearch subsystem it returns an empty diff list forever, because the tracking component behind it short-circuits when search.solrTrackingSupport.enabled is false, which that subsystem sets by default, and an empty diff list is indistinguishable from no change. Deploying a model and refreshing the prefix map is a deliberate sequence, not something that can be automated by watching the repository.

Which makes a check worth having, because the alternative signal is a user reporting missing content. The reference deployments in alfresco-search-community-deployments ship two scripts for this. tools/fetch-prefix-map.sh generates the map and refuses to write one that is missing Alfresco's own namespaces, which is the loud failure described above. tools/check-prefix-map.sh asks the repository what it has deployed, compares that against the file the indexer will read, and turns the silence into an exit status:

./tools/check-prefix-map.sh config/prefixes.json
  • 0. Every namespace the repository knows is in the file, with the same prefix.
  • 1. At least one namespace is missing, or is mapped to a prefix the repository does not use. Both index your data under field names no query will ask for.
  • 2. The check could not run: no file, no addon installed, repository unreachable.

It needs no node to exist, so it can run while the indexer is still stopped. On a migration that is the only point at which you can still act on the answer cheaply, because history is walked once in ascending commit time. It reads the repository over HTTP and writes nothing, so it is safe to point at production and usable in CI against a staging repository. The full procedure, including which nodes need touching once the map is right, is in docs/custom-content-models.md in the same repository.

The silence is a defect, not a step you were meant to discover. It is tracked as ACS-12851, which needs a Hyland account to read. A node whose namespace cannot be resolved is a data-loss condition and belongs in the dead-letter index with the URI that failed, or the chunk should fail outright; better still, the indexer would resolve prefixes from the repository, which already serves that answer to other callers, rather than from a file baked into an image that cannot be correct for an installation with custom models. Until one of those lands, the check above is the earliest signal available.

Getting the artifacts

The JAR

The distribution can be downloaded from Nexus Alfresco: alfresco-elasticsearch-batch-indexing-distribution-5.7.1.zip. Inside the zip you get the executable Spring Boot JAR.

The Docker image

The image is alfresco/alfresco-elasticsearch-batch-indexing, published on Docker Hub. The image is deliberately minimal:

FROM alfresco/alfresco-base-java:jre17-rockylinux9
WORKDIR /opt
COPY *-app.jar app.jar
RUN mkdir -p /opt/db-drivers
VOLUME /opt/db-drivers
ENTRYPOINT exec java $JAVA_OPTS -jar app.jar

JRE 17 on Rocky Linux 9, one JAR, one volume for JDBC drivers, and JAVA_OPTS as the escape hatch for JVM flags such as a custom truststore. Note that the batch indexer is published on Docker Hub under alfresco/, whereas the Enterprise live-indexing images live on quay.io/alfresco/.

Everything else in the stack

  • Repository: alfresco/alfresco-content-repository-community:26.2.0
  • Batch indexer: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
  • Transform Core: alfresco/alfresco-transform-core-aio:5.4.3
  • Search engine: opensearchproject/opensearch:2.11.1
  • Search engine UI (optional): opensearchproject/opensearch-dashboards:2.11.1
  • Database: postgres:15.6

Pin the OpenSearch tag. A floating opensearchproject/opensearch:2 will drift off the one supported patch release, 2.11.1.

Deploying it three ways

1. The fastest way to see it working

If you just want a running 26.2 stack with OpenSearch to poke at, the generator does all of the above for you:

npm install -g yo generator-alfresco-docker-installer

yo alfresco-docker-installer \
--acsVersion=26.2 \
--searchType=opensearch \
--ram=16 \
--serverName=localhost \
--port=8080

docker compose up -d --build

That produces a full stack: repository, Share, Content App, Transform Core, PostgreSQL, OpenSearch, OpenSearch Dashboards, the batch indexer, and an nginx proxy. Two details from the generated output that are instructive in themselves. The proxy explicitly blocks the text-extraction endpoint from outside:

location ~ ^(/.*/service/api/solr/.*)$ { return 403; }

And OpenSearch itself is not published to the host at all, so the way in is OpenSearch Dashboards on port 5601 (/app/dev_tools#/console), which is a much nicer place to explore the index than curl anyway.

2. Running the JAR directly

The documented invocation, with credentials on the command line so they are not left on the filesystem:

java -jar alfresco-elasticsearch-batch-indexing-5.7.1-app.jar \
--spring.elasticsearch.uris=http://localhost:9200 \
--spring.datasource.url=jdbc:postgresql://localhost:5432/alfresco \
--spring.datasource.username=alfresco \
--spring.datasource.password=<database-password> \
--alfresco.acs.url=http://localhost:8080 \
--alfresco.acceptedContentMediaTypesCache.baseurl=http://localhost:8090/transform/config \
--alfresco.content.transform.sharedSecret=<shared-secret>

Six arguments and you are indexing. The one that has no sane default is sharedSecret, and it has to match solr.sharedSecret in the repository or every content fetch returns 401.

3. Docker Compose

A complete, working minimal stack. Replace the two placeholder secrets before using it anywhere real:

services:

  postgres:
    image: postgres:15.6
    environment:
      POSTGRES_USER: alfresco
      POSTGRES_PASSWORD: <database-password>
      POSTGRES_DB: alfresco
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U alfresco"]
      interval: 10s
      retries: 5

  transform-core-aio:
    image: alfresco/alfresco-transform-core-aio:5.4.3
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8090/ready"]
      interval: 15s
      retries: 10
      start_period: 30s

  opensearch:
    image: opensearchproject/opensearch:2.11.1
    environment:
      discovery.type: single-node
      DISABLE_SECURITY_PLUGIN: "true"
      OPENSEARCH_JAVA_OPTS: -Xms1g -Xmx1g
    volumes:
      - opensearch-data:/usr/share/opensearch/data
    healthcheck:
      test: ["CMD-SHELL", "curl -sf http://localhost:9200/_cluster/health || exit 1"]
      interval: 30s
      retries: 5
      start_period: 1m

  alfresco:
    image: alfresco/alfresco-content-repository-community:26.2.0
    depends_on:
      postgres:
        condition: service_healthy
      transform-core-aio:
        condition: service_healthy
    environment:
      JAVA_OPTS: >-
        -Ddb.driver=org.postgresql.Driver
        -Ddb.username=alfresco
        -Ddb.password=<database-password>
        -Ddb.url=jdbc:postgresql://postgres:5432/alfresco
        -Dindex.subsystem.name=elasticsearch
        -Delasticsearch.host=opensearch
        -Delasticsearch.port=9200
        -Delasticsearch.indexName=alfresco
        -Delasticsearch.createIndexIfNotExists=true
        -Dsolr.secureComms=secret
        -Dsolr.sharedSecret=<shared-secret>
        -DlocalTransform.core-aio.url=http://transform-core-aio:8090/
    volumes:
      - alf-data:/usr/local/tomcat/alf_data
    ports:
      - "8080:8080"

  batch-indexer:
    image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
    depends_on:
      alfresco:
        condition: service_started
      opensearch:
        condition: service_healthy
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/alfresco
      SPRING_DATASOURCE_USERNAME: alfresco
      SPRING_DATASOURCE_PASSWORD: <database-password>
      SPRING_ELASTICSEARCH_URIS: http://opensearch:9200
      ELASTICSEARCH_INDEXNAME: alfresco
      ALFRESCO_ACS_URL: http://alfresco:8080
      ALFRESCO_ACCEPTEDCONTENTMEDIATYPESCACHE_BASEURL: http://transform-core-aio:8090/transform/config
      ALFRESCO_CONTENT_TRANSFORM_SHAREDSECRET: <shared-secret>
    ports:
      - "8091:8080"

volumes:
  db-data:
  alf-data:
  opensearch-data:

Then:

docker compose up -d
curl "http://localhost:9200/_cat/indices?v&expand_wildcards=all"
docker compose logs -f batch-indexer

Two things in that file that are not obvious. solr.secureComms=secret is mandatory, not optional: the X509 filter on the text-extraction endpoint no longer accepts none in 26.2, so leaving it unset breaks content indexing while metadata keeps working. And DISABLE_SECURITY_PLUGIN on OpenSearch is a completely separate concern from solr.secureComms; the first is about the search cluster's own authentication, the second is about the repository's endpoint. Turning one on does not affect the other.

Reference Docker Compose deployments of the Alfresco Community 26.2 stack with Alfresco Search Community (OpenSearch) available in https://github.com/aborroy/alfresco-search-community-deployments

Repository-side configuration reference

These go in alfresco-global.properties (or the Repository Admin Console). They configure the query path: how Content Services talks to the search cluster and how it builds queries. The canonical file with all of the defaults is elasticsearch.properties in alfresco-community-repo, and unlike the indexer it is open source, so you can always check a default yourself.

Turning the subsystem on

  • index.subsystem.name = elasticsearch. Selects Alfresco Search Community instead of solr6. This is the switch.
  • solr.secureComms = secret. Required. Protects the text-extraction endpoint with a shared-secret header.
  • solr.sharedSecret, no default. Must match alfresco.content.transform.sharedSecret on the indexer.

Search cluster connection and index names

  • elasticsearch.host, default localhost. Search cluster host.
  • elasticsearch.port, default 9200. Search cluster port.
  • elasticsearch.baseUrl, default /. Path prefix, for clusters served behind a sub-path.
  • elasticsearch.indexName, default alfresco. Main index. Must match the indexer elasticsearch.indexName.
  • elasticsearch.createIndexIfNotExists, default false. Create the index and its mapping at startup if absent. Note the shipped default is false, while the sample configuration in the Install documentation sets it to true. For a first deployment you want true.

Secure communications and authentication

  • elasticsearch.secureComms, default none. none for plain HTTP, https for one-way TLS. With https the server certificate is verified against the repository truststore (encryption.ssl.truststore.*).
  • elasticsearch.ssl.host.name.verification, default false. Set to false when the server certificate has no CN matching the DNS hostname.
  • elasticsearch.auth.mode, default basic. basic or aws-iam. These are the only two values.
  • elasticsearch.user, empty by default. HTTP Basic user name.
  • elasticsearch.password, empty by default. HTTP Basic password.
  • elasticsearch.aws.region, empty by default. AWS region for SigV4 signing when auth.mode=aws-iam.
  • elasticsearch.aws.service, default es. AWS signing service name.

Connection pool, timeouts, and retries

  • elasticsearch.max.total.connections, default 30
  • elasticsearch.max.host.connections, default 30
  • elasticsearch.http.socket.timeout, default 30000 ms
  • elasticsearch.http.connection.timeout, default 1000 ms
  • elasticsearch.http.response.timeout, default 30000 ms
  • elasticsearch.retryPeriodSeconds, default 10
  • elasticsearch.retryAttempts, default 3
  • elasticsearch.lockRetryAttempts, default 3
  • elasticsearch.lockRetryPeriodSeconds, default 10

Language analyzers

  • elasticsearch.index.locale, empty by default. Locale used to select the language analyzer for text fields. Set it to your primary content language for better tokenization and matching.
  • elasticsearch.index.custom.analyzer.config.files, empty by default. Comma-separated file: or classpath: locations for custom analyzer definitions.

Query behaviour and limits

  • elasticsearch.index.mapping.total_fields.limit, default 7500. Maximum number of fields in the index mapping.
  • elasticsearch.index.max_result_window, default 10000. Maximum results a single query can return.
  • elasticsearch.scroll.api_time, default 10s. Scroll context lifetime.
  • elasticsearch.scroll.batch_size, default 100. Documents per scroll batch.
  • elasticsearch.defaultFacetLimit, default 100. Maximum facets returned by a single query.
  • elasticsearch.query.analyzeWildcard.fields, default cm:content,cm:persondescription,cm:preferenceValues,cm:tagScopeCache,sys:keyStore,sys:versionEdition,sys:versionProperties. Fields where wildcard terms are analyzed, which are the fields using the locale_content analyzer. See the query-compatibility section, because this list has real consequences.
  • elasticsearch.query.includeGroupsForRoleAdmin, default false. Whether admin queries expand group membership.

Query routing: the index is not always involved

  • query.fts.queryConsistency, default TRANSACTIONAL_IF_POSSIBLE. Runs an AFTS query against the database when it is eligible, and against the search cluster otherwise.
  • query.cmis.queryConsistency, default TRANSACTIONAL_IF_POSSIBLE. The same for CMIS.
  • query.hybrid.enabled, default false. Hybrid database/index execution.

This is Transactional Metadata Query, and it is unchanged from solr6. It matters here for a debugging reason: a metadata-only query can be answered entirely from the database and never reach OpenSearch at all. If you are testing whether the index behaves a certain way, a plain metadata query may be a false negative. The switching logic skips the database path when the request has faceting, so attaching any facet forces index execution. That is exactly the trick the compatibility test script described later relies on.

Permission checking

  • system.acl.maxPermissionCheckEnabled, default false
  • system.acl.maxPermissionChecks, default 1000
  • system.acl.maxPermissionCheckTimeMillis, default 10000

Worth knowing because "the query returns fewer results than I expected" is usually either permission filtering or these caps.

Batch-indexing configuration reference

These configure the indexing path. Every one of them can be supplied three ways, and this is where a lot of confusion comes from, so it is worth stating the rule once.

One property, three spellings. The indexer is a Spring Boot application, so relaxed binding applies: to turn a property name into an environment variable, uppercase it, replace . with _, and delete any -. So alfresco.reindex.continuous.maxWindow is ALFRESCO_REINDEX_CONTINUOUS_MAXWINDOW, and alfresco.accepted-content-media-types-cache.base-url is ALFRESCO_ACCEPTEDCONTENTMEDIATYPESCACHE_BASEURL. The documentation shows the kebab-case form in the property table, the camelCase form on the command line, and the uppercase form in Compose. They are all the same property, not three different ones.

Repository database

  • spring.datasource.url, default jdbc:postgresql://localhost:5432/alfresco. Repository database URL.
  • spring.datasource.username, default alfresco. Use a read-only user. The indexer never writes here.
  • spring.datasource.password, default alfresco. Override this. The shipped default is a placeholder.
  • spring.datasource.hikari.maximumPoolSize, default 20. Connection pool ceiling.
  • alfresco.db.minimum.schema.version, default 14002. Refuses to start against an older repository schema.

Search cluster connection

  • spring.elasticsearch.uris, default http://localhost:9200. Comma-separated node URIs. Requests are load-balanced across them and fail over when a node is unavailable. Credentials may be embedded, for example https://user:password@host:9200. Use this property for new configurations. spring.elasticsearch.rest.uris is a legacy alias that still works, and it is what a lot of older Compose files use.
  • spring.elasticsearch.username, empty by default. HTTP Basic user name.
  • spring.elasticsearch.password, empty by default. HTTP Basic password.
  • spring.elasticsearch.auth-mode, default basic. basic or aws-iam. An unsupported value fails fast at property binding.
  • spring.elasticsearch.aws-region, empty by default. Region for SigV4 signing when auth-mode=aws-iam.
  • spring.elasticsearch.aws-service, default es. AWS signing service name.
  • spring.elasticsearch.path-prefix, empty by default. Path prefix for clusters behind a sub-path.
  • spring.elasticsearch.restclient.sniffer.enabled, default false. Automatic node discovery. See the warning below.
  • elasticsearch.indexName, default alfresco. Must match the repository's elasticsearch.indexName.

The sniffing trap. Sniffing is off by default for a good reason. When enabled, the client discards the URIs you configured and replaces them with the addresses the cluster advertises. In a container network those advertised addresses are frequently unreachable, so the connection succeeds, then fails immediately after discovery. Leave it off unless you have verified the cluster publishes routable addresses.

Continuous scheduling

The most important set of properties in this post. Durations accept 30s, 10m, 24h style values.

All of the names below take the prefix alfresco.reindex.continuous.

  • autoStart, default true. Start scheduled indexing at application startup. Set false to deploy without indexing yet.
  • pollingInterval, default 30s. Fixed delay between completed cycles. Lower means fresher results and more load on the database and the cluster.
  • maxPollingInterval, default 5m. Upper bound for exponential backoff after consecutive failures.
  • catchUpPollingInterval, default 1s. Delay between chunks while working through a backlog.
  • overlap, default 10m. Look-back applied to every cycle so boundary changes are not missed.
  • maxWindow, default 30m. Maximum time span a single cycle processes. Larger gaps are processed in chunks of this size.
  • maxGapAge, default 24h. Maximum age for automatic gap recovery. Anything older is skipped with a warning. 0 removes the cap, which is what you need for a full initial index.
  • watermarkIndexName, default alfresco-reindex-state. Index holding the cursor.
  • watermarkRetryMaxAttempts, default 3. Retries for cursor reads and writes.
  • watermarkRetryDelay, default 2s. Initial delay between cursor retries.
  • stuckThreshold, default 10m. How long the scheduler may be silent (no cycle start, no chunk commit, no cycle finish) before the liveness endpoint reports DOWN. Must be greater than the worst-case single-chunk wall-clock time, otherwise a legitimate backoff triggers a spurious restart. The application validates that it exceeds maxPollingInterval and refuses to start if it does not.
  • autoCreateStateIndex, default true. Create the state index at startup if missing. Set false to refuse to start when the state index has vanished, which protects you from an accidental full rescan.
  • bootstrapFromAlfrescoIndex, default true. When there is no cursor, seed it from the latest indexed change in the main index, minus the overlap.
  • requireAlfrescoIndex, default false. When the cursor records real progress but the main index is missing, refuse to start rather than silently re-indexing nothing.

Those last three are the guard rails, and they deserve more attention than they usually get. The failure mode they prevent is quiet and expensive: the state index disappears, the indexer helpfully recreates it, starts from now - overlap, and you have a silently stale index that looks healthy. Setting autoCreateStateIndex=false in production turns that into a loud startup failure with an actionable message instead.

Indexing scope

  • alfresco.reindex.metadataIndexingEnabled, default true. Index node metadata.
  • alfresco.reindex.contentIndexingEnabled, default true. Index extracted full text. Turning this off removes all Transform Core dependency.
  • alfresco.reindex.pathIndexingEnabled, default true. Index paths, which is what makes PATH and site-scoped queries work.
  • alfresco.reindex.pathCacheSize, default 10000. Path resolution cache size.

Job tuning

  • alfresco.reindex.jobName, default reindexByDate. The Spring Batch job name. You will see it in the logs.
  • alfresco.reindex.batchSize, default 1000. Items per batch write.
  • alfresco.reindex.pagesize, default 1000. Rows read per database page. Note the lowercase s, unlike every sibling property.
  • alfresco.reindex.concurrentProcessors, default 10. Worker threads for the multi-threaded step.
  • alfresco.reindex.multithreadedStepEnabled, default true. Spread chunks across worker threads.
  • alfresco.reindex.skipLimit, default 100. How many items a cycle may skip before the step fails. Skipped items land in the dead-letter index. The shared reindexing module defaults this to 0; the batch indexer opts in to 100 so that a single poison node cannot stall continuous indexing.
  • alfresco.reindex.retryingEnabled, default true. Retry transient failures.
  • alfresco.reindex.retryingMaxCount, default 3. Retry attempts.
  • alfresco.reindex.retryingInitialDelay, default 1000 ms. First backoff delay.
  • alfresco.reindex.retryingDelayIntervalMultiplier, default 2. Backoff multiplier.
  • alfresco.reindex.retryingMaxDelay, default 30000 ms. Backoff ceiling.
  • alfresco.reindex.writerRetryCount, default 3. Retries for an individual bulk write.
  • alfresco.reindex.writerRetryDelay, default 1000 ms. Delay between bulk-write retries.

Content transformation

  • alfresco.acs.url, default http://localhost:8080. Base URL of Content Services.
  • alfresco.content.transform.urlPath, default /alfresco/service/api/solr/textContent. The text-extraction endpoint.
  • alfresco.content.transform.sharedSecret, empty by default. Must match solr.sharedSecret. Sent as X-Alfresco-Search-Secret.
  • alfresco.content.transform.timeout, default 20s. Per-request timeout.
  • alfresco.content.transform.retryMaxAttempts, default 2. Retries per content fetch.
  • alfresco.content.transform.retryDelay, default 1s. Delay between those retries.
  • alfresco.content.transform.writeConcurrency, default 16. Parallel content fetches per chunk, on a dedicated fork-join pool. Sized to roughly match Transform Core's worker count.
  • alfresco.content.transform.maxResponseSizeMb, default 10. Caps the extracted-text response buffer. See the warning below.
  • alfresco.accepted-content-media-types-cache.base-url, default http://localhost:8090/transform/config. Transform Core config endpoint, used to learn which media types can be transformed.
  • alfresco.accepted-content-media-types-cache.enabled, default true. Cache that response.
  • alfresco.cache.timeout.seconds, default 120. Metadata cache lifetime.

The 10 MB text cap is worth knowing about. maxResponseSizeMb limits the extracted text, not the original file. A 400 MB video is fine because its text extraction is tiny; a 30 MB plain-text log file is not. Documents whose extracted text exceeds the cap keep failing on every overlap and stay silently absent from full-text search, while their metadata and path index normally, so they still appear in metadata queries. Those give-ups are recorded in the dead-letter index per dbId, which is how you find them. Worst-case in-flight memory is writeConcurrency multiplied by maxResponseSizeMb, so raising both at once is how you run the indexer out of heap.

Dead-letter index, tags, and metrics

  • alfresco.reindex.dead-letter.enabled, default true. Record give-up events. Writes are best effort and rate-limited to one per minute per failure type, so a broken dead-letter index never blocks indexing.
  • alfresco.reindex.dead-letter.indexName, default alfresco-reindex-dead-letter. Index name.
  • alfresco.reindex.failOnMissingTag, default true. Tag handling.
  • alfresco.reindex.addEmptyTagAttribute, default true. Tag handling.
  • alfresco.reindex.removeTaggableAttribute, default true. Tag handling.
  • alfresco.metrics.reindex.max-retained-jobs, default 50. How many recent cycles keep their per-cycle metrics. A new cycle starts every polling interval, so this bounds meter growth. 0 or negative retains all cycles, unbounded.
  • server.port, default 8080. Actuator port.
  • management.endpoints.web.exposure.include, default health,info,prometheus,metrics. Exposed Actuator endpoints.
  • management.endpoint.health.show-details, default never. Health detail visibility.

Security

Start from an honest baseline: the reference deployment is not hardened. The Compose files that ship as examples, and the ones the installer generates, run OpenSearch with DISABLE_SECURITY_PLUGIN=true. The only authentication actually configured out of the box is the shared-secret header protecting the repository's text-extraction endpoint. That is fine for a laptop and not fine for anything else.

Here is what is genuinely available, separated from what is not, because this is an area where it is easy to assume more than the product offers.

Supported connection and authentication options

  • Plain HTTP
    • Repository side: elasticsearch.secureComms=none
    • Batch indexer side: http:// in spring.elasticsearch.uris
  • One-way TLS
    • Repository side: elasticsearch.secureComms=https, server certificate verified against the repository truststore (encryption.ssl.truststore.*)
    • Batch indexer side: https:// in spring.elasticsearch.uris, trust configured through the JVM (-Djavax.net.ssl.trustStore)
  • HTTP Basic
    • Repository side: elasticsearch.user and elasticsearch.password
    • Batch indexer side: spring.elasticsearch.username and password, or credentials embedded in the URI
  • AWS SigV4
    • Repository side: elasticsearch.auth.mode=aws-iam plus elasticsearch.aws.region and elasticsearch.aws.service
    • Batch indexer side: spring.elasticsearch.auth-mode=aws-iam plus aws-region and aws-service

What is not there. The authentication mode enum has exactly two values, basic and aws-iam, on both sides. There is no JWT or bearer-token configuration anywhere in the batch indexer or in the repository subsystem, so if your OpenSearch cluster authenticates via an identity provider token you will need a proxy in front of it. mTLS is not a configurable auth mode either; the client inherits mTLS support from the shared indexing library, and in practice you configure it with JVM keystore and truststore flags rather than with application properties. LDAP and Active Directory need no special client configuration at all, because they end up as a user name and a password over Basic auth, but they do require user synchronisation on the Alfresco side so that permission filtering resolves the right authorities.

The checklist

  • Protect the text-extraction endpoint. A non-empty shared secret on both sides, and block the path at your reverse proxy so it is never reachable from outside.
  • Use a read-only database user for the indexer, scoped to the tables it reads.
  • Secure the search cluster with authentication and authorization, and give the indexer a role scoped to just the main, state, and dead-letter indexes.
  • Restrict the Actuator port. Port 8080 on the indexer is not authenticated by the application. Keep it internal, apply a network policy, or put authentication in front of it.
  • Keep secrets in a secret store rather than in manifests or in application.properties on disk.
  • Harden the container. Run as a non-root user and mount the JDBC driver directory read-only.

Community and Enterprise: same code, different mechanism

The two products share a source base, and that is the direct reason the Community source is not published: it reuses Enterprise code. But sharing a subsystem does not mean sharing an indexing mechanism, and the difference is fundamental.

  • Mechanism. Enterprise: live indexing, event-driven. Community: batch indexing, polling.
  • Transport. Enterprise: ActiveMQ events from the repository. Community: read-only JDBC against the repository database.
  • Unit of work. Enterprise: nodes. Community: transactions and database identifiers, processed incrementally by time window.
  • Latency. Enterprise: near real time. Community: one polling interval, 30 seconds by default.
  • Query path. Both sit on the same elasticsearch subsystem, so this part is identical: same query languages, same field support, same limitations.

The query path being identical is the good news: everything you know about querying Alfresco Search Enterprise transfers directly. The indexing path being different is where the operational differences live, and one of them deserves its own callout.

Only one indexer may write to a cluster at a time. The application uses optimistic concurrency control on the cursor document as a lock. If a second instance detects a conflicting write, it terminates rather than continuing with interleaved writes. So you cannot scale indexing horizontally, and you cannot run active-active. For Kubernetes this means a single replica, and it means a rolling update briefly has no indexer running, which is harmless because the cursor guarantees the new pod resumes exactly where the old one stopped.

There is also one asymmetry in the query surface that is genuinely surprising given the shared code: the fields that describe the indexing mechanism itself, DBID, TXID, TXCOMMITTIME and friends, are not queryable, even though the Community mechanism is the one that works in terms of transactions and database identifiers. More on that in the query section below.

Performance: polling versus events

The section above says the mechanism differs. This one is about what that costs, because polling instead of consuming events is the single fact that governs how Alfresco Search Community behaves under load. It cuts both ways: worse latency on a quiet repository, better throughput on a busy one.

Where the latency actually goes

Community's indexing lag is not simply the polling interval. Three things stack up:

  • The poll is a delay, not a tick. A cycle starts pollingInterval after the previous one finished, not 30 seconds after the previous one started. A cycle that does 20 seconds of work therefore repeats every 50 seconds.
  • The window closes before the work begins. Each cycle fixes its window end at the current time and then runs the job. Anything committed while that job is running falls outside the window and waits for the next cycle.
  • The index still has to refresh. The bulk write does not force a refresh, so a document becomes searchable on the cluster's own refresh interval, one second by default.

Add them up and the floor for a single change is about one polling interval plus one cycle's wall time plus a refresh. The worst case, a commit landing just after a window closed, is roughly twice that. Enterprise has no equivalent stack: a node change produces a message, the message is routed once and consumed once, and the write follows immediately.

The gap in what you can observe is wider than the gap in the mechanism. Enterprise publishes indexing lag directly, as alfresco.live.indexing.lag and alfresco.live.indexing.reception.lag, both in seconds, plus timers that split content extraction into transform time and ingest time. Community publishes none of these, because there is no source event timestamp to measure against. What it does publish is the cursor, and the distance between the cursor and now is your indexing lag.

If you alert on one thing, alert on the cursor gap. It is the only lag signal Community has. Every other indexing metric tells you about work done, not about how far behind you are.

Unit of work, and why it inverts the cost curve

This is the part worth internalising, because it explains why the two products degrade in opposite directions.

Enterprise works in documents. One node event becomes one indexing request carrying exactly one document. There is no aggregation anywhere on the live path and no batch-size setting to tune, because nothing is ever batched: a bulk request on that path holds a single operation. That is efficient when a handful of nodes change, and the cost is strictly proportional to how much actually changed.

Community works in transactions and time windows. Each cycle reads a window of committed transactions from the database, pages the nodes out at pagesize, and writes them to the search cluster as one bulk request per chunk of batchSize. At a thousand documents per request the per-document overhead is a fraction of what Enterprise pays, which is why bulk loading is the case Community handles well.

The catch is the floor. Every cycle re-reads the last overlap of history, ten minutes by default, so that a transaction committed near a window boundary cannot slip through. That re-read happens whether or not anything changed. Community's steady-state cost is therefore set by overlap and the shape of the repository's recent history rather than by the change rate, and an idle repository is not free.

  • Few changes, latency matters. Enterprise wins, and it is not close.
  • Many changes, throughput matters. Community's batching is the more efficient shape.
  • Nothing changing at all. Enterprise does nothing. Community re-reads the overlap window every cycle.

Scaling: what you can add, and what you cannot

Neither product scales its front door, which is more symmetry than the architecture diagrams suggest.

For Community the constraint is the single writer described above. Worth adding to it: the losing instance does not fail fast. It starts, runs a complete cycle including writes to the search cluster, and only discovers the conflict when it tries to advance the cursor. Two replicas therefore means duplicated indexing work followed by a crash loop, not a clean rejection at startup.

Enterprise is more nuanced than "it scales". Its pipeline has four stages and they do not scale alike:

  • Mediation cannot scale out. It consumes the repository event topic through a durable subscription with a fixed client identifier, which makes that subscription exclusive. One instance, by construction.
  • Metadata, content and path do scale out. These read from queues as competing consumers, so you add instances. In-process concurrency defaults to ten consumers each for metadata and content, and to one for path, which makes path the stage most likely to be a quiet bottleneck.
  • Scaling is a messaging exercise. Officially you scale by enabling the broker connection pool and raising consumer counts, not by resizing the indexers.

Inside Community's single process there is real parallelism, and it is the only parallelism available: concurrentProcessors threads working on chunks, and writeConcurrency parallel content fetches within each chunk.

Two programs or one: the reindexing application

Enterprise splits initial load and steady state into two different applications. Live indexing keeps the index current from events. A separate reindexing application does the historical pass, and that one reads the repository database directly over JDBC, which makes it the closer relative of the Community indexer. It runs as a one-shot job in one of two modes, over a node-identifier range or over a date range, and it has two capabilities Community does not:

  • Remote partitioning. A manager splits the range into partitions and hands them out to any number of worker processes over the message broker. This is how Enterprise throws hardware at a large initial index.
  • A transform-bypass build. A variant that skips content transformation entirely and substitutes generated text, for when the point of the exercise is loading metadata at volume rather than producing a usable content index.

Community's batch indexer is built from that reindexing application, with the messaging removed and a continuous scheduler added. That is the whole explanation for the shape of its performance envelope: the historical pass and the ongoing sync are the same code running on the same schedule in one process, so there is no partitioning to enable and no separate tuning profile for the initial load. What you tune for the first index is what you then live with, unless you change the settings again afterwards.

For a small repository that matters less than it sounds. The migration section below reports the initial catch-up for a demo repository of roughly 870 nodes finishing in about a minute. Do not scale that figure linearly, for the reason in the next subsection.

Content extraction is the bottleneck on both sides

Metadata indexing is fast on both products. Extracting text from documents is not, and it dominates everything else as soon as real content is involved.

Community does it synchronously. For each content-bearing node it calls the repository's text-content endpoint over HTTP, with a 20 second timeout and two retries, and the chunk thread blocks until every fetch in that chunk has returned. Because chunk threads and per-chunk fetches multiply, the shipped defaults can put well over a hundred concurrent extraction requests against the repository and the Transform Service. That is the setting most likely to hurt you, and it is why an indexer that looks idle is often just waiting on transforms.

Enterprise moves the same work off the critical path. The event goes to a queue, a transform request goes to the Transform Service, the reply returns on another queue, the extracted text is fetched from the shared file store, and only then is the document updated: six hops, all asynchronous, with timers on the stages. More moving parts, but no thread blocked waiting.

The shared constraint is the Transform Service itself, and Enterprise's documentation is explicit about it: keep transform request consumers below ten, or transformations get missed. Both products drive that same service, so treat it as the ceiling on either.

What to tune, and in what order

Shipped defaults for the 5.7.1 indexer, in the order worth touching:

  • alfresco.reindex.continuous.pollingInterval, default 30s. Lower it for fresher results, at the cost of re-reading the overlap window more often.
  • alfresco.reindex.continuous.maxWindow, default 30m. The cap on how much history one cycle may cover. Raise it for an initial load so catch-up proceeds in larger chunks.
  • alfresco.reindex.continuous.overlap, default 10m. Your safety margin against boundary misses, and also your idle cost. Do not set it to zero.
  • alfresco.reindex.batchSize, default 1000. Both the chunk size and the bulk request size. This is the throughput knob.
  • alfresco.reindex.pagesize, default 1000. How many rows come back from the database per page.
  • alfresco.reindex.concurrentProcessors, default 10. Chunk threads. Raising it raises database and cluster pressure together.
  • alfresco.content.transform.writeConcurrency, default 16. Parallel content fetches per chunk. It multiplies against concurrentProcessors, so treat the product of the two as your real concurrency against the repository.
  • spring.datasource.hikari.maximumPoolSize, default 20. Raise it alongside concurrentProcessors, or the threads will queue waiting for connections.

Two of these are validated at startup and will stop the application rather than misbehave quietly: maxWindow must be greater than pollingInterval, and catchUpPollingInterval must be less than it.

Check the metric cardinality before you build dashboards. Every cycle is a new job with a new jobId, and every reindex metric carries that tag. At the default interval that is roughly 2,880 new tag values a day. The application retains only the fifty most recent jobs and evicts the rest, so a jobId-keyed dashboard shows a sliding window, and a long-term metrics store accumulates series that never repeat. Aggregate across jobId rather than grouping by it.

Operations and monitoring

Gap recovery, and the one setting that can lose data

When the indexer starts, or resumes after an outage, it compares the cursor against the present and picks one of three behaviours

Gap RecoveryGap Recovery

Treat maxGapAge as a data-loss boundary. If the indexer has been down longer than maxGapAge (24 hours by default), it does not catch up. It fast-forwards to now - maxGapAge, logs a warning, and everything in the skipped interval is simply never indexed. Nothing errors, nothing retries, and the index looks healthy. Set it wide enough to cover a realistic worst-case outage, including a long weekend, and alert on the warning.

Health and metrics

Spring Boot Actuator on port 8080:

  • /actuator/health - overall health.
  • /actuator/health/liveness - liveness probe. Includes continuousReindexingMonitor, so a scheduler stuck for longer than stuckThreshold reports DOWN and Kubernetes restarts the pod.
  • /actuator/info - build information.
  • /actuator/metrics - application and JVM metrics.
  • /actuator/prometheus - the same metrics in Prometheus text format, for scraping.

The single most useful thing to monitor is not any of those, though. It is whether the cursor is keeping up:

curl -s http://localhost:9200/alfresco-reindex-state/_doc/reindexByDate-watermark \
  | python3 -m json.tool

That document is the whole state of the system. Its fields are lastSuccessfulFromTimeEpochMs, lastSuccessfulToTimeEpochMs, lastRunStatus, lastRunReadCount, lastRunWriteCount, lastRunSkipCount and updatedAt. If lastSuccessfulToTimeEpochMs stops closing on the present, you have a problem, and the size of the gap is your indexing lag in milliseconds. Alert on that.

Failures and the dead-letter index

Every give-up is recorded in alfresco-reindex-dead-letter, keyed by node or by failed window, with the failure stage (reader, processor, or one of the writers), the failure type, a failure count, and first and last failure times. Note that the index is created with no mapping at all, so it looks empty and schemaless until the first failure arrives and OpenSearch infers a schema.

Recovery is a manual task. There is no retry endpoint and no automatic replay. You fix the upstream cause and let a later cycle re-index the affected nodes. In practice that means the dead-letter index is an audit trail that outlives log rotation and container restarts, which is exactly what it was designed to be.

Restart and recovery

  • Spring Batch metadata is in memory and is not preserved across restarts. Job history is lost; indexing progress is not, because that lives in the cursor.
  • Restart the indexer periodically in long-running deployments, to release accumulated in-memory batch metadata. This is documented guidance, not a workaround.
  • Log level is INFO by default. Watch for startup, cursor bootstrap, and cycle-completion messages. Raise org.alfresco.elasticsearch.batchindexing to DEBUG only for incident triage: debug output includes node identifiers and window bounds, but never content.

A useful marker while a backlog is being processed: catch-up cycles log [chunked gap recovery - more chunks pending], so docker compose logs -f batch-indexer | grep "reindexByDate cycle" gives you a live view of progress through history.

Migrating from Solr to OpenSearch

Two facts to set expectations before anything else. There is no data migration between Solr and OpenSearch. The schemas have nothing in common and no conversion tool exists. The path is always a full reindex. And the default configuration will not do it for you: as covered above, a fresh indexer starts at now - overlap and never looks at history. Getting ten years of repository into a new index is a deliberate, manual procedure.

The five phases

Migration PhasesMigration Phases

 The ordering of steps 2 and 3 is not arbitrary, and this is the part that is easy to get wrong. The index and its mapping are created by Content Services, not by the indexer: elasticsearch.createIndexIfNotExists and the ContentModelSynchronizer both live in the repository's elasticsearch subsystem. So the subsystem has to be active before the indexer has anything to write into.

The consequence is that from step 2 onwards, searches are served from an index that is still catching up, and results will be incomplete until step 4 finishes. You have two ways to handle that: run the whole thing in a maintenance window, or keep Solr alive as a rollback target.

Keeping Solr alive as a rollback target

Remember that search.solrTrackingSupport.enabled defaults to false under the elasticsearch subsystem. That default quietly kills the tracking endpoints Solr polls, so if you leave it alone, your Solr index starts going stale the moment you switch subsystems, and by the time you discover a problem the rollback target is useless. Force it on for the duration of the migration:

-Dindex.subsystem.name=elasticsearch
-Dsearch.solrTrackingSupport.enabled=true

Now both engines are live: OpenSearch answers queries while it catches up, and Solr keeps tracking so it stays current. If you need to roll back, point index.subsystem.name at solr6 and restart. One caveat: while you are on solr6, the content-model synchronizer is not running, so content-model changes made during that period will not reach the OpenSearch mapping.

Seeding the cursor

This is the step the official documentation describes in prose but does not give you an API call for, so here it is. Two properties and one HTTP request.

First, remove the gap-age cap, otherwise the historical cursor you are about to write is older than maxGapAge and gets discarded on the very first cycle:

ALFRESCO_REINDEX_CONTINUOUS_MAXGAPAGE: 0

Then write a starting cursor. The simple version, using a fixed date safely before your repository existed:

# 1262304000000 = 2010-01-01T00:00:00Z
curl -X PUT "$OS/alfresco-reindex-state/_doc/reindexByDate-watermark" \
  -H 'Content-Type: application/json' \
  -d '{"schemaVersion":1,"lastSuccessfulToTimeEpochMs":1262304000000}'

Do not use a fixed date on a real repository. Catch-up walks the calendar, not the documents: one maxWindow chunk per cycle regardless of whether that window contains any changes. Seed at 2010 with the default 30 minute window and you have committed to roughly 280,000 cycles of empty windows before the indexer reaches 2026. Seed from the repository's own first transaction instead, and the catch-up lasts in proportion to the history that actually exists.

For a repository with years of history, raise the window so you are not spending a cycle per half hour of calendar time. MAX_WINDOW=7d is a reasonable starting point, and the documentation also suggests sizing batchSize and concurrentProcessors up for the initial load, since it reads and writes far more than steady state.

Attaching to an already-populated index? Then do not seed anything. Leave bootstrapFromAlfrescoIndex at its default of true and the cursor resumes near the existing index progress instead of re-indexing from the start.

Verifying and completing the switch

Four checks, in order:

  1. The cursor has reached the present. lastSuccessfulToTimeEpochMs stops trailing and starts tracking within one polling interval of now.
  2. All three document types are present. Metadata, content, and path. A repository where content indexing silently failed will still return plausible metadata results, which is how this gets missed.
  3. The dead-letter index has been reviewed and every entry is either understood and accepted or fixed and re-indexed.
  4. Representative searches return correct results through Content Services, not through the cluster directly.

Some commands for those:

# cursor position
curl -s "$OS/alfresco-reindex-state/_doc/reindexByDate-watermark" | python3 -m json.tool

# document count in the main index
curl -s "$OS/alfresco/_count"

# anything in the dead-letter index?
curl -s "$OS/alfresco-reindex-dead-letter/_search?size=20&expand_wildcards=all"

# a real search, through Content Services
curl -s -u admin:admin -X POST \
  "http://localhost:8080/alfresco/api/-default-/public/search/versions/1/search" \
  -H 'Content-Type: application/json' \
  -d '{"query":{"language":"afts","query":"budget"}}'

Then return the scheduling properties to steady state, which mainly means putting the gap cap back:

ALFRESCO_REINDEX_CONTINUOUS_MAXGAPAGE: 24h
ALFRESCO_REINDEX_CONTINUOUS_MAXWINDOW: 30m

And only then stop Solr and set search.solrTrackingSupport.enabled back to its default.

For scale: a demo repository catches up in minutes. Real numbers depend almost entirely on how much text there is to extract, because content transformation, not indexing, is the bottleneck.

Will my queries still work?

Mostly yes, and the exceptions matter more than the count suggests. AFTS, Lucene and CMIS are all supported on the elasticsearch subsystem. What changes is the set of pseudo-fields available, and, critically, how the system behaves when you use one that is not.

Field support

  • Supported: TYPE, EXACTTYPE, ASPECT, EXACTASPECT, CLASS, PATH, ANCESTOR, PARENT, PRIMARYPARENT, TEXT, ALL, ID, ISNODE, ISNOTNULL, EXISTS, ISNULL, ISUNSET, OWNER, READER, DENIED, AUTHORITY, TAG, SITE.
  • Silently ignored: PATHWITHREPEATS, PNAME, NPATH, QNAME, PRIMARYASSOCQNAME, PRIMARYASSOCTYPEQNAME, OWNERSET, READERSET, DENYSET, AUTHORITYSET (AUTHSET), DBID, TX, TXID, INTXID, TXCOMMITTIME, ACLID, INACLTXID, ACLTXID, ACLTXCOMMITTIME, FTSSTATUS, ISROOT, ISCONTAINER, TENANT, FINGERPRINT, id, CASCADETX.

A few of the supported ones come with caveats: CLASS works for term queries only, PATH silently ignores invalid XPath, and ANCESTOR and PARENT do not support category paths. TEXT expands to cm:name, cm:title, cm:description and cm:content. Three more fields, ASSOCTYPEQNAME, ISCATEGORY and LINKASPECT, are not special-cased at all and get treated as ordinary property names.

The loss of FINGERPRINT is the one most likely to break a feature rather than a query: that is document-similarity search, and there is no equivalent.

The hazard: unsupported conditions do not fail

This is the single most important thing in this post. An unsupported condition does not raise an error. The parser accepts it, because the AFTS and CMIS grammars are shared between both subsystems, and then the query builder returns null for that condition, logs a WARN, and the query proceeds without it. So a query written to narrow a result set can silently widen it instead. banana AND TXCOMMITTIME:taxi returns every document matching banana, with HTTP 200 and no indication that half of your query was discarded.

Think about what that means for anything that uses search as a filter: a rule, a scheduled job, a report, a permission-adjacent listing. Under solr6 a broken query returned nothing and somebody noticed. Under elasticsearch it returns too much and nobody does.

The only warning you get is in the repository log, so grep for it:

docker compose logs -f alfresco \
  | grep -E "Ignoring query condition|Ignorning sort on field"

The second pattern is misspelled in the product source, so match both spellings rather than the correct one.

Because silent drops return HTTP 200, you cannot detect them from a single response. You have to test differentially: run a baseline query and a supposedly narrower one, and if the counts are identical, the restriction was dropped. There is a companion script for this that runs 25 documented cases as 28 checks against a live instance, grouped into loud failures, silent drops, missing response sections, and constrained behaviour. Two traps it has to work around are worth knowing about even if you write your own: every probe attaches a facet, because otherwise Transactional Metadata Query answers from the database and never exercises the index at all; and every differential check asserts a non-zero baseline first, because zero-versus-zero proves nothing.

Full-text search limits

  • Wildcard, for example budg* or budge?. Works, but only analyzed on the fields listed in elasticsearch.query.analyzeWildcard.fields. Rejected by design on a long list of fields including PATH, ANCESTOR, PARENT, QNAME, DBID and CLASS.
  • Prefix, for example budg* as a prefix term. Same reject list as wildcard. Works on TYPE, EXACTTYPE, ASPECT, EXACTASPECT, SITE, TEXT, ALL and ID.
  • Fuzzy, for example budget~0.8. Rejects more than prefix does: the prefix list plus SITE, TYPE, EXACTTYPE, ASPECT, EXACTASPECT and ISNODE.
  • Fuzzy on CLASS, for example CLASS:cm\:content~0.8. Throws. The one construct that fails loudly instead of being dropped.
  • Range, for example cm:created:[2024 TO 2025]. Works on TEXT, ALL, resolvable properties and datatypes. Everything else is silently ignored.
  • Exact term, for example =running. Disabled by default, as it is on Solr. Requires enabling datatypes or properties in exactTermSearch.properties, and because that changes the index mapping, it requires a reindex.
  • Regexp, for example /bud.*/. Never works. Always returns "Regexp queries are not supported".
  • Phrase, boolean, proximity, boost and date math, for example "quarterly budget"~2, budget^4 or cm:modified:[NOW/DAY-7DAYS TO NOW/DAY]. Supported.

Note the asymmetry there: TYPE accepts a prefix query but silently drops a fuzzy one. Field support and construct support are two separate lists, and a field being in one does not put it in the other.

CMIS

  • SELECT ... FROM ... WHERE - supported.
  • CONTAINS() - supported, but the AFTS limits above apply inside it.
  • LIKE with % and _ - supported. % is translated to * unless escaped.
  • IN_FOLDER() and IN_TREE() - supported.
  • ORDER BY on a property - supported.
  • cmis:objectId with a version label - supported, including private working copies and unversioned nodes.
  • ORDER BY on a function, for example SCORE() - not supported. Throws. Fails loudly.
  • Exact search on tokenized-only fields - not supported. Throws.
  • JOIN - parses, but join support is advertised as NONE on both subsystems, so it is not executable on either. Not a regression.

Query languages that are gone

Three language bindings are wired to UnsupportedQueryLanguage in the Community context, so they fail rather than degrade:

  • xpath. This is the one to audit for. Code calling searchService.query with language xpath, and selectNodes-style usage that resolves through the search subsystem, needs review before migrating. XPath internals survive only to serve PATH: conversion, and even there only a basic subset (root, absolute, relative, child, descendant steps).
  • index-sql, the Solr SQL endpoint. Also, there is no stats bean and no suggester bean registered on this subsystem at all, so those features are simply absent.
  • index-alfresco and the legacy solr-* aliases.

A migration checklist for queries

  1. Grep your codebase, your rules, and your saved searches for the silently-ignored field names above. DBID, TXID and PNAME are the common ones in custom code.
  2. Grep for language values of xpath, index-sql and index-alfresco.
  3. Grep for FINGERPRINT and for ORDER BY SCORE().
  4. Check anything relying on facet intervals, ranges, pivots, stats or spellcheck.
  5. Run your integration test suite against 26.2 with the repository log filtered on Ignoring query condition. Anything that logs is a query whose meaning changed even if the test still passes.

Licensing

Alfresco Search Community is distributed as a binary only: a Docker image and a JAR, with the source code private. As covered earlier, the reason is that the module reuses Alfresco Search Enterprise code.

The final license text has not been approved or published yet. What follows is the announced intent, and the published text is what will actually govern your use. Check it when it appears.

As announced, the terms will permit:

  • Downloading, installing, running and using the unmodified binary, including in production.

And will not permit:

  • Redistribution by third parties.
  • Reverse engineering.
  • Use in a SaaS or managed-service offering.

The stated longer-term intention is to publish the source code and license it under LGPLv3, which is the license the rest of Alfresco Community uses. No date has been committed to; 27.1 has been mentioned as a possibility for the source release.

Alfresco and the Hyland AI Ready Index

Worth addressing because the question comes up immediately once OpenSearch is in the picture: the Hyland AI Ready Index (AIR Index) also uses OpenSearch natively, including vector indexes for embeddings. So can they share a cluster?

Infrastructure yes, data no. The AIR Index indexes are entirely independent of Alfresco's. You can point both at the same OpenSearch instance to save on infrastructure, but there is no interoperability between the data: Alfresco cannot query the vector indexes and the AIR Index does not read the alfresco index. Treat it as co-tenancy, not integration.

If you do co-locate them, three things need to line up:

  1. OpenSearch version. The Content Lake App and hxpr default to OpenSearch 3.5.0, which is newer than the 2.11.1 that Alfresco Search Community lists as supported. In practice ACS 26.2 and the batch indexer work unchanged against 3.5.0, which reports a minimum wire compatibility version of 2.19.0. That is a tested observation, not a supported configuration, so verify it for your own deployment.
  2. Security plugin off, in the default configuration of both, via plugins.security.disabled: "true" or the equivalent DISABLE_SECURITY_PLUGIN.
  3. An index template workaround for a knn.derived_source bug in OpenSearch 3.5, applied to the embeddings indexes:
curl -X PUT "http://localhost:9200/_index_template/nuxeo-embeddings-noderivedsource" \
  -H 'Content-Type: application/json' \
  --data-binary '{
    "index_patterns":["nuxeo_embeddings","nuxeo_embeddings_*"],
    "priority":500,
    "template":{"settings":{"index":{"knn":true,"knn.derived_source.enabled":false}}}
  }'

The broader point is that 26.2 moves Alfresco onto the same search substrate that the AI-facing parts of the Hyland platform already use. That does not create integration on its own, but it removes the reason there could not be any.

Summary

If you read one section, read the query compatibility one. But the short version of everything above:

  • Solr is out, Alfresco Search Community is in, GA in 26.2, on the same elasticsearch subsystem Enterprise has used since 7.1.
  • Two moving parts: the query path inside Content Services, and a standalone batch-indexing application. Configure them separately.
  • Standard OpenSearch 2.11.1 or Elasticsearch 8.17.x, no Alfresco plugin, so managed services are viable. Java 17. Five supported databases, not just PostgreSQL.
  • Polling, not events. 30 second default, one writer only, cursor stored in a hidden index.
  • Pre-existing content is not indexed automatically. Migration means a full reindex with a manually seeded cursor and maxGapAge=0.
  • Watch maxGapAge: exceed it and history is skipped silently.
  • Watch unsupported query fields: they are dropped, not rejected, so a narrowing query can silently widen.
  • Binary-only distribution, final license text pending, LGPLv3 intended eventually.
3 Comments
cesarista
World-Class Innovator
World-Class Innovator

Hi Angel:

Thanks for the session and the documentation.

Just a question. If I understood correctly the prefixes.json part, when I install a new content model in Alfresco repository (either dynamic or bootstraped, or a new addon which contains a content model), I have to (re)generate prefixes.json and to restart the batch-indexer service with -Dalfresco.reindex.prefixes-file=file:/config/prefixes.json

Does it apply for live indexing process in ACS EE or it is only for reindexing app ?

Regards.

--C.

angelborroy
Community Manager Community Manager
Community Manager

Sorry, César. I've missed this.

That applies for all applications using DB for indexing, as Enterprise Re-indexing and Search Community. As a general rule, when the source of truth is DB, prefixes.json is required.

mikel_asla_tsystems
Confirmed Champ
Confirmed Champ

Thanks Angel! 😍