Packaging & declarative state
Build and push a package
docker run --rm -v "$PWD:/work" -w /work \
git.morlana.online/f.weber/aptly-deb-builder:<tag> \
aptly-release --config nfpm.yaml -- --repo stable --distribution stable
aptly-release = aptly-pack (builds a .deb) + aptly-push (uploads it, includes
it into the repo, refreshes the publish target) in one call. Everything before --
goes to aptly-pack, everything after goes to aptly-push; the built .deb files
are appended automatically.
Two packaging paths, auto-detected:
- no
debian/directory present → nfpm (nfpm.yaml, no Debian toolchain knowledge needed) debian/directory present → realdpkg-buildpackage -us -uc -b(unsigned — signing is aptly's job at publish time, not the package's)
Auth: APTLY_USER/APTLY_PASSWORD (or none at all, for an open repo) — works
unchanged across all four presets from Security. Against a signed
repo: do not pass --no-sign, and make sure the server has a signing key (see
below); against an unsigned repo: pass --no-sign.
Reusable Gitea Action
- uses: f.weber/aptly-containerized@image/v1.6.3-1
with:
url: https://apt.example.com
repo: stable
distribution: stable
username: ${{ secrets.APTLY_USER }}
password: ${{ secrets.APTLY_PASSWORD }}
Lives at the repo root deliberately: uses: references to other Gitea repos with
a subpath (owner/repo/path/action@ref) are not reliable, only root and local
(./.gitea/actions/x) references are. A more robust fallback that needs no action
resolution at all: call the image directly (see above).
Declarative state (state.yaml)
Consumed by aptly-reconcile — a single file in Compose (compose/config/state.yaml),
generated from values.yaml's aptly.{localRepos,mirrors,publish} in the Helm
chart. Runs exclusively against the REST API, never against the aptly CLI:
aptly api serve holds the LevelDB lock, and a CLI call against the same rootDir
would either block or (with -no-lock) endanger the database.
localRepos:
- { name: stable, comment: "", defaultDistribution: stable, defaultComponent: main }
mirrors:
- name: debian-security
archiveURL: http://security.debian.org/debian-security
distribution: trixie-security
components: [main]
architectures: [amd64, arm64]
publish:
- name: stable-root # only internal to the reconciler, not part of the aptly API
prefix: "" # "" = repo root
distribution: stable
sourceKind: local # local | snapshot
sources: [{ name: stable, component: main }]
architectures: [amd64, arm64]
acquireByHash: true
Known, deliberate limitation: mirrors[].components cannot be changed after a
mirror has been created — aptly's API has no endpoint for that. Changing it requires
deleting and recreating the mirror. Everything else in a mirror definition (filter,
architectures, download flags, …), and all of a local repo's metadata, is
synchronized on every run.
Idempotency: GET-before-write, never POST-and-swallow-40x (a swallowed 400 would
also skip real changes). reconcile.failOnError (Helm) resp. APTLY_FAIL_ON_ERROR
(Compose/env) defaults to false — an unreachable mirror must not make
helm upgrade/docker compose up fail.
The prefix-escaping pitfall: aptly's API expects the root prefix in the URL as
the literal . (not an empty path) — and curl normalizes away /./ segments
client-side unless you set --path-as-is (every script here already does). Coming
back the other way, GET /api/publish also returns the root prefix as the literal
"." (empirically verified, not "") — so when checking "does this publish target
already exist", you need to compare against ".", not against the empty string from
values.yaml/state.yaml. Nested prefixes (debian/stable), on the other hand, are
returned with real slashes, not underscore-escaped — the escaped form (/ → _) is
only a quirk of URL path construction, not of the stored/listed values. Both are
already handled correctly in rootfs/usr/local/bin/aptly-reconcile (api_prefix()
and the prefix_for_compare special case) — worth knowing if you write your own
scripts against the same API.
GPG
Default: the gpg provider (a real gnupg binary, included in the image). Key source
in production is always a Secret (aptly.gpg.signingKey.existingSecret in the
chart, compose/config/gpg/private.asc in Compose) — never inline in values.yaml.
The key ID is auto-detected (gpg --list-secret-keys --with-colons); the public key
is served under /signing-key.asc so clients can reference it directly via
signed-by= (a copy-paste snippet appears in the Helm NOTES resp. compose/README).