Most Spring Boot deployment guides end at docker compose up. This one starts where that stops:
a single Red Hat Enterprise Linux host, no container daemon, an application supervised by
systemd through Podman Quadlet, SELinux left in Enforcing, and
images built on a laptop with a different CPU architecture than the server.
It is the release pipeline currently used for Proactive Idea's own website, and it can be applied to other
Java services that follow the same deployment model. Every command below comes from that workflow. Hostnames, addresses, image names and paths are sanitized placeholders.
The architecture
The whole path, from keyboard to visitor:
- Development — MacBook Air M4, macOS,
arm64 - Build —
linux/amd64OCI image, built locally with Podman - Artifact —
podman saveto an OCI archive plus a SHA-256 - Server — RHEL 10,
x86_64,podman load - Supervision — Podman + Quadlet, run as a systemd service
- Reverse proxy — HAProxy on a separate host, routing by
Hostheader - Edge — Cloudflare in front of the proxy
- Application — Spring Boot on Java 25, server-side rendered with Thymeleaf
The design rule that shaped everything else: the artifact you test is the artifact you ship. The server never builds anything. It only receives a verified image, points a unit file at it, and restarts.
Why Quadlet fits this deployment
This is an engineering choice for one specific shape of deployment, not a verdict on tools. Our target is a single Red Hat Enterprise Linux host running one long-lived Spring Boot service, with Podman as the container engine and systemd already managing everything else on the machine. In that setting, Quadlet lines up well:
- The service lifecycle is systemd's. A
.containerfile is turned into a normal service by a generator, so start order, restart behavior, dependencies,journalctlandsystemctl statuswork the way they do for every other service on the host. - Startup and restart are handled at boot.
Restart=alwaysandWantedBy=multi-user.targetbring the application back after a crash or a reboot without an extra supervisor. - Podman is daemonless. The container's parent is the systemd unit (through
conmon), so no separate container daemon has to be running for the service to run. - Health and resources live beside the image. The health check, memory and PID limits sit in
the same small file as
Image=, and the container runs confined under the host's SELinux policy. - A release is a small, reviewable change. The deployed state is one text file, so a release or rollback is a one-line diff of it.
Docker Compose remains a good fit in other deployment models: local development, multi-service stacks that
are started and stopped together, teams standardized on Docker tooling, or hosts without systemd-centred
operations. Quadlet also has .pod and .kube units for multi-container cases, but that
is beyond what this guide covers. The point is only that, for a single supervised service on RHEL, the
unit-file approach removed moving parts.
The cross-architecture build
This is the part most guides skip. Building on an Apple Silicon Mac produces arm64 images by
default. The server is x86_64. An ARM image on an x86 host fails at start with
exec format error — and you only find out after you have shipped it.
Do not build ARM and try to convert it afterwards. Build for the target platform from the first layer:
podman build --platform linux/amd64 -t example-app:prod-20260101-120000 .
On macOS, Podman runs inside a small Linux VM (podman machine). That VM ships with user-mode
QEMU registered for x86_64 through binfmt_misc, which is what lets it execute amd64 binaries
during the build and later when you test. Emulation is slower — our multi-stage Maven build takes a few
minutes rather than seconds — and that is an acceptable price for a correct artifact. Do not change
dependencies just to make the emulated build faster.
Three checks we treat as mandatory before an image is allowed to leave the laptop:
- Every
FROMpublishes amd64. Inspect the manifests of the build and runtime base images; do not assume a tag is multi-arch.podman manifest inspect docker.io/library/eclipse-temurin:25-jre | grep -A1 '"architecture": "amd64"' - The built image says amd64. Read it from the image metadata, not from memory:
If it printspodman image inspect example-app:prod-20260101-120000 --format '{{.Architecture}} {{.Os}}' # amd64 linuxarm64, stop. Nothing gets exported. - The image runs. "It built" is not validation. Start the exact image under emulation and
hit real endpoints:
We exercise every public route, including the endpoint that generates a PDF, because that is where native-library assumptions tend to hide. (We found no JNI or other architecture-specific libraries in our dependency set; check yours rather than assuming.)podman run --rm --platform linux/amd64 --entrypoint java example-app:prod-20260101-120000 -version podman run -d --name smoke --platform linux/amd64 -p 18080:8080 example-app:prod-20260101-120000 curl -s -o /dev/null -w '%{http_code}\n' http://localhost:18080/
Shipping the image: OCI archive and checksum
No registry is required for a single host. Export the tested image, name the archive after its architecture so nobody uploads the wrong one, and record a checksum:
podman save --format oci-archive -o example-app-prod-20260101-120000-linux-amd64.tar \
example-app:prod-20260101-120000
shasum -a 256 example-app-prod-20260101-120000-linux-amd64.tar # macOS
scp example-app-prod-20260101-120000-linux-amd64.tar [email protected]:/var/tmp/release/
sha256sum /var/tmp/release/example-app-prod-20260101-120000-linux-amd64.tar # on the server
The two hashes must match before anything is imported. Then load it and confirm the architecture again on the server:
sudo podman load -i /var/tmp/release/example-app-prod-20260101-120000-linux-amd64.tar
sudo podman image inspect localhost/example-app:prod-20260101-120000 --format '{{.Architecture}} {{.Os}} {{.Id}}'
One detail worth knowing: after podman load the image ID matched the one we built
locally, but the reported digest was different. The ID identifies the image content
configuration and is what you should record for traceability; the digest is recomputed for the repackaged
manifest and is not a stable cross-machine identity for an archive round trip.
The Quadlet unit
A system-level Quadlet lives in /etc/containers/systemd/ (rootful) or
~/.config/containers/systemd/ (rootless, which additionally needs
loginctl enable-linger if the service must run without an active login). We use the system-level
location. The generator turns the file into a unit under /run/systemd/generator/; you never edit
that output. A sanitized version of our service:
[Unit]
Description=Example Spring Boot application
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/example-app:prod-20260101-120000
ContainerName=example-app
Network=appnet.network
PublishPort=192.0.2.10:8080:8080
HealthCmd=curl -fsS http://localhost:8080/ || exit 1
HealthInterval=30s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=30s
Memory=768M
PidsLimit=512
EnvironmentFile=/etc/containers/systemd/example-app.env
Secret=example-mail-password,type=env,target=MAIL_PASSWORD
[Service]
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
Choices in that file that are easy to get wrong:
- Bind the published port to one address (
192.0.2.10:8080:8080), not to every interface. The reverse proxy is the only intended client. - Secrets are not in the unit or the image. Non-sensitive settings go in a root-only
environment file (mode
0600); passwords and API secrets are Podman secrets injected as environment variables at start. The Quadlet itself is world-readable, so it must never contain a secret. - Pin the image to an immutable tag (
prod-<timestamp>), neverlatest. A rollback is only deterministic if the previous tag still means the same bytes. TimeoutStartSecmatters: a JVM on a small host can take a while to start and you do not want systemd to declare failure early.- A Quadlet backup named
example-app.container.bak-20260101is ignored by the generator because it no longer ends in.container— a convenient way to keep a restorable copy next to the live file.
After editing, the sequence is always the same: systemctl daemon-reload so the generator re-reads
the file, then systemctl restart example-app.service. Restart only that unit; other containers on
the host keep running.
Health checks that tell the truth
Two lessons from the health check. First, HealthCmd runs inside the container, so the
runtime image must contain the tool it calls. Our slim JRE image does not ship curl by default;
we install it in the runtime stage of the Dockerfile.
Second, decide what "healthy" should mean. Spring Boot Actuator's aggregate /actuator/health
includes every indicator, and in our test environment it reported DOWN purely because the
mail-server indicator had no SMTP credentials — while /actuator/health/readiness was
UP and the application was serving traffic perfectly. If an optional dependency can flip a
container to unhealthy, you will eventually restart a working service. Probe something that reflects
"can serve requests," and keep dependency indicators for monitoring.
Rather than trusting the first log line, our deploy script polls the container's health state until it reports
healthy or a timeout expires.
SELinux stays on
A service that only fails under SELinux tempts people to run setenforce 0. Do not. That turns off
a control the rest of the platform depends on, to fix a problem that has a precise cause and a precise fix.
- Verify the mode throughout a release:
getenforceshould printEnforcingbefore and after. - If a start fails, look for the denial instead of guessing:
sudo ausearch -m avc -ts recent sudo journalctl -u example-app.service --since "10 minutes ago" - Bind mounts are the usual culprit, because host files carry labels the container is not allowed to read.
Label them for the container with the volume option, e.g.
Volume=/srv/example-app/data:/data:Z(:Zgives the container a private label,:za shared one).
Our Spring Boot service mounts no host directories — configuration arrives through environment and secrets — so a normal release produces no AVC denials at all. Designing the container to need no bind mounts is the cheapest SELinux strategy there is.
HAProxy and Cloudflare in front
The application host does not face the internet. A separate HAProxy host receives traffic and routes to the
published address of the container by Host header. The shape of the backend is unremarkable:
backend be_example_app
option httpchk GET /
server app1 192.0.2.10:8080 check
Because the app sits behind proxies, it must be told which proxies to trust for forwarded headers
(Spring's server.forward-headers-strategy plus an allow-list of proxy CIDRs for resolving the
real client address). Trusting X-Forwarded-For from arbitrary sources lets anyone spoof their IP.
Cloudflare sits in front of HAProxy as the public edge. We did not need to change any Cloudflare or HAProxy configuration for a release; after each restart we simply confirm that the public URLs still return healthy responses end to end, which proves the whole chain rather than one hop.
One caching lesson from this chain: browsers cached a stylesheet for a day under an unchanging URL, so a
release that added new CSS rules was paired with an old cached stylesheet and some icons rendered as solid
squares. The fix is boring and effective — serve static assets under a content-hashed URL
(styles-<hash>.css) so a new release always means a new URL.
Release and rollback
The release procedure is short enough to be scripted and short enough to reason about at 2 a.m.:
- Record the image that is running now, and give it a rollback tag:
sudo podman tag localhost/example-app:prod-PREVIOUS localhost/example-app:rollback-20260101-120000 - Back up the unit file next to the original.
sudo cp -p /etc/containers/systemd/example-app.container \ /etc/containers/systemd/example-app.container.bak-20260101-120000 - Import the verified archive (see above) and confirm
amd64 linux. - Change only the
Image=line and review the diff. A one-line diff is the evidence that nothing else moved — ports, network, limits, secrets and health check are unchanged. systemctl daemon-reload, then restart that one unit.- Wait for the container to report
healthy, then check the journal, the backend port directly, the proxy and the public URLs.
The safety net is a guard around step 5–6: if the container is not healthy within a timeout, copy the backup
back, daemon-reload and restart — automatically. Then diagnose calmly with the old version
already serving.
What we deliberately do not do: broad cleanup such as podman system prune -a or
podman image prune -a. The previous image and its rollback tag stay on disk until the new release
has proven itself and a human decides to remove them, one image at a time.
Lessons learned
- Verify architecture from metadata, at both ends.
podman image inspecton the laptop and again afterpodman loadon the server. Assumptions are how ARM images reach x86 hosts. - Validation means running the artifact, under emulation if you must, against real routes — not a green build.
- Immutable tags plus a one-line unit diff make releases and rollbacks boring, which is the goal.
- Keep secrets out of the world-readable unit. Root-only env file for settings, Podman secrets for credentials.
- Choose a health probe that reflects serving ability, not the sum of every optional dependency.
- Leave SELinux enforcing; read the denial, fix the label, design away bind mounts.
- On a developer Mac using Podman, Spring Boot's Docker Compose auto-support will look for a
Docker daemon and fail startup unless you set
spring.docker.compose.enabled=false. - Hash your static asset URLs so caches cannot pair new HTML with old CSS.
None of this is exotic; the value is in doing every step the same way every time. If you are moving a Java service from ad-hoc container hosting to something you can operate with confidence, this pattern scales down well to a single server.