Hosting your own apt repo with reprepro and GitLab

󰃭 2025-08-16

Intro

Have you ever wondered about hosting your own apt repository? Looked at all the options and thought they’re woefully complex and hard to piece together? If so, read on! I’ll show you how I managed to host my own on a small server using reprepro and publish to it from GitLab CI.

Tools

The first question you might be wondering is, if I’m already running GitLab, why not just use the GitLab-provided apt repo? Frankly, because it’s not good. The documentation is poor, it doesn’t work as it should, and even GitLab say it’s not ready for production use.

What about tools like Aptly or Pulp? Those are just fine but are pretty overkill if all you want is to host a few self-published packages.

Reprepro Setup

The upstream guide is quite good and I recommend following it. I’ll excerpt it here along with a few notes / recommendations.

Install your prereqs:

apt install -y reprepro nginx

Make a user and a location for your repo:

useradd -r -s /bin/bash -m repo
mkdir -p /srv/repo/conf
chown repo:repo /srv/repo

Add the following to the beginning of ~repo/.bashrc. This makes it so we don’t have to be in the base directory when running reprepro commands.

export REPREPRO_BASE_DIR='/srv/repo'

From here on out, do all following steps as the repo user (su - repo):

Generate a certificate. For homelab use I’d recommend not specifying a passphrase.

gpg --gen-key

# Grab the fingerprint

gpg --list-secret-key

Copy the long string on the second line. That’s your key fingerprint, you will need it for the next couple steps.

Create a keyring file:

gpg --export-options export-minimal --export <fingerprint> \
    > /srv/repo/keyring

Configuring distributions

Now, make a file at /srv/repo/conf/distributions. It should follow this template:

Codename: <release-name>
Suite: <release-pseudonym>
Architectures: source i386 amd64 <...>
Components: main <...>
Contents:
SignWith: <fingerprint>
Origin: <Your project name>
Label: <Your project name>
Description: <Your project description>

From the upstream:

Codename: is e.g. trixie or sid. For a list of common values, do debian-distro-info –all. This is not supposed to change - if you need another codename, add a new section.

Suite: is e.g. stable or testing. Unlike the previous name, this can change when new releases come out.

Architectures: is a space-separated list of Debian architecture names.

Contents: is a list of zero or more flags. Including this but leaving it empty causes reprepro to generate Contents files, which are used by apt-file. This will make updates slower, but is valuable to apt-file users.

SignWith: is the signing fingerprint from Generate a PGP certificate, above.

Origin:, Label: and Description: are free-form text displayed to the user or used for pinning. For examples, do e.g. grep -C6 Label: /var/lib/apt/lists/*_InRelease.

or, if you’d like to see my example:

Codename: trixie
Suite: trixie-stable
Architectures: amd64 arm64
Components: main
SignWith: <fingerprint>
Origin: Super Cool Packages
Label: Super Cool Packages
Description: Packages from me

Codename: noble
Suite: noble-stable
Architectures: amd64 arm64
Components: main
SignWith: <fingerprint>
Origin: Super Cool Packages
Label: Super Cool Packages
Description: Packages from me

You’ll note that the suite incorporates the release codename, which isn’t called out in the upstream docs. This is because each distribution must have a unique suite.

Creating repo

Now, we can actually do the setup:

# create symlinks from Suites to Codenames:
reprepro createsymlinks

# Update metadata in dists/:
reprepro export

If you already have a package you want to install (or just want to do a quick test):

reprepro includedeb <release-name> <package>.deb

# e.g.
reprepro includedeb trixie-stable amtool.deb

nginx config

You want to be able to actually access this from other machines, right? Thought so. That’s what we installed nginx for earlier. You’ll need to become the root user for this bit.

On TLS

This setup serves your repository over plain HTTP. Debian says that this is fine for an apt repository since the packages are all PGP-signed, and the signing key should be shared over secure channels.

For ease of setup, we do not serve the signing key over secure channels. For homelab use, this should be reasonable. You can instead set up TLS using e.g. certbot, or configure the key on your clients in a different way.


Create the nginx config file at /etc/nginx/sites-available/repo:

server {
    listen 80;
    server_name apt.example.com ;          # Change to your hostname

    root /srv/repo;
    index index.html;

    # Security: deny access to anything outside dists/ & pool/, plus keyring
    location ~ ^/(?!dists|pool|keyring).* {
        deny all;
    }

    # Serve Release files uncompressed *and* compressed
    location ~ /dists/.*/(Release|InRelease|Release.gpg)$ {
        add_header Cache-Control "public, max-age=300";
    }

    # Serve Packages files compressed
    location ~ /dists/.*/binary-.*/Packages(\.gz|\.bz2)?$ {
        add_header Cache-Control "public, max-age=300";
    }

    # Serve .deb packages
    location ~ /pool/.*\.deb$ {
        add_header Cache-Control "public, max-age=86400";
    }

    location /keyring {
        add_header Cache-Control "public, max-age=86400";
    }

    # Auto index for easy browsing (optional)
    autoindex on;
    autoindex_exact_size off;
}

Then, enable this site and reload nginx:

ln -s /etc/nginx/sites-available/repo /etc/nginx/sites-enabled/
systemctl reload nginx

Configuring Repo

Now, to actually configure a client to use your repo, it’s fairly straightforward. You can do so manually like so:

wcurl -o /usr/share/keyrings/apt-my-org.pgp http://apt.example.com/keyring

cat <<EOF >/etc/apt/sources.list.d/my-apt.list
deb [signed-by=/usr/share/keyrings/apt-my-org.pgp] http://apt.example.com/ trixie-stable main

apt update

If you want to automate it, here’s an Ansible snippet to make your life easier:

- name: "[repo] install prereqs"
  ansible.builtin.package:
    name:
      - gpg
      - python3-debian
    state: present
  tags: [repo]
  when:
    - "ansible_os_family == 'Debian'"
- name: "[repo] add internal debian repo"
  ansible.builtin.deb822_repository:
    name: my
    types: deb
    uris: http://apt.example.com/
    suites: '{{ ansible_distribution_release }}-stable'
    components: main
    signed_by: http://apt.example.com/keyring
  tags: [repo]
  when:
    - "ansible_os_family == 'Debian'"

GitLab Setup

Now comes the fun part. We need to teach GitLab CI how to build and publish .deb packages to our repo.

Start by creating an ssh keypair that can be used in CI to auth to the repo user:

ssh-keygen -t ed25519 -C 'repo CI builder' -f repo-key

Put the public key in ~repo/.ssh/authorized_keys. Make sure it’s owned by repo and has mode 0600.

Take the private key and create a GitLab CI variable of type File, and name it APT_SSH_PRIVATE_KEY. Make sure that there is an empty line at the bottom of the variable’s contents.

If you’re looking to build from multiple places, I’d set the variable on a group level so that it can propagate down to all child projects.

Publishing template

Now to create the pipeline config. I use CI/CD Components. I have one generic ci-components repo that’s enabled as a CI catalog project. In there, I have an nfpm component that can handle building a package and publishing it. It looks like so (irrelevant bits omitted):

spec:
  inputs:
    nfpm-image:
      description: The Docker image used for nfpm, including tag
      default: ghcr.io/goreleaser/nfpm:latest
    nfpm-packager:
      description: which packager implementation to use
      default: deb
      options:
        - apk
        - archlinux
        - deb
        - ipk
        - rpm
    nfpm-config:
      description: config file to be used
      default: nfpm.yaml
    nfpm-target:
      description: where to save the generated package (filename, folder or empty for current folder)
      default: build/
    debian-distribution:
      description: Distribution to publish to
      default: trixie
    apt-host:
      description: Hostname of apt repo host
      default: apt.example.com
  # expects APT_SSH_PRIVATE_KEY file-type variable
---
# default workflow rules: Merge Request pipelines
workflow:
  rules:
    # prevent MR pipeline originating from production or integration branch(es)
    - if: '$CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ $PROD_REF || $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ $INTEG_REF'
      when: never
    # on non-prod, non-integration branches: prefer MR pipeline over branch pipeline
    - if: '$CI_COMMIT_BRANCH && $CI_OPEN_MERGE_REQUESTS && $CI_COMMIT_REF_NAME !~ $PROD_REF && $CI_COMMIT_REF_NAME !~ $INTEG_REF'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*tag(,[^],]*)*\]/" && $CI_COMMIT_TAG'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*branch(,[^],]*)*\]/" && $CI_COMMIT_BRANCH'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*mr(,[^],]*)*\]/" && $CI_MERGE_REQUEST_ID'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*default(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $CI_DEFAULT_BRANCH'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*prod(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $PROD_REF'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*integ(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME =~ $INTEG_REF'
      when: never
    - if: '$CI_COMMIT_MESSAGE =~ "/\[(ci skip|skip ci) on ([^],]*,)*dev(,[^],]*)*\]/" && $CI_COMMIT_REF_NAME !~ $PROD_REF && $CI_COMMIT_REF_NAME !~ $INTEG_REF'
      when: never
    - when: always

# test job prototype: implement adaptive pipeline rules
.test-policy:
  rules:
    # on tag: auto & failing
    - if: $CI_COMMIT_TAG
    # on ADAPTIVE_PIPELINE_DISABLED: auto & failing
    - if: '$ADAPTIVE_PIPELINE_DISABLED == "true"'
    # on production or integration branch(es): auto & failing
    - if: '$CI_COMMIT_REF_NAME =~ $PROD_REF || $CI_COMMIT_REF_NAME =~ $INTEG_REF'
    # early stage (dev branch, no MR): manual & non-failing
    - if: '$CI_MERGE_REQUEST_ID == null && $CI_OPEN_MERGE_REQUESTS == null'
      when: manual
      allow_failure: true
    # Draft MR: auto & non-failing
    - if: '$CI_MERGE_REQUEST_TITLE =~ /^Draft:.*/'
      allow_failure: true
    # else (Ready MR): auto & failing
    - when: on_success

variables:
  NFPM_IMAGE: $[[ inputs.nfpm-image ]]
  NFPM_PACKAGER: $[[ inputs.nfpm-packager ]]
  NFPM_CONFIG: $[[ inputs.nfpm-config ]]
  NFPM_TARGET: $[[ inputs.nfpm-target ]]
  DEBIAN_DISTRIBUTION: $[[ inputs.debian-distribution ]]
  APT_HOST: $[[ inputs.apt-host ]]

stages:
  - build
  - package-build
  - publish

.nfpm-base:
  image:
    name: $NFPM_IMAGE
    entrypoint: [""]

nfpm-build:
  extends: .nfpm-base
  stage: package-build
  script:
    - mkdir -p "$(dirname $NFPM_TARGET)"
    - nfpm package --packager "$NFPM_PACKAGER" --config "$NFPM_CONFIG" --target "$NFPM_TARGET"
  artifacts:
    name: "$CI_JOB_NAME artifacts from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
    expire_in: 1 day
    paths:
      - $NFPM_TARGET

nfpm-publish-deb:
  stage: publish
  image:
    name: public.ecr.aws/docker/library/alpine:latest
    entrypoint: [""]
  dependencies:
    - nfpm-build
  before_script:
    - apk add --no-cache openssh-client
    - mkdir -p -m 0700 "$HOME/.ssh"
    - ssh-keyscan "$APT_HOST" > "$HOME/.ssh/known_hosts"
    - chmod 0600 "$APT_SSH_PRIVATE_KEY"
  script:
    # copy file
    - >
      scp
      -i "$APT_SSH_PRIVATE_KEY"
      "build/${PACKAGE_NAME}-${VERSION}-${RELEASE}-${ARCH}.deb"
      "repo@${APT_HOST}:/tmp/$PACKAGE_NAME.deb"
    # register package
    - >
      ssh
      -i "$APT_SSH_PRIVATE_KEY"
      "repo@${APT_HOST}"
      --
      reprepro includedeb "${DEBIAN_DISTRIBUTION}-stable" "/tmp/${PACKAGE_NAME}.deb"

Since it’s a CI component, in order to use it as such you have to publish a release for it from CI. See how to do so in the official docs linked above.

Example pipeline

Now, here’s a pipeline that uses this to build and publish the amtool binary:

include:
  - component: $CI_SERVER_FQDN/core/ci-components/nfpm@~latest

variables:
  ARCH: amd64
  VERSION: 0.28.1 # bump this to get newer upstream version
  RELEASE: "1"    # bump this if you've changed something e.g. config
  NFPM_TARGET: build/amtool-$VERSION-$RELEASE-$ARCH.deb
  PACKAGE_NAME: amtool
  DISTRIBUTION: trixie
  COMPONENT: main

# this goes out and fetches the binary we need, putting it in place for the build in the next stage
fetch:
  stage: build
  image:
    name: public.ecr.aws/docker/library/alpine:latest
    entrypoint: [""]
  before_script:
    - apk add --no-cache curl tar
  script:
    - mkdir -p build
    - curl -fsSL -o build/alertmanager-${VERSION}-${ARCH}.tar.gz https://github.com/prometheus/alertmanager/releases/download/v${VERSION}/alertmanager-${VERSION}.linux-${ARCH}.tar.gz
    - tar -xzf build/alertmanager-${VERSION}-${ARCH}.tar.gz -C build/ --strip-components=1
  artifacts:
    name: "Fetched am binaries from $CI_PROJECT_NAME on $CI_COMMIT_REF_SLUG"
    expire_in: "1 day"
    when: always
    paths:
      - build
  parallel:
    matrix:
      - ARCH:
        - amd64
        - arm64

nfpm-build:
  parallel:
    matrix:
      - ARCH:
        - amd64
        - arm64
        DEBIAN_DISTRIBUTION:
        - trixie
        - noble

nfpm-publish-deb:
  stage: publish
  parallel:
    matrix:
      - ARCH:
        - amd64
        - arm64
        DEBIAN_DISTRIBUTION:
        - trixie
        - noble

…and boom, you have automated multi-arch multi-distribution builds that get published to your own internal apt repo. Enjoy and good luck!

Alternate option

I ran into issues with parallel.matrix and simultaneous uploads failing because the reprepro database was locked.

I could’ve fixed this with a little random wait, but there’s no engineering like over-engineering!

So I wrote reprepro-api. Directions are in the readme.

Enter your instance's address