Skip to main content

Command Palette

Search for a command to run...

From Code to Clean Uninstall: A Practical Guide to RPM Packaging with a Java HTTP Server

Updated
•20 min read•View as Markdown
From Code to Clean Uninstall: A Practical Guide to RPM Packaging with a Java HTTP Server

Introduction

If you’ve ever built an application and wondered how it becomes a cleanly installable, upgradeable, and removable package on a Linux system, you’re about to connect all the dots.

This guide walks through a complete journey:

build application → package as RPM → install → upgrade → uninstall cleanly

Instead of theory alone, we’ll use a real Java HTTP server application to understand how RPM packaging works in practice.

What is Package in Linux?

Most software applications designed for Linux or Unix systems are distributed as packages, which are archives that contain:

  • Application files (binaries, scripts, libraries)

  • Metadata (name, version, dependencies)

  • Installation instructions (scriptlets)

  • Information about dependencies, documentation

For example, when installing a widely used tool like Python, the operating system retrieves a pre-built package from its package repositories, which contains the Python interpreter, standard libraries, and all required dependencies.

These packages are typically specific to a particular distribution and formatted in that distribution's preferred package format, such as .deb for Debian/Ubuntu and .rpm for CentOS/RHEL/Fedora.

RedHat Package Manager

The RPM Package Manager (RPM) is a package management system that runs on Red Hat Enterprise Linux (RHEL), CentOS, and Fedora. RPM provides tools to install, update, query, verify, and remove software packages. It ensures consistency across systems by managing dependencies and maintaining a database of installed packages.

RPM Packages

An RPM package is the file format used by the RPM system to distribute software, usually identified by the .rpm file extension.


Prerequisites

Before proceeding with the steps in this guide, ensure that the Java Development Kit (JDK) is installed on the system. The JDK provides tools such as javac and jar, which are required to compile the source code and generate the application JAR file.

We can verify the installation using:

javac --version
java --version
jar --version

If these commands return version information, our environment is ready for the compilation and packaging steps that follow.

Otherwise, we can also install JDK on our system using

sudo dnf install java-21-openjdk-devel -y

💡 Any modern JDK version (such as JDK 17 or later) should work for this tutorial.

Overview of the Java HTTP Server Application

To make things practical, we built a simple Java-based HTTP server called student-api.

Project Directory Structure

rpm-playground/
`-- java-http-server/
    |-- bin
    |-- data/
    |   `-- students.json
    |-- dist
    `-- src/
        `-- studentapi/
            `-- StudentApiServer.java

Key Project Components

StudentApiServer.java

This is the main source code of our HTTP server.

It uses Java’s built-in HttpServer to create a lightweight web server that listens for incoming HTTP requests on port 5000 (default).

package studentapi;
import com.sun.net.httpserver.HttpServer;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;

import java.io.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Random;
import java.util.concurrent.Executors;

public class StudentApiServer {

    static Random random = new Random();
    static String STUDENT_FILE;

    public static void main(String[] args) throws Exception {

        if (args.length == 0) {
            System.err.println("Student file path not provided. Usage: java -jar student-api.jar <student_file> <port>");
            System.exit(1);
        }

        STUDENT_FILE = args[0];

        int port = 5000;

        if (args.length >= 2) {
            port = Integer.parseInt(args[1]);
        }

        HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);

        server.createContext("/", new HelpHandler());
        server.createContext("/students", new StudentHandler());

        server.setExecutor(Executors.newFixedThreadPool(4));
        server.start();

        System.out.println("Student API running on port " + port);
    }

    static class HelpHandler implements HttpHandler {

        @Override
        public void handle(HttpExchange exchange) throws IOException {

            String html =
                    "<html>" +
                    "<h1>Student API</h1>" +
                    "<p>Available endpoints:</p>" +
                    "<ul>" +
                    "<li>GET /students - list all students</li>" +
                    "<li>POST /students - create a fake student</li>" +
                    "</ul>" +
                    "</html>";

            sendResponse(exchange, 200, html, "text/html");
        }
    }

    static class StudentHandler implements HttpHandler {

        @Override
        public void handle(HttpExchange exchange) throws IOException {

            String method = exchange.getRequestMethod();

            if (method.equalsIgnoreCase("GET")) {
                handleGet(exchange);
            }
            else if (method.equalsIgnoreCase("POST")) {
                handlePost(exchange);
            }
            else {
                sendResponse(exchange, 405, "Method Not Allowed", "text/plain");
            }
        }

        private void handleGet(HttpExchange exchange) throws IOException {

            File file = new File(STUDENT_FILE);

            if (!file.exists()) {
                sendResponse(exchange, 500,
                        "{\"error\":\"students.json not found\"}",
                        "application/json");
                return;
            }

            String json = Files.readString(Paths.get(STUDENT_FILE));

            sendResponse(exchange, 200, json, "application/json");
        }

        private void handlePost(HttpExchange exchange) throws IOException {

            InputStream body = exchange.getRequestBody();
            String name = new String(body.readAllBytes(), StandardCharsets.UTF_8).trim();

            int id = random.nextInt(10000);

            String response = "{\"id\":" + id + ",\"name\":\"" + name + "\"}";

            sendResponse(exchange, 201, response, "application/json");
        }
    }

    static void sendResponse(HttpExchange exchange, int status, String response, String type) throws IOException {

        exchange.getResponseHeaders().add("Content-Type", type);

        byte[] bytes = response.getBytes(StandardCharsets.UTF_8);

        exchange.sendResponseHeaders(status, bytes.length);

        OutputStream os = exchange.getResponseBody();
        os.write(bytes);
        os.close();
    }
}

Features

  • Defines API endpoints:

    • GET / → Displays a simple help page

    • GET /students → Returns student data from a file

    • POST /students → Generates a new student (does not persist it)

  • Uses a thread pool to handle multiple requests efficiently.

students.json

This file acts as a data source for the application. It is read by the API when handling GET /students.

[
  {"id":1001,"name":"Alice"},
  {"id":1002,"name":"Bob"},
  {"id":1003,"name":"Charlie"},
  {"id":1004,"name":"David"}
]

Compilation and JAR creation

Before packaging, we need to compile the code and create a JAR file.

In the java-http-server directory,

  1. execute the following command to generate .class files inside the bin/ directory

    javac -d bin ./src/studentapi/StudentApiServer.java
    
  2. create a JAR inside the dist/ with the compiled classes using

    jar cfe dist/student-api.jar studentapi.StudentApiServer -C bin .
    

We're using the dist/ to store application artifact.

Verification

In the java-http-server directory, execute

java -jar ./dist/student-api.jar data/students.json 6000

Open your browser or use curl:

Configuration and Service Files

In addition to the core application logic, our project includes several supporting files that help in configuring, running, and managing the application.

Within the rpm-playground directory, we create a new folder named student-api-1.0 to store the required configuration and supporting files.

rpm-playground/
|-- java-http-server/
|   `-- ...
`-- student-api-1.0/
    |-- run.sh
    |-- student-api.service
    |-- student-api.conf
    `-- students.json

student-api.service:

This is a systemd service file used to run the application as a background service on Linux.

It is crucial as it

  • defines how the application starts (ExecStart)

  • ensures the app restarts automatically on failure

  • runs the app as a non-root user (nobody) for better security

  • integrates the app with the Linux service manager

[Unit]
Description=Student API Service
After=network.target

[Service]
Type=simple
ExecStart=/usr/lib/student-api/run.sh
SuccessExitStatus=0 143
Restart=on-failure
RestartSec=5s
User=nobody
Group=nobody
WorkingDirectory=/usr/lib/student-api

[Install]
WantedBy=multi-user.target

student-api.conf

This is a configuration file for the application. It stores configurable values like port number, path to the student data file to keep the configuration separate from code.

PORT=5000
STUDENT_FILE=/etc/student-api/students.json

run.sh

This is a shell script used to start the application. It loads configuration from student-api.conf, passes configuration values to the Java application, executes the JAR file.

#!/bin/bash

CONFIG="/etc/student-api/student-api.conf"

if [ -f "$CONFIG" ]; then

    source "$CONFIG"
       
fi


exec /usr/bin/java -jar /usr/lib/student-api/student-api.jar "$STUDENT_FILE" \
"${PORT}"

Additionally, we shall copy the dist/student-api.jar and data/students.json from java-http-server/ to student-api-1.0/.

rpm-playground/
|-- java-http-server/
|   `-- ...
`-- student-api-1.0/
    |-- run.sh
    |-- student-api.service
    |-- student-api.conf
    |-- students.json
    `-- student-api.jar

RPM Packaging Workspace

Next, we create a directory named rpm-build inside rpm-playground. This directory will serve as the workspace for building the RPM package, and it must contain the following standard subdirectories:

rpm-playground/
|-- java-http-server
|-- student-api-1.0
`-- rpm-build/
    |-- BUILD
    |-- BUILDROOT
    |-- SOURCES
    |-- SPECS
    |-- RPMS
    `-- SRPMS

Before diving into building packages, it’s important to understand a few core concepts.

Basics of RPM Packaging

RPM uses a predefined directory structure:

  • SOURCES → compressed source code archive, the rpmbuild command looks for them here

  • SPECS → the spec file (instructions for building RPM)

  • BUILDROOT → temporary install location, think of it as a fake root filesystem used during packaging

  • BUILD → temporary compilation area

  • RPMS → final RPM packages, in subdirectories for different architectures, e.g. in subdirectories x86_64 and noarch.

Preparing the Source Archive

We need to package our application files into a compressed archive which will be used by the RPM build system during the packaging process.

From within the rpm-playground directory, run the following commands:

tar -czf student-api-1.0.tar.gz student-api-1.0
mv student-api-1.0.tar.gz rpm-build/SOURCES

The first command creates a gzipped tar archive of the student-api-1.0 directory, and the second command moves it into the SOURCES directory inside rpm-build, where RPM expects source files to reside.

SPEC File

A spec file is a file with instructions that the rpmbuild utility uses to build an RPM package. It tells the build system what to do by defining instructions in a series of sections.

We shall keep our spec file student-api.spec in the rpm-build/SPECS directory we just created.

Name:           student-api
Version:        1.0
Release:        1%{?dist}
Summary:        Simple Java Student API service

License:        MIT
URL:            https://example.com/%{name}

BuildArch:      noarch


Source0:        https://example.com/%{name}/release/%{name}-%{version}.tar.gz
 
Requires:       java-21-openjdk

%description
A simple HTTP API service written in Java that exposes student endpoints.

%prep
%setup -q

%build
# we have nothing to build.

%install

mkdir -p %{buildroot}%{_prefix}/lib/student-api
mkdir -p %{buildroot}%{_unitdir}
mkdir -p %{buildroot}%{_sysconfdir}/student-api

install -m 0755 run.sh %{buildroot}%{_prefix}/lib/student-api/run.sh
install -m 0644 student-api.jar %{buildroot}%{_prefix}/lib/student-api/student-api.jar
install -m 0644 student-api.service %{buildroot}%{_unitdir}
install -m 0644 student-api.conf %{buildroot}%{_sysconfdir}/student-api/
install -m 0644 students.json %{buildroot}%{_sysconfdir}/student-api/

%files

%dir %{_prefix}/lib/student-api
%{_prefix}/lib/student-api/student-api.jar
%{_prefix}/lib/student-api/run.sh

%{_unitdir}/student-api.service

%dir %{_sysconfdir}/student-api/
%config(noreplace) %{_sysconfdir}/student-api/student-api.conf
%config(noreplace) %{_sysconfdir}/student-api/students.json

%post
%systemd_post student-api.service

%preun
%systemd_preun student-api.service

%postun
%systemd_postun_with_restart student-api.service

%changelog
* Tue Mar 10 2026 Steve <steve@example.com> - 1.0-1
- Initial package

Understanding RPM SPEC File Directives

The SPEC file consists of various directives that control how the package is created and managed.

Here's a detailed explanation of the common directives used in our spec file

Directive Description
Name The base name of the package, which should match the SPEC file name.
Version The upstream version number of the software.
Release The number of times this version of the software was released. Normally, we set the initial value to 1%{?dist}, and increment it with each new release of the package.
Summary A brief, one-line summary of the package.
URL The upstream project website for the software being packaged.
License The license of the software being packaged.
Source0 Path or URL to the compressed archive of the upstream source code. In case of an URL, RPM internally extracts the base filename. Also, RPM expects the compressed archive in the SOURCES directory.
BuildArch Since, our package is written in Java which is not architecture dependent, we set this to BuildArch: noarch. If not set, the package automatically inherits the Architecture of the machine on which it is built, for example x86_64.
Requires A comma or whitespace-separated list of packages required by the software to run once installed. There can be multiple entries of Requires, each on its own line in the SPEC file.
%description A full description of the software packaged in the RPM. This description can span multiple lines and can be broken into paragraphs.
%prep Specifies how to prepare the build environment, for example unpacking the compressed archives of the source code.
%build Specifies how to build the software into machine code (for compiled languages) or byte code (for some interpreted languages).
%install Defines how the application files are copied into a temporary build directory BUILDROOT which resembles the end user’s root directory. At this stage, we are not installing anything on the actual system. Instead, we are preparing a staging area that mimics how the files will look once the RPM is installed.
%files The list of files that will be installed in the end user’s system and their full path location on the end user’s system. Anything not listed in this section is silently discarded and never reaches the RPM
%changelog A record of changes that have happened to the package between different Version or Release builds.

Understanding RPM Macros

In addition to directives, RPM provides a rich set of macros that make package creation easier, cleaner, and more consistent.

Instead of hardcoding paths or values (like /usr or version numbers), it is recommended to use macros. This improves maintainability and reduces the chances of errors.

Consider a scenario where the package version needs to be referenced multiple times in the SPEC file.

Instead of writing the version manually everywhere, we define it once:

Version: 1.0

Then, we can reuse it using:

%{version}

RPM will automatically replace %{version} with 1.0 wherever it appears.

Following are the common macros used in our SPEC file:

Macro Description
%{?dist} Represents the distribution tag used during the build. For example, on RHEL 8, it evaluates to .el8.
%{name} The name of the RPM package as defined in the SPEC file.
%{version} The version of the package defined in the SPEC file.
%{buildroot} The temporary directory where files are staged before packaging. It usually expands to a path like %{_buildrootdir}/%{name}-%{version}-%{release}.%{_arch}.
%{prefix} The default installation prefix, typically /usr.
%{_unitdir} The directory where systemd service files are stored, usually /usr/lib/systemd/system.
%{_sysconfdir} The directory for configuration files, typically /etc.

RPM provides several macros that add clarity and control over how files are handled during installation and uninstallation.

Below are some commonly used macros in this section:

Macro Description
%dir Indicates that the specified path is a directory owned by the RPM. This ensures RPM can properly track and remove the directory during uninstallation if required.
%config(noreplace) Marks a file as a configuration file. If the file has been modified by the user, it will not be overwritten during package upgrades, preserving user changes.

Understanding RPM Scriptlets

RPM spec files have several sections which allow packages to run code on installation and removal. These bits of code are called scriptlets and are mostly used to update the running system with information from the package.

Common ones are:

Scriptlet Runs when
%pre before installation.
%post after installation
%preun before uninstall
%postun after uninstall or upgrade

Understanding systemd macros

Our package installs a systemd service, but merely placing the student-api.service file at /usr/lib/systemd/system/ does not make systemd aware of it.

systemd will not perform any of the following on its own:

  • reload its unit database

  • enable the service

  • restart it during upgrades

  • stop it during uninstall

So the service file just sits there doing nothing. RPM solves this with systemd macros. RPM provides helper macros so packagers don’t need to write raw shell logic.

For example, When installing a service, the package must ensure systemd notices the new unit. Without the systemd macros, the user would have to reload the daemon manually.

Similarly, when uninstalling a service, the package must ensure that systemd is updated to remove references to the deleted unit. Without it, the user would have to manually reload the daemon or clean up stale service entries.

The most common ones are:

  • %systemd_post reloads daemon and handles preset logic, so that systemd can detect the new unit file. It doesn't enable a service.

  • %systemd_preun stops a service and disables it so that it doesn't run after its files disappear.

  • %systemd_postun_with_restart reloads systemd units and restarts the service if needed after upgrades.

Building the RPM Package

To build RPM packages, the rpmbuild utility must be available on our system.

We can verify its installation using:

rpmbuild --version

If the command returns version information, we're ready to proceed.

Otherwise, we can install it using:

sudo dnf install rpm-build -y

Once installed, confirm that the utility is available:

rpmbuild --version

💡 The rpmbuild utility is responsible for processing the SPEC file and generating both source and binary RPM packages.

To create the RPM package for our Java application, navigate to the rpm-build directory and execute the following command:

rpmbuild --define "_topdir /home/ec2-user/java-http-server-rpm/rpm-build" -ba SPECS/student-api.spec

Make sure to replace the value of _topdir with the absolute path of your rpm-build directory.

Once the build process completes successfully, the generated RPM package student-api-1.0-1.el10.noarch.rpm can be found in the RPMS/noarch directory.

Lifecycle of an RPM Package

Now comes the most important part — understanding how the RPM package behaves in a real-world scenario.

1️⃣ Installation

From within the rpmbuild directory, install the package using:

sudo dnf install RPMS/noarch/student-api-1.0-1.el10.noarch.rpm -y

Once the installation completes successfully, we can verify the file placement:

  • Application files (run.sh, student-api.jar) should be present in:

    /usr/lib/student-api
    
  • Configuration files (student-api.conf, students.json) should be located in:

    /etc/student-api
    

To confirm that the package has been installed correctly, run:

dnf list --installed student-api

This should display the name and currently installed version of the package :

Installed Packages
student-api.noarch     1.0-1.el10                  @commandline

🔍 Verifying the Service Installation

To confirm that the service has been registered correctly, run:

systemctl list-unit-files student-api.service

We should see something like

UNIT FILE           STATE    PRESET
student-api.service disabled disabled

▶️ Starting the Service

Start the application using:

sudo systemctl start student-api

🧪 Testing the Application

To verify that the API is functioning as expected:

curl http://localhost:5000/students

If everything is configured correctly, we should receive the following response:

[
  {"id":1001,"name":"Alice"},
  {"id":1002,"name":"Bob"},
  {"id":1003,"name":"Charlie"},
  {"id":1004,"name":"David"}
]

2️⃣ Upgrade

An upgrade replaces an already installed version of a package with a newer version, while preserving the existing system state.

💡 Unlike what many beginners assume, an upgrade is not simply uninstalling the old version and installing a new one—RPM handles this transition intelligently.

Making Changes to the Application

To simulate an upgrade scenario, let’s introduce a small change in the application.

Navigate to the java-http-server/src/studentapi directory. In the handle method of the HelpHandler class defined inside the StudentApiServer.java file, update the text within the <h1> tag:

<h1>Student API v1.1</h1>

Rebuild the Application

Recompile the source code and generate a new JAR file by following the steps outlined in the Compilation and JAR Creation section.

Preparing the New Version Directory

Create a copy of the existing student-api-1.0 directory and rename it to student-api-1.1. Replace the old JAR file with the newly generated one.

rpm-playground/
|-- java-http-server/
|-- student-api-1.0/
|-- student-api-1.1/
|   |-- run.sh
|   |-- student-api.service
|   |-- student-api.conf
|   |-- student-api.jar  # updated JAR
|   `-- students.json
`-- rpm-build

Creating the Source Archive

Next, create a compressed archive of the new version and place it in the SOURCES directory, as demonstrated earlier in the Preparing the Source Archive section.

The archive should be named:

student-api-1.1.tar.gz

Updating the SPEC file

Finally, we update the Version field in the SPEC file:

Version: 1.1

Also, add a new entry at the top of the %changelog section:

%changelog
* Sun Apr 12 2026 Steve <steve@example.com> - 1.1-1
- Updated application to version 1.1
- Modified help page to reflect application version v1.1

Building the Updated RPM Package

Follow the same steps outlined in the Building the RPM Package section to generate the new RPM for version 1.1.

The updated package will be available in the RPMS/noarch directory:

student-api-1.1-1.el10.noarch.rpm

Upgrading the Installed Package

Now that the updated RPM package is ready, we can upgrade the existing installation.

From within the rpmbuild directory, run the following command:

sudo dnf upgrade RPMS/noarch/student-api-1.1-1.el10.noarch.rpm

This will replace the currently installed version (1.0) with the newer version (1.1) while preserving the existing configuration and system state.

🧪 Verifying the Upgrade

To confirm that the upgrade was successful, let's first check the installed package version:

dnf list --installed student-api

We should see output indicating version 1.1 is installed.

Next, we verify the application behavior by executing:

curl http://localhost:5000/

If the upgrade has been applied correctly, the response should reflect the changes made:

<html>
<h1>Student API v1.1</h1>
<p>Available endpoints:</p>
<ul>
  <li>GET /students - list all students</li>
  <li>POST /students - create a fake student</li>
</ul>
</html>

Overview of How Update Works

During upgrade, RPM performs a controlled replacement, ensuring that existing data and configurations are preserved wherever possible.

RPM compares the files in the existing installation with those in the new package:

  • Regular (non-configuration) files These are typically replaced with the newer versions from the updated package.

  • Configuration files (%config(noreplace)) If a configuration file has been modified by the user, RPM will preserve the existing file instead of overwriting it.

💡 This ensures that user customizations are not lost during upgrades.

RPM also manages service behavior during upgrades. Using the standard systemd macros (like %systemd_post, %systemd_preun, etc.), RPM ensures that:

  • The service definition is updated if needed

  • The systemd daemon is reloaded automatically

  • The service continues to function correctly after the upgrade.

3️⃣ Uninstallation

At some point, we may need to remove the installed package. RPM provides a straightforward way to uninstall while ensuring the system remains in a consistent state.

To uninstall the package, run:

sudo dnf remove student-api -y

When an RPM package is removed, several things happen behind the scenes:

File Removal

RPM removes all files that were explicitly listed in the %files section of the SPEC file.

This includes:

  • Application binaries

  • Scripts (like run.sh)

  • Service files

  • Configuration files (unless preserved due to user modifications)

💡 RPM relies entirely on the %files section to know what to clean up.

After uninstalling the package, we can verify whether the files were removed successfully:

ls /usr/lib/student-api

We should get

ls: cannot access '/usr/lib/student-api': No such file or directory

Similarly, we can verify that the configuration directory has been removed:

$ ls /etc/student-api
ls: cannot access '/etc/student-api': No such file or directory

Service Handling

If the application is running as a systemd service, RPM ensures proper cleanup by:

  • Stopping the service

  • Disabling it (so it doesn’t start on boot)

  • Reloading the systemd daemon

This prevents stale or broken service references after removal.

To verify that the service has been removed correctly, run:

systemctl list-unit-files student-api.service

The service should no longer appear in the output.

We can also confirm that the service is no longer active:

systemctl status student-api

We should see

Unit student-api.service could not be found.

It indicates that the unit could not be found.

Files and Data Preserved After Uninstallation

RPM intentionally avoids deleting certain types of data:

  • Log files

  • Runtime data

  • User-generated files

💡 This ensures that important data is not lost unintentionally, especially in production environments.

Wrapping Up

In this blog, we walked through the complete lifecycle of creating and managing an RPM package for a Java application — from building the application and organizing the RPM workspace to packaging, upgrading, and uninstalling it.

Along the way, we explored:

  • The structure and purpose of an RPM SPEC file

  • Common RPM directives and macros Service integration with systemd

  • Package upgrades and configuration handling

  • Safe and clean package uninstallation

More importantly, we understood that RPM packaging is not just about bundling files into a .rpm archive. It is about creating a package that integrates cleanly with the operating system and behaves predictably throughout its lifecycle.

Thank you for staying with me till the end of this journey. Hopefully, RPM packaging now feels a little less intimidating and a lot more approachable.