Skip to main content
Liferay is an open-source digital experience platform built on Java. It provides content management, user identity, and digital commerce capabilities. Deploying Liferay traditionally involves managing a Tomcat server, a database, and an Elasticsearch cluster — each with its own provisioning, networking, and maintenance overhead. Upsun handles all of that infrastructure for you: services are declared in a single configuration file, environments are provisioned on every Git push, and scaling is a one-command operation. This tutorial walks you through deploying Liferay Community Edition (CE) 7.4 on Upsun with PostgreSQL for persistence and Elasticsearch for search.

About Liferay CE

Liferay CE 7.4 is the last release of the Community Edition. Liferay has since shifted to a commercial-only model with Liferay DXP. CE 7.4 remains freely available and functional, but it no longer receives new features or updates. If you need long-term support and continued updates, consider Liferay DXP.

Elasticsearch Enterprise required

Liferay requires elasticsearch-enterprise, not elasticsearch or opensearch. Liferay’s bundled Elasticsearch client expects validation fields that are only present in the Enterprise edition. Using the standard elasticsearch type or opensearch will cause search indexing failures.See the Elasticsearch documentation for more details on available types.
Liferay is a resource-intensive application. It performs poorly with a single shared CPU. For production workloads, use guaranteed resources to ensure consistent performance.

What you’re building

The deployment consists of three containers:
  • Liferay CE 7.4 running on Java 17 with Tomcat 9
  • PostgreSQL 16 as the primary database
  • Elasticsearch Enterprise 8.19 for full-text search with multilingual analysis plugins

Project setup

Create a new directory for your project and initialize a Git repository:
Terminal
mkdir my-liferay-project
cd my-liferay-project
git init
The following sections walk you through each file you need to create.

Project structure

Your project will contain five files:
FilePurpose
.upsun
config.yaml
Upsun infrastructure configuration
.environment
Sets Tomcat paths and JVM options
build.sh
Downloads and prepares the Liferay bundle during build
configure-liferay.py
Generates runtime configuration from environment variables
portal-log4j-ext.xml
Custom Log4j2 configuration for cleaner log output

Upsun configuration

Create the .upsun/config.yaml file with the following content:
.upsun/config.yaml
applications:
  liferay:
    source:
      root: "/"
    type: "java:17"
    container_profile: HIGH_MEMORY

    relationships:
      postgresql:
      elasticsearch:

    build:
      flavor: none

    hooks:
      build: bash /app/build.sh

    mounts:
      # Liferay data (document library, portal-ext.properties)
      'data':
        source: storage
        source_path: liferay-data
      # Hot-deploy directory for plugins/themes
      'deploy':
        source: storage
        source_path: liferay-deploy
      # Ephemeral directories — rebuilt or repopulated automatically
      'logs':
        source: instance
        source_path: liferay-logs
      'osgi/state':
        source: instance
        source_path: liferay-osgi-state
      'osgi/configs':
        source: instance
        source_path: liferay-osgi-configs
      'work':
        source: instance
        source_path: liferay-work
      'routes':
        source: instance
        source_path: liferay-routes
      'tomcat/logs':
        source: instance
        source_path: tomcat-logs
      'tomcat/temp':
        source: instance
        source_path: tomcat-temp
      'tomcat/work':
        source: instance
        source_path: tomcat-work

    web:
      commands:
        pre_start: python3 /app/configure-liferay.py
        post_start: |
          until curl -sfo /dev/null http://localhost:${PORT}/; do sleep 2; done
        start: /app/tomcat/bin/catalina.sh run

services:
  postgresql:
    type: postgresql:16
  elasticsearch:
    type: elasticsearch-enterprise:8.19
    configuration:
      plugins:
        - analysis-smartcn
        - analysis-kuromoji
        - analysis-icu
        - analysis-stempel

routes:
  "https://{default}/":
    type: upstream
    upstream: "liferay:http"
    cache:
      enabled: true
      default_ttl: 300
      cookies: ["JSESSIONID"]
      headers:
        - Accept
        - Accept-Language

Key configuration details

Container profile: HIGH_MEMORY allocates more memory relative to CPU, which suits Liferay’s runtime needs. Mounts: The configuration separates persistent storage (storage) from ephemeral storage (instance):
  • data and deploy use storage — they survive redeployments and instance reprovisioning.
  • Logs, caches, and working directories use instance — they persist across deploys but are cleared on reprovisioning, which is fine since they can be rebuilt.
Web commands: The startup uses a three-phase process:
  1. pre_start generates configuration files from environment variables.
  2. start launches Tomcat.
  3. post_start polls Liferay’s health endpoint. Upsun only routes traffic after this command returns, preventing requests to a still-booting instance.

Build process

Note on dependency management

This tutorial downloads the Liferay bundle and JDBC driver directly via curl for simplicity. In a production project, consider using a Liferay Workspace with Gradle to manage dependencies and build the bundle.
Create the build.sh file. This script runs during the build phase and prepares the Liferay bundle:
build.sh
#!/usr/bin/env bash
set -eux

# --- Liferay CE bundle (cached across builds) ---
LIFERAY_VERSION="7.4.3.132-ga132"
LIFERAY_CACHE="${PLATFORM_CACHE_DIR}/liferay-portal-tomcat-${LIFERAY_VERSION}.tar.gz"

# Guard against corrupted downloads (error pages are tiny)
if [ -f "$LIFERAY_CACHE" ] && [ $(stat -c%s "$LIFERAY_CACHE") -lt 1000000 ]; then
  rm -f "$LIFERAY_CACHE"
fi
if [ ! -f "$LIFERAY_CACHE" ]; then
  LIFERAY_FILENAME=$(curl -sSL "https://releases.liferay.com/portal/${LIFERAY_VERSION}/" \
    | grep -o "liferay-portal-tomcat[^\"]*\.tar\.gz" | head -1)
  curl -sSL "https://releases.liferay.com/portal/${LIFERAY_VERSION}/${LIFERAY_FILENAME}" \
    -o "$LIFERAY_CACHE"
fi
tar xzf "$LIFERAY_CACHE" -C . --strip-components=1

# --- PostgreSQL JDBC driver (not bundled) ---
PG_DRIVER_VERSION="42.7.3"
PG_DRIVER_CACHE="${PLATFORM_CACHE_DIR}/postgresql-${PG_DRIVER_VERSION}.jar"
if [ ! -f "$PG_DRIVER_CACHE" ]; then
  curl -sSL "https://jdbc.postgresql.org/download/postgresql-${PG_DRIVER_VERSION}.jar" \
    -o "$PG_DRIVER_CACHE"
fi
mkdir -p tomcat-*/lib/ext
cp "$PG_DRIVER_CACHE" tomcat-*/lib/ext/

# --- Normalize the Tomcat directory name ---
# The archive extracts as tomcat-9.0.x; rename so mount paths are stable.
mv tomcat-* tomcat

# --- Patch Tomcat to listen on $PORT ---
# Upsun assigns a dynamic port via $PORT. We inject a property
# placeholder here and resolve it at runtime with -Dport.http=$PORT.
sed -i 's/port="8080"/port="${port.http}"/' tomcat/conf/server.xml

# --- Remove bundled setenv.sh ---
# It hardcodes -Xmx and other memory flags that conflict with our
# container-aware values computed from /run/config.json at runtime.
rm -f tomcat/bin/setenv.sh

# --- Log4j override for shorter stack traces ---
mkdir -p tomcat/webapps/ROOT/WEB-INF/classes/META-INF
cp ~/portal-log4j-ext.xml tomcat/webapps/ROOT/WEB-INF/classes/META-INF/
The script:
  1. Downloads and caches the Liferay bundle (~1 GB). It uses PLATFORM_CACHE_DIR so subsequent builds skip the download.
  2. Installs the PostgreSQL JDBC driver, which isn’t included in the Community Edition bundle.
  3. Normalizes the Tomcat directory from tomcat-9.0.x to tomcat so mount paths remain stable across versions.
  4. Patches Tomcat’s port from hardcoded 8080 to a ${port.http} placeholder resolved at runtime via -Dport.http=$PORT.
  5. Removes the bundled setenv.sh which hardcodes memory flags that conflict with container-aware values.
  6. Installs a custom Log4j configuration for abbreviated stack traces in console output.

Runtime configuration

Create the configure-liferay.py file. This script runs as pre_start before Tomcat boots. It reads Upsun environment variables and generates the configuration files Liferay needs:
configure-liferay.py
#!/usr/bin/env python3
"""Generate Liferay configuration from Upsun environment.

Runs as pre_start — before Tomcat boots but after mounts are available.
Reads service credentials from PLATFORM_RELATIONSHIPS and route domains
from PLATFORM_ROUTES, then writes:

  1. portal-ext.properties  — JDBC, virtual hosts, reverse-proxy settings
  2. ElasticsearchConfiguration.config — remote ES connection for OSGi
  3. virtualhost DB update   — maps the real domain to the default company
"""

import base64
import json
import os
import subprocess
from urllib.parse import urlparse

LIFERAY_HOME = "/app"
OSGI_CONFIGS = os.path.join(LIFERAY_HOME, "osgi", "configs")


def get_relationships():
    """Decode PLATFORM_RELATIONSHIPS (base64 JSON) into a dict."""
    encoded = os.environ.get("PLATFORM_RELATIONSHIPS", "")
    if not encoded:
        return {}
    return json.loads(base64.b64decode(encoded))


def get_liferay_hosts():
    """Return sorted hostnames from PLATFORM_ROUTES that route to this app."""
    encoded = os.environ.get("PLATFORM_ROUTES", "")
    if not encoded:
        return []
    routes = json.loads(base64.b64decode(encoded))
    hosts = set()
    for url, route in routes.items():
        if route.get("type") == "upstream" and route.get("upstream") == "liferay":
            hosts.add(urlparse(url).hostname)
    return sorted(hosts)


def write_portal_properties(rels):
    """Write JDBC, virtual host, and reverse-proxy config."""
    pg = rels["postgresql"][0]
    hosts = get_liferay_hosts()
    valid_hosts = ",".join(hosts)
    primary_host = hosts[0] if hosts else "localhost"
    props = (
        f"jdbc.default.driverClassName=org.postgresql.Driver\n"
        f"jdbc.default.url=jdbc:postgresql://{pg['host']}:{pg['port']}/{pg['path']}\n"
        f"jdbc.default.username={pg['username']}\n"
        f"jdbc.default.password={pg['password']}\n"
        f"liferay.home={LIFERAY_HOME}\n"
        f"setup.wizard.enabled=false\n"
        f"virtual.hosts.valid.hosts={valid_hosts}\n"
        f"web.server.protocol=https\n"
        f"web.server.host={primary_host}\n"
        f"web.server.https.port=443\n"
    )
    path = os.path.join(LIFERAY_HOME, "data", "portal-ext.properties")
    with open(path, "w") as f:
        f.write(props)


def update_default_virtual_host(rels):
    """Point Liferay's default virtual host to the real domain."""
    hosts = get_liferay_hosts()
    if not hosts:
        return
    primary_host = hosts[0]
    pg = rels["postgresql"][0]
    env = dict(os.environ, PGPASSWORD=pg["password"])
    subprocess.run(
        [
            "psql", "-h", pg["host"], "-p", str(pg["port"]),
            "-U", pg["username"], pg["path"],
            "-c", f"UPDATE virtualhost SET hostname = '{primary_host}' "
                  f"WHERE defaultvirtualhost = true AND hostname = 'localhost';",
        ],
        env=env,
        stderr=subprocess.DEVNULL,
    )


def write_elasticsearch_config(rels):
    """Write the OSGi config file for Liferay's ES7 connector."""
    es = rels["elasticsearch"][0]
    url = f"{es['scheme']}://{es['host']}:{es['port']}"
    config = (
        f'operationMode="REMOTE"\n'
        f'productionModeEnabled=B"true"\n'
        f'networkHostAddresses=["{url}"]\n'
    )
    path = os.path.join(
        OSGI_CONFIGS,
        "com.liferay.portal.search.elasticsearch7.configuration."
        "ElasticsearchConfiguration.config",
    )
    with open(path, "w") as f:
        f.write(config)


def cleanup_search_configs():
    """Remove search configs when no search service is configured."""
    for name in [
        "com.liferay.portal.search.elasticsearch7.configuration."
        "ElasticsearchConfiguration.config",
    ]:
        path = os.path.join(OSGI_CONFIGS, name)
        if os.path.exists(path):
            os.remove(path)


def main():
    rels = get_relationships()
    if not rels:
        print("No PLATFORM_RELATIONSHIPS found, skipping configuration")
        return
    write_portal_properties(rels)
    update_default_virtual_host(rels)
    if "elasticsearch" in rels:
        write_elasticsearch_config(rels)
    else:
        cleanup_search_configs()
    print("Liferay configuration generated")


if __name__ == "__main__":
    main()
The script performs three tasks:
  1. Generates portal-ext.properties with PostgreSQL connection details, virtual host mappings, and HTTPS reverse-proxy settings. This file is written to the data mount and loaded via -Dexternal-properties at runtime.
  2. Creates an Elasticsearch OSGi configuration that points Liferay’s search connector to the remote Elasticsearch service.
  3. Updates the virtual host in the database so asset URLs use the real domain instead of localhost. This runs silently on first boot when the database tables don’t exist yet.

Environment variables

Create the .environment file to configure Tomcat paths and JVM options:
.environment
export CATALINA_HOME=/app/tomcat
export CATALINA_TMPDIR=/app/tomcat/temp
export CATALINA_OUT=/dev/stderr

XMX=$(python3 -c "import json; print(int(json.load(open('/run/config.json'))['info']['limits']['memory'] * 0.8))")

ADDS="--add-opens=java.base/java.lang=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.lang.invoke=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.lang.reflect=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.io=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.net=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.util=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/java.util.concurrent=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/sun.net.www.protocol.http=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/sun.net.www.protocol.https=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.base/sun.util.calendar=ALL-UNNAMED"
ADDS="$ADDS --add-opens=jdk.zipfs/jdk.nio.zipfs=ALL-UNNAMED"
ADDS="$ADDS --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED"

export JAVA_OPTS="$ADDS -Xms256m -Xmx${XMX}m -Dfile.encoding=UTF-8 -Djava.net.preferIPv4Stack=true -Duser.timezone=UTC -Dport.http=${PORT} -Dexternal-properties=/app/data/portal-ext.properties"
Key details:
  • Memory: The maximum heap size (-Xmx) is set to 80% of the container’s memory limit, read dynamically from /run/config.json. This ensures the JVM scales with the container size.
  • --add-opens flags: Required for Java 17 compatibility. Liferay uses deep reflection that needs explicit module access.
  • CATALINA_OUT=/dev/stderr: Sends Tomcat output to the container’s stderr stream, which Upsun captures in logs.
  • -Dport.http=${PORT}: Resolves the ${port.http} placeholder in server.xml to the dynamic port assigned by Upsun.

Log4j configuration

Create the portal-log4j-ext.xml file for cleaner console log output with abbreviated stack traces:
portal-log4j-ext.xml
<?xml version="1.0"?>

<Configuration strict="true">
	<Appenders>
		<Appender name="CONSOLE" type="Console">
			<Layout pattern="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5p [%t][%c{1}:%L] %m%ex{short}%n" type="PatternLayout" />
		</Appender>
	</Appenders>
</Configuration>
The %ex{short} pattern abbreviates exception stack traces, keeping logs readable without losing diagnostic value.

Create your Upsun project

Create a new project on Upsun and connect it to the repository:
Terminal
upsun project:create --title "Liferay"

Deploy

Commit and push to deploy:
Terminal
git add .
git commit -m "Initial Liferay deployment"
upsun push
The first deployment takes several minutes as the Liferay bundle (~1 GB) is downloaded and the database schema is initialized. Subsequent deployments are faster thanks to the build cache.

After deployment

Once the deployment completes, open your site:
Terminal
upsun url --primary
Liferay’s setup wizard is disabled (setup.wizard.enabled=false in the generated portal-ext.properties). The default admin credentials are:
  • Email: test@liferay.com
  • Password: test
Change these credentials immediately after your first login.

Hot-deploying plugins

The deploy mount is a persistent directory where you can drop WAR/JAR files for hot deployment. Use SSH to upload plugins:
Terminal
upsun ssh -- ls deploy/

Further resources

Documentation

External resources

Last modified on April 7, 2026