Self-Hosting Aptly: Run Your Own Debian APT Repository Behind NGINX

Debian itself publishes roughly 74,000 binary packages across its main archive, and every single one of them is signed, indexed, and served from the same battle-tested format any of us can run on a small VPS. deb.myguard.nl is a much smaller example of the same thing: a few hundred performance-optimised NGINX, Angie, and supporting packages, all served behind plain NGINX from filesystem endpoints generated by aptly. No magic. No SaaS. No proprietary “package registry” lock-in. Just a CLI, a GPG key, and a static web server.

This walkthrough is the full self-hosting recipe. We’ll install aptly on Debian or Ubuntu, create a repository, import some .debs, sign them properly, publish to a filesystem endpoint, serve it through NGINX, set the clients up correctly (including the modern deb822 sources format), and finish with a cron-driven snapshot rotation that keeps history without filling the disk. By the end you’ll have something apt update trusts and you control top to bottom.

Why aptly (and not reprepro, dak, or just a folder of .debs)

People who’ve been around Debian a while will reach for reprepro on instinct. It works. It’s older. It’s in main. It’s also brittle in ways that bite you the third time you do anything non-trivial — like republishing after a rollback, or running parallel suites for testing/stable, or maintaining snapshots you can pin to. Reprepro’s mental model is “one big mutable archive”. Aptly’s is closer to git: you have repositories (mutable working sets), you take snapshots (immutable, named points in time), and you publish snapshots (or repos) to endpoints (filesystem or S3). You can keep three months of nightly snapshots, publish only the ones you want, and roll back a publish in seconds.

Dak (the Debian Archive Kit) is the other option, and it’s what the actual Debian project uses. It’s also a beast — designed for hundreds of mirrors, an army of FTP-masters, and a complicated NEW queue. For self-hosters, it’s wildly overkill. You don’t want dak unless you are, in fact, becoming a distribution.

“Just put the .debs in a folder and serve them” is the joke answer that almost works. apt won’t index a flat folder; it needs Packages, Packages.gz, Release, Release.gpg, and InRelease files with the right hashes and a valid signature. You could generate those by hand with dpkg-scanpackages + apt-ftparchive + gpg --clearsign, and historically lots of small shops did. It’s about forty lines of shell that you’ll get wrong subtly until something breaks two months later. Aptly is that script done properly, with a state database and a publish workflow on top.

The short version: aptly is the right answer for one to ten thousand packages, one to ten architectures, and a single admin (or small team). Above that you’re probably looking at Pulp or hosted offerings. Below that you’re overengineering.

Installing aptly on Debian or Ubuntu

The version of aptly in the Debian/Ubuntu archive lags upstream by years (1.5.x at the time of writing, versus upstream 1.6.x). For a host you’re going to live with, install upstream’s apt repo:

# Trust the upstream signing key (modern signed-by pattern, no apt-key)
sudo install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://www.aptly.info/pubkey.txt | \
  sudo gpg --dearmor -o /etc/apt/keyrings/aptly.gpg

# Add the repo (deb822 format, the modern one)
sudo tee /etc/apt/sources.list.d/aptly.sources >/dev/null <<'EOF'
Types: deb
URIs: http://repo.aptly.info/
Suites: squeeze
Components: main
Signed-By: /etc/apt/keyrings/aptly.gpg
EOF

sudo apt update
sudo apt install aptly gnupg2

Yes, the suite is called squeeze even on bookworm or noble — upstream just never renamed it. It’s fine; aptly itself is a static Go binary that doesn’t care about distro versions. Verify:

aptly version
# expect aptly version 1.6.x

Aptly’s state lives in ~/.aptly/ by default. For a server install, you almost certainly want it elsewhere — a dedicated mount with room to grow. Create ~/.aptly.conf (or pass -config= on every command, which gets tedious):

{
  "rootDir": "/srv/aptly",
  "downloadConcurrency": 8,
  "downloadSpeedLimit": 0,
  "architectures": ["amd64", "arm64"],
  "dependencyFollowSuggests": false,
  "dependencyFollowRecommends": false,
  "dependencyFollowAllVariants": false,
  "dependencyFollowSource": false,
  "gpgDisableSign": false,
  "gpgDisableVerify": false,
  "gpgProvider": "gpg",
  "downloadSourcePackages": false
}

The rootDir matters more than it looks — and there’s a footgun here we’ll come back to in the gotchas section. For now, give it its own LVM volume or ZFS dataset if you can. Packages are small but they accumulate.

Generate a signing key

An unsigned APT repository will work on the client only with [trusted=yes] flags everywhere, which is the apt equivalent of disabling TLS verification. Don’t. Generate a proper GPG signing key:

gpg --batch --gen-key <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Subkey-Type: RSA
Subkey-Length: 4096
Name-Real: My APT Repo Signer
Name-Email: apt@example.com
Expire-Date: 5y
%commit
EOF

The %no-protection means no passphrase, which is what you want for an unattended signing key on a build server. If that makes you nervous: it should, a little, but it’s the same trade-off every CI system makes. Lock down the home directory (chmod 700 ~/.gnupg) and the key file inside it (chmod 600), and keep that machine off the public internet for SSH if you can.

Export the public key for clients to install:

gpg --armor --export apt@example.com > /srv/aptly/public/pubkey.asc
# Also useful for older clients that want a binary keyring file:
gpg --export apt@example.com > /srv/aptly/public/pubkey.gpg

The /srv/aptly/public/ path is whatever aptly will publish into — we’ll wire it up in a moment. Clients install this key into /etc/apt/keyrings/your-repo.gpg and point at it with Signed-By: in their sources file. Modern, no global trust pollution.

Create a repo, import .debs, snapshot, publish

The aptly workflow has four verbs you actually use day-to-day: repo, snapshot, publish, and db cleanup. Walk through it once and you’ve got the muscle memory for life.

# 1. Create a repository
aptly repo create -distribution=bookworm -component=main myrepo-bookworm

# 2. Import .deb files
aptly repo add myrepo-bookworm /path/to/build/output/*.deb

# 3. Snapshot it (immutable named point-in-time)
aptly snapshot create myrepo-bookworm-$(date +%Y%m%d) from repo myrepo-bookworm

# 4. Publish the snapshot to a filesystem endpoint
aptly publish snapshot \
  -distribution=bookworm \
  -architectures=amd64,arm64 \
  -gpg-key=apt@example.com \
  myrepo-bookworm-$(date +%Y%m%d)

That last command does the real work: it writes Packages, Packages.gz, Packages.xz, Release, Release.gpg, InRelease, and a pool/ directory with the actual .deb files (hardlinked, not copied — disk-efficient if your filesystem supports it) into $rootDir/public/dists/bookworm/ and $rootDir/public/pool/main/. The Release.gpg and InRelease are GPG-signed with your key. APT clients fetch InRelease first, verify the signature, then trust the hashes inside it to verify every other file. Standard chain.

To update the repo later: add new .debs with aptly repo add, take a new snapshot, then switch the published distribution to the new snapshot:

aptly publish switch bookworm myrepo-bookworm-$(date +%Y%m%d)

This is the magic that makes aptly nicer than reprepro: a switch is atomic to the client (one HTTP request to InRelease sees old, the next sees new), and if the new snapshot breaks something you can publish switch back to yesterday’s snapshot in two seconds and nothing on the client side is the wiser.

Serve it with NGINX

Aptly NGINX config snapshot publish signing workflow
Aptly writes a static tree; NGINX serves it. No PHP, no DB, no SaaS.

Aptly only generates files. It does not serve HTTP. That’s NGINX’s job, and the config is mercifully short:

server {
    listen 80;
    listen [::]:80;
    server_name apt.example.com;

    root /srv/aptly/public;

    # APT clients want correct MIME types; defaults are fine for .gz/.xz
    # but explicitly state .deb so they don't get served as text/html
    types {
        application/x-debian-package deb;
        application/pgp-signature     gpg sig asc;
        text/plain                    Release InRelease;
    }

    # Directory listings are optional; many people leave them on
    # for human browsability. Off is safer for production.
    autoindex off;

    # Long cache for pool files (they're content-addressed, never change)
    location /pool/ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Short cache for dists/ (Release/InRelease/Packages change)
    location /dists/ {
        expires 5m;
        add_header Cache-Control "public, must-revalidate";
    }

    access_log /var/log/nginx/apt.access.log;
    error_log  /var/log/nginx/apt.error.log;
}

That’s it. No PHP, no Python, no database. NGINX serves static files; APT clients consume them. Add a TLS listener if you want HTTPS (and you do, even for a public-read repo — at minimum it stops corporate proxies from caching old InRelease files at random). If the repo is private, slap basic-auth on top:

auth_basic           "apt repo";
auth_basic_user_file /etc/nginx/.htpasswd_apt;

Then on the client, embed credentials in the URI (https://user:pass@apt.example.com/) or use /etc/apt/auth.conf.d/example.conf with machine apt.example.com login user password pass.

Client setup, the modern way

For years everyone wrote one-line entries into /etc/apt/sources.list and called it done. APT now prefers the deb822 format in /etc/apt/sources.list.d/. It’s more verbose, more readable, and lets you put Signed-By: directly in the source — no more polluting the global apt trust store.

Client install in three commands:

# 1. Install the repo's public key as a binary keyring
sudo install -d -m 0755 /etc/apt/keyrings
sudo curl -fsSL https://apt.example.com/pubkey.asc \
  -o /etc/apt/keyrings/example-repo.asc

# 2. Write a deb822-format source file
sudo tee /etc/apt/sources.list.d/example.sources >/dev/null <<'EOF'
Types: deb
URIs: https://apt.example.com/
Suites: bookworm
Components: main
Architectures: amd64
Signed-By: /etc/apt/keyrings/example-repo.asc
EOF

# 3. Use it
sudo apt update
sudo apt install your-package

The Signed-By: binding is what stops a compromised different repo from injecting packages signed with a different key. Each repo’s signature is scoped to that repo only. Modern APT enforces this strictly. Don’t use the deprecated apt-key add path on any client newer than Debian 11 or Ubuntu 22.04 — it’ll work, with warnings, but it adds your key to global trust which is exactly what we’re trying to avoid.

This is also how our own how-to-use page tells clients to install our repo. Same pattern. Same security model.

The shared-rootDir trap (and other gotchas)

This is where our own scar tissue earns its keep. Every aptly endpoint you publish lives under the same $rootDir/public/ directory. If you have, say, a primary endpoint serving deb.example.com and a secondary endpoint serving internal.example.com from the same aptly install, they share the publish tree. A aptly publish drop on one endpoint can — and will — wipe files used by the other endpoint, because the cleanup logic walks the shared pool/ and removes anything not referenced by a currently-published snapshot. We learned this the hard way: a drop on the secondary endpoint took out a chunk of packages on the primary. Recovery was a full republish from snapshots. Lesson: if you run multiple aptly endpoints, give each one its own rootDir (separate aptly state directory), even if it costs you some disk to duplicate the pool. Or accept that all your endpoints are coupled and never publish drop without thinking hard.

Other ones to watch:

  • Snapshot accumulation eats disk. Every aptly snapshot create ref-keeps the .debs it points at, even after you’ve stopped publishing them. aptly db cleanup removes orphans (.debs no snapshot references). Run it weekly or you’ll find /srv/aptly/pool/ at 200 GB of old NGINX nightlies you forgot about.
  • GPG agent and unattended signing. Modern GPG insists on talking to gpg-agent, which insists on a TTY. On a headless server, set GPG_TTY=$(tty) in the cron environment or use gpg --pinentry-mode loopback. If your aptly publish runs hang with no output, this is almost always why.
  • Architecture mismatch. If your aptly.conf says architectures: ["amd64"] and you add an arm64 .deb, aptly silently drops it. Always include every arch you build for.
  • The InRelease + Release + Release.gpg trio. Older clients want Release and Release.gpg (detached signature). Newer clients want InRelease (clear-signed combined file). Aptly writes both by default; if your NGINX is doing aggressive caching, make sure all three are served fresh, not just one.
  • Time skew breaks signature verification. A signed InRelease has a Valid-Until field. If your server clock is wrong, you can sign files that are already expired. systemd-timesyncd is your friend.

Automation: cron, snapshot rotation, signal-safe

The whole point of running your own repo is to make publishing trivial. A working build-and-publish cron is short:

#!/bin/bash
# /usr/local/bin/apt-publish-nightly
set -euo pipefail

export GNUPGHOME=/srv/aptly/.gnupg
export GPG_TTY=$(tty || echo /dev/null)
SUITE=bookworm
REPO=myrepo-bookworm
SNAP="${REPO}-$(date +%Y%m%d-%H%M)"

# 1. Add any new .debs the build system dropped
aptly repo add -force-replace "$REPO" /srv/build/output/*.deb

# 2. New snapshot
aptly snapshot create "$SNAP" from repo "$REPO"

# 3. Atomically switch the published suite to the new snapshot
aptly publish switch -gpg-key=apt@example.com "$SUITE" "$SNAP"

# 4. Drop snapshots older than 30 days
aptly snapshot list -raw | grep "^${REPO}-" | while read -r s; do
  d=$(echo "$s" | sed -E "s/^${REPO}-([0-9]{8}).*/\1/")
  age_days=$(( ( $(date +%s) - $(date -d "$d" +%s) ) / 86400 ))
  if [ "$age_days" -gt 30 ]; then
    aptly snapshot drop "$s" || true
  fi
done

# 5. Garbage-collect orphaned .debs
aptly db cleanup

Drop it in cron with 15 2 * * * /usr/local/bin/apt-publish-nightly >> /var/log/apt-publish.log 2>&1 and you have a self-rotating, signed, snapshotted APT repository that needs zero attention until something genuinely breaks. The -force-replace on repo add lets you re-add a .deb with the same version (useful if you rebuilt to fix a packaging-only bug); without it, aptly refuses, which is also a defensible default.

For the truly paranoid: pipe the publish step through a wrapper that publishes to a staging endpoint first, runs apt-get -s install against it from a test container, and only switches the production endpoint if the dry-run succeeds. This is what our build farm does for every nightly. The whole pipeline is <200 lines of shell.

FAQ

Is aptly suitable for a single-developer self-hosted setup?

Yes. It’s a static Go binary, the state is a few hundred MB plus your .debs, and it has zero runtime dependencies beyond GPG. For one to ten thousand packages and one to ten architectures, it’s the obvious choice. Above that, look at Pulp.

Can I use aptly with Ubuntu as well as Debian?

Yes. Aptly doesn’t care which distro the .debs were built for; it just indexes and signs them. Plenty of self-hosters run a single aptly install serving separate suites for bookworm, trixie, noble, and oracular side by side. Each suite is just a different distribution in publish-snapshot terms.

Do I need HTTPS for my apt repo?

You don’t strictly need it because the package contents are already GPG-signed and APT verifies the signatures itself. But you should add it anyway — TLS prevents corporate proxies from caching stale InRelease files, hides which packages a client is downloading, and avoids confused-deputy attacks from network middleboxes. Let’s Encrypt makes it free.

How do I roll back a bad release?

Find the previous snapshot with aptly snapshot list, then run aptly publish switch . Clients pick up the change on their next apt update (within minutes, faster if you’ve shortened the InRelease cache). This is the killer feature aptly has that reprepro effectively doesn’t.

What’s the difference between a repo and a snapshot in aptly?

A repo is a mutable working set — you add and remove .debs from it freely. A snapshot is an immutable named pointer to a specific set of .deb versions at a specific point in time. You publish snapshots, not repos directly, so that what’s live on the mirror is always a frozen point you can name and roll back to.

Can multiple machines push to the same aptly instance?

Not safely without coordination — aptly’s state DB doesn’t have locking against concurrent writers from separate processes. Run the aptly CLI on one host and have other build machines scp their .debs into a directory that aptly picks up via a serialised cron, or use the aptly API server with a single API process in front of the state DB.

How big does disk usage get?

Pool files are stored once and hardlinked into every snapshot that references them. For a typical repo serving ~500 packages across two architectures, expect 5–20 GB depending on package sizes. The trap is keeping years of old snapshots — run aptly db cleanup weekly and you’ll be fine.

Related reading