Capstone: A Real Java Application

Packaging & Running

15 min Lesson 9 of 13

Packaging & Running

Writing correct code is only half the job. A professional Java application must be reliably buildable, transportable, and executable on any target machine — from a colleague's laptop to a production server. This lesson covers the full packaging pipeline: compiling with Maven or Gradle, producing executable JARs, managing the runtime environment, and running the finished artifact both locally and on a server.

Understanding the Build Lifecycle

Both Maven and Gradle model builds as a sequence of phases. Understanding the lifecycle prevents the common mistake of running the wrong goal at the wrong time.

Maven lifecycle phases (ordered):

  1. validate — checks the project is correct and all required information is present.
  2. compile — compiles source code into target/classes.
  3. test — runs unit tests via Surefire; the build fails if any test fails.
  4. package — bundles compiled classes into a JAR (or WAR) in target/.
  5. verify — runs integration checks (e.g. Failsafe plugin).
  6. install — installs the artifact into the local Maven repository (~/.m2).
  7. deploy — pushes the artifact to a remote repository (Nexus, Artifactory, GitHub Packages).
Each phase implies all previous ones. Running mvn package automatically runs validate, compile, and test first. If you only want to compile without testing, use mvn package -DskipTests — but do that only on a developer workstation, never in CI.

Building an Executable Fat JAR with Maven

A standard JAR contains only your own classes. To run it anywhere without a separate classpath, you need a fat JAR (also called an uber JAR) — your classes plus every dependency bundled into one file. The Maven Shade plugin is the standard way to produce one.

<!-- pom.xml — add inside <build><plugins> --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <transformers> <transformer implementation= "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.app.Main</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin>

After adding this plugin, run:

mvn clean package # produces target/myapp-1.0.0-shaded.jar java -jar target/myapp-1.0.0-shaded.jar

Building with Gradle

Gradle's equivalent is the shadowJar plugin (com.github.johnrengelman.shadow). Add it to build.gradle:

plugins { id 'java' id 'com.github.johnrengelman.shadow' version '8.1.1' } jar { manifest { attributes 'Main-Class': 'com.example.app.Main' } }

Then build and run:

./gradlew clean shadowJar # produces build/libs/myapp-all.jar java -jar build/libs/myapp-all.jar
Use clean before every release build. Stale class files from a previous compilation can slip into the JAR unnoticed, causing subtle bugs that never reproduce from a fresh checkout. Make clean package (or clean shadowJar) a habit in CI.

The MANIFEST.MF File

The entry point of an executable JAR is declared in META-INF/MANIFEST.MF inside the archive. The critical line is:

Main-Class: com.example.app.Main

If this line is missing or points to a class without a public static void main(String[] args) method, the JVM throws java.lang.NoSuchMethodError: main or "Main manifest attribute not found". Both plugins above write this file automatically when you declare the mainClass.

Externalising Configuration at Runtime

A JAR built once must run in development, staging, and production without recompilation. Externalise all environment-specific values:

// Reading a system property (passed via -D on the command line) String dbUrl = System.getProperty("db.url", "jdbc:sqlite:local.db"); // Reading an environment variable (preferred for containers) String apiKey = System.getenv("API_KEY"); if (apiKey == null) { throw new IllegalStateException("API_KEY environment variable is required"); }

Pass system properties at runtime:

java -Ddb.url=jdbc:mysql://prod-host/mydb \ -Ddb.user=appuser \ -jar target/myapp-1.0.0-shaded.jar
Never bake credentials into the JAR. Any value hardcoded in source code or bundled inside the archive is visible to anyone who unzips the JAR with jar tf or unzip. Use environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault) for all sensitive configuration.

Loading a Properties File at Startup

For non-sensitive configuration, a .properties file loaded at startup is cleaner than a long list of -D flags:

import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; public class AppConfig { private static final Properties props = new Properties(); static { // Look for config.properties next to the JAR first, // then fall back to the classpath default Path external = Path.of("config.properties"); try { if (Files.exists(external)) { try (InputStream in = Files.newInputStream(external)) { props.load(in); } } else { try (InputStream in = AppConfig.class .getResourceAsStream("/config.properties")) { if (in != null) props.load(in); } } } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static String get(String key, String defaultValue) { return props.getProperty(key, defaultValue); } }

JVM Tuning Flags for Production

The default JVM settings are calibrated for developer machines. In production, tune at minimum:

java \ -Xms256m \ # initial heap (pre-allocate to avoid GC spikes at startup) -Xmx512m \ # maximum heap (prevents unbounded growth; tune to your host) -XX:+UseG1GC \ # G1 garbage collector — best default for most workloads -XX:+HeapDumpOnOutOfMemoryError \ # capture heap dump for post-mortem analysis -XX:HeapDumpPath=/var/log/myapp/ \ -jar myapp-shaded.jar

Running as a Background Service (Linux systemd)

On a Linux server, wrap the JAR in a systemd unit so it starts on boot and restarts on failure:

# /etc/systemd/system/myapp.service [Unit] Description=My Java Application After=network.target [Service] User=appuser WorkingDirectory=/opt/myapp ExecStart=/usr/bin/java \ -Xms256m -Xmx512m \ -Dspring.profiles.active=prod \ -jar /opt/myapp/myapp-shaded.jar Restart=on-failure RestartSec=5 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target
# Enable and start the service sudo systemctl daemon-reload sudo systemctl enable myapp sudo systemctl start myapp sudo journalctl -u myapp -f # tail the logs

Smoke-Testing the Packaged Artifact

Before deploying, always verify the JAR works from a clean directory — not from your IDE's working directory:

# Copy the JAR to a temp directory with no source code mkdir /tmp/smoke-test cp target/myapp-1.0.0-shaded.jar /tmp/smoke-test/ cp config.properties /tmp/smoke-test/ cd /tmp/smoke-test # Run with production-equivalent flags API_KEY=test-key java -Xmx256m -jar myapp-1.0.0-shaded.jar # Verify exit code echo "Exit: $?"

This catches the most common packaging mistake: a resource or file that your IDE exposes on the classpath during development but that is absent from the bundled JAR.

Summary

A production-ready Java artifact is built with mvn clean package (Shade plugin) or ./gradlew clean shadowJar. The MANIFEST.MF declares the entry point; all environment-specific configuration is externalised via system properties, environment variables, or a side-car properties file. JVM heap flags and a systemd unit complete the deployment story. Smoke-test the JAR from a clean directory before every release to catch classpath-only resources early.