Client Extensions & Workspace Integration Guide¶
Note
This guide details how Liferay Docker Manager (LDM) discovers, links, routes, and hot-reloads Liferay Client Extensions (CX) and Liferay Workspace projects.
๐ 1. Linking a Client Extension Workspace (ldm link)¶
To attach a local Liferay Workspace or standalone Client Extension directory to a running Liferay container, run:
When ldm link is executed, LDM automatically performs the following actions:
- Workspace & CX Discovery: Scans the directory for
client-extension.yaml,LCP.json, andbnd.bndconfigurations. - Subdomain Route Generation: Parses
client-extension.yamlandLCP.jsonto generate virtual subdomains (e.g.http://my-cx.my-project.local:8080). - Container Link Storage: Persists the link mapping in
.liferay-docker.metaso the extension automatically re-attaches across container restarts. - File Watcher Initialization: Launches a background file watcher to synchronize built assets (
build/libs/*.zipordist/) into Liferay's auto-deploy directory.
โก 2. Server-Side Client Extensions (SSCE)¶
Server-Side Client Extensions (such as Spring Boot OAuth2 resource servers or Node.js microservices) require dedicated container sidecars.
LDM manages SSCE containers out of the box:
LCP.jsonParsing: Reads port specifications, environment variables, and memory limits fromLCP.json.- Dynamic Docker Compose Sidecars: Generates dynamic Compose fragments to spin up the SSCE microservice alongside Liferay on the same internal container network.
- Automated OAuth2 ERC Wiring: Injects Liferay OAuth2 External Reference Codes (ERC) so the microservice and Liferay authenticate seamlessly.
Your extension's config tree can be rewritten after it starts¶
LDM points your extension at LIFERAY_ROUTES_CLIENT_EXTENSION and
LIFERAY_ROUTES_DXP, and Liferay โ not LDM โ writes what is inside them.
It rewrites that tree when it deploys a client extension, which can be minutes
after your container started and read it.
That matters because the tree carries the OAuth2 credentials Liferay generates when it registers your application. An extension that resolves its credentials once at startup and caches the result for the life of the process will keep working until its access token expires, then fail to renew and never recover โ the cached client id no longer matches what Liferay has.
The failure is unpleasant to diagnose: authentication works for several minutes, then stops permanently, with nothing in the logs at the moment anything changed.
Re-resolve on an authentication failure rather than caching for the process lifetime. Re-reading the tree on a 401 is correct whether or not the rewrite happened, and costs nothing when it did not.
Note
There is no event or signal for this today. LDM tells your container the two paths and nothing else, so failure-driven re-resolution is the mechanism available โ and it is the right one regardless, because it also covers a redeploy later in your service's life.
The directory is named from the extension's projectName, not the id in
its LCP.json. The two differ for most extensions, and reading the id-named
path finds a real, empty, readable directory rather than an error
(LDM-#1944).
LDM rewrites localhost OAuth URLs as it imports your extension¶
Your extension's client-extension.yaml (or its
*.client-extension-config.json) is written for local development, so its
.serviceAddress, homePageURL and redirectURIs normally name localhost.
When the project has a real host name, LDM rewrites those to the externally
reachable URL while importing the zip, and bumps buildTimestamp so Liferay
re-evaluates the extension instead of reusing what a restored snapshot
database already holds.
Both forms are rewritten -- http://localhost:8080/callback and
http://localhost/callback alike. Only the localhost origin is
replaced, so a host that merely contains the label, such as
https://app.localhost.example.com/callback, is deliberately left as it is.
If a URL LDM is responsible for still points at localhost once the rewrite
has run, it now says so:
WARNING: These OAuth URLs in 'my-ext' still point at localhost after the
rewrite to http://my-ext.my-host.local: https://app.localhost.example.com/cb
That is a warning rather than an error, because the URL may well be correct.
It exists because the rewrite used to skip any URL it could not match and
report nothing at all, leaving the extension pointing at localhost with no
indication of why (LDM-#2175).
๐ 3. Live Hot-Reloading Workflow¶
LDM uses an atomic file synchronization pipeline to eliminate deployment race conditions:
- Staged Copy: Built archives are written to a temporary staging buffer outside the active deploy scanner path.
- Permission Fixing: Applies POSIX permissions (
chmod 666,chown 1000:1000) so the internal Liferay process (liferayuser) can read the deployment archive immediately. - Atomic Move: Atomically moves the archive into the
deploy/directory, preventing Liferay'sAutoDeployScannerfrom reading incomplete zip headers.
To monitor live deployment logs and hot-reloading events:
4. Site Initializers Are Deployed After the First Boot¶
A site-initializer client extension is the one CX type LDM does not hand
to Liferay during ldm import. It is staged under
<project>/.ldm/deferred-client-extensions/ and copied into
osgi/client-extensions/ once the portal reports healthy.
Why¶
Measured on a live DXP 2026.q3.0. When a site initializer is already present
in osgi/client-extensions/ as the portal starts against an empty database,
SiteInitializerClientExtension's bundle tracker opens before the built-in
welcome and cms site initializers have created the Guest site's layouts,
and ServiceContextFactory needs one:
ERROR bundle com.liferay.site.initializer.extender:1.0.147
[SiteInitializerClientExtension(4567)] : The activate method has thrown an exception
java.lang.RuntimeException: java.lang.NullPointerException:
Cannot invoke "com.liferay.portal.kernel.model.Layout.getGroupId()" because "layout" is null
at com.liferay.portal.util.PortalImpl.getCanonicalURL(PortalImpl.java:1556)
at com.liferay.portal.kernel.service.ServiceContextFactory._getInstance(ServiceContextFactory.java:176)
at ...SiteResourceImpl.putSiteSiteInitializer(SiteResourceImpl.java:201)
at ...SiteInitializerClientExtension.addingBundle(SiteInitializerClientExtension.java:94)
The failure is silent. The bundle still logs STARTED, no site is created,
no fragments are imported, no page exists, and the only sign is one stack trace
among several thousand startup lines. The same artifact dropped into the
already-running portal initialises in ~100 ms.
It does not reproduce on a later boot, because
SiteResourceImpl.putSiteByExternalReferenceCode early-returns once a group
with that external reference code exists โ so a project hydrated from a
database snapshot never sees it. Only a fresh project does, which is
exactly what ldm import creates.
How LDM detects one¶
By the header the extender itself tracks โ Liferay-Client-Extension-Site-Initializer
in the built zip's WEB-INF/liferay-plugin-package.properties. Not the file
name, and not the type: siteInitializer line in client-extension.yaml:
that is a source-side descriptor which the Gradle build consumes and does not
place in the artifact.
What you will see¶
...during ldm import, and then, once the portal is healthy:
Scope of the deferral¶
| Path | Behaviour |
|---|---|
ldm import hydration |
Deferred โ the portal has not booted yet |
ldm deploy |
Not deferred โ the portal is already up, which is the moment that works |
ldm dev file monitor |
Not deferred โ same reason |
| Every other CX type | Not deferred โ they are fine where they are |
If you boot with --no-wait, readiness is never reached and the artifact stays
staged until a later ldm run or ldm wait completes. Nothing is lost; the
zip is still in .ldm/deferred-client-extensions/ and travels with a .ldmp
package built from the project.
To deploy a staged initializer by hand into a portal that is already running:
๐ 5. Testing Authorisation¶
Your LDM project is more permissive than a customer's instance. An authorisation check that passes here can fail in production, and the local test cannot tell you.
LDM never sets omniadmin.users, so projects run with Liferay's default:
If the
omniadmin.usersproperty is not set or is empty, users with the Administrator role in the default instance are also considered Omni Admins.
So the Administrator account satisfies isOmniadmin(), and any endpoint gated
on it succeeds. The same code deployed somewhere that sets omniadmin.users
explicitly, or where the caller belongs to a non-default instance, returns
403.
That is a test which cannot fail โ the assertion is fine, the environment guarantees it passes.
Reproducing the stricter posture¶
Set this in files/portal-ext.properties and restart:
Then re-run the check. If it still passes, the gate is genuinely satisfied rather than satisfied by the default.
Worth doing whenever you are testing:
- an OSGi module or client extension that gates on
isOmniadmin() - anything called by a service account rather than an interactive user, since a service account is far less likely to hold Administrator in a real deployment
- a permission cascade, where the branch you think is being exercised may not be
LDM keeps the permissive default deliberately: it matches stock Liferay, and this is a local development and demo sandbox (see Security Posture). The point is not that the default is wrong, but that it is invisible โ and it makes authorisation tests optimistic.
Last Updated: 2026-10-19 | Last Reviewed: 2026-10-19