If you’re a developer who works with Linux systems, particularly Debian-based distributions, creating packages for your software is a powerful skill. Debian packages provide a standardized way to distribute applications, making installation, upgrading, and removal seamless for users. However, the packaging process can seem intimidating with its specific file structure and conventions.
In this comprehensive guide, I’ll walk you through creating production-ready Debian packages, including how to target multiple distributions efficiently using Docker. Rather than covering theoretical examples, I’ll share real automation techniques from my own projects like uv-debian and lazygit-debian.
Before diving into the technical details, let’s understand why Debian packages are worth the effort:
apt install
commandA Debian package (.deb file) is essentially an archive containing your application files and metadata. The basic structure includes:
package-name-version/
├── DEBIAN/
│ ├── control # Package metadata
│ ├── preinst # Pre-installation script (optional)
│ ├── postinst # Post-installation script (optional)
│ ├── prerm # Pre-removal script (optional)
│ └── postrm # Post-removal script (optional)
└── usr/
├── bin/ # Executable files
├── lib/ # Library files
├── share/
│ ├── doc/ # Documentation
│ └── man/ # Man pages
└── ... # Other directories following FHS
The most critical file is DEBIAN/control
, which contains package metadata. Here’s an example from my uv package:
Source: uv
Section: utils
Priority: optional
Maintainer: Dario Griffo <dariogriffo@gmail.com>
Homepage: https://github.com/astral-sh/uv
Package: uv
Version: 0.7.0-1+bookworm
Architecture: amd64
Depends:
Description: An extremely fast Python package and project manager, written in Rust.
Let’s break down the key fields:
[upstream-version]-[debian-revision]+[distribution]
Let’s create a package for lazygit
, a terminal UI for Git commands:
mkdir -p lazygit-0.40.0/DEBIAN
mkdir -p lazygit-0.40.0/usr/bin
mkdir -p lazygit-0.40.0/usr/share/doc/lazygit
cat > lazygit-0.40.0/DEBIAN/control << EOF
Source: lazygit
Section: utils
Priority: optional
Maintainer: Dario Griffo <dariogriffo@gmail.com>
Homepage: https://github.com/jesseduffield/lazygit
Package: lazygit
Version: 0.40.0-1
Architecture: amd64
Depends: git
Description: Simple terminal UI for git commands
A simple terminal UI for git commands, written in Go with the gocui library.
Main features: Easily manage your git workflow from a terminal-based UI.
EOF
# Download the lazygit binary
wget -O lazygit-0.40.0/usr/bin/lazygit \
https://github.com/jesseduffield/lazygit/releases/download/v0.40.0/lazygit_0.40.0_Linux_x86_64.tar.gz
tar -xzf lazygit-0.40.0/usr/bin/lazygit
cp lazygit lazygit-0.40.0/usr/bin/
chmod +x lazygit-0.40.0/usr/bin/lazygit
cat > lazygit-0.40.0/usr/share/doc/lazygit/changelog.Debian << EOF
lazygit (0.40.0-1) unstable; urgency=low
* Packaging lazygit version 0.40.0
-- Dario Griffo <dariogriffo@gmail.com> $(date -R)
EOF
gzip -9 -n lazygit-0.40.0/usr/share/doc/lazygit/changelog.Debian
cat > lazygit-0.40.0/usr/share/doc/lazygit/copyright << EOF
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: lazygit
Source: https://github.com/jesseduffield/lazygit
Files: *
Copyright: $(date +%Y) Jesse Duffield <jessedduffield@gmail.com>
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction...
EOF
dpkg-deb --build lazygit-0.40.0
This command produces a .deb
file that can be installed using dpkg -i lazygit-0.40.0.deb
.
One of the challenges in Debian packaging is supporting multiple distributions (Bookworm, Trixie, Sid, etc.). Each distribution might have different dependencies or packaging requirements.
I’ve solved this problem in my uv-debian and lazygit-debian repositories by using Docker to build packages for different distributions. Let’s see how I’ve set this up:
Here’s the Dockerfile I use for building the uv
package:
ARG DEBIAN_DIST=bookworm
FROM debian:$DEBIAN_DIST
ARG DEBIAN_DIST
ARG UV_VERSION
ARG BUILD_VERSION
ARG FULL_VERSION
RUN apt update && apt install -y wget
RUN mkdir -p /output/usr/bin
RUN mkdir -p /output/usr/share/doc/uv
RUN cd /output/usr/bin && wget https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-musl.tar.gz && tar -xf uv-x86_64-unknown-linux-musl.tar.gz && rm -f uv-x86_64-unknown-linux-musl.tar.gz
RUN mkdir -p /output/DEBIAN
COPY output/DEBIAN/control /output/DEBIAN/
COPY output/copyright /output/usr/share/doc/uv/
COPY output/changelog.Debian /output/usr/share/doc/uv/
COPY output/README.md /output/usr/share/doc/uv/
RUN sed -i "s/DIST/$DEBIAN_DIST/" /output/usr/share/doc/uv/changelog.Debian
RUN sed -i "s/FULL_VERSION/$FULL_VERSION/" /output/usr/share/doc/uv/changelog.Debian
RUN sed -i "s/DIST/$DEBIAN_DIST/" /output/DEBIAN/control
RUN sed -i "s/UV_VERSION/$UV_VERSION/" /output/DEBIAN/control
RUN sed -i "s/BUILD_VERSION/$BUILD_VERSION/" /output/DEBIAN/control
RUN dpkg-deb --build /output /uv_${FULL_VERSION}.deb
Here’s the build script I use to automate building packages for multiple distributions:
#!/bin/bash
UV_VERSION=$1
BUILD_VERSION=$2
declare -a arr=("bookworm" "trixie" "sid")
for i in "${arr[@]}"
do
DEBIAN_DIST=$i
FULL_VERSION=$UV_VERSION-${BUILD_VERSION}+${DEBIAN_DIST}_amd64
docker build . -t uv-$DEBIAN_DIST --build-arg DEBIAN_DIST=$DEBIAN_DIST --build-arg UV_VERSION=$UV_VERSION --build-arg BUILD_VERSION=$BUILD_VERSION --build-arg FULL_VERSION=$FULL_VERSION
id="$(docker create uv-$DEBIAN_DIST)"
docker cp $id:/uv_$FULL_VERSION.deb - > ./uv_$FULL_VERSION.deb
tar -xf ./uv_$FULL_VERSION.deb
done
Running the script with version parameters is simple:
./build.sh 0.7.0 1
This will produce three packages:
uv_0.7.0-1+bookworm_amd64.deb
uv_0.7.0-1+trixie_amd64.deb
uv_0.7.0-1+sid_amd64.deb
For this to work, I prepare template files with placeholders. Here’s what the control
file looks like for the uv package:
Source: uv
Section: utils
Priority: optional
Maintainer: Dario Griffo <dariogriffo@gmail.com>
Homepage: https://github.com/astral-sh/uv
Package: uv
Version: UV_VERSION-BUILD_VERSION+DIST
Architecture: amd64
Depends:
Description: An extremely fast Python package and project manager, written in Rust.
And the changelog.Debian
template:
uv (FULL_VERSION) DIST; urgency=low
The build process automatically replaces placeholders like UV_VERSION
, BUILD_VERSION
, DIST
, and FULL_VERSION
with the appropriate values for each distribution.
I’m maintaining packages for several tools, including:
Both projects follow a similar structure, with a Dockerfile that:
This approach allows me to:
Based on my experience maintaining packages like uv and lazygit, here are some best practices:
Follow Debian Policy: Familiarize yourself with the Debian Policy Manual for guidelines.
Version Carefully: Use a clear versioning scheme that allows for future updates. I use the format upstream-version-debian-revision+distribution
(e.g., 0.7.0-1+bookworm
).
Automate Building: Use scripts to automate the build process, making it reproducible.
Test Your Packages: Install and test packages in clean environments to verify they work correctly.
Include Proper Documentation: Always provide copyright, changelog, and README files.
Use Lintian: Run lintian
on your packages to check for common issues:
lintian uv_0.7.0-1+bookworm_amd64.deb
Sign Your Packages: Use GPG to sign packages for improved security:
dpkg-sig --sign builder uv_0.7.0-1+bookworm_amd64.deb
Consider Continuous Integration: Set up CI/CD pipelines to automatically build packages when your source code changes. My repositories use GitHub Actions for this purpose.
Dependencies are specified in the control
file’s Depends
field. You can specify version requirements using operators:
Depends: libc6 (>= 2.14), python3 (>= 3.9), libgcc1 (>= 1:4.2)
For lazygit, the only dependency is git:
Depends: git
For more complex packages, you might need maintainer scripts:
Here’s a simple example of a postinst
script:
#!/bin/bash
set -e
# Create a configuration file if it doesn't exist
if [ ! -f /etc/my-package/config.yaml ]; then
mkdir -p /etc/my-package
echo "# Default configuration" > /etc/my-package/config.yaml
echo "enabled: true" >> /etc/my-package/config.yaml
fi
# Reload systemd if present
if command -v systemctl >/dev/null; then
systemctl daemon-reload
fi
exit 0
While a comprehensive guide to hosting your own repository will come in a future post, here’s a quick overview of the process I use for my debian.griffo.io repository:
Create a directory structure:
mkdir -p repo/dists/{bookworm,trixie,sid}/{main,contrib,non-free}/binary-amd64
Copy packages to the appropriate directories:
cp uv_0.7.0-1+bookworm_amd64.deb repo/dists/bookworm/main/binary-amd64/
Generate package indexes:
cd repo
apt-ftparchive packages dists/bookworm/main/binary-amd64 > dists/bookworm/main/binary-amd64/Packages
gzip -9c dists/bookworm/main/binary-amd64/Packages > dists/bookworm/main/binary-amd64/Packages.gz
Make the repository accessible:
# Add to sources.list.d
echo "deb https://debian.griffo.io/apt $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
Creating Debian packages is a valuable skill that allows you to distribute your software in a standardized way. By using Docker for package building, you can easily target multiple Debian distributions with minimal effort, as demonstrated in my uv-debian and lazygit-debian repositories.
In this guide, we’ve covered:
If you’re interested in learning how to host these packages in your own Debian repository, check out my follow-up guide: The Ultimate Guide to Self-Hosting a Debian Repository, which shows you how to set up a repository that allows users to install your packages using apt
and receive automatic updates.
I hope this guide helps you streamline your Debian packaging process. If you have any questions or suggestions, feel free to reach out!