Spring Boot 4 Actuator in production: endpoints, security, custom health indicators

Companion repository for the ankurm.com article. Every transcript in docs/output/
was produced by running this project; scripts/run-all.sh regenerates all of them.

Verified against Spring Boot 4.1.1 / Framework 7.0.9 / Security 7.1.1 /
Micrometer 1.17.1 / kafka-clients 4.2.1 on Temurin JDK 25.0.4.1+1.
This commit is contained in:
2026-09-04 10:40:04 +05:30
commit 4b6cefa60a
64 changed files with 3195 additions and 0 deletions

40
scripts/demo-custom-health.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# The three custom indicators, and what happens when the upstream goes down.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/07-custom-health-indicators.txt
B=http://localhost:8080
{
echo "### profile: details (show-details: always, show-components: always)"
echo
echo "--- upstream UP ---"
curl -s -o /dev/null -u ops:ops-password -X POST "$B/stub/upstream/mode?value=up"
sleep 1
echo "\$ curl -s -o /dev/null -w '%{http_code}' $B/actuator/health"
curl -s -o /dev/null -w ' HTTP %{http_code}\n' -u ops:ops-password "$B/actuator/health"
curl -s -u ops:ops-password "$B/actuator/health" | python3 -m json.tool
echo
echo "--- flip the upstream to DOWN, change nothing else ---"
echo "\$ curl -s -X POST '$B/stub/upstream/mode?value=down'"
curl -s -u ops:ops-password -X POST "$B/stub/upstream/mode?value=down"; echo
sleep 1
curl -s -o /dev/null -w ' HTTP %{http_code}\n' -u ops:ops-password "$B/actuator/health"
curl -s -u ops:ops-password "$B/actuator/health" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print(json.dumps({"status":d["status"],"externalApi":d["components"]["externalApi"]},indent=2))'
echo
echo " The aggregate went DOWN and /actuator/health now answers 503. If that URL is your"
echo " Kubernetes readiness probe, every pod in the deployment has just left the load"
echo " balancer because a third party had a bad minute."
echo
echo "--- a single component, addressed directly ---"
echo "\$ curl -s $B/actuator/health/ordersDatabase"
curl -s -u ops:ops-password "$B/actuator/health/ordersDatabase" | python3 -m json.tool
echo "\$ curl -s $B/actuator/health/kafka"
curl -s -u ops:ops-password "$B/actuator/health/kafka" | python3 -m json.tool
echo
echo "--- restore ---"
curl -s -u ops:ops-password -X POST "$B/stub/upstream/mode?value=up"; echo
} > "$OUT" 2>&1
echo "wrote $OUT"

View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# What Actuator exposes when you add the starter and configure nothing.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/01-default-exposure.txt
{
echo "### Spring Boot Actuator, starter added, ZERO management.* configuration"
echo
echo "\$ curl -s -u ops:ops-password http://localhost:8080/actuator"
curl -s -u ops:ops-password http://localhost:8080/actuator | python3 -m json.tool
echo
echo "\$ curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://localhost:8080/actuator/env"
curl -s -o /dev/null -w '%{http_code}\n' -u ops:ops-password http://localhost:8080/actuator/env
echo " 404 = discovered but NOT exposed over HTTP. Exposure and existence are different things."
echo
echo "\$ curl -s -u ops:ops-password http://localhost:8080/actuator/health"
curl -s -u ops:ops-password http://localhost:8080/actuator/health | python3 -m json.tool
echo
echo " Only 'health' is web-exposed by default. show-details defaults to 'never', so even an"
echo " authenticated caller sees a bare status until you say otherwise."
} > "$OUT" 2>&1
echo "wrote $OUT"

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# The authoritative endpoint list: what the RUNNING application publishes with exposure = "*".
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/02-endpoint-catalogue.txt
{
echo "### Every web-exposed endpoint, read from the running application"
echo "### profiles: exposeall,open management.endpoints.web.exposure.include: \"*\""
echo
echo "\$ curl -s http://localhost:8080/actuator/diag"
curl -s http://localhost:8080/actuator/diag | python3 -m json.tool
} > "$OUT" 2>&1
echo "wrote $OUT"

36
scripts/demo-groups-probes.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Health groups: keeping a dependency outage out of the liveness probe.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/08-groups-and-probes.txt
B=http://localhost:8080/actuator/health
{
echo "### profile: groups"
echo
echo "--- baseline: upstream UP ---"
for g in "" liveness readiness startup; do
u="$B${g:+/$g}"
printf ' %-40s HTTP %s\n' "GET ${u#http://localhost:8080}" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password "$u")"
done
echo
echo "--- upstream DOWN (a third party is having an outage) ---"
curl -s -o /dev/null -u ops:ops-password -X POST "http://localhost:8080/stub/upstream/mode?value=down"
sleep 1
for g in "" liveness readiness startup; do
u="$B${g:+/$g}"
printf ' %-40s HTTP %s\n' "GET ${u#http://localhost:8080}" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password "$u")"
done
echo
echo " liveness stayed 200. readiness went 503."
echo " Kubernetes takes this instance out of the Service and leaves the process alone."
echo " Wire readiness to /actuator/health and you get a restart loop instead."
echo
echo "--- what each group contains ---"
echo "\$ curl -s $B/liveness"
curl -s -u ops:ops-password "$B/liveness" | python3 -m json.tool
echo "\$ curl -s $B/readiness"
curl -s -u ops:ops-password "$B/readiness" | python3 -m json.tool
echo
curl -s -o /dev/null -u ops:ops-password -X POST "http://localhost:8080/stub/upstream/mode?value=up"
} > "$OUT" 2>&1
echo "wrote $OUT"

23
scripts/demo-heapdump-leak.sh Executable file
View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# The endpoint that really does hand over your secrets - once you turn it on.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/04-heapdump-leak.txt
B=http://localhost:8080/actuator
{
echo "### profiles: exposeall,open PLUS --management.endpoint.heapdump.access=unrestricted"
echo
echo "\$ curl -s -o /tmp/heap.hprof -w 'status=%{http_code} bytes=%{size_download} type=%{content_type}' $B/heapdump"
curl -s -o /tmp/heap.hprof -w 'status=%{http_code} bytes=%{size_download} type=%{content_type}\n' "$B/heapdump"
echo
echo "\$ strings /tmp/heap.hprof | grep -c 'S3CRET-partner-credential'"
strings /tmp/heap.hprof 2>/dev/null | grep -c 'S3CRET-partner-credential'
echo "\$ strings /tmp/heap.hprof | grep -o 'not-a-real-password[^\"]*' | head -1"
strings /tmp/heap.hprof 2>/dev/null | grep -o 'not-a-real-password[^\"]*' | head -1
echo
echo " /actuator/env masked both of these to ******."
echo " /actuator/heapdump handed over the process memory that contains them in plaintext."
echo " Sanitisation is a property-rendering feature. It is not a security boundary."
rm -f /tmp/heap.hprof
} > "$OUT" 2>&1
echo "wrote $OUT"

19
scripts/demo-kafka-timeout.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# The single most useful number in this repository: how long a naive Kafka health check blocks.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/09-kafka-timeout.txt
MODE="${1:-tuned}"
B=http://localhost:8080/actuator/health/kafka
{
echo "### Kafka health check against an unreachable broker -- ${MODE}"
echo
echo "\$ time curl -s $B"
s=$(date +%s.%N)
body=$(curl -s -u ops:ops-password --max-time 180 "$B")
e=$(date +%s.%N)
echo "$body" | python3 -m json.tool 2>/dev/null || echo "$body"
echo
printf 'wall clock: %.1f s\n' "$(echo "$e - $s" | bc)"
} > "$OUT.$MODE" 2>&1
echo "wrote $OUT.$MODE"

34
scripts/demo-management-port.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Actuator on its own port and path.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/06-management-port.txt
{
echo "### profile: mgmtport"
echo "### management.server.port: 9001 / management.server.address: 127.0.0.1 / base-path: /manage"
echo
echo "--- the application port no longer serves Actuator at all ---"
printf ' %-52s %s\n' "GET :8080/actuator/health" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/actuator/health)"
printf ' %-52s %s\n' "GET :8080/manage/health" "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/manage/health)"
printf ' %-52s %s\n' "GET :8080/orders/count" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://127.0.0.1:8080/orders/count)"
echo
echo "--- the management port serves it on the new base path ---"
printf ' %-52s %s\n' "GET :9001/manage/health" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://127.0.0.1:9001/manage/health)"
printf ' %-52s %s\n' "GET :9001/actuator/health" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://127.0.0.1:9001/actuator/health)"
printf ' %-52s %s\n' "GET :9001/orders/count" "$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://127.0.0.1:9001/orders/count)"
echo
echo " Note the last line. The management context has its own DispatcherServlet and does NOT"
echo " see application controllers. That is the isolation you are paying for."
echo
echo "--- what the management context reports about itself ---"
echo "\$ curl -s -u ops:ops-password http://127.0.0.1:9001/manage/diag"
curl -s -u ops:ops-password http://127.0.0.1:9001/manage/diag | python3 -m json.tool
echo
echo "--- listening sockets ---"
echo "\$ ss -ltn | grep -E ':(8080|9001)'"
ss -ltn 2>/dev/null | grep -E ':(8080|9001)' || netstat -ltn 2>/dev/null | grep -E ':(8080|9001)'
echo
echo " 9001 is bound to 127.0.0.1 only. 8080 is bound to *. An ingress that forwards to 8080"
echo " cannot reach Actuator no matter how the security rules are written."
} > "$OUT" 2>&1
echo "wrote $OUT"

53
scripts/demo-open-actuator.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# What an unauthenticated caller actually gets when exposure is "*" and the chain permits all.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/03-open-actuator-leak.txt
B=http://localhost:8080/actuator
{
echo "### profiles: exposeall,open -- NO credentials are sent on any request below"
echo
echo "--- 1. /actuator/env does NOT leak values in Spring Boot 4 ---"
echo "\$ curl -s $B/env/spring.datasource.password | jq .property"
curl -s "$B/env/spring.datasource.password" | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["property"],indent=2))'
echo "\$ curl -s $B/env/acme.partner.credential | jq .property"
curl -s "$B/env/acme.partner.credential" | python3 -c 'import json,sys;print(json.dumps(json.load(sys.stdin)["property"],indent=2))'
echo
echo " Note the second one. 'acme.partner.credential' matches none of the classic"
echo " password/secret/token key patterns, and it is still masked. Masking is driven by"
echo " management.endpoint.env.show-values, which defaults to 'never' - not by key names."
echo
echo "--- 2. /actuator/heapdump is NOT exposed by 'include: \"*\"' ---"
echo "\$ curl -s -o /dev/null -w '%{http_code}' $B/heapdump"
curl -s -o /dev/null -w '%{http_code}\n' "$B/heapdump"
echo " management.endpoint.heapdump.access defaults to 'none'. So does shutdown."
echo " They are the only two endpoints that do."
echo
echo "--- 3. /actuator/loggers: an unauthenticated WRITE ---"
echo "\$ curl -s -X POST -d '{\"configuredLevel\":\"TRACE\"}' -H 'Content-Type: application/json' $B/loggers/org.springframework"
curl -s -o /dev/null -w ' status=%{http_code}\n' -X POST -H 'Content-Type: application/json' \
-d '{"configuredLevel":"TRACE"}' "$B/loggers/org.springframework"
echo "\$ curl -s $B/loggers/org.springframework"
curl -s "$B/loggers/org.springframework" | python3 -m json.tool
curl -s -o /dev/null -X POST -H 'Content-Type: application/json' \
-d '{"configuredLevel":null}' "$B/loggers/org.springframework"
echo " (level reset). An attacker who can flip your root logger to TRACE has both a"
echo " denial-of-service primitive and a way to get request bodies written to disk."
echo
echo "--- 4. /actuator/beans and /actuator/mappings: your whole application, described ---"
echo "\$ curl -s $B/mappings | python3 -c 'count the URL patterns'"
curl -s "$B/mappings" | python3 -c '
import json,sys
d=json.load(sys.stdin)
n=0
for ctx in d["contexts"].values():
for m in ctx["mappings"].get("dispatcherServlets",{}).values():
n+=len(m)
print(f" {n} servlet mappings disclosed")'
curl -s "$B/beans" | python3 -c '
import json,sys
d=json.load(sys.stdin)
n=sum(len(c["beans"]) for c in d["contexts"].values())
print(f" {n} beans disclosed, each with its type and dependencies")'
} > "$OUT" 2>&1
echo "wrote $OUT"

45
scripts/demo-secured.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# The authorisation matrix produced by SecuredActuatorConfig.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/05-secured-matrix.txt
B=http://localhost:8080/actuator
probe() { # $1 label, $2 path, $3 curl auth args...
local label="$1" path="$2"; shift 2
printf ' %-46s %s\n' "$label" "$(curl -s -o /dev/null -w '%{http_code}' "$@" "$B/$path")"
}
{
echo "### profile: secured (SecuredActuatorConfig + application-secured.yaml)"
echo "### exposure is \"*\" - the security chain, not the exposure list, is what protects it"
echo
echo "ANONYMOUS"
probe "GET /actuator/health" health
probe "GET /actuator/info" info
probe "GET /actuator/env" env
probe "GET /actuator/beans" beans
probe "GET /actuator/threaddump" threaddump
probe "GET /actuator (the links index)" ""
echo
echo "AUTHENTICATED as ops (ROLE_ACTUATOR)"
probe "GET /actuator/health" health -u ops:ops-password
probe "GET /actuator/env" env -u ops:ops-password
probe "GET /actuator/beans" beans -u ops:ops-password
probe "GET /actuator/threaddump" threaddump -u ops:ops-password
echo
echo "WRONG PASSWORD"
probe "GET /actuator/env" env -u ops:wrong
echo
echo "--- health body, anonymous (show-details: when-authorized) ---"
curl -s "$B/health" | python3 -m json.tool
echo
echo "--- health body, authenticated as ROLE_ACTUATOR ---"
curl -s -u ops:ops-password "$B/health" | python3 -m json.tool
echo
echo " Same endpoint, same status code, different body. An anonymous prober learns that the"
echo " service is unhealthy but not WHICH dependency is unhealthy."
echo
echo "--- the business endpoint is untouched by the actuator chain ---"
printf ' %-46s %s\n' "GET /orders/count (anonymous)" \
"$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/orders/count)"
} > "$OUT" 2>&1
echo "wrote $OUT"

24
scripts/demo-slow-indicator.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# A health indicator with no timeout, and what it does to the endpoint.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/10-slow-upstream.txt
B=http://localhost:8080
{
echo "### profile: details upstream deliberately sleeping 30s per request"
echo "### demo.upstream.timeout-ms = 750, so the indicator gives up long before the stub replies"
echo
curl -s -o /dev/null -u ops:ops-password -X POST "$B/stub/upstream/mode?value=slow"
sleep 1
echo "\$ time curl -s $B/actuator/health/externalApi"
s=$(date +%s.%N)
curl -s -u ops:ops-password --max-time 60 "$B/actuator/health/externalApi" | python3 -m json.tool
e=$(date +%s.%N)
printf 'wall clock: %.2f s\n' "$(echo "$e - $s" | bc)"
echo
echo " The read timeout is what bounds this, not the endpoint. Remove setReadTimeout from"
echo " ExternalApiHealthIndicator and this call blocks for the full 30 seconds, holding a"
echo " Tomcat worker the whole time."
curl -s -o /dev/null -u ops:ops-password -X POST "$B/stub/upstream/mode?value=up"
} > "$OUT" 2>&1
echo "wrote $OUT"

19
scripts/demo-versions.sh Executable file
View File

@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Exactly what this repository was built and run against.
set -uo pipefail
cd "$(dirname "$0")/.."
OUT=docs/output/00-versions.txt
{
echo "\$ java -version"
java -version 2>&1 | grep -viE 'JAVA_TOOL_OPTIONS|Picked up'
echo
echo "\$ mvn -v | head -3"
mvn -v 2>&1 | grep -viE 'WARNING|sun\.misc|Picked up|Please consider' | head -3
echo
echo "\$ mvn dependency:list -- the resolved versions behind spring-boot-starter-parent 4.1.1"
mvn -B dependency:list 2>/dev/null \
| grep -oE '(org\.springframework[a-z.]*|io\.micrometer|org\.apache\.kafka|com\.h2database):[A-Za-z0-9.-]+:jar:[0-9][A-Za-z0-9.-]*' \
| awk -F':' '{printf " %-52s %s\n", $1":"$2, $4}' | sort -u \
| grep -E 'spring-boot-actuator |spring-boot-health |spring-boot |spring-core |spring-web |spring-security-core |spring-security-web |micrometer-core |micrometer-registry-prometheus |kafka-clients |h2 |spring-boot-restclient '
} > "$OUT" 2>&1
echo "wrote $OUT"

8
scripts/env.sh Executable file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Shared environment. Point JAVA_HOME at a JDK 25 (or newer) installation.
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
export PATH="$JAVA_HOME/bin:$PATH"
MVN="${MVN:-mvn}"
APP_MAIN="com.ankurm.actuator.ActuatorProductionApplication"
APP_PORT="${APP_PORT:-8080}"
MGMT_PORT="${MGMT_PORT:-9001}"

70
scripts/run-all.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Regenerate every file in docs/output/ from scratch.
#
# JAVA_HOME=/path/to/jdk25 ./scripts/run-all.sh
#
# Each scenario starts a fresh JVM, runs its probes, and stops it. Timing figures vary between
# machines; the status codes and bodies do not.
set -uo pipefail
cd "$(dirname "$0")/.."
: "${JAVA_HOME:?set JAVA_HOME to a JDK 25+ installation}"
export PATH="$JAVA_HOME/bin:$PATH"
JAR=target/actuator-production-1.0.0.jar
[ -f "$JAR" ] || mvn -B -q -DskipTests package
HEAP="${HEAP:--Xmx384m}"
APP_PID=""
start() { # $1 profiles, rest: extra --args
local profiles="$1"; shift
java $HEAP -jar "$JAR" --spring.profiles.active="$profiles" "$@" > /tmp/actuator-demo.log 2>&1 &
APP_PID=$!
local port=8080 url="http://127.0.0.1:8080/orders/count"
for _ in $(seq 1 90); do
c=$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password "$url" || true)
[ -n "$c" ] && [ "$c" != "000" ] && return 0
sleep 1
done
echo "FAILED TO START (profiles=$profiles)" >&2; tail -30 /tmp/actuator-demo.log >&2; return 1
}
stop() {
[ -n "$APP_PID" ] && kill -9 "$APP_PID" 2>/dev/null
wait "$APP_PID" 2>/dev/null
APP_PID=""
for _ in $(seq 1 40); do
(exec 3<>/dev/tcp/127.0.0.1/8080) 2>/dev/null || break
sleep 0.25
done
exec 3<&- 2>/dev/null || true
}
trap stop EXIT
mkdir -p docs/output
scripts/demo-versions.sh
start "" && { scripts/demo-default-exposure.sh; stop; }
start "exposeall,open" && { scripts/demo-endpoint-catalogue.sh;
scripts/demo-open-actuator.sh; stop; }
start "exposeall,open" --management.endpoint.heapdump.access=unrestricted \
&& { scripts/demo-heapdump-leak.sh; stop; }
start "secured" && { scripts/demo-secured.sh; stop; }
start "details" && { scripts/demo-custom-health.sh;
scripts/demo-slow-indicator.sh;
scripts/demo-kafka-timeout.sh tuned; stop; }
start "details,kafkanaive" && { scripts/demo-kafka-timeout.sh naive; stop; }
start "groups" && { scripts/demo-groups-probes.sh; stop; }
# The management-port scenario readies on a different URL, so it is started by hand.
java $HEAP -jar "$JAR" --spring.profiles.active=mgmtport > /tmp/actuator-demo.log 2>&1 &
APP_PID=$!
for _ in $(seq 1 90); do
c=$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password http://127.0.0.1:9001/manage/health || true)
[ -n "$c" ] && [ "$c" != "000" ] && break
sleep 1
done
scripts/demo-management-port.sh
stop
echo
echo "docs/output/ regenerated:"
ls -1 docs/output/

26
scripts/run.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Start the application with the given comma-separated profiles and block until it answers.
# ./scripts/run.sh # defaults
# ./scripts/run.sh exposeall,open # every endpoint, no authentication
set -euo pipefail
cd "$(dirname "$0")/.."
source scripts/env.sh
PROFILES="${1:-}"
LOG="${LOG:-/tmp/actuator-demo.log}"
scripts/stop.sh
ARGS=(-B -o org.springframework.boot:spring-boot-maven-plugin:run)
[ -n "$PROFILES" ] && ARGS+=("-Dspring-boot.run.profiles=$PROFILES")
setsid nohup "$MVN" "${ARGS[@]}" > "$LOG" 2>&1 < /dev/null &
READY_URL="${READY_URL:-http://127.0.0.1:${APP_PORT}/orders/count}"
for _ in $(seq 1 90); do
code=$(curl -s -o /dev/null -w '%{http_code}' -u ops:ops-password "$READY_URL" || true)
[ "$code" != "000" ] && [ -n "$code" ] && exit 0
sleep 2
done
echo "application did not start; tail of $LOG:" >&2
tail -40 "$LOG" >&2
exit 1

14
scripts/stop.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Kill by main class, never by a pattern that could match this script's own command line.
# `pkill -f spring-boot` matches the shell running it and takes the terminal with it.
set -u
for p in $(ps -eo pid,args | grep '[A]ctuatorProductionApplication' | awk '{print $1}'); do
kill -9 "$p" 2>/dev/null || true
done
# Wait for the port to actually close. Killing the PID is not the same as the socket being
# free, and a stale listener looks exactly like your configuration change having no effect.
for _ in $(seq 1 40); do
if ! (exec 3<>/dev/tcp/127.0.0.1/"${APP_PORT:-8080}") 2>/dev/null; then break; fi
sleep 0.25
done
exec 3<&- 2>/dev/null || true