26 April 2026 · 20 min read
Liferay DXP — Modern Architecture Playbook
Field manual for Client Extensions, Headless APIs, OAuth, workspaces, and shipping without bricking prod.
The Complete Liferay DXP Development Playbook
Modern architecture from zero to production. Liferay 7.4 / DXP 2024.Q4+. Written for senior developers and portal architects — direct, opinionated, scar-tissue informed.
Twelve sections, in order. DXP architecture, master templates, style books, fragment anatomy, web content & structures, custom JS/CSS, Liferay Objects, Asset Publisher, page types, navigation, permissions, CI/CD. Each section has what it is, when to use it, how it works, and a pro tip from production.
01 · Liferay DXP architecture overview
Before you touch a fragment, understand the spine. Liferay is not a CMS bolted onto a Java app server — it is a layered runtime where the browser, fragment renderer, portlet container, service layer and persistence each own a contract. Break a contract and your portal will rot from the inside out.
The seven layers, top to bottom
| Layer | Owns | Stack |
|---|---|---|
| Edge / CDN | TLS · WAF · static cache | CloudFront · Akamai |
| Frontend | Browser markup + tokens | Clay UI · Style Book CSS vars · Fragment HTML · ES modules |
| Fragment runtime | Server FreeMarker pass | ${configuration.*} · editable mappings · collection providers |
| Portlet container | OSGi bundles · MVC | MVCPortlet · Headless · SPA · friendly URLs |
| Service layer | Business APIs | Service Builder · Object services · Headless Delivery / Admin / Batch / GQL |
| Integration bus | Async · scheduled work | Message Bus · Quartz · Object Actions · Batch Engine |
| Data & cache | Persistence + speed | RDBMS · Document Library · Hazelcast / Redis · Search index |
Liferay DXP — layered runtime
Browser → edge → portal → services → persistence
Browser
HTML · JS · Clay
CDN / WAF
Cache · TLS · WAF
Liferay Portal
Tomcat · OSGi
Fragment runtime
FreeMarker pass
Editables · ADT
Service layer
SB · Objects
Headless · GQL
Search index
Elasticsearch
OpenSearch
RDBMS · cache · DLAPP
PostgreSQL · S3 · Redis
Document Library · Hazelcast
Request lifecycle, end to end
What actually happens on every GET /web/site/about:
- Browser sends TLS request.
- CDN serves cached HTML if hot, otherwise misses to origin.
- Liferay portal handles the request inside Tomcat/OSGi.
- Fragment Runtime runs the FreeMarker pass and resolves editables.
- Service Layer is called for any data the fragments or portlets need.
- Search index, RDBMS and cache are queried in parallel.
- Response bubbles back: service → fragment → portal → CDN → browser.
02 · Master page templates
Master Templates are the load-bearing wall of the portal. They define the chrome — header, footer, skip links, locked branding zones — that every Content Page inherits. Ship a portal without one and every page becomes a snowflake.
How a master wraps a content page
Master page template
Locked chrome · editable drop zone · one canvas per content page
Header · locked
Logo · primary nav · search · auth
Hero fragment (editable)
Mapped to Web Content title + summary
Body fragment
Two-column section
Asset Publisher
Sidebar list
Footer · locked
Utility nav · localization · legal
Exactly one drop zone per master. Lock zones by default; unlock only when you mean it.
A master is a Page Template with type master. Mark the chrome rows as locked through the page builder right rail (or via the locked attribute in the page definition JSON when shipping as a Site Initializer).
{
"settings": { "masterPage": true },
"pageElement": {
"type": "Root",
"pageElements": [
{
"type": "Section",
"definition": { "locked": true },
"pageElements": [
{ "type": "Fragment", "definition": {
"fragmentKey": "site-header",
"fragmentConfig": { "sticky": true, "variant": "glass" }
}}
]
},
{ "type": "DropZone" },
{
"type": "Section",
"definition": { "locked": true },
"pageElements": [
{ "type": "Fragment", "definition": { "fragmentKey": "site-footer" }}
]
}
]
}
}The drop zone is rendered as <lfr-drop-zone></lfr-drop-zone> in the master's root fragment. Liferay enforces exactly one drop zone per master.
03 · Style Books
A Style Book is the design system contract between brand, marketing and engineering — codified as overrideable CSS variables and made available to every fragment, master and page. Done right, the entire site re-themes from one form.
Token flow · from JSON to pixel
| # | Stop | Lives in | Form |
|---|---|---|---|
| 1 | Definition | frontend-token-definition.json | Theme module |
| 2 | Style Book | --brand-primary: #1f4ad6 | Site Builder UI / JSON |
| 3 | Theme CSS | Clay/site mappings using the variable | Theme CSS extension |
| 4 | Fragment CSS | color: var(--brand-primary) | fragments/**/styles.css |
{
"frontendTokenCategories": [
{
"label": "Colors", "name": "colors",
"frontendTokenSets": [
{ "label": "Brand", "name": "brand",
"frontendTokens": [
{ "name": "brand-primary", "label": "Brand Primary",
"type": "Color", "defaultValue": "#1f4ad6",
"mappings": [
{ "type": "cssVariable", "value": "primary" },
{ "type": "cssVariable", "value": "link-color" }
]
}
]
}
]
},
{
"label": "Typography", "name": "typography",
"frontendTokenSets": [
{ "label": "Display", "name": "display",
"frontendTokens": [
{ "name": "font-display", "label": "Display Font",
"type": "FontFamily",
"defaultValue": "'Fraunces', Georgia, serif",
"mappings": [{ "type": "cssVariable", "value": "font-family-display" }]
}
]
}
]
}
]
}A Style Book is then created in Site Builder → Design → Style Books, picks values for those tokens, and is assigned to a Page (or set as the default for the site). At render time Liferay emits:
<style id="_style_book_css">
:root {
--primary: #1f4ad6;
--link-color: #1f4ad6;
--font-family-display: 'Fraunces', Georgia, serif;
--spacer-3: 1rem;
--spacer-5: 2.5rem;
}
</style>How fragments consume the tokens
/* fragments/hero/styles.css */
.hero__title {
font-family: var(--font-family-display);
color: var(--primary);
margin-block-end: var(--spacer-5);
}04 · Fragment architecture
Fragments are the unit of composition for content pages. Each fragment is a self-describing bundle of HTML + CSS + JS + Configuration, with editable hot spots that map to Web Content, Objects, or static text. Get the anatomy right and you can hand the keys to marketing without losing sleep.
Anatomy · the four files of every fragment
| File | Role | Boundaries |
|---|---|---|
index.html | Markup | data-lfr-editable-id, data-lfr-editable-type, FreeMarker ${configuration.*}, <lfr-drop-zone> only in section fragments. |
styles.css | Scoped CSS | Only var(--token) from the Style Book. Single class root scope. No !important wars. |
main.js | Behaviour | Runs once per instance with fragmentElement, configuration, fragmentEntryLinkNamespace. Idempotent. |
configuration.json | Editor fields | Text · select · checkbox · color · length · item-selector. Drives configuration.* in HTML/JS. |
{
"category": "Marketing",
"cssPath": "styles.css",
"htmlPath": "index.html",
"jsPath": "main.js",
"configurationPath": "configuration.json",
"name": "CTA Banner",
"type": "component",
"typeOptions": { "lazy": true }
}{
"fieldSets": [{
"label": "Banner",
"fields": [
{ "name": "variant", "label": "Variant", "type": "select",
"defaultValue": "solid",
"typeOptions": { "validValues": [
{ "value": "solid", "label": "Solid" },
{ "value": "outline", "label": "Outline" },
{ "value": "glass", "label": "Glass" }
]}
},
{ "name": "alignment", "label": "Alignment", "type": "select",
"defaultValue": "start",
"typeOptions": { "validValues": [
{ "value": "start", "label": "Start" },
{ "value": "center", "label": "Center" }
]}
}
]
}]
}<section class="cta cta--${configuration.variant} cta--${configuration.alignment}">
<h2 data-lfr-editable-id="cta-title" data-lfr-editable-type="text">
Ship something the rest of the org can run.
</h2>
<p data-lfr-editable-id="cta-body" data-lfr-editable-type="rich-text">
Drop a one-liner that earns the click.
</p>
<a class="cta__btn"
data-lfr-editable-id="cta-action" data-lfr-editable-type="link"
href="#">Get started</a>
</section>(function () {
const root = fragmentElement.querySelector('.cta');
if (!root || root.dataset.bound === '1') return;
root.dataset.bound = '1';
const btn = root.querySelector('.cta__btn');
btn?.addEventListener('click', function (e) {
Liferay.fire('analytics:cta-click', {
variant: configuration.variant,
href: btn.href
});
});
})();Mapping a fragment field to Web Content
Editables become first-class mapping points. In the page editor, an editor selects an editable, picks Map to Content and binds it to a Web Content article's field (e.g. title on a Press Release structure). On render, the fragment is reused while the data is supplied by the chosen article — no fragment changes required.
Fragment sets & deployment
A Fragment Set is a directory of fragments shipped together. The Fragment Toolkit (CLI) lints, packages and uploads:
# scaffold
yarn create liferay-fragment my-fragments
# dev import on save
cd my-fragments && yarn import --host localhost:8080 --watch
# prod build → ZIP for upload via UI or CI
yarn build && ls dist/*.zip05 · Web Content & Structures
Structures are the schema of your editorial content; Templates are the projection. Display Page Templates are how a single article becomes a fully laid-out page. Master this triangle and your content team will never beg engineering to "just add a field" again.
Triangle of content
Structure · template · display page
Schema → projection → SEO-friendly URL
Structure · DDM
title · summary
body · hero · tags
Template · FTL
Renders an article
Used by WC portlet
Display Page Template
Structure → master + content page
/web/site/-/<slug>
A real structure
{
"availableLanguageIds": ["en_US"],
"definition": {
"defaultLanguageId": "en_US",
"fields": [
{ "name": "title", "type": "text", "required": true, "localizable": true },
{ "name": "summary", "type": "text_area", "required": true },
{ "name": "hero", "type": "image", "required": false },
{ "name": "body", "type": "html", "required": true },
{ "name": "category", "type": "select",
"options": [
{ "value": "product", "label": "Product" },
{ "value": "company", "label": "Company" },
{ "value": "partner", "label": "Partner" }
]
},
{ "name": "publishDate", "type": "date", "required": true }
]
}
}FreeMarker template — render a single article
<article class="press">
<header class="press__head">
<p class="press__cat">${category.getData()}</p>
<h1>${title.getData()}</h1>
<time datetime="${publishDate.getData()}">
${publishDate.getData()?date}
</time>
</header>
<#-- hero is optional -->
<#if hero.getData()?has_content>
<img class="press__hero" src="${hero.getData()}" alt="" />
</#if>
<p class="press__lede">${summary.getData()}</p>
<div class="press__body">${body.getData()}</div>
</article>Mapping fields to fragment editables
On a Display Page Template, drop the same generic fragments you use elsewhere (Hero, Rich Text, Image) and bind their editables to title, summary, body, hero via the right rail. The same fragment renders 1 000 articles — one fragment, one structure, infinite content.
06 · Adding custom JS & CSS · four ways, one decision
There are exactly four sanctioned places to inject custom CSS or JS into a Liferay site. Each one has a blast radius, a cache profile, and a maintainability cost. Pick wrong and you will fight the platform forever.
Decision matrix
| Method | Scope | Cache | Versioned? | Use it for |
|---|---|---|---|---|
| 1 · Theme CSS Client Extension | Site / page set | Long, hashed | Git | Brand stylesheet, base typography reset, Clay overrides shared by every page. |
| 2 · Style Book token override | Page / page set | Embedded inline | Page def | Brand variants without code: change colors / spacing / fonts via a form. |
| 3 · Fragment-level inline CSS / JS | Single fragment instance | Inlined per page | Fragment repo | Behaviour or styling that only makes sense for this fragment's markup. |
| 4 · System-level Custom JS / CSS | Whole portal / instance | Configurable | .config files | Tiny snippets: GTM, consent banner, polyfills, security headers JS. |
1 · Theme CSS Client Extension
# client-extensions/papu-theme-css/client-extension.yaml
papu-theme-css:
type: themeCSS
name: "Papu Theme CSS"
url: papu-theme-css.css/* loaded as <link rel=stylesheet> on every site page */
:root { --radius-pill: 999px; }
body { font-family: var(--font-family-base); }
.btn-primary { border-radius: var(--radius-pill); }2 · Style Book override
Already covered in chapter 03. This is the no-code lane: editors change a token in a form, every fragment reflects it instantly. Only route brand variants here.
3 · Fragment inline CSS & JS
Use when the styling or behaviour is meaningless without this specific markup. Lives inside the fragment's styles.css and main.js — scoped to the fragment instance and shipped as part of the fragment set.
4 · Portal-wide via System Settings
Configured at Control Panel → System Settings → Themes → Theme Settings → Top HTML / Bottom HTML, or via OSGi .config files for portal-wide injection. Ship as a .config file in your CI artifact.
# configs/com.liferay.portal.instance.lifecycle.PortalInstance...config
topHTML="<script async src='https://www.googletagmanager.com/gtag/js?id=GTM-XXX'></script>"
bottomHTML="<script>window.dataLayer=window.dataLayer||[];</script>"07 · Liferay Objects
Objects are Liferay's low-code answer to Service Builder. You define a typed entity in the UI (or as JSON), and the platform auto-generates the table, the REST + GraphQL API, the audit log, the permission model, and the form widgets. Use them for application data; reach for Service Builder only when you must.
Object vs Service Builder
| Concern | Object | Service Builder |
|---|---|---|
| Time to first record | Minutes (UI / JSON) | Days (Java + XML) |
| Custom Java logic | Object Actions + Groovy scripts | Full local + remote services |
| Complex transactions | Limited | First-class |
| Auto REST API | Yes, versioned | Manual (Headless module) |
| Multi-VM cache fine-tuning | Platform default | Configurable |
| When to choose | Default. Application data, forms, dashboards. | Heavy logic, large transactions, deep integrations. |
Define an object · job posting
{
"name": "JobPosting",
"label": { "en_US": "Job Posting" },
"pluralLabel": { "en_US": "Job Postings" },
"scope": "site",
"status": "approved",
"objectFields": [
{ "name": "title", "type": "String", "required": true,
"label": { "en_US": "Title" } },
{ "name": "slug", "type": "String", "required": true, "indexed": true },
{ "name": "location", "type": "String" },
{ "name": "remote", "type": "Boolean" },
{ "name": "salaryMin", "type": "Integer" },
{ "name": "description", "type": "Clob",
"businessType": "RichText" },
{ "name": "openUntil", "type": "Date" }
],
"objectValidations": [
{ "engine": "ddm", "errorLabel": { "en_US": "Salary must be positive" },
"script": "isEmpty(salaryMin) || salaryMin > 0" }
]
}Auto-generated REST surface
| Method | Path | Purpose |
|---|---|---|
| GET | /o/c/jobpostings?filter=remote eq true&page=1&pageSize=20 | List + filter (OData) |
| GET | /o/c/jobpostings/{id} | Read one |
| POST | /o/c/jobpostings | Create |
| PATCH | /o/c/jobpostings/{id} | Partial update |
| DELETE | /o/c/jobpostings/{id} | Delete |
| POST | /o/c/jobpostings/batch | Bulk via Batch Engine |
Surfacing object data in a fragment · two routes
Route A · Collection Provider (server-rendered). Drop a Collection Display fragment on the page, pick Job Posting as the source, choose a fragment template (per item) and let Liferay render server-side. No JS, indexable, fast TTFB.
Route B · Headless from fragment JS (dynamic).
(async function () {
const root = fragmentElement.querySelector('.jobs');
if (!root || root.dataset.bound) return;
root.dataset.bound = '1';
const res = await Liferay.Util.fetch(
'/o/c/jobpostings?filter=remote eq true&pageSize=10',
{ headers: { 'Accept': 'application/json' }}
);
const data = await res.json();
root.innerHTML = data.items.map(j => `
<a class="job" href="/web/site/-/${j.slug}">
<h3>${j.title}</h3>
<p>${j.location} ${j.remote ? '· remote' : ''}</p>
</a>`).join('');
})();08 · Asset Publisher vs alternatives
Asset Publisher is the Swiss army knife for surfacing assets — Web Content, Documents, Blogs, Objects — on a page. It is old, it is flexible, and it is often misused. Knowing when not to reach for it is half the win.
Decision tree · pick the right surface mechanism, in this order
| # | Question | Pick |
|---|---|---|
| 1 | Single article on its own URL? | Display Page Template bound to the structure or object |
| 2 | List with filtering, paging, manual override? | Asset Publisher with ADT |
| 3 | Highly bespoke UX, custom logic? | Custom MVCPortlet or React Client Extension |
| 4 | Cards driven by Object data on a Content Page? | Collection Page / Collection Display fragment |
| 5 | Pure SEO landing list, content-team owned? | Asset Publisher (manual mode) embedded in Content Page |
ADT — Application Display Template
<ul class="cards">
<#list entries as entry>
<#assign r = entry.getAssetRenderer() />
<li class="card">
<a href="${assetPublisherHelper.getAssetViewURL(renderRequest, renderResponse, entry)}">
<#if r.getThumbnailPath(renderRequest)?has_content>
<img src="${r.getThumbnailPath(renderRequest)}" alt="" />
</#if>
<h3>${r.getTitle(locale)}</h3>
<p>${r.getSummary(renderRequest, renderResponse)}</p>
</a>
</li>
</#list>
</ul>Configuration cheatsheet
| Setting | Pick | Why |
|---|---|---|
| Scope | current site / global / specific site | Multi-site portals: be deliberate |
| Asset selection | Dynamic (filters) vs Manual (curated) | Live lists vs editorial control |
| Filter rules | type + categories + tags + custom fields | Use Preview Asset Selection button |
| Order & group | date · modification · priority · view count · structure field | Typed assets unlock structure-field sorting |
| Display style | per-purpose ADT (cards / list / compact) | Avoid one mega-template that does everything |
09 · Page types & when to use each
Not every page is a Content Page. Liferay ships five page archetypes; using the right one keeps the editor honest, the URL clean and the renderer fast.
| Type | What it is | Ideal for | Editor |
|---|---|---|---|
| Content Page | Drop-zone canvas built from fragments + master | Marketing pages, landings, articles, almost everything | Marketing |
| Widget Page | Legacy column layout hosting portlet widgets | Admin tools, dashboards, Document Library, Forums | Site admin |
| Display Page Template | Page bound to a structure or object → renders one record | News article, job posting, product detail | Site builder |
| Collection Page | Page bound to a collection (Web Content, Object) → list view | News index, job board, knowledge base index | Site builder |
| Utility Page | Page used as a system endpoint (404, sign-in, logout, search) | Error pages, login screens, search results layout | Site admin |
10 · Navigation menus
A Navigation Menu is a versionable, permissionable, localizable IA artifact — not just "the header links". Treat it as content, manage it like content.
Anatomy of a menu
Navigation menu · primary
Curated IA tree rendered by the nav widget in the master header
├── Page ─ Home
├── Page ─ Solutions ▸
│ ├── Page ─ For Architects
│ └── Page ─ Playbook
├── Submenu ─ Resources ▸
│ └── URL ─ github.com/codepapu
└── URL ─ status.papu.dev
Nav widget
Site Navigation Menu
Horizontal · primary
Embedding the menu in a master template header
<header class="site-header">
<a class="site-header__brand" href="/">
<img src="[resources:logo.svg]" alt="code.papu" />
</a>
<!-- the navigation menu widget is dropped INTO the fragment via the page editor -->
<div class="site-header__nav"
data-lfr-widget-id="com_liferay_site_navigation_menu_web_portlet_SiteNavigationMenuPortlet">
<!-- editor binds menu = primary, display = horizontal -->
</div>
<a class="site-header__cta" href="/web/site/contact">Buzz me</a>
</header>Inside the master, lock that section so editors cannot accidentally remove the navigation widget. The widget itself can be styled with the Style Book token nav-link-color and a navigation ADT for sub-mega-menu layouts.
11 · Permissions architecture
Liferay's permission model is one of the most powerful in the portal world — and one of the easiest to misconfigure. The mental model is just three words: Roles → Permissions → Resources. Everything else is plumbing.
Three role scopes
| Role type | Scope | Use for |
|---|---|---|
| Regular Role | Portal-wide (all sites + global resources) | Cross-site administrators, portal-level platform engineers, support staff |
| Site Role | Within one site only (assignment per site) | Site editor, site reviewer, site approver. Most editorial roles live here |
| Organization Role | Within one organization tree | Multi-tenant portals, partner portals, B2B account-scoped admins |
Permission matrix · a real site
| Resource / Action | Guest | User | Site Member | Site Editor | Site Admin |
|---|---|---|---|---|---|
| Web Content · view published | ✓ | ✓ | ✓ | ✓ | ✓ |
| Web Content · edit / submit workflow | — | — | — | ✓ | ✓ |
| Web Content · approve workflow | — | — | — | ⚠ assigned | ✓ |
| Object · view record | ⚠ public | ✓ | ✓ | ✓ | ✓ |
| Object · update / delete | — | ⚠ owner | ⚠ owner | ✓ | ✓ |
| Page · configure (drop-zone, master) | — | — | — | — | ✓ |
| Style Book · edit | — | — | — | — | ✓ |
| Permissions · manage roles | — | — | — | — | ⚠ portal admin |
Defining roles as code
{
"name": "Site Editor",
"type": "site",
"permissions": [
{ "resource": "com.liferay.journal",
"actions": ["VIEW", "ADD_ARTICLE", "UPDATE"] },
{ "resource": "com.liferay.journal.model.JournalArticle",
"actions": ["VIEW", "UPDATE", "EXPIRE"] },
{ "resource": "com.liferay.object.model.ObjectDefinition#JobPosting",
"actions": ["VIEW", "UPDATE"] }
]
}Protecting an Object record
Objects support per-object permissions (Object level + record level). When defining the object, set enableObjectEntryHistory and pick the action keys you want to expose. Then assign VIEW, UPDATE, DELETE to roles in the Object's Permissions tab. For record-level scoping, add an owner field and add an Object Action / validation that restricts updates to the owner.
12 · CI/CD & deployment
Liferay can be deployed by hand. Do not. Treat the portal like any other JVM service: build artifacts in CI, deploy declaratively, version everything that is editable as code, and reserve UI clicks for runtime configuration only.
Pipeline at a glance · from Git push to running portal
| Step | Stage | What happens |
|---|---|---|
| 01 | Git push | monorepo · modules · client extensions · fragments · site initializer |
| 02 | Gradle build | workspace · OSGi modules → JARs · LPKG bundle |
| 03 | Fragment + CX zip | fragment toolkit · client-extension zips · .config files |
| 04 | Artifact registry | Nexus / Artifactory / S3 · immutable tag per commit |
| 05 | Deploy | DXP Cloud · or Helm / Ansible to self-hosted |
| 06 | Smoke + audit | health · cache warmup · permission diff · search reindex |
Recommended repository layout
liferay-workspace/
Recommended monorepo layout for modules, CX, fragments, and site initializer
liferay-workspace/
├── modules/ # OSGi service builder + portlet modules
├── themes/ # theme + frontend-token-definition.json
├── client-extensions/ # theme-css, custom-element, JS extensions
├── fragments/ # fragment sets (toolkit-managed)
├── site-initializer/ # roles, structures, objects, page templates
│ ├── pages/
│ ├── page-templates/
│ ├── structures/
│ ├── objects/
│ └── roles/
├── configs/ # .config files for System Settings
│ ├── common/
│ ├── dev/
│ ├── uat/
│ └── prod/
├── docker/ # Dockerfile, dxp.properties, license
├── settings.gradle
└── gradle.properties
Gradle build & LPKG packaging
# .github/workflows/build.yml
name: build
on: { push: { branches: [main, release/**] } }
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with: { java-version: 21, distribution: temurin }
- name: Build modules + LPKG
run: ./gradlew clean distBundleZip
- name: Build fragments
run: yarn --cwd fragments build
- name: Build client extensions
run: ./gradlew clientExtensionBuild
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: liferay-${{ github.sha }}
path: |
build/dist/*.lpkg
fragments/dist/*.zip
client-extensions/dist/*.zip
configs/${{ github.ref_name }}/*.configDeploy targets
A · DXP Cloud. Push the artifacts to the DXP Cloud Git repo (or its liferay/configs registry). DXP Cloud picks them up, runs the Liferay container with your overrides, hot-deploys modules, and applies .config files at startup. Site Initializer runs on first boot of a fresh environment.
B · Self-hosted (Kubernetes).
# helm-values/prod.yaml
image: liferay/dxp:7.4.13-u110
extraDeployments:
- "oci://registry.papu.dev/liferay/papu-portal-bundle:1.42.0"
configMaps:
liferay-configs:
mountPath: /opt/liferay/osgi/configs
files:
- configs/prod/*.config
env:
LIFERAY_JDBC_DEFAULT_URL: jdbc:postgresql://pg/papu
LIFERAY_CLUSTER_LINK_ENABLED: "true"
LIFERAY_ELASTICSEARCH_ENABLED: "true"Fragment Toolkit deploy — CI version
# in CI, after build, push fragments to Liferay over the public API
yarn --cwd fragments import \
--host https://liferay.${ENV}.papu.dev \
--user "${LIFERAY_CI_USER}" \
--password "${LIFERAY_CI_PASS}" \
--groupId "${SITE_GROUP_ID}" \
--overrideClient Extension deployment
Each Client Extension builds into a versioned ZIP. Drop it in $LIFERAY_HOME/osgi/client-extensions/ on self-hosted (or push the artifact in DXP Cloud). The extension auto-registers on hot deploy. Use oauth-application-headless-server.config in configs/ if it needs portal API access.
Configuration as .config files
# configs/prod/com.liferay.captcha.configuration.CaptchaConfiguration.config
maxChallenges=1
captchaEngine="reCAPTCHA"
reCaptchaPublicKey="${RECAPTCHA_PUB}"
reCaptchaPrivateKey="${RECAPTCHA_PRIV}"One .config per OSGi service PID. Liferay reads them at startup and on hot reload. Secret values are interpolated from environment variables — never commit raw secrets.
Appendix · 50-year cheatsheet
- Architecture first. Draw the seven layers before opening an IDE.
- Masters are the spine. 1–3 per site, locked by default, drop zone in the middle.
- Style Book or it didn't happen. Zero color literals in fragment CSS.
- Fragments stay dumb. Rich behaviour is a Client Extension custom element.
- Structures for editorial. Objects for application data. Don't bend either.
- Display Page Templates > bespoke portlets for "single record per URL".
- Asset Publisher with one ADT per purpose — never a mega-template.
- Right page type, right URL. Content / Display / Collection / Widget / Utility.
- Navigation is content. Curated menus, not page hierarchy in production.
- Permissions in code. If it's clicked in the UI, it's drift.
- CI deploys; humans approve. Six pipeline stages or you're cosplaying DevOps.
- Drift detection > documentation. Make reality reconcile to the repo, not the wiki.