TomEE as code: the same server in a container

Page content

Engineers seal a TomEE server into a barrel (the container image); identical barrels roll on a rail (one image everywhere); one is unpacked into the rack (the cluster); the tavern keeper hands keys and vials from a safe (secrets and configuration); an engineer tests a miniature rack (Compose)

A server described in the repository helps little if production runs a different one. This post builds a container image from the same TomEE description and deploys it to Kubernetes, with each environment’s values and files kept as versioned manifests. Your machine, a local container and the cluster then run one server from one description.

This is the third part of TomEE as code and builds on the server in the repository.

From description to image

The plugin’s build goal does the same work as tomee:run, but leaves the finished server, with the application deployed, in target/apache-tomee. A multi-stage Dockerfile runs Maven in the first stage and keeps only that folder:

# Stage 1: build the server from the repository
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /src
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package tomee:build -DskipTests

# Stage 2: run it
FROM eclipse-temurin:21-jre
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=build --chown=app:app /src/target/apache-tomee/ /app/
COPY --chmod=755 docker/entrypoint.sh /entrypoint.sh

ARG VERSION=dev
ARG GIT_COMMIT=unknown
LABEL org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.revision="${GIT_COMMIT}"

USER app
EXPOSE 8080
ENTRYPOINT ["/entrypoint.sh"]

A clean checkout and docker build . are enough. The cache mount keeps the Maven repository between builds.

If the pipeline already runs Maven in its own step, drop the first stage and use COPY target/apache-tomee/ /app/. A docker build without that earlier step then fails at the COPY, which is the correct result.

The image contains a JRE, the server, an unprivileged user and a start script, and no environment-specific value. Every environment receives the same image, and its labels name the version and commit.

  • Run as a regular user, so a vulnerability in the application does not give root inside the container.
  • Pin the base image to a JDK version, and by digest in a production pipeline.
  • Log to the console. catalina.sh run writes to standard output and the platform collects it, so the image needs no log volume.

Environment configuration as code

Most of what an environment supplies is values. In Kubernetes, a ConfigMap holds ordinary settings and a Secret holds passwords and keys:

containers:
  - name: app
    image: registry.example.com/app:1.4.0
    envFrom:
      - configMapRef:
          name: app-env
      - secretRef:
          name: app-secrets

When a whole file differs per environment, such as logging.properties, mount it from a ConfigMap with the overlay’s layout, so a file for conf/ sits under conf/:

volumes:
  - name: server-config
    configMap:
      name: app-server-config
      items:
        - key: logging.properties
          path: conf/logging.properties
containers:
  - name: app
    volumeMounts:
      - name: server-config
        mountPath: /config
        readOnly: true

Keep these manifests in a repository and change them through pull requests. An edit made directly in the cluster brings the drift back.

The entrypoint

The start script applies the mounted files, checks required values, sizes the JVM and starts TomEE. It names no application file:

#!/usr/bin/env bash
set -euo pipefail

# Required values: fail now, with a clear message, instead of later with a stack trace
: "${APP_DB_URL:?is required}"
: "${APP_DB_USER:?is required}"
: "${APP_DB_PASSWORD:?is required}"

# Apply every file mounted under /config to the same path under /app
if [ -d /config ]; then
    cd /config
    find -L . -type f ! -path './..*' | while read -r file; do
        mkdir -p "/app/$(dirname "$file")"
        cp "$file" "/app/$file"
        echo "config: applied $file"
    done
fi

# Size the heap from the container's memory limit, not from a fixed number
export JAVA_OPTS="-XX:MaxRAMPercentage=75 ${JAVA_OPTS:-}"

exec sh /app/bin/catalina.sh run
LinePurpose
set -euo pipefailStops at the first failure, so the container never starts half configured
${VAR:?is required}A missing value stops the container in the first second and names the variable
find -L … ! -path './..*'Applies whatever the ConfigMap mounts, following Kubernetes’ symbolic links and skipping its hidden ..data folders
echo "config: applied …"The container log lists the files each environment received
MaxRAMPercentageThe heap follows the container’s memory limit, so one image fits small and large pods
exec sh … catalina.sh runTomEE replaces the script and receives the platform’s stop signal. sh covers .sh files that lost their executable bit

Testing the cluster setup locally

A Compose file runs the same image the way the cluster does:

services:
  app:
    build: .
    ports:
      - "8080:8080"
    env_file:
      - local.env
    environment:
      APP_DB_URL: jdbc:postgresql://host.docker.internal:5432/app
      APP_DATA_ROOT: /app/data
    volumes:
      - ./src/main/tomee:/config:ro
      - ./data:/app/data
    deploy:
      resources:
        limits:
          memory: 1g

It reads the same local.env as the IDE and overrides only the two values that differ in a container. It mounts the overlay itself as the configuration directory, so a local edit takes the same path as a ConfigMap, and Compose keeps no copy of any file. The memory limit matches the cluster, so a server that needs more memory than a pod has fails on the developer’s machine first.

Shortcuts that bring the drift back

ShortcutInstead
A copy of the configuration for ComposeMount the overlay and put differences in variables
A list of files in the start scriptLet the mounted directory’s layout be the list
The same value in a Windows and a Linux formatUse one root variable and relative paths
A tracked file hidden by .gitignoreTrack it, and move local differences into values
The same setting in the overlay and in the plugin or DockerfileKeep it where every way of starting the server reads it
A Dockerfile that runs sed or downloads JARsChange the server through the POM or the overlay
An edit made directly in the clusterChange the manifest through a pull request

All six principles

PrincipleHow the setup passes the check
DeclarativeThe POM, the overlay and the manifests describe the server and its environments
Versioned and reviewableThe server, the image and every environment’s configuration change through pull requests
ReproducibleA clean checkout builds the same image, and the image runs the same everywhere
No manual stateThe build creates the server again for every run and image, and only the manifests change the cluster
Single sourceThe overlay holds the structure, the environment holds the values, the manifests hold per-environment files, and properties hold the versions
Same server everywhereThe IDE, Compose and the cluster run the server built from the same description