Why You Need a Run Script
Operating a single JAR file without mistakes is trickier than it sounds. If you run java -jar app.jar, the process dies the moment your terminal closes. Adding nohup helps, but “starting another instance on top of one that’s already running” is a classic production incident. On a real server, you need a thin run script that handles deployment automation, restart, log toggling, and PID tracking in one place.
This article dissects a production-ready run.sh template step by step. We’ll assume a JDK 8+ Java service (a backend called PMS) and build a script that supports five commands: start | stop | restart | status | run.
Full Script Overview
Let’s see the complete run.sh first, then break it down section by section. The script below manages a Java service deployed at /sw/pms.
#!/bin/sh
# run.sh — Java service start/stop/restart/status script
ulimit -n 65536
SERVICE_NAME="PMS"
SERVICE_PATH="/sw/pms"
RUN_LOG="/sw/pms/logs/pms.run.log"
NULL_LOG="/dev/null"
JAVA_PATH="/sw/jdk8_471/bin/java"
JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"
JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"
PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"
RUN_SUDO=""
SERVICE_LOG="$NULL_LOG"
if [ "$2" = "log" ]; then
SERVICE_LOG="$RUN_LOG"
fi
mkdir -p "$(dirname "$RUN_LOG")"
mkdir -p "$(dirname "$PID_PATH_NAME")"
if [ ! -x "$JAVA_PATH" ]; then
echo "Java not found or not executable: $JAVA_PATH"
exit 1
fi
if [ ! -d "$SERVICE_PATH" ]; then
echo "Service path not found: $SERVICE_PATH"
exit 1
fi
is_running() {
if [ -f "$PID_PATH_NAME" ]; then
PID=$(cat "$PID_PATH_NAME")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
return 0
fi
fi
return 1
}
start_service() {
echo "Starting $SERVICE_NAME ..."
if is_running; then
echo "$SERVICE_NAME is already running ..."
echo "pid = $(cat "$PID_PATH_NAME")"
return 0
fi
rm -f "$PID_PATH_NAME"
cd "$SERVICE_PATH" || exit 1
nohup "$JAVA_PATH" \
-classpath "$JAVA_CLASS_PATH" \
$JAVA_OPT \
"$JAVA_MAIN_CLASS" \
> "$SERVICE_LOG" 2>&1 &
PID=$!
echo "$PID" > "$PID_PATH_NAME"
sleep 1
if kill -0 "$PID" 2>/dev/null; then
echo "$SERVICE_NAME started ..."
echo "pid = $PID"
else
echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
rm -f "$PID_PATH_NAME"
exit 1
fi
}
stop_service() {
if ! is_running; then
echo "$SERVICE_NAME is not running ..."
rm -f "$PID_PATH_NAME"
return 0
fi
PID=$(cat "$PID_PATH_NAME")
echo "$SERVICE_NAME stopping ..."
kill "$PID"
COUNT=0
while kill -0 "$PID" 2>/dev/null; do
COUNT=$((COUNT + 1))
if [ "$COUNT" -ge 10 ]; then
echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
kill -9 "$PID" 2>/dev/null
break
fi
sleep 1
done
rm -f "$PID_PATH_NAME"
echo "$SERVICE_NAME stopped ..."
}
case "$1" in
run)
cd "$SERVICE_PATH" || exit 1
exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
;;
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
status)
if is_running; then
echo "$SERVICE_NAME is running."
echo "pid = $(cat "$PID_PATH_NAME")"
else
echo "$SERVICE_NAME is not running."
fi
;;
*)
echo "$SERVICE_NAME Service..."
echo "Usage: $0 { run | start | stop | restart | status } { log }"
exit 1
;;
esac
The script has four distinct sections: the configuration block at the top (path and JVM-option variables), the pre-flight checks (directory creation, Java executable existence), the function block (is_running, start_service, stop_service), and finally the dispatcher block (case "$1" in ...). When porting to a new server, the only lines you actually need to touch are the top variables — the rest of the template reuses verbatim. We’ll walk through each block below.
1. Shebang and File Descriptor Limit
#!/bin/sh
ulimit -n 65536
#!/bin/sh declares that this script runs under a POSIX-compatible shell. You could use #!/bin/bash instead, but sticking to /bin/sh keeps it compatible with minimal images like Alpine or BusyBox.
ulimit -n 65536 raises the number of file descriptors the process can open to 65,536. This prevents the Too many open files error when a Java service has many concurrent sockets or open files. Keep in mind that the system-wide limit must also be raised in /etc/security/limits.conf separately.
2. Classpath and JVM Options
JAVA_CLASS_PATH="${SERVICE_PATH}:${SERVICE_PATH}/bin:${SERVICE_PATH}/conf:${SERVICE_PATH}/lib/*:${SERVICE_PATH}/dependency/*"
JAVA_MAIN_CLASS="moa.service.pms.MainService"
JAVA_OPT="-Dfile.encoding=UTF-8 \
-Dlog4j.configurationFile=${SERVICE_PATH}/conf/log4j2.xml \
-Djava.security.egd=file:/dev/./urandom \
-Xms256m -Xmx512m"
| Item | Description |
|---|---|
lib/*, dependency/* | Wildcards are expanded by the JVM itself to include every JAR in the directory |
-Dfile.encoding=UTF-8 | Reads/writes files as UTF-8 regardless of system locale |
-Dlog4j.configurationFile | Path to the Log4j2 configuration file |
-Djava.security.egd=file:/dev/./urandom | Avoids /dev/random blocking. Essential to prevent Tomcat startup delays |
-Xms256m -Xmx512m | Initial/maximum heap size. Setting Xms = Xmx reduces GC variance |
JAVA_OPT is intentionally left unquoted so the shell word-splits it on expansion. If you ever need options that contain whitespace, switch to an array (JAVA_OPT=(... )) for safety.
3. PID-Based Duplicate Execution Guard
PID_PATH_NAME="${SERVICE_PATH}/proc/pms.pid"
is_running() {
if [ -f "$PID_PATH_NAME" ]; then
PID=$(cat "$PID_PATH_NAME")
if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then
return 0
fi
fi
return 1
}
The heart of any ops script is answering “is this service actually alive right now?” in one function. A PID file alone isn’t enough — if the process dies abnormally, the PID file lingers even though the process is gone. That’s the stale PID problem.
kill -0 "$PID" is the idiomatic way to probe a process without sending a real signal. It exits 0 if the process exists and non-zero otherwise, so stale PIDs are filtered out automatically.
4. start: nohup + PID Recording
start_service() {
echo "Starting $SERVICE_NAME ..."
if is_running; then
echo "$SERVICE_NAME is already running ..."
echo "pid = $(cat "$PID_PATH_NAME")"
return 0
fi
rm -f "$PID_PATH_NAME"
cd "$SERVICE_PATH" || exit 1
nohup "$JAVA_PATH" \
-classpath "$JAVA_CLASS_PATH" \
$JAVA_OPT \
"$JAVA_MAIN_CLASS" \
> "$SERVICE_LOG" 2>&1 &
PID=$!
echo "$PID" > "$PID_PATH_NAME"
sleep 1
if kill -0 "$PID" 2>/dev/null; then
echo "$SERVICE_NAME started ..."
echo "pid = $PID"
else
echo "$SERVICE_NAME failed to start. Check log: $SERVICE_LOG"
rm -f "$PID_PATH_NAME"
exit 1
fi
}
Three key points here.
- The nohup + & combination creates a background process that survives terminal disconnection.
> "$SERVICE_LOG" 2>&1merges stdout and stderr into a single file. $!is the PID of the most recently backgrounded process. You must save it to the PID file immediately so the nextis_runningcheck works.sleep 1+kill -0performs a minimal health check: is it still alive one second later? This catches immediate crashes from classpath errors or port conflicts.
$SERVICE_LOG defaults to /dev/null; passing log as the second argument (./run.sh start log) redirects output to RUN_LOG instead. Since Log4j2 writes its own operational logs to separate files, this pattern keeps stdout at /dev/null by default and turns it on only when reproducing an issue.
5. stop: Graceful SIGTERM → Forceful SIGKILL
stop_service() {
if ! is_running; then
echo "$SERVICE_NAME is not running ..."
rm -f "$PID_PATH_NAME"
return 0
fi
PID=$(cat "$PID_PATH_NAME")
echo "$SERVICE_NAME stopping ..."
kill "$PID"
COUNT=0
while kill -0 "$PID" 2>/dev/null; do
COUNT=$((COUNT + 1))
if [ "$COUNT" -ge 10 ]; then
echo "$SERVICE_NAME did not stop gracefully. Killing forcefully ..."
kill -9 "$PID" 2>/dev/null
break
fi
sleep 1
done
rm -f "$PID_PATH_NAME"
echo "$SERVICE_NAME stopped ..."
}
kill PID sends SIGTERM (15) by default. The JVM catches this signal, runs shutdown hooks, and exits cleanly. In-flight DB transactions, message queue ACKs, and Log4j buffer flushes all get a chance to finish at this moment.
But if a shutdown hook blocks or deadlocks, the process might never exit. That’s why we poll every second up to 10 times, and if the process still lives, we escalate to kill -9 (SIGKILL). The number 10 is a tunable knob — raise it to 30–60 for batch services with long-running queue operations.
6. status and Dispatch
case "$1" in
run)
cd "$SERVICE_PATH" || exit 1
exec "$JAVA_PATH" -classpath "$JAVA_CLASS_PATH" $JAVA_OPT "$JAVA_MAIN_CLASS"
;;
start) start_service ;;
stop) stop_service ;;
restart) stop_service; start_service ;;
status)
if is_running; then
echo "$SERVICE_NAME is running."
echo "pid = $(cat "$PID_PATH_NAME")"
else
echo "$SERVICE_NAME is not running."
fi
;;
*)
echo "$SERVICE_NAME Service..."
echo "Usage: $0 { run | start | stop | restart | status } { log }"
exit 1
;;
esac
This is the command dispatcher. Here’s what each case means.
| Command | Behavior | When to use |
|---|---|---|
run | Foreground execution via exec | Docker ENTRYPOINT, systemd Type=simple |
start | Background via nohup | Manual start on an SSH-attached VM |
stop | SIGTERM → SIGKILL after 10s | Safe shutdown before deployment |
restart | stop → start in sequence | Restart after config changes |
status | Checks PID file + live process | Monitoring, health checks |
The exec in the run case replaces the shell with the JVM process. With no intermediate shell, systemd and Docker manage the JVM directly, and docker stop delivers SIGTERM straight to the JVM.
7. Usage Examples
# Grant execute permission
chmod +x /sw/pms/run.sh
# Basic start (stdout to /dev/null)
/sw/pms/run.sh start
# Start and capture stdout to a file
/sw/pms/run.sh start log
# Check status
/sw/pms/run.sh status
# PMS is running.
# pid = 23145
# Restart
/sw/pms/run.sh restart
# Stop
/sw/pms/run.sh stop
Pairing with systemd
Once you have a working run.sh, the cleanest way to register it with systemd is to use the run subcommand as the ExecStart value. Because foreground execution is required and systemd owns PID management, you avoid the double-bookkeeping of PID files.
# /etc/systemd/system/pms.service
[Unit]
Description=PMS Service
After=network.target
[Service]
Type=simple
User=pms
WorkingDirectory=/sw/pms
ExecStart=/sw/pms/run.sh run
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
On the other hand, environments without systemd (older SysVinit boxes, cron-driven servers, simple VMs) can just use start and stop. The biggest advantage of this pattern is that a single run.sh covers both operational models.
Production Tips and Pitfalls
- Don’t run as root. Create a dedicated service account (
pms, etc.), runchown -R pms:pms /sw/pms, and execute as that user. This limits the blast radius of any security incident. - Hard-code the absolute JDK path. Relying on
javafrom$PATHpicks up whichever version the shell happens to resolve, causing unreproducible bugs. - Verify write permissions on the PID directory up front.
/var/runis wiped on reboot, so the application directory ($SERVICE_PATH/proc) is usually safer for operations. - Tune JVM options: adding
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=$SERVICE_PATH/logsby default gives you a post-mortem artifact when OOM strikes. - Set graceful shutdown timeout longer than your longest expected task (e.g., one full batch cycle). Too short a timeout risks data loss.
Copy this template and change only SERVICE_NAME, SERVICE_PATH, JAVA_PATH, and JAVA_MAIN_CLASS — it will fit almost any Java service as-is. A well-designed run script is a reusable asset for years, so it’s worth promoting to a team-wide standard once you’ve tuned it.