The official acs-deployment repo ships a community-compose.yaml that gets you a full Alfresco Content Services stack - repository, Share, Postgres, Solr, ActiveMQ, a transform engine, and a Traefik proxy - with a single docker compose up. It is a sample. It is honest about that: default passwords, every internal port published to your laptop, no resource floors, no log rotation.
That is exactly the right tradeoff for "try Alfresco in five minutes." It is exactly the wrong one for anything that outlives the demo.
So let's treat the sample as a starting line and walk it toward production, applying Guillaume Lours Docker Compose Tips one at a time. Every change below cites the tip it comes from, so you can read the original for the full reasoning. The finished files live in the companion repository at github.com/aborroy/alfresco-community-compose-hardened; every snippet here is taken from a config that passes docker compose config.
Here is the shape of the sample, trimmed:
services:
alfresco:
image: docker.io/alfresco/alfresco-content-repository-community:26.1.0
mem_limit: 1900m
environment:
JAVA_OPTS: >-
-Ddb.password=alfresco
-Dsolr.sharedSecret=secret
-Dmessaging.broker.password=admin
...
postgres:
image: postgres:17.9
environment:
- POSTGRES_PASSWORD=alfresco
ports:
- "5432:5432"
solr6:
ports:
- "8083:8983"
activemq:
ports:
- "8161:8161"
- "5672:5672"
- "61616:61616"
- "61613:61613"
# share, transform-core-aio, content-app, control-center, proxy...
Five secrets in plaintext, a Postgres bound to 0.0.0.0:5432, and ActiveMQ's OpenWire port wide open. Let's fix it.
The first thing - docker compose config (Tip #1) - tells you what Compose actually thinks your stack is after merging, interpolation, and anchor expansion. Run it before and after every change in this post. It is the cheapest insurance there is.
Then set a project name in the file itself (Tip #53). Without it, Compose derives the project name from the directory, so renaming the folder orphans your volumes:
name: alfresco
Now containers, networks, and volumes are all prefixed alfresco_, regardless of where the file lives.
This is the single most important change, and it comes straight from Tip #73 (expose vs. ports) reinforced by Tip #21 (bridge networking) and Tip #6 (service discovery).
The sample publishes Postgres, Solr, ActiveMQ, and the transform engine to the host. None of that is necessary. Containers on the same Compose network reach each other by service name on any port. alfresco already talks to postgres:5432 and solr6:8983 over the internal network. Publishing those ports to the host adds nothing but attack surface. Worse, as Tip #73 warns, "5432:5432" binds to 0.0.0.0. On a cloud VM, that is your database on the public internet.
So in the base file, only Traefik publishes ports:
# commons/base.yaml
proxy:
image: traefik:3.6
ports:
- "8080:8080" # the one front door
- "8888:8888" # Traefik dashboard for local/dev use
Postgres, Solr, ActiveMQ, and the transform engine lose their ports: entirely. They keep talking to each other over the network exactly as before. Verify it with docker compose config, which now shows a single published front door.
Production note: the Traefik dashboard is useful during development, but do not leave --api.insecure=true exposed in production. The prod overlay drops both --api.insecure=true and the published :8888 port (see section 11), so the prod chain publishes only :8080. Disable it, bind it to localhost, or protect it behind proper authentication.
If you do want the Solr admin UI or the ActiveMQ console on your laptop, put them in the dev override bound to 127.0.0.1, never 0.0.0.0.
Publishing nothing is good; making the data tier unroutable is better. Tip #31 (network isolation) says not to put everything on one default network. Give the stack a frontend network the proxy can see, and a backend network marked internal: true so it has no route off the host at all:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no gateway; nothing here can reach, or be reached from, outside
Postgres, Solr, ActiveMQ, and the transform engine join only backend. The UIs (share, content-app, control-center) join only frontend. The repository is the bridge. It sits on both, because it is the only service that talks to both the data tier and the users:
alfresco:
networks: [frontend, backend]
postgres:
networks: [backend]
share:
networks: [frontend]
Defense in depth: even if something published a port by accident, backend has nowhere to publish to.
Putting the repository on two networks introduces a trap that will bite immediately. When a container is attached to more than one network, Traefik has no way to know which IP to route to. It may pick the one on the internal backend network, which the proxy cannot reach. The symptom is a 504 Gateway Timeout even though every container is healthy and the router shows up in the dashboard.
The fix is one label per proxied service, naming the network the proxy shares with it:
alfresco:
labels:
# alfresco is on frontend + backend; without this, Traefik may dial the
# unreachable backend IP and every request 504s.
- "traefik.docker.network=alfresco_frontend"
The network name is project-prefixed: <project>_<network>. With name: alfresco, that is alfresco_frontend. Any service that both joins multiple networks and sits behind Traefik needs this. In this stack that is the repository. It is harmless to add it to the single-network UIs too, for consistency.
.envThe sample hardcodes versions, hostnames, and credentials inline. Tip #42 (variable substitution) and Tip #2 (--env-file per environment) say to lift them out. Pin every image tag through a variable, and guard the values that must be set with ${VAR:?} so a missing value fails loudly at parse time instead of booting a broken stack:
alfresco:
image: docker.io/alfresco/alfresco-content-repository-community:${REPO_TAG:?set REPO_TAG in .env}
# .env (gitignored; commit .env.example instead)
REPO_TAG=26.1.0
SOLR_TAG=2.0.19
ALFRESCO_HOST=localhost
JVM_RAM_OPTS=-XX:MinRAMPercentage=50 -XX:MaxRAMPercentage=80
That JVM_RAM_OPTS knob is a nice payoff: the sample repeats the same -XX:Min/MaxRAMPercentage flags in four services. Now it is defined once and referenced as ${JVM_RAM_OPTS} everywhere.
Five plaintext passwords is the sample's biggest "do not ship this" sign. Tip #22 (secrets) is the fix, with a caveat worth being honest about.
Postgres natively supports the file convention: hand it POSTGRES_PASSWORD_FILE and it reads the password from a mounted file under /run/secrets:
postgres:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
secrets:
db_password:
file: ./secrets/db_password.txt
The caveat: Alfresco, Share, and Solr take some credentials embedded in -D JVM flags, so they cannot read a secret file without a custom entrypoint. For those, the realistic intermediate step is to keep the values in the gitignored .env rather than baking them into a committed YAML file. That is a real improvement over the sample even if it is not a mounted secret.
When you are ready, wrap those services with an entrypoint that reads from /run/secrets/* and builds the JVM flags at container start, or move to externally managed secrets backed by your runtime platform.
Either way: real passwords should not live in a tracked file.
The sample repeats mem_limit, logging intentions, restart hints, and JVM flags across seven services. Tip #17 (YAML anchors) and Tip #27 (x- extension fields) let you declare each once:
x-logging: &default-logging
driver: json-file
options: { max-size: "10m", max-file: "3" }
x-restart: &restart-policy
restart: unless-stopped
x-hardening: &hardening
security_opt: ["no-new-privileges:true"]
services:
alfresco:
<<: [*restart-policy, *hardening]
logging: *default-logging
docker compose config expands these so you can confirm every service really got them.
Three small production essentials, three tips:
json-file driver grows without bound until it fills the disk. The &default-logging anchor above caps each service at 3 x 10 MB.restart: unless-stopped through the &restart-policy anchor, so the stack survives a daemon restart but still honors a deliberate docker compose stop.docker compose up, mem_limit is what gets enforced - it maps straight to the container --memory flag:alfresco:
mem_limit: 1900m
The deploy.resources.limits block is the orchestrator-era equivalent; if you never run this under Swarm or Kubernetes, mem_limit alone is enough and avoids carrying two syntaxes that say the same thing. If you see exit code 137, that is the OOM killer. Raise the limit.
The sample already uses health-gated depends_on in places: Tip #3 done right. We extend it so the repository waits for its hard dependencies to be genuinely ready, not merely started:
alfresco:
depends_on:
postgres: { condition: service_healthy }
activemq: { condition: service_healthy }
transform-core-aio: { condition: service_healthy }
We also give Solr a readiness probe, so you have an explicit signal for when the search service is ready.
Then, for CI and scripts, Tip #51 (up --wait) replaces every sleep 60 workaround. It blocks until every healthchecked service reports healthy and exits non-zero if one does not:
docker compose up --wait --wait-timeout 300
A JVM repository flushing content and a Postgres instance checkpointing both need more than the default 10-second SIGKILL window. Tip #18 (stop_grace_period) covers that:
alfresco:
stop_grace_period: 2m
postgres:
stop_grace_period: 1m
And the two services that open thousands of files - the repository for content store and connections, and Solr for index segments - get raised nofile limits. Postgres gets the shared-memory bump it likes. That comes from Tip #63 (ulimits / shm_size😞
solr6:
ulimits:
nofile: { soft: 65535, hard: 65535 }
postgres:
shm_size: 256mb
${REPO_TAG} pins the tag, but tags are mutable. For production, Tip #67 (pull_policy) plus Tip #55 (config --resolve-image-digests) pin the actual image bytes:
docker compose config --resolve-image-digests > compose.lock.yaml
That rewrites every image: to name@sha256:.... Combined with pull_policy: missing, prod will never silently pick up a re-pushed tag.
Now the structure. The Tips give a clean three-way split (Tip #37, Tip #38, Tip #39😞
include (Tip #30) for a self-contained, reusable sub-stack. The Traefik proxy and its routing labels live in commons/base.yaml, the same layout the upstream repo uses, and the main file pulls them in:include:
- path: commons/base.yaml
extends for single-service config sharing. Each UI service inherits its Traefik labels from commons/base.yaml:share:
extends: { file: commons/base.yaml, service: share }
Remember Tip #37's gotcha: extends does not carry depends_on, networks, or volumes. Those stay explicit in the main file.
compose.override.yaml is merged automatically and is where the dev-only ports go, bound to localhost per Tip #73:# compose.override.yaml - laptop only
postgres:
ports: ["127.0.0.1:5432:5432"]
solr6:
ports: ["127.0.0.1:8083:8983"]
Production bypasses the override with an explicit file chain:
docker compose -f compose.yaml -f compose.prod.yaml up -d --wait
Plenty of Alfresco deployments are headless: another application drives the repository over its REST API and never opens a browser-based UI. CI runs the same way. So the three web UIs - Share, the ACA content app, and the Admin Control Center - are exactly the kind of optional component Tip #24 (profiles) is for. Put all three behind one profile:
share:
profiles: ["ui"]
content-app:
profiles: ["ui"]
control-center:
profiles: ["ui"]
Now docker compose up brings up the engine only: repository, Postgres, Solr, ActiveMQ, the transform engine, and the proxy. Its REST API is immediately usable. docker compose --profile ui up adds all three UIs when a human needs them.
One word of warning, because it looks like a bug the first time: with the UIs profiled out, hitting /share/ or /content-app/ through the proxy returns 404, not a friendly error. Traefik simply has no router for a service that was never started. That is the profile working as designed, not a misconfiguration.
Starting from a sample that published four services to the world with five plaintext passwords, a handful of tips got us to:
internal network with no route off-host, with Traefik pinned to the reachable network on the multi-homed repository.--wait for CI.ulimits/shm_size.include/extends/override structure with dev vs. prod separation and optional UIs via profiles.None of it changed what Alfresco does. All of it changed whether you would be comfortable running it somewhere real. Validate the whole thing the way you started:
docker compose config
Then ship it.
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.