Initial implementation: aptly container image, Compose stacks, Helm chart, and Gitea Actions pipelines
CI / lint (push) Failing after 24s
CI / smoke-test (push) Failing after 2m4s
Release image / release (push) Successful in 23m18s
Release chart / release (push) Successful in 7s

Provides a self-contained, containerized aptly (Debian repo manager)
stack with independently releasable image and Helm chart versions.

- images/: aptly-server (aptly built from source, cross-compiled) and
  aptly-deb-builder (nfpm + dpkg-buildpackage) container images
- rootfs/: shared aptly-init/aptly-reconcile/aptly-push/aptly-pack
  scripts consumed identically by Compose and the Helm chart, driven
  by one declarative state.yaml contract
- compose/: test (ephemeral, open) and production docker-compose
  stacks with an nginx read/auth sidecar
- charts/aptly/: aptly-native Helm chart covering every security
  posture from fully open to authenticated read+write, Ingress and
  Gateway API support (usable in parallel for migration scenarios),
  metrics, and declarative repo/mirror/publish reconciliation via a
  Helm hook
- .gitea/workflows/: CI (lint, template, kubeconform, E2E smoke test)
  plus separately tagged image (image/v*) and chart (chart/v*)
  releases, weekly rebuilds, and a preflight workflow validating the
  runner's Docker/Helm-OCI capabilities
- pubkeys/: RSA chart-signing key for Helm --sign / Artifact Hub's
  signKey annotation (Helm can't verify Ed25519 keys)
- docs/, README.md, charts/aptly/README.md: usage, security, and
  versioning documentation
This commit is contained in:
2026-08-12 12:21:08 +02:00
commit 103ad311b7
71 changed files with 4843 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
# Copy to .env and edit. See docs/quickstart-compose.md for the full walkthrough.
# Image tag to run — set this to a released image/vX.Y.Z-N tag once you have
# one (see docs/versioning.md); "latest" is fine to try things out with.
APTLY_IMAGE_TAG=latest
# Host port nginx (reads + the auth-gated /api/) is published on.
APTLY_PUBLISH_PORT=8080
# --- Auth (security.preset "publicRead" equivalent) ---
# Password for the internal user aptly-reconcile/aptly-mirror-refresh use to
# talk through nginx. Required — has no default, compose refuses to start
# without it. Any non-trivial value; nothing external ever needs to know it.
APTLY_INTERNAL_PASSWORD=changeme-generate-a-real-secret
# Also edit config/users (copy from config/users.example) with the
# username:password pairs that external CI/uploaders should use.
# --- GPG signing ---
# true (default): publishing requires a signing key at config/gpg/private.asc
# (+ config/gpg/passphrase if it's passphrase-protected). Missing key ->
# aptly-init logs a WARN and publishes unsigned instead of failing to start
# — check `docker compose logs aptly-init` after first boot.
# false: explicitly unsigned, no key needed. See docs/security.md.
APTLY_GPG_ENABLED=true
# Directory containing private.asc (and optionally passphrase). Defaults to
# ./config/gpg, gitignored.
#APTLY_GPG_DIR=./config/gpg
# --- Reconcile ---
# false (default): an unreachable mirror or malformed state.yaml entry only
# warns — `docker compose up` still succeeds. Set true in CI to catch
# mistakes in state.yaml.
APTLY_RECONCILE_FAIL_ON_ERROR=false
# --- Backup (docker compose --profile backup run --rm backup) ---
#APTLY_BACKUP_DIR=./backup
View File
+16
View File
@@ -0,0 +1,16 @@
# Source aptly config, rendered by aptly-init into /run/aptly/aptly.yaml.
# ${VAR}-style placeholders are resolved against the aptly container's
# environment at startup — see .env.example for what to set.
#
# This one file is shared by both docker-compose.test.yaml and
# docker-compose.yaml (production); the security posture (auth, network
# exposure) is entirely an nginx concern (see nginx.test.conf / nginx.prod.conf),
# not an aptly config concern — aptly itself never changes between the two.
root_dir: /var/lib/aptly
log_level: info
gpg_provider: internal
serve_in_api_mode: false
filesystem_publish_endpoints:
public:
root_dir: /var/lib/aptly/public
link_method: hardlink
View File
+48
View File
@@ -0,0 +1,48 @@
# Production topology: security.preset "publicRead" equivalent — reading the
# repo (apt clients) needs no credentials, writing (the API under /api/) does.
# Health-check paths stay exempt so container/orchestrator probes never need
# credentials in any mode.
#
# Mounted at /etc/nginx/conf.d/default.conf, which the base image's own
# nginx.conf already `include`s from inside its own http{} block — so this
# file must contain ONLY a server{} block (or other http-context directives),
# never its own http{}/events{}/worker_processes wrapper.
server {
listen 8080;
server_name _;
client_max_body_size 0;
absolute_redirect off;
location = /healthz { access_log off; return 200 "ok\n"; }
location = /api/ready { access_log off; proxy_pass http://aptly:8080; }
location = /api/healthy { access_log off; proxy_pass http://aptly:8080; }
location /api/ {
auth_basic "aptly";
auth_basic_user_file /run/aptly/htpasswd;
proxy_pass http://aptly:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /signing-key.asc {
alias /run/aptly/pub/signing-key.asc;
default_type text/plain;
}
location / {
root /var/lib/aptly/public;
autoindex on;
autoindex_exact_size off;
location ~* /(InRelease|Release|Release\.gpg|Packages(\.[a-z0-9]+)?|Sources(\.[a-z0-9]+)?)$ {
root /var/lib/aptly/public;
add_header Cache-Control "no-cache" always;
}
}
}
+39
View File
@@ -0,0 +1,39 @@
# Test topology: everything open (security.preset "open" equivalent) — no
# auth on read OR write. This is what "komplett unabgesichert" looks like.
# Do not reuse this file for docker-compose.yaml (production).
#
# Mounted at /etc/nginx/conf.d/default.conf, which the base image's own
# nginx.conf already `include`s from inside its own http{} block — so this
# file must contain ONLY a server{} block (or other http-context directives),
# never its own http{}/events{}/worker_processes wrapper.
server {
listen 8080;
server_name _;
client_max_body_size 0;
absolute_redirect off;
location = /healthz { access_log off; return 200 "ok\n"; }
location = /api/ready { access_log off; proxy_pass http://aptly:8080; }
location = /api/healthy { access_log off; proxy_pass http://aptly:8080; }
location /api/ {
proxy_pass http://aptly:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /signing-key.asc {
alias /run/aptly/pub/signing-key.asc;
default_type text/plain;
}
location / {
root /var/lib/aptly/public;
autoindex on;
autoindex_exact_size off;
}
}
+18
View File
@@ -0,0 +1,18 @@
# Demo state for docker-compose.test.yaml: one local repo, published at the
# repo root, completely open and unsigned. Not meant for production — see
# compose/config/state.yaml for the starter template used there.
localRepos:
- name: demo
comment: "docker-compose.test.yaml demo repo"
defaultDistribution: stable
defaultComponent: main
publish:
- name: demo-root
prefix: ""
distribution: stable
sourceKind: local
sources:
- { name: demo, component: main }
architectures: [amd64, arm64]
acquireByHash: true
+31
View File
@@ -0,0 +1,31 @@
# Declarative aptly state for docker-compose.yaml (production). Consumed by
# aptly-reconcile on every start of the `reconcile` service — safe to edit and
# restart that service as often as you like, it only converges towards
# whatever is described here (see docs/packaging.md).
#
# Uncomment and adjust the example below to get your first repo + publish
# target, or add your own. See rootfs/usr/local/bin/aptly-reconcile for the
# full field reference.
localRepos: []
# - name: stable
# comment: "Production package repository"
# 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
# prefix: ""
# distribution: stable
# sourceKind: local
# sources:
# - { name: stable, component: main }
# architectures: [amd64, arm64]
# acquireByHash: true
+7
View File
@@ -0,0 +1,7 @@
# Plaintext "user:password" lines, one per line — hashed into htpasswd by
# aptly-init at container start (never store a bcrypt/apr1 hash here yourself;
# see rootfs/usr/local/bin/aptly-init for why). Copy this file to
# compose/config/users (gitignored) and edit it before starting
# docker-compose.yaml (production). Blank lines and anything without a colon
# are ignored.
ci:change-me-please
+75
View File
@@ -0,0 +1,75 @@
# Test stack: fully open (no auth on read OR write), unsigned, ephemeral
# volumes, ready to use with a single `docker compose -f compose/docker-compose.test.yaml up`.
# This is also what tests/smoke-test.sh drives — see docs/quickstart-compose.md.
#
# Topology mirrors production (aptly-init -> aptly -> nginx, aptly never
# reachable from outside the compose network) so that what you test here is
# what you'd actually run, just with the security matrix dialed to "open".
services:
aptly-init:
build:
context: ..
dockerfile: images/aptly-server/Dockerfile
entrypoint: ["/usr/local/bin/aptly-init"]
environment:
APTLY_GPG_ENABLED: "false"
volumes:
- ./config/aptly.yaml:/etc/aptly-src/aptly.yaml:ro
- aptly_run:/run/aptly
restart: "no"
aptly:
build:
context: ..
dockerfile: images/aptly-server/Dockerfile
depends_on:
aptly-init:
condition: service_completed_successfully
environment:
APTLY_API_LISTEN: "0.0.0.0:8080"
volumes:
- aptly_data:/var/lib/aptly
- aptly_run:/run/aptly
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/healthy"]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
nginx:
image: nginxinc/nginx-unprivileged:1-alpine
depends_on:
aptly:
condition: service_healthy
volumes:
- aptly_data:/var/lib/aptly:ro
- aptly_run:/run/aptly:ro
- ./config/nginx.test.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "8080:8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 5s
timeout: 3s
retries: 20
reconcile:
build:
context: ..
dockerfile: images/aptly-server/Dockerfile
depends_on:
aptly:
condition: service_healthy
entrypoint: ["/usr/local/bin/aptly-reconcile"]
environment:
APTLY_URL: "http://aptly:8080"
APTLY_STATE_FILE: "/state.yaml"
APTLY_FAIL_ON_ERROR: "true"
volumes:
- ./config/state.test.yaml:/state.yaml:ro
restart: "no"
volumes:
aptly_data:
aptly_run:
+134
View File
@@ -0,0 +1,134 @@
# Production stack: security.preset "publicRead" equivalent — reads are
# open (apt clients need no credentials), the mutating API is behind Basic
# Auth. aptly itself is never published to the host; only nginx is. See
# docs/security.md for how to move to the other three presets (fully open,
# fully authenticated, or read-only), and .env.example for every variable
# used below.
#
# First run:
# cp .env.example .env && edit it
# cp config/users.example config/users && edit it (at least change the password)
# docker compose up -d
# docker compose logs -f aptly-init # check for GPG warnings
services:
aptly-init:
image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest}
entrypoint: ["/usr/local/bin/aptly-init"]
environment:
APTLY_GPG_ENABLED: "${APTLY_GPG_ENABLED:-true}"
# Fixed in-container paths — put your key material at the host paths
# below (an empty/missing directory is fine: aptly-init then warns and
# publishes unsigned instead of failing to start).
APTLY_GPG_PRIVATE_KEY_FILE: "/etc/aptly-secrets/gpg/private.asc"
APTLY_GPG_PASSPHRASE_FILE: "/etc/aptly-secrets/gpg/passphrase"
APTLY_USERS_FILE: "/etc/aptly-secrets/users"
APTLY_INTERNAL_USER: "aptly-internal"
APTLY_INTERNAL_PASSWORD: "${APTLY_INTERNAL_PASSWORD:?set APTLY_INTERNAL_PASSWORD in .env}"
volumes:
- ./config/aptly.yaml:/etc/aptly-src/aptly.yaml:ro
- ./config/users:/etc/aptly-secrets/users:ro
- ${APTLY_GPG_DIR:-./config/gpg}:/etc/aptly-secrets/gpg:ro
- aptly_run:/run/aptly
restart: "no"
aptly:
image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest}
depends_on:
aptly-init:
condition: service_completed_successfully
environment:
APTLY_API_LISTEN: "0.0.0.0:8080"
volumes:
- aptly_data:/var/lib/aptly
- aptly_run:/run/aptly
read_only: true
tmpfs:
- /tmp
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/api/healthy"]
interval: 10s
timeout: 5s
retries: 20
start_period: 10s
restart: unless-stopped
deploy:
resources:
limits:
memory: 1g
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
nginx:
image: nginxinc/nginx-unprivileged:1-alpine
depends_on:
aptly:
condition: service_healthy
volumes:
- aptly_data:/var/lib/aptly:ro
- aptly_run:/run/aptly:ro
- ./config/nginx.prod.conf:/etc/nginx/conf.d/default.conf:ro
ports:
- "${APTLY_PUBLISH_PORT:-8080}:8080"
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /run
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 5s
retries: 20
restart: unless-stopped
deploy:
resources:
limits:
memory: 256m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# --- TLS via an external reverse proxy (recommended) ---
# Put Traefik/Caddy/whatever you already run in front of this service
# instead of terminating TLS here. Traefik label example:
# labels:
# - "traefik.enable=true"
# - "traefik.http.routers.aptly.rule=Host(`apt.example.com`)"
# - "traefik.http.routers.aptly.tls.certresolver=letsencrypt"
reconcile:
image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest}
depends_on:
aptly:
condition: service_healthy
entrypoint: ["/usr/local/bin/aptly-reconcile"]
environment:
APTLY_URL: "http://aptly:8080"
APTLY_STATE_FILE: "/state.yaml"
APTLY_FAIL_ON_ERROR: "${APTLY_RECONCILE_FAIL_ON_ERROR:-false}"
volumes:
- ./config/state.yaml:/state.yaml:ro
restart: "no"
# Run on demand: docker compose --profile backup run --rm backup
backup:
image: git.morlana.online/f.weber/aptly:${APTLY_IMAGE_TAG:-latest}
profiles: ["backup"]
entrypoint: ["/bin/sh", "-c"]
command:
- >
set -eu;
ts=$$(date -u +%Y%m%dT%H%M%SZ);
tar -C /var/lib/aptly --exclude=.gnupg -c . | zstd -q -o "/backup/aptly-$${ts}.tar.zst";
echo "wrote /backup/aptly-$${ts}.tar.zst"
volumes:
- aptly_data:/var/lib/aptly:ro
- ${APTLY_BACKUP_DIR:-./backup}:/backup
volumes:
aptly_data:
aptly_run: