Files
aptly-containerized/charts/aptly/README.md
T
f.weber 5af33e9128
CI / lint (push) Failing after 3s
CI / smoke-test (push) Failing after 5s
Release chart / release (push) Successful in 6s
Update Helm chart paths in workflows and documentation to avoid ambiguity in Gitea's package registry
2026-08-12 13:23:07 +02:00

18 KiB

aptly

aptly (Debian repository management tool) on Kubernetes: a single-pod StatefulSet (aptly + an nginx read/auth sidecar), a fully aptly-native values API, and declarative repo/mirror/publish state reconciled by a Helm hook — no library-chart dependency, no concepts to learn beyond aptly's and Kubernetes' own.

TL;DR

helm install my-aptly oci://git.morlana.online/f.weber/charts/aptly --version <version>

Introduction

This chart deploys aptly as:

  • a StatefulSet running two containers in one pod — aptly itself (bound to 127.0.0.1, never reachable off-pod) and an nginx sidecar that is the only thing actually exposed, handling reads, auth, and the write-API proxy;
  • a Service in front of nginx, and optional Ingress and/or Gateway API HTTPRoute resources for apt clients and the API — both can be enabled at the same time, e.g. mid-migration between the two (see Gateway API);
  • a post-install/post-upgrade Job that reconciles the local repos, mirrors, and publish targets declared in values.yaml against the running instance's REST API.

Three things this chart is built around:

  1. A single security switch that actually reaches every mode, including completely open (no auth on read or write) if that's what you want — see Security modes below.
  2. An aptly-native config surface: curated aptly.* keys for the common cases, plus aptly.configOverrides as a raw passthrough so any current or future aptly config key is reachable without waiting on a chart update.
  3. Declarative state: local repos, mirrors, and publish targets live in values.yaml and are converged towards on every helm install/helm upgrade, the same way the rest of the cluster is managed.

Prerequisites

  • Kubernetes 1.24+
  • Helm 3.8+ (for OCI registry support) — Helm 4 also works
  • A StorageClass supporting ReadWriteOnce, unless persistence.enabled: false

Installing the chart

helm install my-aptly oci://git.morlana.online/f.weber/charts/aptly --version <version> \
  --set ingress.enabled=true \
  --set ingress.repo.host=apt.example.com

Without ingress.enabled, the post-install NOTES print a kubectl port-forward command instead — nothing has to be configured up front to try the chart out (e.g. in kind/k3d).

Uninstalling the chart

helm uninstall my-aptly

The PersistentVolumeClaim created by this chart's volumeClaimTemplate (i.e. when persistence.existingClaim is unset) is not deleted — StatefulSet-owned PVCs never are, by Kubernetes' own design, regardless of any Helm annotation. Remove it yourself if you're done with the data — for a release named my-aptly, that PVC is data-my-aptly-0 (data-<statefulset-name>-<ordinal>; find the exact name with kubectl get pvc -l app.kubernetes.io/instance=<release>): kubectl delete pvc data-my-aptly-0.

Security modes

aptly itself ships with no authentication at all; this chart's security.preset switch controls what the nginx sidecar in front of it requires:

security.preset Read (apt clients) Read auth Write (/api/) Write auth
open on no on no
publicRead (default) on no on yes
authenticated on yes on yes
readOnly on no off (404)
security:
  preset: open   # the fully-unsecured mode — one line, no confirmation gate

security.read.* and security.write.* override the preset explicitly, so every individual cell of the matrix is reachable (e.g. authenticated but with anonymous reads, or open restricted to a CIDR on the write path). Health probes (/api/ready, /api/healthy) never require credentials, in any mode.

Credentials come from security.auth.users (plaintext, hashed into an htpasswd file by the initContainer at pod start — never store a pre-hashed password here, see the comment in values.yaml) or from security.auth.existingSecret (a pre-built htpasswd Secret key — the recommended production path, e.g. via ExternalSecrets/SealedSecrets).

Full write-up of the Ingress split mode, the trustedProxies/allowCIDRs pitfall behind an Ingress controller, and write.inClusterOnly: docs/security.md in the repository.

Gateway API

gateway.* is a complete, independent alternative to ingress.* — enable either one, or both at once. Nothing about this chart forces a choice, on purpose: if you're partway through migrating a cluster from Ingress to Gateway API, both resource sets can point at the same Service for as long as that takes.

gateway:
  enabled: true
  parentRefs:
    - name: my-gateway          # a Gateway your cluster admin already manages —
      namespace: gateway-infra  # this chart never creates one itself
  repo:
    hostnames: [apt.example.com]

This mirrors ingress.* concept for concept:

  • gateway.mode: single (default) creates one HTTPRoute that carries both apt reads and /api/ writes — nginx does the split internally, exactly as in Ingress single mode.
  • gateway.mode: split creates a second HTTPRoute for /api/ under gateway.api.hostnames, optionally attached to a different Gateway via gateway.api.parentRefs (falls back to gateway.parentRefs when unset) — e.g. an internal-only Gateway for the write path. Same caveat as Ingress split mode applies: this is a route-level split, not something nginx itself enforces, so combine it with security.write.inClusterOnly or a NetworkPolicy if a request arriving on the wrong hostname must actually be rejected at the network level.
  • security.write.inClusterOnly: true omits the API HTTPRoute entirely in split mode, the same way it omits the API Ingress.
  • The shared proxy.enabled=false guard applies here too: a HTTPRoute (or Ingress) in front of aptly's unauthenticated write API is refused unless security.preset: open confirms it's intended.

One real difference from Ingress: TLS is not configured here. Gateway API deliberately separates infrastructure (the Gateway and its listeners, owned by a cluster admin) from routing (the HTTPRoute, owned by this chart) — so TLS termination is the referenced Gateway's job, not a gateway.repo.tls-style field this chart would need to expose.

Core Gateway API resources have been GA (gateway.networking.k8s.io/v1) since v1.0; gateway.apiVersion exists as an escape hatch only if your cluster's CRDs still predate that.

Configuring aptly itself

Two layers, always merged in this order — nothing in aptly's own configuration is ever unreachable through this chart:

  1. curated aptly.* keys (omitted from the rendered config when unset, so a default install matches aptly's own upstream defaults exactly);
  2. aptly.configOverrides — raw aptly YAML (snake_case keys, same as aptly's own config file), deep-merged over the generated config last, always wins.
aptly:
  architectures: [amd64, arm64]
  metrics:
    enabled: true
  configOverrides:
    s3_publish_endpoints:
      cdn:
        region: eu-central-1
        bucket: apt-example

Declarative state

Local repos, mirrors, and publish targets declared under aptly.* are converged towards by a Helm hook Job on every install/upgrade — talking only to aptly's REST API, never the CLI (the API server holds aptly's database lock).

aptly:
  localRepos:
    - name: stable
      defaultDistribution: stable
      defaultComponent: main
  publish:
    - name: stable-root
      prefix: ""
      distribution: stable
      sourceKind: local
      sources: [{ name: stable, component: main }]
      architectures: [amd64, arm64]

Known limitation: mirrors[].components cannot be changed after a mirror is created (aptly's API has no endpoint for that) — changing it means deleting and recreating the mirror. Everything else is kept in sync on every run.

GPG signing

aptly:
  gpg:
    enabled: true              # false = gpg_disable_sign + Signing.Skip on every publish call
    provider: gpg               # gpg (default) | internal (pure-Go, no gnupg binary)
    signingKey:
      existingSecret: my-signing-key   # keys: privateKey (or secretKeyring), passphrase

The public key is served at aptly.gpg.publishPublicKey.path (default /signing-key.asc) so clients can fetch it directly:

curl -fsSL https://apt.example.com/signing-key.asc | gpg --dearmor \
  | sudo tee /usr/share/keyrings/example.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/example.gpg] https://apt.example.com/ stable main' \
  | sudo tee /etc/apt/sources.list.d/example.list

Never put a real private key inline in values.yamlaptly.gpg.signingKey.privateKey exists only as a quick-test escape hatch.

Storage

persistence.size/persistence.storageClass are immutable once installed — Kubernetes forbids changing a StatefulSet's volumeClaimTemplates in place. Set persistence.existingClaim from the start in production: then no volumeClaimTemplate exists at all, and resizing the referenced PVC directly is a plain, supported operation.

Parameters

Image

Key Default Description
image.repository git.morlana.online/f.weber/aptly aptly-server image
image.tag "" falls back to .Chart.AppVersion — the last released image, never latest
image.pullPolicy IfNotPresent
image.pullSecrets []
nginx.image.repository nginxinc/nginx-unprivileged upstream image, unmodified
nginx.image.tag 1-alpine
nginx.image.pullPolicy IfNotPresent
nginx.resources {}
nginx.securityContext non-root, all caps dropped

aptly configuration

Key Default Description
aptly.architectures [] empty = all available
aptly.logLevel info
aptly.logFormat json
aptly.download.concurrency 4
aptly.download.limit 0 KB/s, 0 = unlimited
aptly.download.retries 0
aptly.download.sourcePackages false
aptly.publishing.skipContents false
aptly.publishing.skipBz2 false
aptly.metrics.enabled false exposes /api/metrics on a separate, unauthenticated port — see Metrics
aptly.swagger.enabled false exposes /docs.html
aptly.gpg.enabled true false disables signing entirely — see GPG signing
aptly.gpg.verify true mirror signature verification
aptly.gpg.provider gpg gpg | internal
aptly.gpg.signingKey.existingSecret "" Secret key privateKey or secretKeyring, optional passphrase
aptly.gpg.signingKey.privateKey "" inline armored key — quick tests only, never for production
aptly.gpg.signingKey.passphrase "" only used with the inline key above
aptly.gpg.publishPublicKey.enabled true serve the public key over HTTP
aptly.gpg.publishPublicKey.path /signing-key.asc
aptly.gpgKeys [] trusted keys imported for mirror verification — [{name, armored}]
aptly.localRepos [] see Declarative state
aptly.mirrors [] see Declarative state
aptly.publish [] see Declarative state
aptly.configOverrides {} raw aptly config, deep-merged last — see Configuring aptly itself
aptly.existingSecretEnv [] Secret names to envFrom, for ${VAR} placeholders inside configOverrides

Security

Key Default Description
security.preset publicRead open | publicRead | authenticated | readOnly — see Security modes
security.auth.users {} name: plaintext-password map, hashed at pod start
security.auth.existingSecret "" pre-built htpasswd Secret — wins over users
security.auth.internalUser.enabled true credentials the reconcile Job authenticates through nginx with, in every preset
security.auth.internalUser.username aptly-internal
security.trustedProxies [] CIDRs to trust X-Forwarded-For from — required for allowCIDRs to be meaningful behind an Ingress
security.read.enabled true overrides the preset
security.read.requireAuth null null = preset's value
security.read.allowCIDRs []
security.write.enabled true overrides the preset
security.write.requireAuth null null = preset's value
security.write.allowCIDRs []
security.write.inClusterOnly false in ingress.mode: split, omits the API Ingress entirely

Proxy

Key Default Description
proxy.enabled true false = aptly serves reads itself (serve_in_api_mode), no nginx split — only sane with security.preset: open
proxy.compatPaths true also serve the tree under /repos/<name>/, aptly's own URL shape
proxy.maxUploadSize "0" nginx client_max_body_size, "0" = unlimited
proxy.readTimeout 3600s
proxy.publishEndpointName public the filesystem_publish_endpoints key aptly publishes under

Persistence

Key Default Description
persistence.enabled true false = emptyDir
persistence.existingClaim "" set this in production — see Storage
persistence.storageClass "" falls back to global.defaultStorageClass
persistence.accessMode ReadWriteOnce
persistence.size 20Gi immutable once installed unless using existingClaim
persistence.annotations {}

Workload

Key Default Description
workload.updateStrategy.type RollingUpdate safe with replicas: 1 on a StatefulSet
workload.podManagementPolicy OrderedReady
workload.revisionHistoryLimit 3
workload.terminationGracePeriodSeconds 60
workload.annotations / podAnnotations / podLabels {}
podSecurityContext non-root, uid/gid 10001
containerSecurityContext all caps dropped, read-only root fs
resources {} the aptly container
probes.startup / .readiness / .liveness see values.yaml tuned for LevelDB recovery time on unclean shutdown

Networking

Key Default Description
service.type ClusterIP
service.port 8080
service.annotations {}
ingress.enabled false
ingress.mode single single | split — see docs/security.md
ingress.className ""
ingress.annotations body-size/read-timeout defaults for nginx-ingress
ingress.repo.host / .path / .pathType / .tls apt-client-facing Ingress
ingress.api.enabled / .host / .className / .annotations / .tls split mode only
gateway.enabled false independent of ingress.enabled — both may be true at once, see Gateway API
gateway.apiVersion gateway.networking.k8s.io/v1 override only for pre-GA clusters
gateway.mode single single | split — same meaning as ingress.mode
gateway.parentRefs [] required when gateway.enabled: true[{name, namespace, sectionName}]
gateway.repo.hostnames / .path / .pathType apt-client-facing HTTPRoute; pathType is Gateway API's own enum (PathPrefix/Exact/RegularExpression), distinct from ingress.repo.pathType's
gateway.api.enabled / .hostnames / .parentRefs split mode only; .parentRefs falls back to gateway.parentRefs when unset
networkPolicy.enabled false
networkPolicy.allowedNamespaces [] empty = no ingress restriction
networkPolicy.extraIngress []
networkPolicy.egress.allowAll true disabling this breaks mirrors unless you add egress.extra rules yourself

Metrics

Key Default Description
metrics.service.enabled false separate, unauthenticated port
metrics.service.port 9090
metrics.serviceMonitor.enabled false requires the Prometheus Operator CRDs
metrics.serviceMonitor.interval 30s

Reconcile

Key Default Description
reconcile.enabled true
reconcile.mode hook hook | job (GitOps-friendly, hashed name) | manual
reconcile.failOnError false true makes an unreachable mirror fail the release
reconcile.timeoutSeconds 600
reconcile.image {} overrides repository/tag/pullPolicy; defaults to the main image
reconcile.resources {}

Pod disruption & scheduling

Key Default Description
podDisruptionBudget.enabled false
podDisruptionBudget.maxUnavailable 1 never set minAvailable here — with replicas: 1 it blocks every node drain forever
nodeSelector / tolerations / affinity / topologySpreadConstraints / priorityClassName standard scheduling escape hatches

Escape hatches

Key Default Description
extraEnv / extraEnvFrom [] on the aptly container
extraVolumes / extraVolumeMounts []
extraInitContainers / extraContainers []
global.imageRegistry "" prefixes both image.repository and nginx.image.repository
global.imagePullSecrets []
global.defaultStorageClass ""

License

MIT. See LICENSE and NOTICE.md in the repository.