Building a Self-Hosted Package Mirror for Air-Gapped and Hardened Environments

Air-gapped networks, compliance-mandated environments, and bandwidth-constrained sites all share a common need: internal package mirrors. When your servers cannot reach the internet — or when you need to control exactly which packages are available — a self-hosted mirror is infrastructure you cannot skip.

This guide covers building a complete Debian/Ubuntu package mirror using apt-mirror, serving it with nginx, automating synchronization, distributing GPG keys, and deploying client configuration at scale with Puppet or Ansible.

Why Mirror Internally

The reasons break down into four categories:

Air-gapped compliance. FedRAMP, CMMC, PCI-DSS, and STIG baselines often require that production systems cannot reach the public internet. Packages must come from a controlled internal source.

Supply chain control. When you mirror packages locally, you create a buffer between upstream and your fleet. If a package is compromised upstream, you can freeze your mirror, audit the change, and decide whether to sync. You are not automatically pulling untrusted code onto production systems.

Bandwidth savings. A fleet of 200 servers all pulling the same 500 MB security update from the internet wastes bandwidth and hammers your egress link. An internal mirror downloads once and serves locally at wire speed.

Reliability. External mirrors go down, change URLs, or drop old releases. An internal mirror ensures your package management always works, even if upstream is having a bad day.

Setting Up apt-mirror

Install apt-mirror on a dedicated server (we will call it mirror01):

sudo apt-get install apt-mirror

Configure /etc/apt/mirror.list:

############ apt-mirror configuration ############
set base_path    /srv/apt-mirror
set mirror_path  $base_path/mirror
set skel_path    $base_path/skel
set var_path     $base_path/var
set nthreads     20
set _tilde       0

# Ubuntu 22.04 LTS (Jammy)
deb http://archive.ubuntu.com/ubuntu jammy           main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu jammy-updates    main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu jammy-security   main restricted universe multiverse
deb http://archive.ubuntu.com/ubuntu jammy-backports  main restricted universe multiverse

# Debian 12 (Bookworm)
deb http://deb.debian.org/debian bookworm            main contrib non-free non-free-firmware
deb http://deb.debian.org/debian bookworm-updates     main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security bookworm-security main contrib non-free non-free-firmware

# Optional: source packages (doubles disk usage)
# deb-src http://archive.ubuntu.com/ubuntu jammy main restricted universe multiverse

clean http://archive.ubuntu.com/ubuntu
clean http://deb.debian.org/debian
clean http://security.debian.org/debian-security

Disk Space Planning

A full mirror of Ubuntu Jammy (main, restricted, universe, multiverse) for amd64 alone consumes approximately 200-250 GB. Add Debian Bookworm and you are looking at 400+ GB. Including multiple architectures or source packages can double or triple this.

Plan for at least 1 TB of storage for a moderate mirror setup, and use a dedicated filesystem so mirror growth never fills your root partition:

sudo mkdir -p /srv/apt-mirror
# Ideally mount a dedicated volume here
sudo chown apt-mirror:apt-mirror /srv/apt-mirror

Initial Sync

The first sync downloads everything and takes hours depending on your bandwidth:

sudo -u apt-mirror apt-mirror

Monitor progress by watching the var directory:

tail -f /srv/apt-mirror/var/cron.log

Serving the Mirror with nginx

Install nginx on mirror01 and configure it to serve the mirror directory:

# /etc/nginx/sites-available/apt-mirror
server {
    listen 80;
    listen [::]:80;
    server_name mirror01.internal.example-corp.com;

    root /srv/apt-mirror/mirror;
    autoindex on;

    # Optimize for large file serving
    sendfile on;
    tcp_nopush on;
    tcp_nodelay on;

    # Allow large package downloads
    client_max_body_size 0;

    # Cache control for metadata vs packages
    location ~ /(InRelease|Release|Release.gpg|Packages.gz|Sources.gz) {
        expires 1h;
        add_header Cache-Control "public, must-revalidate";
    }

    location ~ .deb$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }

    # Access logging for audit
    access_log /var/log/nginx/apt-mirror-access.log;
    error_log /var/log/nginx/apt-mirror-error.log;
}

Enable and start:

sudo ln -s /etc/nginx/sites-available/apt-mirror /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

For air-gapped environments where TLS is required internally, add a self-signed or internal CA certificate:

server {
    listen 443 ssl;
    server_name mirror01.internal.example-corp.com;

    ssl_certificate     /etc/ssl/internal/mirror01.crt;
    ssl_certificate_key /etc/ssl/internal/mirror01.key;

    root /srv/apt-mirror/mirror;
    autoindex on;
    # ... same location blocks as above
}

GPG Key Distribution

Clients need the repository signing keys to verify package authenticity. For standard Ubuntu/Debian mirrors, the keys come from the upstream keyring packages. But you need to ensure they are distributed to all clients.

# On mirror01, export the keys clients need
apt-key exportall > /srv/apt-mirror/mirror/repo-keys.gpg

# Or for modern apt (2.4+), place keys in /etc/apt/keyrings/
# and reference them in sources.list with signed-by=

For Puppet-managed fleets:

# modules/apt_mirror/manifests/keys.pp
class apt_mirror::keys {
  file { '/etc/apt/keyrings/ubuntu-archive.gpg':
    ensure => file,
    source => 'puppet:///modules/apt_mirror/ubuntu-archive.gpg',
    mode   => '0644',
  }

  file { '/etc/apt/keyrings/debian-archive.gpg':
    ensure => file,
    source => 'puppet:///modules/apt_mirror/debian-archive.gpg',
    mode   => '0644',
  }
}

Automating Mirror Syncs

Set up a cron job to sync daily during off-peak hours:

# /etc/cron.d/apt-mirror
0 2    apt-mirror /usr/bin/apt-mirror >> /var/log/apt-mirror/sync.log 2>&1

Build a wrapper script that adds monitoring:

#!/bin/bash
# /usr/local/bin/mirror-sync.sh
LOG="/var/log/apt-mirror/sync-$(date +%Y%m%d).log"
ALERT_EMAIL="[email protected]"

echo "=== Mirror sync started: $(date) ===" >> "$LOG"

/usr/bin/apt-mirror >> "$LOG" 2>&1
STATUS=$?

if [ $STATUS -ne 0 ]; then
    echo "Mirror sync FAILED with exit code $STATUS" | 
        mail -s "[ALERT] apt-mirror sync failure on mirror01" "$ALERT_EMAIL"
fi

# Log disk usage
echo "Disk usage: $(du -sh /srv/apt-mirror/mirror)" >> "$LOG"
echo "=== Mirror sync completed: $(date) ===" >> "$LOG"

# Check for stale InRelease files (older than 48 hours)
find /srv/apt-mirror/mirror -name "InRelease" -mmin +2880 | while read f; do
    echo "STALE: $f ($(stat -c %y "$f"))" >> "$LOG"
done

Client Configuration

On each client server, replace the default sources.list:

# /etc/apt/sources.list
deb [signed-by=/etc/apt/keyrings/ubuntu-archive.gpg] http://mirror01.internal.example-corp.com/archive.ubuntu.com/ubuntu jammy main restricted universe multiverse
deb [signed-by=/etc/apt/keyrings/ubuntu-archive.gpg] http://mirror01.internal.example-corp.com/archive.ubuntu.com/ubuntu jammy-updates main restricted universe multiverse
deb [signed-by=/etc/apt/keyrings/ubuntu-archive.gpg] http://mirror01.internal.example-corp.com/archive.ubuntu.com/ubuntu jammy-security main restricted universe multiverse

Note the path structure: apt-mirror preserves the upstream hostname in its directory layout, so the URL path includes archive.ubuntu.com/ubuntu.

Fleet Deployment with Puppet

# modules/apt_mirror/manifests/client.pp
class apt_mirror::client (
  String $mirror_host = 'mirror01.internal.example-corp.com',
) {
  file { '/etc/apt/sources.list':
    ensure  => file,
    content => template('apt_mirror/sources.list.erb'),
    mode    => '0644',
    notify  => Exec['apt-update'],
  }

  exec { 'apt-update':
    command     => '/usr/bin/apt-get update',
    refreshonly => true,
  }
}

Fleet Deployment with Ansible

# roles/apt_mirror_client/tasks/main.yml
  • name: Configure apt sources to use internal mirror
template: src: sources.list.j2 dest: /etc/apt/sources.list mode: '0644' notify: Update apt cache
  • name: Install internal CA certificate
copy: src: internal-ca.crt dest: /usr/local/share/ca-certificates/internal-ca.crt mode: '0644' notify: Update CA certificates handlers: - name: Update apt cache apt: update_cache: yes - name: Update CA certificates command: update-ca-certificates

Verifying Package Integrity

After configuring a client, verify that packages are being fetched from your mirror and that signatures validate:

# Verify the mirror is being used
apt-get update 2>&1 | grep mirror01

# Check that package signatures validate
apt-get install --simulate some-package
# Look for "WARNING" or "UNAUTHENTICATED" — there should be none

# Verify a specific package's integrity
apt-get download coreutils
dpkg-sig --verify coreutils_.deb

Monitoring Mirror Freshness

A stale mirror is worse than no mirror — it gives a false sense of security while missing critical updates.

#!/bin/bash
# /usr/local/bin/check-mirror-freshness.sh
MAX_AGE_HOURS=48

find /srv/apt-mirror/mirror -name "InRelease" | while read f; do
    AGE=$(( ($(date +%s) - $(stat -c %Y "$f")) / 3600 ))
    if [ $AGE -gt $MAX_AGE_HOURS ]; then
        echo "CRITICAL: $f is ${AGE}h old (threshold: ${MAX_AGE_HOURS}h)"
    fi
done

Feed this into your monitoring system (Nagios, Zabbix, Prometheus node_exporter textfile collector) to alert when syncs fail.

Troubleshooting Common Issues

404 errors after sync: Incomplete syncs leave partial metadata. Check /var/log/apt-mirror/sync.log for download errors. Re-run apt-mirror and ensure it completes fully.

Permission errors: apt-mirror runs as the apt-mirror user. Ensure the entire /srv/apt-mirror tree is owned by this user. After manual fixes, run chown -R apt-mirror:apt-mirror /srv/apt-mirror.

Stale InRelease files: If a sync fails midway, old InRelease files remain. Clients will reject packages if the InRelease file is older than the Valid-Until date. Fix by completing a full sync.

Disk space exhaustion: The clean directives in mirror.list tell apt-mirror to remove packages no longer referenced by the index. If you forget these, old package versions accumulate indefinitely. Run /var/spool/apt-mirror/var/clean.sh manually if needed.

Hash sum mismatch on clients: Usually means the mirror was being synced while the client ran apt-get update. Schedule syncs during maintenance windows, or use a two-stage sync (sync to staging directory, then atomic swap).

Scroll to Top