codepapu
the playbook

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

LayerOwnsStack
Edge / CDNTLS · WAF · static cacheCloudFront · Akamai
FrontendBrowser markup + tokensClay UI · Style Book CSS vars · Fragment HTML · ES modules
Fragment runtimeServer FreeMarker pass${configuration.*} · editable mappings · collection providers
Portlet containerOSGi bundles · MVCMVCPortlet · Headless · SPA · friendly URLs
Service layerBusiness APIsService Builder · Object services · Headless Delivery / Admin / Batch / GQL
Integration busAsync · scheduled workMessage Bus · Quartz · Object Actions · Batch Engine
Data & cachePersistence + speedRDBMS · Document Library · Hazelcast / Redis · Search index

Liferay DXP — layered runtime

Browser → edge → portal → services → persistence

Browser

Browser

HTML · JS · Clay

Edge

CDN / WAF

Cache · TLS · WAF

Portal

Liferay Portal

Tomcat · OSGi

Fragment runtime

Fragment runtime

FreeMarker pass

Editables · ADT

Service layer

Service layer

SB · Objects

Headless · GQL

Search

Search index

Elasticsearch

OpenSearch

Data & cache

RDBMS · cache · DLAPP

PostgreSQL · S3 · Redis

Document Library · Hazelcast

Seven-layer spine: every artifact you ship should map to one of these boxes.

Request lifecycle, end to end

What actually happens on every GET /web/site/about:

  1. Browser sends TLS request.
  2. CDN serves cached HTML if hot, otherwise misses to origin.
  3. Liferay portal handles the request inside Tomcat/OSGi.
  4. Fragment Runtime runs the FreeMarker pass and resolves editables.
  5. Service Layer is called for any data the fragments or portlets need.
  6. Search index, RDBMS and cache are queried in parallel.
  7. 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

MASTER · default

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.

Master · default — header and footer stay locked; editors compose inside the drop zone.

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).

json
{
  "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

#StopLives inForm
1Definitionfrontend-token-definition.jsonTheme module
2Style Book--brand-primary: #1f4ad6Site Builder UI / JSON
3Theme CSSClay/site mappings using the variableTheme CSS extension
4Fragment CSScolor: var(--brand-primary)fragments/**/styles.css
json
{
  "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:

html
<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

css
/* 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

FileRoleBoundaries
index.htmlMarkupdata-lfr-editable-id, data-lfr-editable-type, FreeMarker ${configuration.*}, <lfr-drop-zone> only in section fragments.
styles.cssScoped CSSOnly var(--token) from the Style Book. Single class root scope. No !important wars.
main.jsBehaviourRuns once per instance with fragmentElement, configuration, fragmentEntryLinkNamespace. Idempotent.
configuration.jsonEditor fieldsText · select · checkbox · color · length · item-selector. Drives configuration.* in HTML/JS.
json
{
  "category": "Marketing",
  "cssPath": "styles.css",
  "htmlPath": "index.html",
  "jsPath": "main.js",
  "configurationPath": "configuration.json",
  "name": "CTA Banner",
  "type": "component",
  "typeOptions": { "lazy": true }
}
json
{
  "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" }
        ]}
      }
    ]
  }]
}
html
<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>
js
(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:

bash
# 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/*.zip

05 · 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

Structure · DDM

title · summary

body · hero · tags

Template · FTL

Template · FTL

Renders an article

Used by WC portlet

Display Page Template

Display Page Template

Structure → master + content page

/web/site/-/<slug>

Triangle of content: DDM structure, FreeMarker template, and Display Page Template.

A real structure

json
{
  "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

html
<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

MethodScopeCacheVersioned?Use it for
1 · Theme CSS Client ExtensionSite / page setLong, hashedGitBrand stylesheet, base typography reset, Clay overrides shared by every page.
2 · Style Book token overridePage / page setEmbedded inlinePage defBrand variants without code: change colors / spacing / fonts via a form.
3 · Fragment-level inline CSS / JSSingle fragment instanceInlined per pageFragment repoBehaviour or styling that only makes sense for this fragment's markup.
4 · System-level Custom JS / CSSWhole portal / instanceConfigurable.config filesTiny snippets: GTM, consent banner, polyfills, security headers JS.

1 · Theme CSS Client Extension

yaml
# client-extensions/papu-theme-css/client-extension.yaml
papu-theme-css:
  type: themeCSS
  name: "Papu Theme CSS"
  url: papu-theme-css.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.

properties
# 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

ConcernObjectService Builder
Time to first recordMinutes (UI / JSON)Days (Java + XML)
Custom Java logicObject Actions + Groovy scriptsFull local + remote services
Complex transactionsLimitedFirst-class
Auto REST APIYes, versionedManual (Headless module)
Multi-VM cache fine-tuningPlatform defaultConfigurable
When to chooseDefault. Application data, forms, dashboards.Heavy logic, large transactions, deep integrations.

Define an object · job posting

json
{
  "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

MethodPathPurpose
GET/o/c/jobpostings?filter=remote eq true&page=1&pageSize=20List + filter (OData)
GET/o/c/jobpostings/{id}Read one
POST/o/c/jobpostingsCreate
PATCH/o/c/jobpostings/{id}Partial update
DELETE/o/c/jobpostings/{id}Delete
POST/o/c/jobpostings/batchBulk 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).

js
(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

#QuestionPick
1Single article on its own URL?Display Page Template bound to the structure or object
2List with filtering, paging, manual override?Asset Publisher with ADT
3Highly bespoke UX, custom logic?Custom MVCPortlet or React Client Extension
4Cards driven by Object data on a Content Page?Collection Page / Collection Display fragment
5Pure SEO landing list, content-team owned?Asset Publisher (manual mode) embedded in Content Page

ADT — Application Display Template

html
<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

SettingPickWhy
Scopecurrent site / global / specific siteMulti-site portals: be deliberate
Asset selectionDynamic (filters) vs Manual (curated)Live lists vs editorial control
Filter rulestype + categories + tags + custom fieldsUse Preview Asset Selection button
Order & groupdate · modification · priority · view count · structure fieldTyped assets unlock structure-field sorting
Display styleper-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.

TypeWhat it isIdeal forEditor
Content PageDrop-zone canvas built from fragments + masterMarketing pages, landings, articles, almost everythingMarketing
Widget PageLegacy column layout hosting portlet widgetsAdmin tools, dashboards, Document Library, ForumsSite admin
Display Page TemplatePage bound to a structure or object → renders one recordNews article, job posting, product detailSite builder
Collection PagePage bound to a collection (Web Content, Object) → list viewNews index, job board, knowledge base indexSite builder
Utility PagePage used as a system endpoint (404, sign-in, logout, search)Error pages, login screens, search results layoutSite 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

MENU · primary

├── Page ─ Home

├── Page ─ Solutions ▸

│ ├── Page ─ For Architects

│ └── Page ─ Playbook

├── Submenu ─ Resources ▸

│ └── URL ─ github.com/codepapu

└── URL ─ status.papu.dev

Master header

Nav widget

Site Navigation Menu

Horizontal · primary

MENU · primary — pages, submenus, and URLs bound to the Navigation Menu portlet.

Embedding the menu in a master template header

html
<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 typeScopeUse for
Regular RolePortal-wide (all sites + global resources)Cross-site administrators, portal-level platform engineers, support staff
Site RoleWithin one site only (assignment per site)Site editor, site reviewer, site approver. Most editorial roles live here
Organization RoleWithin one organization treeMulti-tenant portals, partner portals, B2B account-scoped admins

Permission matrix · a real site

Resource / ActionGuestUserSite MemberSite EditorSite 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

json
{
  "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

StepStageWhat happens
01Git pushmonorepo · modules · client extensions · fragments · site initializer
02Gradle buildworkspace · OSGi modules → JARs · LPKG bundle
03Fragment + CX zipfragment toolkit · client-extension zips · .config files
04Artifact registryNexus / Artifactory / S3 · immutable tag per commit
05DeployDXP Cloud · or Helm / Ansible to self-hosted
06Smoke + audithealth · cache warmup · permission diff · search reindex

liferay-workspace/

Recommended monorepo layout for modules, CX, fragments, and site initializer

Workspace root

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

Repository layout — everything editable in Control Panel should also exist as code here.

Gradle build & LPKG packaging

yaml
# .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 }}/*.config

Deploy 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).

yaml
# 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

bash
# 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}" \
  --override

Client 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

properties
# 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.