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.
podman
ordocker
for pulling and exporting the images.rpmdevtools
for setting up the RPM build area and creating spec file.rpm-build
for building the RPM.dnf-plugins-core
for downloading any dependent RPMs likepodman
orcurl
etc.
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
BUILD
folder is where you will build your code or have intermediate files.RPMS
folder will contain the RPM for binary type rpms.SOURCES
folder should have all the source code which you want to use for your build.SPECS
folder should have your rpm specSRPMS
folder will contain the RPM, if you are building the source rpm..rpmmacros
file 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.
%install
runs 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.
%post
runs after installing the RPM. That means we will have our image archive available at/opt/docker-rpm/nginx/nginx-1.28.tar
when 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
%pre
and%post
scripts.%pre
script runs before installing the package and both%pre
and%post
scripts 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
CreateCommand
section 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.
%preun
script 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.
podman
ordocker
requires us to stop and remove any containers before deleting any image. So here we will have to update the%preun
script and create a%postun
script.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
systemctl
service 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
Type
as well to indicate thatsystemctl
should 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
-f
flag.
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.