Installing containerized apps using rpms
Configuring and managing `docker` images in airgapped system can be challending. Let's see how we can ease IT Admin's job by letting them use what they have been using for years now.
In previous blog we have gone through manual steps of exporting a container and importing it on an airgapped machine. In this blog we will go through how we can remove the barier of learning docker for the IT Admins.
The Barier
Over many years IT Admins have been installing the software via standard packages like rpm, deb, pkg etc. Now they have to learn docker for the new applications which are being shipped on docker.
Earlier they used to manage the status of different services running on a machine via service or systemctl command but now they have to use docker ps to know the status or a service.
They have been using journalctl to view the logs of a service, but now they have to use docker logs. Let’s see how we can help them use docker via these standard tools.
Creating RPM for a docker service
Prerequiste
We will need following packages to be installed for building an RPM.
podmanordockerfor pulling and exporting the images.rpmdevtoolsfor setting up the RPM build area and creating spec file.rpm-buildfor building the RPM.dnf-plugins-corefor downloading any dependent RPMs likepodmanorcurletc.
Preparing for Building RPM
Before we start building RPM we have to know what an RPM is. RPM stands for RedHat Package Manager. RPM is a package manager which allows you to ship your software packaged as an .rpm file. RPM packages can be of 2 types Binary packages and Source packages. Binary packages are the type of packages where you package pre-built binaries and you don’t have to compile or download anything. Source packages are the type of packages where you have to package source code into the package and then compile and install it on the target machine.
For this exercise we will be building a Binary package using nginx image. We are not building Source package as the application is already compiled and installed inside the container.
You can use rpmdev-setuptree to setup a build area. By default it will treat your $HOME/rpmbuild as the build area. In order to change this path you have to change HOME env variable before running this command.
1
2
3
HOME=/tmp/docker-to-rmp rpmdev-setuptree
ls -la /tmp/docker-to-rmp/
It will create a directory structure like mentioned below:
1
2
3
4
5
6
7
.rpmmacros
rpmbuild/
├── BUILD
├── RPMS
├── SOURCES
├── SPECS
└── SRPMS
BUILDfolder is where you will build your code or have intermediate files.RPMSfolder will contain the RPM for binary type rpms.SOURCESfolder should have all the source code which you want to use for your build.SPECSfolder should have your rpm specSRPMSfolder will contain the RPM, if you are building the source rpm..rpmmacrosfile is macro file.
The next step is to create a spec file. Spec file contains the package specification like package name, version, vendor, scripts, changelog etc.
You can use rpmdev-newspec command to create a spec file.
1
2
3
cd /tmp/docker-to-rmp/rpmbuild/SPECS
rpmdev-newspec -o nginx.spec
cat nginx.spec
Above command will create a minimal spec file which looks like below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
Name: nginx
Version:
Release: 1%{?dist}
Summary:
License:
URL:
Source0:
BuildRequires:
Requires:
%description
%prep
%autosetup
%build
%configure
%make_build
%install
rm -rf $RPM_BUILD_ROOT
%make_install
%files
%license add-license-file-here
%doc add-docs-here
%changelog
* Sat Jul 25 2025 root
-
By default rpmdev-newspec command creates the spec file suitable for Source package. We would need to modify it for our needs.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Name: nginx
Version: 1.28
Release: 0
Summary: nginx container packaged as rpm
Group: Applications/System
License: MIT
Vendor: Bikramjeet Singh
URL: http://blogs.bikramjs.in/
Packager: Software Group <packager@blogs.bikramjs.in>
BuildArch: noarch
Provides: nginx
%description
nginx container packaged as rpm
%pre
%install
%post
%files
%defattr(-,root,root,-) # Tell RPM manager that the default owner of all the files is root
%dir
/opt/docker-rpm/nginx # Tell RPM package manger that this RPM is managing this directory and everything under it
%preun
%postun
%changelog
* Mon Jul 21 2025 Bikramjeet Singh <packager@blogs.bikramjs.in>
- Sample changelog
We can cache this spec file instead of generating it for each build.
Automating image import and clean up
Now the next thing is to import the docker image and make it available for use and manage the whole lifecycle of the image. RPM spec file has many script macros which can be used to automate stuff at variaous stages. Let’s outline what do we need to do and see which script makes sense for them:
We need to bundle the image archive into the RPM.
%installruns at RPM build time for Binary package and RPM installation for Source package. This is the perfect place to export the image.1 2
podman image pull docker.io/library/nginx:1.28 podman image save docker.io/library/nginx:1.28 -o "%{buildroot}/opt/docker-rpm/nginx/nginx-1.28.tar"
We need to import the image archive on target system where we will install the RPM.
%postruns after installing the RPM. That means we will have our image archive available at/opt/docker-rpm/nginx/nginx-1.28.tarwhen this step is being run.1
podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar"
We need to recreate the running containers when upgrading. Here first we need to identify containers which are running the old image for this we would need to split this section into
%preand%postscripts.%prescript runs before installing the package and both%preand%postscripts get 1 argument which tells them if it is a fresh install or upgrade scenario.1 2 3 4 5 6 7 8 9 10 11
%pre if [ $1 == 1 ];then echo "Pre Installing nginx" elif [ $1 == 2 ];then echo "Pre Upgrading nginx" OLD_IMAGE_TAG=`rpm -q --info "nginx" | grep "Release : " | cut -b 15-` echo "docker.io/library/nginx:${OLD_IMAGE_TAG}" > "/tmp/nginx.old_image" podman ps -a --format="\{\{ \.ID \}\} \{\{ .Image \}\}" | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.old_containers" podman ps --format='\{\{ \.ID \}\} \{\{ .Image \}\}' | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.running_containers" fi
Please remove escape char
\from the format. I’ve used it because the blog site generator assumes it as a variable.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
%post podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar" if [ $1 == 1 ];then echo "Post Installing nginx" elif [ $1 == 2 ];then echo "Post Upgrading nginx" podman stop $(cat "/tmp/nginx.running_containers") cat /tmp/nginx.running_containers | while read CONTAINER_ID do echo "Upgrading ${CONTAINER_ID}" # Find the command used to create the container CREATE_COMMAND=`podman inspect ${CONTAINER_ID} | jq -M -r '.[0].Config.CreateCommand |= .[:-1] | .[0].Config.CreateCommand | join(" ")'` # Remvoe the old container podman rm -f ${CONTAINER_ID} # Run or Create the container NEW_CONTAINER_ID=`${CREATE_COMMAND} -d docker.io/library/nginx:1.28` # Start the container in case the create_command is just creating the container podman start ${NEW_CONTAINER_ID} done fi
Please note that
CreateCommandsection is only available inpodman. You may have to tweak the command to find the container command.We need to clean up the old images on target system when user upgrades the package.
%preunscript runs both at after upgrade and before uninstalling. It gets an argument to differentiate between the 2 cases.1 2 3 4 5 6 7
if [ $1 == 1 ];then echo "Pre Uninstall while Upgrading nginx" OLD_IMAGE=`cat "/tmp/nginx.old_image"` podman image rm ${OLD_IMAGE} elif [ $1 == 0 ];then echo "Pre Uninstalling nginx" fi
We need to clean up the image on target system when user choses to uninstall the package.
podmanordockerrequires us to stop and remove any containers before deleting any image. So here we will have to update the%preunscript and create a%postunscript.1 2 3 4 5 6 7 8 9 10 11 12
%preun if [ $1 == 1 ];then echo "Pre Uninstall while Upgrading nginx" OLD_IMAGE=`cat "/tmp/nginx.old_image"` podman image rm ${OLD_IMAGE} elif [ $1 == 0 ];then echo "Pre Uninstalling nginx" podman ps -a --format="\{\{ \.ID \}\} \{\{ .Image \}\}" | grep docker.io/library/nginx | cut -b -12 > "/tmp/nginx.containers" podman stop $(cat "/tmp/nginx.containers") podman rm -f $(cat "/tmp/nginx.containers") fi
1 2 3 4 5 6 7 8 9
%postun if [ $1 == 1 ];then echo "Post Uninstall while Upgrading nginx" elif [ $1 == 0 ];then echo "Post Uninstalling nginx" podman image rm -f docker.io/library/nginx:1.28 rm -Rf /opt/docker-rpm/nginx fi
Building RPM
Now we our spec file is ready to be used. We need to update %_topdir in the macrofile as we changed the build area from the default build area.
1
echo "%_topdir /tmp/docker-to-rmp/rpmbuild/" >> /tmp/docker-to-rmp/.rpmmacros
Finally we can fire the rpmbuild command to build the package.
1
rpmbuild --load=/tmp/docker-to-rmp/.rpmmacros -bb /tmp/docker-to-rmp/rpmbuild/SPECS/nginx.spec
You can define multiple macros in a single file and You can use multiple
--load=flags to specify multiple macro files.
Using systemctl to manage the container
We can wrap the podman commands under a service file as well to make it easy for IT Admins to start/stop the container. To do that first we need to create a service file like below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[Unit]
Description=Docker to RPM
Requires=network-online.target
After=network-online.target
[Service]
Type=simple
ExecStartPre=podman container create --rm --publish-all --label "managed_by=docker-to-rpm" --name nginx docker.io/library/nginx:1.28
ExecStart=podman container start nginx
# ExecReload=
ExecStop=podman container stop nginx
# ExecStopPost=
TimeoutStartSec=120
TimeoutStopSec=60
# Restart behavior:
# Restart the service if it fails, with a delay, to make it restart-safe
Restart=on-failure
RestartSec=10s
[Install]
WantedBy=multi-user.target
Next we would need to update our different scripts for systemctl support.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
%install
podman image pull docker.io/library/nginx:1.28
podman image save docker.io/library/nginx:1.28 -o "%{buildroot}/opt/docker-rpm/nginx/nginx-1.28.tar"
%pre
if [ $1 == 1 ];then
echo "Pre Installing nginx"
elif [ $1 == 2 ];then
echo "Pre Upgrading nginx"
if systemctl is-enabled --quiet nginx.service && systemctl is-active --quiet nginx.service ; then
systemctl stop nginx.service
fi
OLD_IMAGE_TAG=`rpm -q --info "nginx" | grep "Release : " | cut -b 15-`
echo "docker.io/library/nginx:${OLD_IMAGE_TAG}" > "/tmp/nginx.old_image"
fi
%post
podman load -i "/opt/docker-rpm/nginx/nginx-1.28.tar"
systemctl daemon-reload
if [ $1 == 1 ];then
echo "Post Installing nginx"
elif [ $1 == 2 ];then
echo "Post Upgrading nginx"
if systemctl is-enabled --quiet nginx.service ; then
systemctl start nginx.service
fi
fi
%preun
if [ $1 == 1 ];then
echo "Pre Uninstall while Upgrading nginx"
OLD_IMAGE=`cat "/tmp/nginx.old_image"`
podman image rm ${OLD_IMAGE}
elif [ $1 == 0 ];then
echo "Pre Uninstalling nginx"
systemctl disable nginx.service
systemctl stop nginx.service
fi
%postun
if [ $1 == 1 ];then
echo "Post Uninstall while Upgrading nginx"
elif [ $1 == 0 ];then
echo "Post Uninstalling nginx"
rm -Rf "/opt/docker-rpm/nginx/"
rm -f "/etc/systemd/system/nginx.service"
systemctl daemon-reload
podman container rm -f nginx
podman image rm -f docker.io/library/nginx:1.28
fi
Here we are assuming that there will be only 1 container for the image and the container is being managed by the
systemctlservice file.
The service file above doesn’t direct the container logs to journalctl command. To do that we need to update our service file to attach the stdout and stderr to the container.
1
2
3
4
5
6
...
[Service]
Type=exec
...
ExecStart=podman container start --attach nginx
...
Please note that I’ve changed the service
Typeas well to indicate thatsystemctlshould treat the service to be up as long as the command is running.
The Service file is exposing all the ports which container exposes to random ports on the host. We can fix the ports in service file as shown below:
1
2
3
4
5
...
[Service]
...
ExecStartPre=podman container create --rm -p "8080:80" --label "managed_by=docker-to-rpm" --name nginx docker.io/library/nginx:1.28
...
Managing multi-container workloads
Most of the time we develop applications which depends on other applications like Database, Cache and so on. We can adapt above steps to orchestrate multiple containers as well. Here podman’s compose plugin can come handy to manage and connect multiple containers together.
We can use multiple strategies to manage the containers via podman’s compose plugin. I’ve tried to list few of them below:
- We can put multiple containers in a single RPM file along with single compose file.
- We can have multiple app RPMs and 1 compose file RPM to orchestrate the application.
- We can create multiple RPMs and have with app specific compose file for each RPM. And then combine multiple compose files with
-fflag.
Creating Multiple RPMs with their specific compose file is the best option in my opinion as it give you the freedom to manage upgrades for the different components separately.
Conclusion
Containers are becoming defacto standard for shipping new software. IT Admins have to adopt new workflows to install containerized workloads on Airgapped systems. It is a tedious job to export and deploy containerized workloads. We can ease their lifes by packaging the containers as standard packages and then wrapping orchestration part as systemctl services. This provides a better control to the Software vendor in managing their software in Airgapped installations.
I’ve created a python script to automate RPM creation for a given docker image. You can download it from GitHub.
