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.

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:

Target Project
Linux alfresco-ubuntu-installer
Docker alfresco-docker-installer and alfresco-docker-extension
Kubernetes alfresco-helm-charts

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.

Year Engine What it introduced
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.
2026 Standard engine (OpenSearch or Elasticsearch) Continuous polling, no events. One repository subsystem, two interchangeable engines.

The smaller-than-it-looks part: 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. The bigger-than-it-looks part: 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.

Product Subsystem Before 7.1 7.1+ 23.2 26.2
Alfresco Search Services solr6 Community Community Community -
Alfresco Search and Insight Engine solr6 Enterprise Enterprise - -
Alfresco Search Community elasticsearch - - - Community
Alfresco Search Enterprise elasticsearch - Enterprise Enterprise Enterprise

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

Component Supported version Notes
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:

Database JDBC driver
PostgreSQL Bundled with the batch-indexing application.
MySQL Supply the driver at startup.
MariaDB Supply the 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:

   Client applications (Digital Workspace, Share, REST API)
                            |
                            |  (1) search request
                            v
                  Alfresco Content Services
                   "elasticsearch" subsystem
                            |
                            |  (2) translated query + permission filter
                            v
     +===================================================+
     |      OpenSearch 2.11.1 / Elasticsearch 8.17.x     |
     |                                                   |
     |   alfresco                     <- searchable docs |
     |   alfresco-reindex-state       <- cursor (hidden) |
     |   alfresco-reindex-dead-letter <- errors (hidden) |
     +===================================================+
                            ^
                            |  (6) bulk index/update, then advance the cursor
                            |
           alfresco-elasticsearch-batch-indexing
               ContinuousReindexingService
                  (polls every 30 seconds)
              |              |                 |
   (3) read   |   (4) fetch  |   (5) ask which |
   changed    |   extracted  |   media types   |
   nodes      |   text       |   can transform |
   read-only  |              |                 |
   JDBC       |              |                 |
              v              v                 v
       ACS database   ACS textContent    Transform Core
                      endpoint           /transform/config

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

Index Default name Purpose
Main search index alfresco Holds the searchable documents: metadata, content, and path. This is the index Content Services queries.
State index alfresco-reindex-state Holds the indexing cursor, a single watermark document. Hidden.
Dead-letter index 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

There is also a fourth index name on the repository side, elasticsearch.archive.indexName, defaulting to alfresco-archive, used for deleted (archived) nodes. The batch indexer itself only deals with the three above.

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's trackers. It runs inside ContinuousReindexingService, every pollingInterval (30 seconds by default), and the whole behaviour of the system falls out of it:

  +--> (1) read the cursor from alfresco-reindex-state
  |          |
  |          v
  |    (2) compute this cycle's window
  |          from = cursor - overlap          (10m default)
  |          to   = min(now, from + maxWindow) (30m default)
  |          |
  |          v
  |    (3) SELECT nodes changed in [from, to) from the ACS database
  |          |
  |          v
  |    (4) fetch extracted text for those nodes from ACS
  |          |
  |          v
  |    (5) bulk-write metadata + content + path documents
  |          |
  |     +----+----------------+
  |     |                     |
  |  success               failure
  |     |                     |
  |     v                     v
  |  (6) advance          cursor stays put, back off
  |      the cursor       30s -> 60s -> ... -> 5m (maxPollingInterval)
  |     |                     |
  +-----+---------------------+
        wait pollingInterval (30s), or catchUpPollingInterval (1s) if behind

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:

  A property is added or changed in an Alfresco content model
                            |
                            v
  ContentModelSynchronizer loads the supported and per-locale analyzers
                            |
                            v
       Indexable? Tokenized? Needs an _untokenized alias?
              |                                  |
        not indexable                        indexable
              |                                  |
              v                                  v
   ignored, never added            FieldMappingBuilder builds the
   to the mapping                  mapping update request
                                                 |
                                                 v
                              total_fields.limit exceeded? (7500)
                                       |                 |
                                      no                yes
                                       |                 |
                                       v                 v
                        mapping extended         OpenSearch rejects
                        automatically            the update with a
                                |                native error
                                v
                   the property is searchable with
                   no manual reindex

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.

Getting the artifacts

The JAR

The distribuction 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

Component Image and tag
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
Solr 6, if you keep it during migration alfresco/alfresco-search-services:2.0.21

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.

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

Property Value Description
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

Property Default Description
elasticsearch.host localhost Search cluster host.
elasticsearch.port 9200 Search cluster port.
elasticsearch.baseUrl / Path prefix, for clusters served behind a sub-path.
elasticsearch.indexName alfresco Main index. Must match the indexer's elasticsearch.indexName.
elasticsearch.archive.indexName alfresco-archive Index used for deleted (archived) nodes.
elasticsearch.createIndexIfNotExists 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

Property Default Description
elasticsearch.secureComms 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 false Set to false when the server certificate has no CN matching the DNS hostname.
elasticsearch.auth.mode basic basic or aws-iam. These are the only two values.
elasticsearch.user (empty) HTTP Basic user name.
elasticsearch.password (empty) HTTP Basic password.
elasticsearch.aws.region (empty) AWS region for SigV4 signing when auth.mode=aws-iam.
elasticsearch.aws.service es AWS signing service name.

Connection pool, timeouts, and retries

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

Language analyzers

Property Default Description
elasticsearch.index.locale (empty) 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) Comma-separated file: or classpath: locations for custom analyzer definitions.

Query behaviour and limits

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

Query routing: the index is not always involved

Property Default Description
query.fts.queryConsistency TRANSACTIONAL_IF_POSSIBLE Runs an AFTS query against the database when it is eligible, and against the search cluster otherwise.
query.cmis.queryConsistency TRANSACTIONAL_IF_POSSIBLE The same for CMIS.
query.hybrid.enabled 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

Property Default
system.acl.maxPermissionCheckEnabled false
system.acl.maxPermissionChecks 1000
system.acl.maxPermissionCheckTimeMillis 10000

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

Solr tracking support

Property Default Description
search.solrTrackingSupport.enabled false Under the elasticsearch subsystem this defaults to false, which disables the tracking endpoints Solr uses to pull changes. Set it to true during a migration if you want to keep a Solr instance current as a rollback target.
search.solrTrackingSupport.ignorePathsForSpecificTypes false Legacy tracking tuning.
search.solrTrackingSupport.ignorePathsForSpecificAspects false Legacy tracking tuning.

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

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

Search cluster connection

Property Default Description
spring.elasticsearch.uris 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) HTTP Basic user name.
spring.elasticsearch.password (empty) HTTP Basic password.
spring.elasticsearch.auth-mode basic basic or aws-iam. An unsupported value fails fast at property binding.
spring.elasticsearch.aws-region (empty) Region for SigV4 signing when auth-mode=aws-iam.
spring.elasticsearch.aws-service es AWS signing service name.
spring.elasticsearch.path-prefix (empty) Path prefix for clusters behind a sub-path.
spring.elasticsearch.restclient.sniffer.enabled false Automatic node discovery. See the warning below.
elasticsearch.indexName 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 table in this post. Durations accept 30s, 10m, 24h style values.

Property (prefix alfresco.reindex.continuous.) Default Description
autoStart true Start scheduled indexing at application startup. Set false to deploy without indexing yet.
pollingInterval 30s Fixed delay between completed cycles. Lower means fresher results and more load on the database and the cluster.
maxPollingInterval 5m Upper bound for exponential backoff after consecutive failures.
catchUpPollingInterval 1s Delay between chunks while working through a backlog.
overlap 10m Look-back applied to every cycle so boundary changes are not missed.
maxWindow 30m Maximum time span a single cycle processes. Larger gaps are processed in chunks of this size.
maxGapAge 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 alfresco-reindex-state Index holding the cursor.
watermarkRetryMaxAttempts 3 Retries for cursor reads and writes.
watermarkRetryDelay 2s Initial delay between cursor retries.
stuckThreshold 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 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 true When there is no cursor, seed it from the latest indexed change in the main index, minus the overlap.
requireAlfrescoIndex 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

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

Job tuning

Property Default Description
alfresco.reindex.jobName reindexByDate The Spring Batch job name. You will see it in the logs.
alfresco.reindex.batchSize 1000 Items per batch write.
alfresco.reindex.pagesize 1000 Rows read per database page. Note the lowercase s, unlike every sibling property.
alfresco.reindex.concurrentProcessors 10 Worker threads for the multi-threaded step.
alfresco.reindex.multithreadedStepEnabled true Spread chunks across worker threads.
alfresco.reindex.skipLimit 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 true Retry transient failures.
alfresco.reindex.retryingMaxCount 3 Retry attempts.
alfresco.reindex.retryingInitialDelay 1000 ms First backoff delay.
alfresco.reindex.retryingDelayIntervalMultiplier 2 Backoff multiplier.
alfresco.reindex.retryingMaxDelay 30000 ms Backoff ceiling.
alfresco.reindex.writerRetryCount 3 Retries for an individual bulk write.
alfresco.reindex.writerRetryDelay 1000 ms Delay between bulk-write retries.

Content transformation

Property Default Description
alfresco.acs.url http://localhost:8080 Base URL of Content Services.
alfresco.content.transform.urlPath /alfresco/service/api/solr/textContent The text-extraction endpoint.
alfresco.content.transform.sharedSecret (empty) Must match solr.sharedSecret. Sent as X-Alfresco-Search-Secret.
alfresco.content.transform.timeout 20s Per-request timeout.
alfresco.content.transform.retryMaxAttempts 2 Retries per content fetch.
alfresco.content.transform.retryDelay 1s Delay between those retries.
alfresco.content.transform.writeConcurrency 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 10 Caps the extracted-text response buffer. See the warning below.
alfresco.accepted-content-media-types-cache.base-url 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 true Cache that response.
alfresco.cache.timeout.seconds 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

Property Default Description
alfresco.reindex.dead-letter.enabled 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 alfresco-reindex-dead-letter Index name.
alfresco.reindex.failOnMissingTag true Tag handling.
alfresco.reindex.addEmptyTagAttribute true Tag handling.
alfresco.reindex.removeTaggableAttribute true Tag handling.
alfresco.metrics.reindex.max-retained-jobs 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 8080 Actuator port.
management.endpoints.web.exposure.include health,info,prometheus,metrics Exposed Actuator endpoints.
management.endpoint.health.show-details 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

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

A working TLS 1.3 configuration

If you want the security plugin on and TLS 1.3 enforced, this is the shape of it. On the OpenSearch side, a config fragment appended at container start:

plugins.security.ssl.http.enabled: true
plugins.security.ssl.http.enabled_protocols:
  - TLSv1.3
plugins.security.allow_unsafe_democertificates: false
plugins.security.authcz.admin_dn: [ CN=admin ]
plugins.security.nodes_dn:        [ CN=opensearch ]
  opensearch:
    image: opensearchproject/opensearch:2.11.1
    environment:
      DISABLE_INSTALL_DEMO_CONFIG: "true"
      OPENSEARCH_JAVA_OPTS: -Xms1g -Xmx1g
    command:
      - sh
      - -c
      - "cat /usr/share/opensearch/config/opensearch-security-tls.yml
         >> /usr/share/opensearch/config/opensearch.yml &&
         /usr/share/opensearch/opensearch-docker-entrypoint.sh"

On the repository side, point the subsystem at HTTPS and import the CA into a JCEKS truststore:

-Delasticsearch.secureComms=https
-Delasticsearch.ssl.host.name.verification=false
-Delasticsearch.user=admin
-Delasticsearch.password=<opensearch-password>
-Dencryption.ssl.truststore.location=/usr/local/tomcat/shared/classes/alfresco/extension/ssl-keystore/ssl.truststore
-Dencryption.ssl.truststore.type=JCEKS
keytool -importcert -noprompt -alias root-ca -file root-ca.pem \
  -keystore ssl.truststore -storetype JCEKS -storepass <truststore-password>

And on the indexer, credentials in the URI plus a JKS truststore passed through JAVA_TOOL_OPTIONS:

  batch-indexer:
    image: alfresco/alfresco-elasticsearch-batch-indexing:5.7.1
    environment:
      SPRING_ELASTICSEARCH_URIS: https://admin:<opensearch-password>@opensearch:9200
      JAVA_TOOL_OPTIONS: >-
        -Djavax.net.ssl.trustStore=/certs/batch-indexer-truststore.jks
        -Djavax.net.ssl.trustStorePassword=<truststore-password>
    volumes:
      - ./certs:/certs:ro

Then prove it, in both directions. A TLS 1.3 request must succeed and a TLS 1.2 request must be refused. If the second one succeeds, your protocol restriction is not actually applied:

# must work
curl -sk -u admin:'<opensearch-password>' --tlsv1.3 \
  https://localhost:9200/_cluster/health

# must fail with a protocol version alert
curl -sk -u admin:'<opensearch-password>' --tlsv1.2 --tls-max 1.2 \
  https://localhost:9200/_cluster/health

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.

  Alfresco Search Enterprise Alfresco Search Community
Mechanism Live indexing, event-driven Batch indexing, polling
Transport ActiveMQ events from the repository Read-only JDBC against the repository database
Unit of work Nodes Transactions and database identifiers, processed incrementally by time window
Latency Near real time One polling interval, 30 seconds by default
Query path Same elasticsearch subsystem 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 next.

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:

   service starts / resumes after an outage
                  |
                  v
      read the last successful cursor
                  |
                  v
          how big is the gap?
                  |
    +-------------+-------------------+---------------------------+
    |                                 |                           |
  gap <= maxWindow            gap <= maxGapAge            gap > maxGapAge
  (30m)                       (24h)                       (24h)
    |                                 |                           |
    v                                 v                           v
  one normal window            catch-up mode:              FAST-FORWARD to
  every pollingInterval        maxWindow chunks            now - maxGapAge,
  (30s)                        back to back every          log a warning,
                               catchUpPollingInterval      and SKIP the
                               (1s)                        intervening history
    |                                 |                           |
    +---------------+-----------------+---------------------------+
                    |
                    v
        cursor reaches the present

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:

Endpoint Purpose
/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

  1. Provision the OpenSearch / Elasticsearch cluster
     (+ back up the repository database and the ACS configuration)
                          |
                          v
  2. Configure the elasticsearch subsystem in Content Services
     index.subsystem.name=elasticsearch
                          |
                          v
  3. Deploy the batch-indexing application
                          |
                          v
  4. Perform the initial index of the existing repository
     maxGapAge=0, seed the cursor, process history in maxWindow chunks
                          |
                          v
  5. Verify and complete the switch
     cursor at the present, all three document types present,
     dead-letter reviewed, then restore maxGapAge=24h

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.

The better version derives the seed from the database, and is idempotent so that a re-run never restarts a migration in progress:

set -e

# The earliest real transaction in the repository, minus 1ms so the
# first window includes it. This is the same column the indexer walks.
seed=$(psql -h postgres -U alfresco -d alfresco -tAc \
  "SELECT COALESCE(MIN(commit_time_ms), -1) FROM alf_transaction;")

if [ "$seed" -lt 0 ]; then
  echo "Repository has no transactions: nothing to seed."
  exit 0
fi
seed=$((seed - 1))

# Create the state index hidden, as the application would.
curl -s -o /dev/null -X PUT "$OS/alfresco-reindex-state" \
  -H 'Content-Type: application/json' \
  -d '{"settings":{"index.hidden":true}}'

# _create, not _doc: 201 means seeded, 409 means a cursor already
# exists and must not be overwritten.
code=$(curl -s -o /dev/null -w '%{http_code}' \
  -X PUT "$OS/alfresco-reindex-state/_create/reindexByDate-watermark" \
  -H 'Content-Type: application/json' \
  -d "{\"schemaVersion\":1,\"lastSuccessfulToTimeEpochMs\":$seed,\"lastRunStatus\":\"SEEDED\"}")

case "$code" in
  201) echo "Cursor seeded at $seed" ;;
  409) echo "Cursor already exists, preserved" ;;
  *)   echo "Failed to seed cursor (HTTP $code)"; exit 1 ;;
esac

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.

Do not skip the volumes. Phase 2 recreates the repository container. If alf_data was not on a named volume, the content store is gone while the database still references it, and you get CONTENT INTEGRITY ERROR on every node. This is a very easy mistake to make when the migration is rehearsed with Compose.

For scale: a demo repository of roughly 870 nodes catches up in about a minute. 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 Silently ignored
TYPE, EXACTTYPE, ASPECT, EXACTASPECT, CLASS, PATH, ANCESTOR, PARENT, PRIMARYPARENT, TEXT, ALL, ID, ISNODE, ISNOTNULL, EXISTS, ISNULL, ISUNSET, OWNER, READER, DENIED, AUTHORITY, TAG, SITE 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

Construct Example Status on elasticsearch
Wildcard budg*, 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 budg* as a prefix term Same reject list as wildcard. Works on TYPE, EXACTTYPE, ASPECT, EXACTASPECT, SITE, TEXT, ALL and ID.
Fuzzy budget~0.8 Rejects more than prefix does: the prefix list plus SITE, TYPE, EXACTTYPE, ASPECT, EXACTASPECT and ISNODE.
Fuzzy on CLASS CLASS:cm\:content~0.8 Throws. The one construct that fails loudly instead of being dropped.
Range cm:created:[2024 TO 2025] Works on TEXT, ALL, resolvable properties and datatypes. Everything else is silently ignored.
Exact term =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 /bud.*/ Never works. Always returns "Regexp queries are not supported".
Phrase, boolean, proximity, boost, date math "quarterly budget"~2, budget^4, cm:modified:[NOW/DAY-7DAYS TO NOW/DAY] Supported.

Note the asymmetry in that table: 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

Feature Status
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(), 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.

One documentation discrepancy to be aware of

Verify range facets before you rely on them. The official "Using" page states that supported facets include field facets, range facets, and query facets. The alfresco-community-repo source disagrees on the middle one: ElasticsearchResultSet.getFacetRanges() returns an empty map unconditionally, and RangeParameters is never consumed. Facet intervals, pivot facets, stats and spellcheck likewise come back empty. Test the specific facet types your application depends on against a real instance rather than trusting either the documentation or this post.

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.