Containerizing Projects with Apptainer
Author
Reproducibility in computational work is often constrained less by code and more by the surrounding environment: operating system, libraries, system dependencies, and subtle configuration differences. These factors become especially visible in high-performance computing (HPC) settings, where users typically do not control the base system and where software stacks vary across clusters.
Apptainer provides a way to package an entire computational environment into a single, portable artifact that can be executed consistently across systems. Unlike traditional container tools, it is designed to align with HPC security and infrastructure constraints, such as unprivileged execution and integration with shared filesystems.
This notebook explores how environments can be captured, modified, validated, and distributed in a way that reduces ambiguity between development and execution contexts. The emphasis is on building a mental model of how containerized environments behave, and where their boundaries interact with the host system.
By the end of this notebook, you will have hands-on experience building container images, modifying environments, defining reproducible builds, and distributing your work through registries and automated pipelines. Please experiment, make changes, and observe what persists versus what is ephemeral.
Setup
Install Apptainer with Apt
add-apt-repository -y ppa:apptainer/ppa
apt update
apt install apptainer
apt install squashfuse fuse2fs gocryptfs # optionalSection 1: Section 1: Using Existing Containers: Building an Apptainer SIF Image from Docker
A large ecosystem of pre-built environments already exists in the form of Docker images. Rather than recreating environments from first principles, it is often more efficient to reuse and adapt these existing artifacts.
Apptainer can convert Docker images into its native SIF format, which is optimized for portability and single-file distribution. This translation step raises a few important questions:
- What aspects of the original environment are preserved?
- What assumptions are baked into the image?
- How does execution differ when running inside the container versus on the host?
Understanding this layer helps clarify what a container actually encapsulates, and where implicit dependencies may still exist.
Reference
| Code | Description |
|---|---|
| apptainer build |
Build a SIF file from a local or remote source. |
| apptainer exec |
Run some commands from within the SIF image. |
| apptainer exec –fakeroot … | Run commands as though you were a super-user. |
| apptainer exec –bind |
Map a local file to the container’s filesystem. |
build sif docker:: shell –bind
Exercises
Example: build an Apptainer SIF image file from Dockerhub’s rockylinux container.
apptainer build rocky.sif docker://rockylinux:9 Example: Run cat etc/os-release from within the SIF image to print out the operating system information, to confirm that it does indeed contain Rocky Linux.
apptainer exec rocky.sif cat etc/os-releaseExercise: build an Apptainer SIF image file from Dockerhub’s debian container.
Solution
apptainer build deb.sif docker://debian:latestExercise: Run cat etc/os-release from within the SIF image to print out the operating system information, to confirm that it does indeed contain Debian.
Solution
apptainer exec deb.sif cat etc/os-releaseExercise: build an Apptainer SIF image file from Dockerhub’s python container, and run python --version from inside it.
Solution
apptainer build py.sif docker://python:latest
apptainer exec py.sif python --versionExercise: What username and permissions do you have from inside the container, and does adding --fakeroot to an apptainer command change that? Run id from inside a container, both with and without adding --fakeroot to compare.
Solution
apptainer exec deb.sif id
apptainer exec --fakeroot deb.sif idExercise: The host’s current directory and the home directoy are available from within Apptainer environments, but you can also bind other host directories to the Apptainer filesystem. use --bind .:/mnt/data and run the command ls /mnt/data to see that the current directory is now additionally mapped to /mnt/data:
Solution
apptainer exec --bind .:/mnt/data deb.sif ls /mnt/dataExercise: Tired of always typing apptainer exec? You can start a new interactive shell from within the container by typing apptainer shell. Try it out and call cat etc/os-release from within the interactive shell. exit or <CTRL-D> will get you out of the shell.
Solution
apptainer shell deb.sif
cat etc/os-release
exitSection 2: Section 2: Interactively Updating an Apptainer environment with a Sandbox
Containers are often presented as immutable artifacts, but during development, immutability can be a limitation. Iteration requires a way to explore changes without committing to a full rebuild each time.
Apptainer supports both ephemeral and persistent modification workflows. Temporary writable layers allow experimentation without altering the base image, while sandbox environments provide a directory-based representation that can be incrementally modified.
This introduces a key distinction:
- ephemeral state: useful for exploration, but discarded
- persistent state: incorporated into a reproducible artifact
Being able to move intentionally between these modes helps avoid a common failure mode where environments are modified interactively but cannot be reconstructed.
build –fakeroot build –writable-tmpfs build –sandbox shell –writable build a a.sif build a.sif a
Exercises
Exercise: Create a new SIF image based on DockerHub’s Debian image and install git into it with the following command: bash -lc "apt update && apt install git && git". As you get errors, add the --writable-tmpfs (which creates a temporary writable filesystem in RAM) and --fakeroot options to the apptainer exec command to see how they help make the command work:
Solution
apptainer build deb2.sif docker://debian:latest
apptainer exec --writable-tmpfs --fakeroot deb2.sif bash -lc "apt update && apt install git && git"Exercise: Is git permanently installed in the SIF image now? Try running just the git command in the image to check.
Solution
apptainer exec deb2.sif git # expect command not found errorExercise: To get a permanent filesystem to work with, build a “Sandbox” image from the SIF file with the apptainer build --sandbox option. When install git into the new sandbox with --writable and --fakeroot options.
Solution
apptainer build --sandbox deb deb2.sif
apptainer exec --writable --fakeroot deb bash -lc "apt update && apt install -y git && git"Exercise: Is git permanently installed in the Sandbox SIF image now? Try running just the git command in the image to check.
Solution
apptainer exec deb git --versionExercise: Shell into the sandbox and install python with apt install python:
Solution
apptainer shell --writable --fakeroot deb
apt update
apt install -y python3
python3 --version
exitExercise: Now we can freeze the sandbox back into a SIF file with the apptainer build command. Try it out, and verify the new SIF file has git installed:
Solution
apptainer build deb3.sif deb
apptainer exec deb3.sif git --version
apptainer exec deb3.sif python3 --versionSection 3: Section 3: Definition Files
Interactive modification is useful for discovery, but it does not scale well for collaboration or long-term reproducibility. At some point, environments need to be described declaratively.
Apptainer definition files provide a structured format for encoding:
- the origin of the environment
- the transformations applied to it
- validation checks
- expected runtime behavior
This shifts environment creation from a sequence of manual steps to a documented, repeatable process. It also makes assumptions explicit, which reduces the risk of hidden dependencies or irreproducible states.
Reference documentation: https://apptainer.org/docs/user/main/definition_files.html
Example File
# rand.def
Bootstrap: docker
From: ghcr.io/prefix-dev/pixi:latest
%files
# Which files in the host filesystem to copy into the container's filesystem (SOURCE TARGET)
pixi.toml /app/pixi.toml
*.py /app/
%post
# What commands to run while building the file
cd /app && pixi install
%test
# At the end of the file build, what code to run to confirm everything works correctly.
echo "--- checking pixi is available ---"
pixi --version
echo "--- checking python is at expected path ---"
test -x /app/.pixi/envs/default/bin/python || { echo "FAIL"; exit 1; }
echo "--- all tests passed ---"
%runscript
# What to run for the `apptainer run image.sif` command.
exec /app/.pixi/envs/default/bin/python /app/main.py "$@"
%help
# Help text. Displays on `apptainer run-help image.sif`
Print a Random Number using Numpy in Python:
apptainer build rand.sif rand.def
apptainer run rand.sifhttps://apptainer.org/docs/user/main/definition_files.html#scif-app-sections
Exercises
Exercise: Use apptainer build <target> <source> to build and run the Apptainer image defined in rand.sif.
Solution
apptainer build rand.sif rand.def
apptainer run rand.sifExercise: Add another test to the Apptainer image to check that numpy is importable by python (`python -c “import numpy”), then re-build the image.
Solution
Add this check to the %test section of rand.def, then rebuild and test the image:
python -c "import numpy"
apptainer build rand.sif rand.def
apptainer test rand.sifExercise: Create a new definition file based on the requirements below, and build and run it!
- Is based off of Debian 9
- Has Python 3 installed (apt install python3)
- Contains a Python file that calculates a random integer using built-in packages (`import random; print(random.randint(1, 100)))
- Has help text (check you can view the help text with
apptainer run-help rand2.sif) - Once the build is ready, tests that python is available.
Solution
cat > rand2.def <<'EOF'
Bootstrap: docker
From: debian:9
%post
apt update
apt install -y python3
cat > /usr/local/bin/randint.py <<'PY'
import random
print(random.randint(1, 100))
PY
chmod +x /usr/local/bin/randint.py
%test
python3 --version
%runscript
exec python3 /usr/local/bin/randint.py
%help
Print a random integer from 1 to 100.
EOF
apptainer build rand2.sif rand2.def
apptainer test rand2.sif
apptainer run rand2.sif
apptainer run-help rand2.sifSection 4: Section 4: Storing them in GitHub GHCR
Once an environment is packaged, it becomes more useful when it can be shared and versioned. Registries provide a way to store and retrieve container images as addressable artifacts.
In collaborative or distributed settings, this supports workflows where:
- environments are referenced by version rather than rebuilt ad hoc
- results can be traced back to a specific image
- updates can be propagated without manual transfer
Using a registry introduces additional concerns, such as authentication, permissions, and naming conventions, which become part of the overall system. Here, we’ll push to the GitHub Container Registry (GHCR).
registry login
push pull
Web Interface
Exercises
Exercise: Log in to the GitHub Container Registry (GHCR) with a token that has permission to push packages to your GitHub account. Create the token from GitHub Settings -> Developer settings -> Personal Access Tokens -> Tokens (classic).
Solution
apptainer registry login -u <github-username-or-orgname> oras://ghcr.ioExercise: Push the package onto GHCR. Once pushed, view your packages at https://github.com/<github-username-or-orgname>/?tab=packages.
Solution
apptainer push deb.sif oras://ghcr.io/<github-username-or-orgname>/<package-name>:<tag>Exercise: Pull the package from GHCR to your local filesystem.
Solution
apptainer pull deb10.sif oras://ghcr.io/<github-username-or-orgname>/<package-name>:<tag>Section 5: Section 5: Build SIF Packages on Git Push: Using CI Services like GitHub Actions to build your Apptainers
Manual workflows introduce variability: steps may be skipped, environments may drift, and results may depend on local state. Automation reduces this variability by encoding the build process into a system that runs consistently.
Continuous integration (CI) systems allow container builds to be triggered by changes in version-controlled code. This creates a tighter coupling between:
- the definition of an environment
- the artifact that results from it
It also provides immediate feedback when assumptions break, such as missing dependencies or failing tests.
In this sense, CI is not just a convenience—it is a mechanism for enforcing reproducibility over time.
Example Workflow
# .github/workflows/apptainer.yml
name: apptainer
on:
push:
branches: [main]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-24.04
container:
image: kaczmarj/apptainer
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Build SIF
run: sudo apptainer build rand.sif rand.def
- name: Log in to GHCR for ORAS push
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
apptainer registry login \
--username "${{ github.actor }}" \
--password-stdin \
docker://ghcr.io
- name: Push SIF to GHCR via ORAS
run: apptainer push rand.sif oras://ghcr.io/${{ github.repository_owner }}/rand:latestExercises
Project: Add the rand.def definition and the .github/workflows/apptainer.yml files to a git repository and push to GitHub, checking the workflow progress under the GitHub repository’s Actions tab. Does the build work successfully? As you detect errors, make corrections to the files locally and re-push under the build is successful.
Solution
rand.def into the repository root, copy github-ci.yaml to .github/workflows/apptainer.yml, commit both files, and push to main. Then open the repository Actions tab, inspect the build logs, fix any failing definition or workflow step locally, and push again until the workflow builds and pushes rand.sif successfully.