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
StatefulSetrunning two containers in one pod —aptlyitself (bound to127.0.0.1, never reachable off-pod) and annginxsidecar that is the only thing actually exposed, handling reads, auth, and the write-API proxy; - a
Servicein front ofnginx, and optionalIngressand/or Gateway APIHTTPRouteresources 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
Jobthat reconciles the local repos, mirrors, and publish targets declared invalues.yamlagainst the running instance's REST API.
Three things this chart is built around:
- 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.
- An aptly-native config surface: curated
aptly.*keys for the common cases, plusaptly.configOverridesas a raw passthrough so any current or future aptly config key is reachable without waiting on a chart update. - Declarative state: local repos, mirrors, and publish targets live in
values.yamland are converged towards on everyhelm 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
StorageClasssupportingReadWriteOnce, unlesspersistence.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 oneHTTPRoutethat carries both apt reads and/api/writes — nginx does the split internally, exactly as in Ingresssinglemode.gateway.mode: splitcreates a secondHTTPRoutefor/api/undergateway.api.hostnames, optionally attached to a different Gateway viagateway.api.parentRefs(falls back togateway.parentRefswhen unset) — e.g. an internal-only Gateway for the write path. Same caveat as Ingresssplitmode applies: this is a route-level split, not something nginx itself enforces, so combine it withsecurity.write.inClusterOnlyor aNetworkPolicyif a request arriving on the wrong hostname must actually be rejected at the network level.security.write.inClusterOnly: trueomits the APIHTTPRouteentirely insplitmode, the same way it omits the APIIngress.- The shared
proxy.enabled=falseguard applies here too: aHTTPRoute(orIngress) in front of aptly's unauthenticated write API is refused unlesssecurity.preset: openconfirms 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:
- curated
aptly.*keys (omitted from the rendered config when unset, so a default install matches aptly's own upstream defaults exactly); 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.yaml — aptly.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 |
"" |