Building Containers with RPM: A Declarative Approach

Comparing procedural Docker build scripts with declarative RPM package definitions to show how proper separation of concerns leads to more maintainable, reusable container builds.

← Back to Main Page

Two Approaches to Container Builds

When compiling software from source in containers, you can choose between procedural build scripts or declarative package definitions. Let's compare both approaches using a container that needs OpenSSL and Apache httpd.

Building OpenSSL + Apache httpd

Traditional Dockerfile
~120 lines of code
High complexity
# Multi-stage Dockerfile for OpenSSL + Apache httpd
FROM registry.fedoraproject.org/fedora:43 AS builder

# Install build dependencies
RUN dnf install -y \
    gcc \
    make \
    perl \
    perl-IPC-Cmd \
    wget \
    tar \
    zlib-devel \
    pcre-devel \
    apr-devel \
    apr-util-devel \
    expat-devel \
    && dnf clean all

# Build OpenSSL
WORKDIR /usr/src
RUN wget https://www.openssl.org/source/openssl-3.4.0.tar.gz \
    && tar xzf openssl-3.4.0.tar.gz \
    && cd openssl-3.4.0 \
    && ./config \
       --prefix=/opt/openssl \
       --openssldir=/opt/openssl/ssl \
       shared \
       zlib \
    && make -j$(nproc) \
    && make install

# Build Apache httpd
WORKDIR /usr/src
RUN wget https://dlcdn.apache.org/httpd/httpd-2.4.62.tar.gz \
    && tar xzf httpd-2.4.62.tar.gz \
    && cd httpd-2.4.62 \
    && ./configure \
       --prefix=/opt/httpd \
       --enable-ssl \
       --with-ssl=/opt/openssl \
       --enable-so \
       --enable-deflate \
       --enable-http2 \
       --with-mpm=event \
    && make -j$(nproc) \
    && make install

# Production stage - minimal image
FROM registry.fedoraproject.org/fedora:43

# Install runtime dependencies only
RUN dnf install -y \
    zlib \
    pcre \
    apr \
    apr-util \
    expat \
    && dnf clean all

# Copy built software from builder
COPY --from=builder /opt/openssl /opt/openssl
COPY --from=builder /opt/httpd /opt/httpd

# Set up environment
ENV PATH="/opt/httpd/bin:${PATH}"
ENV LD_LIBRARY_PATH="/opt/openssl/lib64:/opt/httpd/lib"

# Configure httpd
RUN sed -i \
    -e 's/^#ServerName.*/ServerName localhost/' \
    -e 's/^Listen 80/Listen 8080/' \
    /opt/httpd/conf/httpd.conf

EXPOSE 8080

CMD ["/opt/httpd/bin/httpd", "-D", "FOREGROUND"]
  • Hard-coded versions Manually tracking upstream releases for OpenSSL and httpd
  • Complex dependency management Must manually identify and install all build and runtime dependencies
  • No reusability Each component build is custom scripted
  • Difficult to extend Adding a third component (e.g., PHP) requires significant additional complexity
  • No package metadata No dependency tracking, version info, or upgrade paths
RPM build-assist.yaml
~15 lines of config
Low complexity
# build-assist.yaml
base: fedora-43-x86_64

build:
  - type: dist-git
    url: https://src.fedoraproject.org/rpms/
    packages:
      - openssl:release-3.4
      - httpd:release-2.4

install:
  type: container
  packages:
    - openssl
    - httpd
  • Automatic version tracking Dist-git repos track latest stable releases automatically
  • Dependency resolution built-in RPM spec files define all build and runtime dependencies
  • Highly reusable Each package is independently maintained with proper boundaries
  • Easy to extend Adding PHP is just one more line in the packages list
  • Full package metadata Version tracking, dependency chains, upgrade paths all handled

Real-World Scenario: Adding a Third Component

Requirement: Add PHP Support

Your application now needs PHP in addition to OpenSSL and Apache httpd. Let's compare how much work this requires in each approach.

Traditional Docker
+40-60 lines of code
# Add to builder stage
RUN dnf install -y \
    libxml2-devel \
    sqlite-devel \
    libcurl-devel \
    oniguruma-devel \
    # ... many more dependencies

# Build PHP
WORKDIR /usr/src
RUN wget https://www.php.net/distributions/php-8.3.14.tar.gz \
    && tar xzf php-8.3.14.tar.gz \
    && cd php-8.3.14 \
    && ./configure \
       --prefix=/opt/php \
       --with-openssl=/opt/openssl \
       --with-apxs2=/opt/httpd/bin/apxs \
       --enable-mbstring \
       --with-curl \
       --with-pdo-mysql \
       # ... many more configure flags \
    && make -j$(nproc) \
    && make install

# Add to production stage
RUN dnf install -y \
    libxml2 \
    sqlite-libs \
    libcurl \
    oniguruma \
    # ... corresponding runtime deps

COPY --from=builder /opt/php /opt/php

# Update environment variables
ENV PATH="/opt/php/bin:${PATH}"
ENV LD_LIBRARY_PATH="/opt/php/lib:${LD_LIBRARY_PATH}"

# Configure Apache to load PHP module
RUN echo "LoadModule php_module modules/libphp.so" \
    >> /opt/httpd/conf/httpd.conf

Each component requires researching dependencies, download locations, configure flags, and integration steps. The Dockerfile grows linearly with complexity.

RPM build-assist
+1 line of config
# build-assist.yaml
base: fedora-43-x86_64

build:
  - type: dist-git
    url: https://src.fedoraproject.org/rpms/
    packages:
      - openssl:release-3.4
      - httpd:release-2.4
      - php:release-8.3          # ← Only change needed

install:
  type: container
  packages:
    - openssl
    - httpd
    - php

The RPM spec for PHP already defines all dependencies, build flags, and Apache integration. Just add it to the list. The configuration stays simple regardless of how many components you add.

Key Differences

Traditional Docker: Procedural Build Scripts

  • Every build step must be explicitly scripted
  • Dependencies must be manually researched and listed
  • Version updates require manual tracking
  • No separation between build and runtime dependencies (must manually track)
  • Adding components means adding more scripting
  • Complexity grows linearly with the number of components
  • No metadata about what's installed or how components relate

RPM Build Assist: Declarative Package Definitions

  • Build steps are defined once in the RPM spec file
  • Dependencies are declared in spec files and automatically resolved
  • Version tracking can be automated via dist-git monitoring
  • Clear separation of BuildRequires and Requires
  • Adding components means adding package names
  • Complexity stays constant regardless of component count
  • Full metadata: versions, dependencies, file ownership, changelog

Long-Term Maintainability

The difference becomes even more pronounced over time.

Updating to New Versions

Traditional Docker: When OpenSSL 3.5.0 or httpd 2.4.63 is released:

  • Manually update version numbers in wget URLs
  • Check if new dependencies are needed
  • Test if existing configure flags are still valid
  • Update runtime dependency list if needed
  • Repeat for every component

RPM Build Assist: When packages are updated in dist-git:

  • Automated tools (dist-git-manager) can detect new releases
  • RPM spec is updated once, tested once
  • Dependencies are automatically updated if spec changes
  • Rebuild with same build-assist.yaml configuration
  • All components benefit from the same update mechanism

Team Collaboration

Traditional Docker:

  • Each team member needs to understand the build process for all components
  • Dockerfile becomes tribal knowledge
  • Hard to split ownership of different components

RPM Build Assist:

  • Each component can be owned by different team members
  • Package maintainers only need to know their component
  • Integration is automatic via dependency resolution
  • Clear boundaries and ownership

How RPM Build Assist Works

Behind the Scenes

When you run rpm-build-assist with the build-assist.yaml configuration:

  1. Fetch specs: Clones the dist-git repos for openssl and httpd
  2. Resolve dependencies: Reads BuildRequires from each spec, creates a complete dependency graph
  3. Build packages: Builds RPMs in dependency order in isolated environments
  4. Create container: Installs only the runtime packages (not build dependencies) into a container image
  5. Layer optimization: Properly separates base layer, dependencies, and application layers for efficient caching

The Multi-Stage Build is Built-In

The traditional Dockerfile manually implements a multi-stage build to separate build and runtime dependencies. With RPM build assist, this is automatic:

  • BuildRequires defines what's needed to compile (gcc, make, devel packages)
  • Requires defines what's needed to run (shared libraries, runtime configs)
  • The container install type automatically uses only Requires, not BuildRequires
  • Result: minimal runtime image with no build tools or headers

Conclusion

When Traditional Docker Makes Sense

  • Simple, single-purpose containers with no compilation
  • Deploying pre-built binaries
  • Quick prototypes and one-off solutions
  • When you don't need version tracking or updates

When RPM Build Assist Shines

  • Building software from source
  • Multiple components with complex dependencies
  • Need for version tracking and automated updates
  • Long-term maintenance and team collaboration
  • Reusing components across multiple containers
  • When you want separation of concerns (each package = one responsibility)

The traditional Docker approach isn't just longer—it's fundamentally less maintainable and reusable. RPM-based builds provide the structure and tooling needed for sustainable, production-grade container images.