> ## Documentation Index
> Fetch the complete documentation index at: https://developer.upsun.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Deploy Liferay on Upsun

> Deploy Liferay Community Edition on Upsun with PostgreSQL and Elasticsearch.


export const GuidesRequirements = ({name}) => {
  const isSymfony = name === "Symfony";
  return <>
      <h2>Before you begin</h2>
      <p>You need:</p>
      <ul>
        <li>
          <a href="https://git-scm.com/downloads">Git</a>.{' '}
          Git is the primary tool to manage everything your app needs to run.
          Push commits to deploy changes and control configuration through YAML files.
          These files describe your infrastructure, making it transparent and version-controlled.
        </li>
        <li>
          An Upsun account.{' '}
          If you don't already have one, <a href="https://auth.upsun.com/register">register for a trial account</a>.{' '}
          You can sign up with an email address or an existing GitHub, Bitbucket, or Google account.
          If you choose one of these accounts, you can set a password for your Upsun account later.
        </li>
        <li>
          The {isSymfony ? <a href="https://symfony.com/download">Symfony CLI</a> : <a href="/cli">Upsun CLI</a>}.{' '}
          This lets you interact with your project from the command line.
          You can also do most things through the <a href="/docs/administration/web">Web Console</a>.
        </li>
      </ul>
    </>;
};

[Liferay](https://www.liferay.com/) 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.

<Info>
  <h4>About Liferay CE</h4>

  Liferay CE 7.4 is the last release of the Community Edition. Liferay has since shifted to a commercial-only model with [Liferay DXP](https://www.liferay.com/products/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.
</Info>

<Info>
  <h4>Elasticsearch Enterprise required</h4>

  Liferay requires [`elasticsearch-enterprise`](/docs/add-services/elasticsearch), 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](/docs/add-services/elasticsearch) for more details on available types.
</Info>

<Info>
  <h4>Guaranteed resources recommended</h4>

  Liferay is a resource-intensive application.
  It performs poorly with a single shared CPU.
  For production workloads, use [guaranteed resources](/docs/manage-resources/guaranteed-resources) to ensure consistent performance.
</Info>

<GuidesRequirements name="Liferay" />

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

```bash Terminal theme={null}
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:

| File                                                                                               | Purpose                                                    |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
| <Tree><Tree.Folder name=".upsun" defaultOpen><Tree.File name="config.yaml" /></Tree.Folder></Tree> | Upsun infrastructure configuration                         |
| <Tree><Tree.File name=".environment" /></Tree>                                                     | Sets Tomcat paths and JVM options                          |
| <Tree><Tree.File name="build.sh" /></Tree>                                                         | Downloads and prepares the Liferay bundle during build     |
| <Tree><Tree.File name="configure-liferay.py" /></Tree>                                             | Generates runtime configuration from environment variables |
| <Tree><Tree.File name="portal-log4j-ext.xml" /></Tree>                                             | Custom Log4j2 configuration for cleaner log output         |

## Upsun configuration

Create the `.upsun/config.yaml` file with the following content:

```yaml .upsun/config.yaml theme={null}
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**](/docs/configure-apps/image-properties/container_profile): `HIGH_MEMORY` allocates more memory relative to CPU, which suits Liferay's runtime needs.

[**Mounts**](docs/configure-apps/image-properties/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**](/docs/configure-apps/image-properties/web#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

<Info>
  <h4>Note on dependency management</h4>

  This tutorial downloads the Liferay bundle and JDBC driver directly via `curl` for simplicity.
  In a production project, consider using a [Liferay Workspace](https://learn.liferay.com/w/dxp/building-applications/tooling/liferay-workspace) with Gradle to manage dependencies and build the bundle.
</Info>

Create the `build.sh` file. This script runs during the [build phase](/docs/configure-apps/hooks/index#build-dependencies) and prepares the Liferay bundle:

```bash build.sh theme={null}
#!/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:

```python configure-liferay.py theme={null}
#!/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:

```bash .environment theme={null}
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:

```xml portal-log4j-ext.xml theme={null}
<?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:

```bash Terminal theme={null}
upsun project:create --title "Liferay"
```

## Deploy

Commit and push to deploy:

```bash Terminal theme={null}
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:

```bash Terminal theme={null}
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`

<Warning>
  Change these credentials immediately after your first login.
</Warning>

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

```bash Terminal theme={null}
upsun ssh -- ls deploy/
```

## Further resources

### Documentation

* [Java documentation](/docs/languages/java)
* [Elasticsearch documentation](/docs/add-services/elasticsearch)
* [Guaranteed resources](/docs/manage-resources/guaranteed-resources)

### External resources

* [Liferay Community Edition](https://www.liferay.com/open-source)
* [Liferay documentation](https://learn.liferay.com/)
* [Template repository](https://github.com/ralt/liferay-upsun/)
