← All posts
Liferay DXPLiferay PaaSModSecuritySecurityLogging

Getting Useful ModSecurity Logs Out of Liferay PaaS

By LR Tools · August 23, 2026 · 6 min read

This is LR Tools’ rewrite of a field-lessons post by David H Nebinger on Liferay.dev, following up on Liferay’s official ModSecurity documentation with what actually happened when a real client configuration turned “logs exist” into “logs are useful.”

ModSecurity logging in Liferay PaaS

The gap the official docs leave open

Liferay’s docs already explain how to turn ModSecurity on, supply a custom rule set, and route the audit log to the Liferay Cloud Console. What they don’t fully cover is the gap between ModSecurity is enabled and producing logs and ModSecurity is producing the logs you actually want, at a volume your logging pipeline can handle. That gap is where a real deployment can go wrong even while every individual setting looks reasonable in isolation.

Turning it on

ModSecurity is enabled per-environment in webserver/LCP.json:

{
    "env": {
        "LCP_WEBSERVER_MODSECURITY": "DetectionOnly"
    }
}

The three supported values are Off (rules aren’t processed at all), DetectionOnly (rules run and get evaluated, but nothing is blocked), and On (rules run and are enforced). Starting in DetectionOnly, watching what would have been blocked, and only then flipping to On is the path Liferay recommends — and it’s worth actually following rather than skipping straight to enforcement. One config detail that’s easy to miss: if you supply a custom webserver/configs/[ENV]/modsec/modsecurity.conf, it replaces Liferay’s default configuration outright rather than layering on top of it, so it needs to be complete on its own.

Why the log destination matters more on PaaS

The ModSecurity default audit log path (/var/log/modsec_audit.log) is fine on a traditional server with a persistent filesystem. It’s a poor fit for PaaS, where containers get replaced, rescaled, and restarted routinely — taking any log written only to local disk with them. Liferay’s own docs already recommend the fix:

SecAuditLog /dev/stdout

Writing to stdout surfaces the audit log alongside the webserver’s normal service logs in the Cloud Console, across every container — including ones spun up by autoscaling — and makes the output downloadable and retainable instead of vanishing with the container.

Where it actually goes wrong

The troubleshooting configuration that caused real problems looked, roughly, like this:

SecAuditEngine On
SecAuditLogParts ABIJDEFHZK
SecAuditLogType Serial
SecAuditLog /dev/stdout
SecAuditLogFormat JSON

Every individual choice here has a reasonable-sounding justification — audit everything, capture verbose detail, use structured JSON, ship it to stdout. Combined, they add up to something else entirely.

SecAuditEngine controls which transactions get recorded at all: Off disables auditing, On records every single transaction, and RelevantOnly records only transactions that triggered a rule warning/error or matched a configured response status. On sounds like the safe, thorough choice until you count what “every transaction” actually includes — every image, script, and stylesheet request, plus routine API and monitoring/health-check traffic. The fix that resolved the volume problem:

SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"

SecAuditLogParts is the more subtle culprit. Each letter in that string selects one part of the HTTP transaction to include in the audit record:

  • A — audit log header (always required)
  • B — request headers
  • C — request body
  • D — reserved, unimplemented in ModSecurity 3.x
  • E — response body
  • F — final response headers
  • G — reserved, unimplemented in ModSecurity 3.x
  • H — audit log trailer / extra transaction detail
  • I — unimplemented in ModSecurity 3.x
  • J — uploaded file info
  • K — unimplemented in ModSecurity 3.x
  • Z — end-of-entry marker (always required)

E is the one that matters most. Paired with SecAuditEngine On, JSON formatting, and stdout as the destination, E means every response body — a 300KB rendered HTML page, easily — gets serialized into JSON and pushed through the container’s logging pipeline, for every logged request. SecAuditLogParts doesn’t control whether ModSecurity inspects response bodies at all — that’s a separate setting, SecResponseBodyAccess — it only controls what gets written to the log. ModSecurity 3.x’s own documented default set is ABCFHZ; the problem configuration used ABIJDEFHZK, and the fix was simply dropping the E:

SecAuditLogParts ABIJDFHZK

The general advice here: don’t copy a sample string wholesale. Decide deliberately about C (request bodies — useful, but can carry sensitive data), E (response bodies — the main volume driver), and J (uploaded file metadata), since each one trades detail for size and, in the case of C and J, potential sensitive-data exposure in your logs.

Format is a separate decision from content

SecAuditLogFormat chooses between Native (the default, human-readable) and JSON. This is independent of SecAuditLogParts — Native is fine when a person is reading the log directly and nothing downstream needs to parse it; JSON earns its keep once you’re feeding a SIEM or building automated analysis. Either way, format doesn’t fix volume: a bloated record with a full response body is still just as large whether it’s Native or JSON.

The log that isn’t the audit log

The familiar ModSecurity: Warning. Matched ...-style messages don’t come from the audit log at all — they come through Nginx’s error log. To see them in the Console, Nginx needs its own stdout routing:

error_log /dev/stdout warn;

Nginx’s log levels run debug, info, notice, warn, error, crit, alert, emerg, each including everything more severe than itself. warn is a reasonable steady-state level; drop to notice, info, or debug only while actively troubleshooting, then dial it back.

Debug logging is a temporary tool, not a setting

SecDebugLogLevel 9 is genuinely useful while diagnosing a specific issue, but it isn’t meant to run indefinitely. A sensible resting level:

SecDebugLogLevel 3

A practical starting configuration

Putting the pieces above together gives a baseline that’s sustainable rather than a firehose:

SecRuleEngine ${LCP_WEBSERVER_MODSECURITY}

SecAuditEngine RelevantOnly
SecAuditLogRelevantStatus "^(?:5|4(?!04))"

SecAuditLogParts ABIJDFHZK

SecAuditLogType Serial
SecAuditLog /dev/stdout

SecDebugLogLevel 3

paired with Nginx’s error log routed the same way:

error_log /dev/stdout warn;

From there, pick SecAuditLogFormat Native or SecAuditLogFormat JSON deliberately, based on whether a human or a downstream system is the primary consumer. The two operational choices worth repeating: use RelevantOnly, not On, and leave E out of SecAuditLogParts unless you have a specific, ongoing need for response bodies in the log.

If you also want Nginx’s normal access log centralized — handy for correlating a ModSecurity event back to the exact request that triggered it — that’s a separate, optional addition:

access_log /dev/stdout main;

Getting logs out for the long term

The Cloud Console retains logs for 30 days, not indefinitely, so anything you need to keep longer has to be exported. The Liferay CLI can pull service logs directly:

lcp log -p <project>-<environment> -s webserver

and constrain the pull to a time window:

lcp log \
    -p <project>-<environment> \
    -s webserver \
    --since "<start_time>" \
    --until "<end_time>" \
    >> webserver-logs.txt

Automating a weekly export, filtering for ModSecurity-related lines, and retaining what compliance or security requirements demand is the practical pattern here — and JSON-formatted output makes that filtering considerably easier, since you’re parsing fields instead of pattern-matching raw text.

The actual lesson

There’s no single directive that fixes ModSecurity logging on PaaS — the lesson is treating the whole path (application → ModSecurity → Nginx’s error and access logs → stdout → the container runtime → platform logging infrastructure → the Console) as a pipeline you design, not a log file you point somewhere and forget. Auditing everything, including full response bodies, in JSON, sent to stdout, are all individually defensible choices — the combination is what turns “logging is on” into “logging is a problem.” Audit only what’s relevant, leave out what you don’t specifically need, keep routine verbosity sane, and export deliberately for anything that needs to outlive 30 days.

This article is LR Tools’ rewrite of the original post by David H Nebinger — read it on Liferay.dev for the author’s own framing, or see Liferay’s Web Application Firewall documentation and command-line tool reference for full setup details.

This article is adapted from: David H Nebinger, Liferay.dev

More from the blog

Liferay DXPPostgreSQLDatabase Migration

Migrating Liferay to PostgreSQL: A More Reliable Alternative to Liferay's Beta Tool

August 23, 2026 · 3 min read

Liferay's own database migration tool is still Beta and unreliable in practice. Here's a third-party alternative, the exact 8-step migration procedure, and links to the tool and its docs.

Liferay DXPSecurityCVE

When a CVE Isn't a Liferay Vulnerability: Presence vs. Reachability

August 23, 2026 · 7 min read

A security scanner finding a CVE in a bundled library isn't proof that Liferay DXP is exploitable through it. Here's how Liferay's security team tells the difference — and why an unnecessary dependency upgrade isn't automatically the safer choice.

Liferay DXPFree TierLicensing

DXP Free Tier Licenses: Why the Product Version Doesn't Have to Match Your Runtime

August 23, 2026 · 3 min read

A DXP Free Tier activation key stamped with a quarterly release that hadn't shipped yet looked like a bug. It wasn't — here's what the product-version field in a Liferay license actually means, and why it isn't a strict compatibility gate.