TomEE as code: the same server in a container

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 runwrites 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
| Line | Purpose |
|---|---|
set -euo pipefail | Stops 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 |
MaxRAMPercentage | The heap follows the container’s memory limit, so one image fits small and large pods |
exec sh … catalina.sh run | TomEE 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
| Shortcut | Instead |
|---|---|
| A copy of the configuration for Compose | Mount the overlay and put differences in variables |
| A list of files in the start script | Let the mounted directory’s layout be the list |
| The same value in a Windows and a Linux format | Use one root variable and relative paths |
A tracked file hidden by .gitignore | Track it, and move local differences into values |
| The same setting in the overlay and in the plugin or Dockerfile | Keep it where every way of starting the server reads it |
A Dockerfile that runs sed or downloads JARs | Change the server through the POM or the overlay |
| An edit made directly in the cluster | Change the manifest through a pull request |
All six principles
| Principle | How the setup passes the check |
|---|---|
| Declarative | The POM, the overlay and the manifests describe the server and its environments |
| Versioned and reviewable | The server, the image and every environment’s configuration change through pull requests |
| Reproducible | A clean checkout builds the same image, and the image runs the same everywhere |
| No manual state | The build creates the server again for every run and image, and only the manifests change the cluster |
| Single source | The overlay holds the structure, the environment holds the values, the manifests hold per-environment files, and properties hold the versions |
| Same server everywhere | The IDE, Compose and the cluster run the server built from the same description |

