Local End-to-End Testing with Podman Compose#
1. Introduction#
This guide shows you how to run the complete AetherPak distribution pipeline entirely on your local machine — no GitHub, no GHCR, no GitHub Pages required. Using a single compose file with Podman (or Docker), you can spin up a local OCI registry, run the AetherPak CLI to import a Flatpak bundle and push it, and then serve the generated Flatpak repository index and landing page from a local nginx container — all in a single compose up.
The same plumbing and porcelain commands that power the GitHub Actions pipeline work identically in this local setup, because the ghcr.io/aetherpak/cli:latest container image already bundles the entire Flatpak toolchain.
Why run the stack locally?#
| Use case | Details |
|---|---|
| Offline pipeline testing | Validate the full import → OCI push → static site generation flow without touching GHCR or GitHub Pages |
| Bundle validation | Test that a new .flatpak bundle imports cleanly and produces a correct OCI image before committing it to CI |
| Local CLI development | Iterate on CLI changes and see end-to-end results immediately |
| Onboarding | Walk new team members through the full data flow in a self-contained environment |
The examples in this guide use the Lemonade Flatpak bundle as a concrete test subject, but the pattern applies to any .flatpak bundle or project directory.
2. Prerequisites#
Before getting started, ensure the following are available on your machine:
Container runtime and compose
- Podman (v4.0+) with
podman compose— the commands in this guide usepodman compose, but every example works identically withdocker composeby replacingpodmanwithdocker. - Docker with Docker Compose v2 (the
docker composeplugin, not the legacydocker-composebinary) is a fully supported alternative.
Container images
ghcr.io/aetherpak/cli:latest— the AetherPak CLI container, which includes the full Flatpak toolchain (flatpak,ostree, etc.). This image is pulled automatically on first run. See the Utility Images guide if you want to build a local variant instead.docker.io/registry:2anddocker.io/nginx:alpine— standard upstream images pulled automatically.
Flatpak content
Choose one of:
- A URL pointing to an upstream
.flatpakbundle (e.g. a GitHub Releases asset) — used in the Bundle URL example. - A local directory containing one or more
.flatpakbundle files — used in the local directory examples. - A project directory with an
aetherpak.ymlconfiguration file — used in the config-driven example.
SELinux note (Podman on Fedora / RHEL / CentOS): All bind mounts in the examples below include the
:zsuffix, which tells Podman to relabel the host directory so the container can read it. This is required on SELinux-enforcing systems and harmless on systems without SELinux.
3. The Compose Stack (Bundle URL Example)#
The simplest way to exercise the full pipeline is to point the stack at an upstream .flatpak bundle URL. The compose file below downloads the Lemonade bundle, imports it, pushes it to a local OCI registry, and serves the generated repository via nginx.
Architecture#
Three services collaborate through a shared compose network (aetherpak-local-stack):
AETHERPAK_BUNDLE_URL
│
▼
┌───────────┐ OCI push ┌──────────┐
│ generator │ ──────────▶ │ registry │ (port 5001 on host)
└───────────┘ └──────────┘
│ /site volume
▼
┌─────────┐
│ web │ (port 8080 on host)
└─────────┘
registry— a plain OCI registry (docker.io/registry:2) that receives the Flatpak OCI image from the generator.generator— runsghcr.io/aetherpak/cli:latest, downloads the bundle, imports it into a local OSTree repository, pushes it to the registry, and writes the static site to a shared volume.web— an nginx container that serves the static site files produced by the generator.
Compose file#
Save this as compose.yml in an empty working directory:
services:
registry:
image: docker.io/registry:2
ports:
- "5001:5000"
restart: always
generator:
image: ghcr.io/aetherpak/cli:latest
depends_on:
registry:
condition: service_started
environment:
AETHERPAK_BUNDLE_URL: https://github.com/abn/ai.lemonade_server.Lemonade/releases/download/2026.05.1/Lemonade.flatpak
AETHERPAK_ARCH: ${ARCH:-x86_64}
AETHERPAK_APP_ID: ai.lemonade_server.Lemonade
AETHERPAK_REMOTE_NAME: aetherpak-development
AETHERPAK_OCI_REPOSITORY: aetherpak
AETHERPAK_REGISTRY: registry:5000
AETHERPAK_SITE_DIR: /site
AETHERPAK_PAGES_URL: http://localhost:8080/flatpak
AETHERPAK_NO_SIGN: 1
AETHERPAK_ALLOW_UNSIGNED: 1
AETHERPAK_INSECURE: 1
command: >
sh -cx "
aetherpak publish &&
aetherpak build-site --landing-page
"
volumes:
- site:/site
working_dir: /data
web:
image: docker.io/nginx:alpine
ports:
- "8080:80"
environment:
VIRTUAL_HOST: 127.0.0.1:8080
proxy_set_header: Host
volumes:
- site:/usr/share/nginx/html/flatpak
restart: always
depends_on:
generator:
condition: service_completed_successfully
volumes:
site:
networks:
default:
name: aetherpak-local-stack
Service breakdown#
registry
A stock Docker Distribution registry. Host port 5001 maps to container port 5000. The generator reaches it over the compose network at registry:5000 (set via AETHERPAK_REGISTRY). No authentication or TLS is configured — the generator uses AETHERPAK_INSECURE=1 to communicate over plain HTTP .
generator
The ghcr.io/aetherpak/cli:latest image contains the full Flatpak toolchain . The container runs two commands in sequence:
aetherpak publish— downloads the bundle fromAETHERPAK_BUNDLE_URL, imports it into a local OSTree repository, and pushes an OCI image to the local registry.aetherpak build-site --landing-page— reads the OSTree repository and generates a static site (Flatpak index,.flatpakrepofile, and a landing page) intoAETHERPAK_SITE_DIR(/site).
The site named volume bridges the generator and nginx — no host filesystem is touched, making this setup fully standalone.
web
nginx serves the files from the site volume at /usr/share/nginx/html/flatpak, matching the path component of AETHERPAK_PAGES_URL (http://localhost:8080/flatpak). The depends_on: condition: service_completed_successfully ensures nginx only starts after the generator finishes without error.
Environment variable reference#
| Variable | Value in example | Purpose |
|---|---|---|
AETHERPAK_BUNDLE_URL | upstream release URL | URL of the .flatpak bundle to download and import |
AETHERPAK_ARCH | ${ARCH:-x86_64} | Target architecture; defaults to x86_64 via shell substitution |
AETHERPAK_APP_ID | ai.lemonade_server.Lemonade | Flatpak application ID in reverse-DNS format |
AETHERPAK_REMOTE_NAME | aetherpak-development | Name embedded in the generated .flatpakrepo file |
AETHERPAK_OCI_REPOSITORY | aetherpak | Repository path within the OCI registry |
AETHERPAK_REGISTRY | registry:5000 | Registry hostname as seen from inside the compose network |
AETHERPAK_SITE_DIR | /site | Directory where build-site writes its output |
AETHERPAK_PAGES_URL | http://localhost:8080/flatpak | Public-facing URL embedded in generated metadata |
AETHERPAK_NO_SIGN | 1 | Disables GPG signing |
AETHERPAK_ALLOW_UNSIGNED | 1 | Allows publishing without a signing key |
AETHERPAK_INSECURE | 1 | Uses plain HTTP when communicating with the registry |
All
AETHERPAK_-prefixed variables are automatically picked up by the CLI via Viper's environment prefix mechanism — the same pattern used throughout the CLI for zero-config operation .
4. Running the Stack#
Start#
From the directory containing your compose.yml, run:
podman compose up
Or with Docker:
docker compose up
What happens#
The compose runtime brings services up in dependency order:
-
Registry starts.
docker.io/registry:2binds tolocalhost:5001. The generator waits for it viadepends_on: condition: service_started. -
Generator runs. The
ghcr.io/aetherpak/cli:latestcontainer executes:- Downloads the
.flatpakbundle fromAETHERPAK_BUNDLE_URL - Imports it into a local OSTree repository (auto-detecting app ID, arch, and branch from the bundle metadata)
- Pushes the resulting OCI image to the local registry at
registry:5000 - Generates the static site — repository index,
.flatpakrepoconfiguration, and a landing page — into thesitevolume
- Downloads the
-
nginx starts. Once the generator exits with code
0(service_completed_successfully), nginx mounts thesitevolume and starts serving athttp://localhost:8080/flatpak/.
The entire process typically takes one to two minutes on a fast connection, dominated by the bundle download.
Access the result#
Once the stack is up, open your browser to:
http://localhost:8080/flatpak/
You should see the AetherPak landing page listing the published app.
Tear down#
To stop all containers and remove the volumes:
podman compose down -v
The -v flag removes the site named volume. Omit it if you want to inspect the generated site files after stopping the stack.
Run in the background: Add
-dto start detached —podman compose up -d— and follow logs withpodman compose logs -f generator.
5. Using a Local Directory (Without aetherpak.yml)#
If you've built a bundle locally and want to test it through the full publish pipeline, you can bind-mount a host directory containing your .flatpak files into the generator instead of providing a download URL.
SELinux: All bind mounts in this guide use the
:zsuffix to allow Podman to relabel the host directory for container access. This is required on SELinux-enforcing systems (Fedora, RHEL, CentOS) and is harmless elsewhere.
Compose file#
Place your .flatpak bundle files in a local directory (e.g. ./my-bundles), then use this compose.yml:
services:
registry:
image: docker.io/registry:2
ports:
- "5001:5000"
restart: always
generator:
image: ghcr.io/aetherpak/cli:latest
depends_on:
registry:
condition: service_started
environment:
AETHERPAK_ARCH: ${ARCH:-x86_64}
AETHERPAK_APP_ID: ai.lemonade_server.Lemonade
AETHERPAK_REMOTE_NAME: aetherpak-development
AETHERPAK_OCI_REPOSITORY: aetherpak
AETHERPAK_REGISTRY: registry:5000
AETHERPAK_SITE_DIR: /site
AETHERPAK_PAGES_URL: http://localhost:8080/flatpak
AETHERPAK_NO_SIGN: 1
AETHERPAK_ALLOW_UNSIGNED: 1
AETHERPAK_INSECURE: 1
command: >
sh -cx "
aetherpak publish --bundle-path /bundles/*.flatpak &&
aetherpak build-site --landing-page
"
volumes:
- ./my-bundles:/bundles:z
- site:/site
working_dir: /data
web:
image: docker.io/nginx:alpine
ports:
- "8080:80"
volumes:
- site:/usr/share/nginx/html/flatpak
restart: always
depends_on:
generator:
condition: service_completed_successfully
volumes:
site:
networks:
default:
name: aetherpak-local-stack
Key differences from the bundle URL example#
./my-bundles:/bundles:z— the local directory is bind-mounted into the container at/bundles. The:zsuffix applies the correct SELinux label.--bundle-path /bundles/*.flatpak— instead ofAETHERPAK_BUNDLE_URL, thepublishcommand is given an explicit glob path to the bundle files inside the container. The CLI supports glob patterns and processes all matched files .- No
AETHERPAK_BUNDLE_URL— remove this variable entirely; the bundle location is provided via the command flag.
This pattern is ideal when iterating on locally-built bundles before they're published to a release page: just drop the .flatpak file into ./my-bundles, run podman compose up, and the full pipeline runs against your local build.
6. Using a Local Directory (With aetherpak.yml)#
For the closest local simulation of what the GitHub Actions pipeline does in production, mount a project directory that contains an aetherpak.yml configuration file and use the aetherpak release porcelain command. This exercises the config-driven workflow — the same code path invoked by the reusable publish.yml workflow in CI.
SELinux: All bind mounts in this guide use the
:zsuffix to allow Podman to relabel the host directory for container access.
Compose file#
services:
registry:
image: docker.io/registry:2
ports:
- "5001:5000"
restart: always
generator:
image: ghcr.io/aetherpak/cli:latest
depends_on:
registry:
condition: service_started
environment:
AETHERPAK_REGISTRY: registry:5000
AETHERPAK_PAGES_URL: http://localhost:8080/flatpak
AETHERPAK_SITE_DIR: /site
AETHERPAK_NO_SIGN: 1
AETHERPAK_ALLOW_UNSIGNED: 1
AETHERPAK_INSECURE: 1
command: >
sh -cx "
aetherpak release --no-sign &&
aetherpak build-site --landing-page
"
volumes:
- ./my-project:/workspace:z
- site:/site
working_dir: /workspace
web:
image: docker.io/nginx:alpine
ports:
- "8080:80"
volumes:
- site:/usr/share/nginx/html/flatpak
restart: always
depends_on:
generator:
condition: service_completed_successfully
volumes:
site:
networks:
default:
name: aetherpak-local-stack
Key differences from the previous examples#
./my-project:/workspace:z— your local project directory (the one containingaetherpak.yml) is bind-mounted at/workspace. The:zsuffix applies the correct SELinux label.working_dir: /workspace— the CLI automatically discoversaetherpak.ymlin the current working directory, so no--configflag is needed .aetherpak release --no-sign— thereleaseporcelain command readsaetherpak.yml, resolves the build plan, runspublishfor every declared app, and produces the full set of OCI images.--no-signskips GPG signing for local testing.- Minimal environment variables — app-specific configuration (
AETHERPAK_APP_ID,AETHERPAK_OCI_REPOSITORY, etc.) is read fromaetherpak.yml. The environment variables here —AETHERPAK_REGISTRY,AETHERPAK_PAGES_URL,AETHERPAK_SITE_DIR— override the values in the config file, redirecting traffic to the local stack instead of GHCR.
Why this matters#
Environment variable overrides mean you can keep your aetherpak.yml pointing at your production GHCR registry and Pages URL, and use this compose stack to redirect to local services for testing without modifying any committed files. This mirrors the isolation that CI ephemeral environments provide, and makes it easy to confirm a config change is safe before pushing.
7. Example: Testing the actions-demo Repository Locally#
This example demonstrates using the actions-demo repository as a concrete test case. The actions-demo repository (https://github.com/aetherpak/actions-demo) builds GNOME Sudoku (org.gnome.Sudoku) from a Flatpak manifest (org.gnome.Sudoku.json). It uses the aetherpak/actions reusable workflow in CI. For local testing, you can clone this repository and use it as your project directory.
Setup#
First, clone the actions-demo repository:
git clone https://github.com/aetherpak/actions-demo.git
Compose Configuration#
This example shows the full flow: building the Flatpak from the manifest using flatpak-builder, then publishing it locally through the AetherPak pipeline.
Create a compose.yaml file:
services:
registry:
image: docker.io/registry:2
ports:
- "5001:5000"
restart: always
generator:
image: ghcr.io/aetherpak/cli:latest
depends_on:
registry:
condition: service_started
environment:
AETHERPAK_ARCH: ${ARCH:-x86_64}
AETHERPAK_APP_ID: org.gnome.Sudoku
AETHERPAK_REMOTE_NAME: actions-demo
AETHERPAK_OCI_REPOSITORY: aetherpak
AETHERPAK_REGISTRY: registry:5000
AETHERPAK_SITE_DIR: /site
AETHERPAK_PAGES_URL: http://localhost:8080/flatpak
AETHERPAK_NO_SIGN: 1
AETHERPAK_ALLOW_UNSIGNED: 1
AETHERPAK_INSECURE: 1
command: >
sh -cx "
flatpak-builder --force-clean --repo=repo build-dir /workspace/org.gnome.Sudoku.json &&
flatpak build-bundle repo org.gnome.Sudoku.flatpak org.gnome.Sudoku stable &&
aetherpak publish --bundle-path org.gnome.Sudoku.flatpak &&
aetherpak build-site --landing-page
"
volumes:
- ./actions-demo:/workspace:z
- site:/site
working_dir: /data
web:
image: docker.io/nginx:alpine
ports:
- "8080:80"
volumes:
- site:/usr/share/nginx/html/flatpak
restart: always
depends_on:
generator:
condition: service_completed_successfully
volumes:
site:
networks:
default:
name: aetherpak-local-stack
Key Configuration Points#
- Same app as CI: This example uses the actions-demo repository which builds GNOME Sudoku — the same app built in CI by the actions-demo GitHub Actions workflow
- Build from manifest: The generator runs
flatpak-builderto build from the manifest, thenflatpak build-bundleto create a.flatpakbundle, and finally the usualaetherpak publish+aetherpak build-sitecommands - App ID:
AETHERPAK_APP_IDis set toorg.gnome.Sudoku, matching the manifest'sapp-id - Remote name:
AETHERPAK_REMOTE_NAMEis set toactions-demoto match the repository name - Volume mount: The
./actions-demo:/workspace:zbind mount makes the cloned repository available inside the container at/workspace - Build time: Note that the
flatpak-builderstep requires downloading SDK/runtime dependencies on first run, so this may take longer than the bundle URL example shown in earlier sections
Running the Stack#
podman-compose up
After the stack completes successfully, verification follows the same steps as the "Verifying the Setup" section, but using actions-demo as the remote name and org.gnome.Sudoku as the app ID.
8. Verifying the Setup#
After podman compose up completes successfully, use the following steps to confirm that the full pipeline worked correctly.
Check the landing page#
Open a browser and navigate to:
http://localhost:8080/flatpak/
You should see the AetherPak landing page listing the published app with its name, description, and an install button. If the page loads but shows no apps, the site generation may have completed before the OCI push finished — check the generator logs with podman compose logs generator.
Add the local repository to Flatpak#
On a Linux system with Flatpak installed, you can add the locally served repository as a remote:
flatpak remote-add \
--if-not-exists \
--no-gpg-verify \
aetherpak-development \
http://localhost:8080/flatpak/aetherpak-development.flatpakrepo
(use lemonade-local as the remote name if following the Lemonade example from section 3, or actions-demo if following the actions-demo example from section 7)
--no-gpg-verifyis required here because the stack was run withAETHERPAK_NO_SIGN=1. In production, the GPG key would be bundled in the.flatpakrepofile and signature verification would be enabled automatically.
List available apps#
flatpak remote-ls aetherpak-development
(or flatpak remote-ls lemonade-local / flatpak remote-ls actions-demo depending on which example you're testing)
You should see the app ID from your example listed in the output.
Install the app#
flatpak install aetherpak-development ai.lemonade_server.Lemonade
(adjust both the remote name and app ID to match your example)
The Flatpak client will resolve the app from the local Pages index and pull the application layers directly from the local registry — demonstrating the full end-to-end flow that mirrors the production setup described in the Maintainer Guide.
Clean up the remote#
Once you're done testing, remove the local remote to avoid confusion with production remotes:
flatpak remote-delete aetherpak-development
(adjust the remote name to match whichever example you followed)
9. Troubleshooting#
Generator fails to connect to the registry#
Symptom: The generator exits early with a connection refused or dial error against registry:5000.
Cause: depends_on: condition: service_started only waits for the registry container to start, not for the registry process inside it to become ready. On slower machines there can be a brief gap between container start and port readiness.
Fix: Add a small readiness loop to the generator command:
command: >
sh -cx "
until wget -qO- http://registry:5000/v2/ >/dev/null 2>&1; do sleep 1; done &&
aetherpak publish &&
aetherpak build-site --landing-page
"
Permission denied on bind-mounted directories (Podman / SELinux)#
Symptom: The generator or nginx fails to read from or write to a bind-mounted host directory.
Cause: SELinux is blocking the container from accessing the host directory without the correct label.
Fix: Ensure the :z suffix is appended to every bind mount. For example:
- ./my-bundles:/bundles:z
The :z flag tells Podman to relabel the host directory with a shared container label. See the SELinux note in Prerequisites for more context.
The site is not accessible after startup#
Symptom: http://localhost:8080/flatpak/ returns 404 or a blank nginx page.
Cause: The AETHERPAK_PAGES_URL path component does not match the nginx mount path, or the generator did not write any files to the site volume.
Fix:
- Verify
AETHERPAK_PAGES_URLends with the same path where nginx serves the volume. If the URL ishttp://localhost:8080/flatpak, the volume must be mounted at/usr/share/nginx/html/flatpak. - Inspect the generator logs:
podman compose logs generator. Look for errors frombuild-site. - Check that the
sitevolume actually received files:podman volume inspect aetherpak-local-stack_site(ordocker volume inspect).
Registry communication fails with TLS errors#
Symptom: The generator reports a TLS handshake failure or "http: server gave HTTP response to HTTPS client".
Cause: AETHERPAK_INSECURE was not set (or was set to 0), so the CLI attempted HTTPS against the plaintext local registry.
Fix: Set AETHERPAK_INSECURE: 1 in the generator's environment block. This instructs the CLI to use plain HTTP when pushing to the registry .
Generator reports "allow unsigned" error#
Symptom: The generator exits with an error referencing AETHERPAK_ALLOW_UNSIGNED.
Cause: The CLI requires explicit opt-in before it will publish without a GPG signing key.
Fix: Ensure both AETHERPAK_NO_SIGN: 1 and AETHERPAK_ALLOW_UNSIGNED: 1 are set in the generator's environment. Both flags are required for an unsigned local run .
10. Notes#
Environment variable names may change. The AETHERPAK_-prefixed variable names used throughout this guide are derived from CLI flag names via Viper's automatic prefix mechanism . If a flag is renamed in a future CLI release, the corresponding environment variable name changes too. Always cross-reference with aetherpak --help or the CLI README when upgrading to a new CLI version.
Service communication uses the compose network. All three services in the compose file are connected via a named network (aetherpak-local-stack). This means the generator can reach the registry by the service name registry rather than an IP address. The AETHERPAK_REGISTRY: registry:5000 value in the environment works because Docker/Podman DNS resolves registry to the correct container IP automatically.
The CLI image is self-contained. The ghcr.io/aetherpak/cli:latest image includes flatpak, ostree, git, jq, curl, and all other required tooling — nothing needs to be installed on the host or pre-configured outside the compose file . This is the same image that underpins the GitHub Actions container steps described in the Maintainer Guide.
Architecture override. The AETHERPAK_ARCH: ${ARCH:-x86_64} pattern lets you switch architectures without editing the compose file. On an aarch64 machine, run ARCH=aarch64 podman compose up to test the arm64 variant.
Production parity. The local stack deliberately mirrors the production pipeline structure. The generator runs the same aetherpak publish and aetherpak build-site commands that the reusable GitHub Actions workflow invokes, against the same CLI binary. Any behaviour you observe locally — including generated file paths, OCI image tags, and .flatpakrepo content — is representative of what will happen in CI.