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:

  1. Development — MacBook Air M4, macOS, arm64
  2. Build — linux/amd64 OCI image, built locally with Podman
  3. Artifact — podman save to an OCI archive plus a SHA-256
  4. Server — RHEL 10, x86_64, podman load
  5. Supervision — Podman + Quadlet, run as a systemd service
  6. Reverse proxy — HAProxy on a separate host, routing by Host header
  7. Edge — Cloudflare in front of the proxy
  8. 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 .container file is turned into a normal service by a generator, so start order, restart behavior, dependencies, journalctl and systemctl status work the way they do for every other service on the host.
  • Startup and restart are handled at boot. Restart=always and WantedBy=multi-user.target bring 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:

  1. Every FROM publishes 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"'
  2. The built image says amd64. Read it from the image metadata, not from memory:
    podman image inspect example-app:prod-20260101-120000 --format '{{.Architecture}} {{.Os}}'
    # amd64 linux
    If it prints arm64, stop. Nothing gets exported.
  3. The image runs. "It built" is not validation. Start the exact image under emulation and hit real endpoints:
    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/
    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.)

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>), never latest. A rollback is only deterministic if the previous tag still means the same bytes.
  • TimeoutStartSec matters: 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-20260101 is 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: getenforce should print Enforcing before 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 (:Z gives the container a private label, :z a 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.:

  1. 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
  2. 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
  3. Import the verified archive (see above) and confirm amd64 linux.
  4. 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.
  5. systemctl daemon-reload, then restart that one unit.
  6. 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 inspect on the laptop and again after podman load on 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.

Related work: cloud & DevOps, software development and technology consulting.