📦 .deb Package Guide

A how-to manual: turn a finished Python game into a .deb, check it with lintian, install, upgrade and remove it, test it on a machine that has nothing, put it in a small repository, and fix the common build errors, with every command run for real.

Video: deb Package Guide (Sysadmin video)
Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
dpkg-deb --root-owner-group --build cmd-hangman_1.0.0_all
lintian cmd-hangman_1.0.0_all.deb
sudo apt install ./cmd-hangman_1.0.0_all.deb

A .deb is how Ubuntu, Debian and Linux Mint ship software. Building one for a program that needs no compiling takes a folder, one text file and one command. This guide does it with the Linux command hangman game from the series (first guide: Python Game: Build Linux Command Hangman), and then does what a careful packager does: checks the package against Debian policy, installs and upgrades it, tests it on a clean machine, and puts it in a small repository so apt can install it by name.

Every example ran for real on Ubuntu 24.04.5 (dpkg 1.22.6, lintian 2.117.0). The lab was a container, which is why one thing differs from a normal desktop: the man pages are not installed by default, and the guide says where that matters. The next guide in the series builds the same game as an .rpm for Rocky Linux.

What a .deb is, and what you need

A .deb file is how Debian, Ubuntu, Linux Mint and their relatives ship software. Inside, it is only an archive with three parts: a version marker, a small archive of control information (name, version, what it needs, install scripts), and an archive of the files to put on the disk. dpkg unpacks the files into place and records the package in its database; apt is the tool that fetches .deb files and works out what else they need.

Building one needs no compiler for a program written in Python: you arrange the files in a folder the way they should end up on the disk, add one text file that describes the package, and run one command. That is what this guide does with the cmd-hangman game from the first guide in the series (Python Game: Build Linux Command Hangman).

What you need, and what was used for every example here: Ubuntu 24.04.5, dpkg 1.22.6, lintian 2.117.0 (a checker, optional but the guide uses it). dpkg-deb, the build tool, is part of dpkg and is on every Debian and Ubuntu machine already. The commands are run as an ordinary user; only installing needs sudo.

$ dpkg-deb --version | head -1
Debian 'dpkg-deb' package archive backend version 1.22.6 (amd64).
$ lintian --version
Lintian v2.117.0ubuntu1.5
$ python3 --version
Python 3.12.3

If lintian is missing: sudo apt install lintian (it is only needed for the checking step).

The game is the file hangman.py from the first guide. Copy it into a new folder, under the name the command will have. A command name has no .py ending:

$ mkdir ~/deb
$ cd ~/deb
$ cp ~/hangman/hangman.py ~/deb/
$ mv hangman.py cmd-hangman
$ ls -l
total 4
-rwxr-xr-x 1 tc tc 2185 Oct 30 22:21 cmd-hangman
$ head -1 cmd-hangman
#!/usr/bin/env python3

It starts with the #! line, so it runs as a command once it is executable. If you skipped the first guide, take the finished file from there.

The folder that becomes the package

A .deb is built from a folder. Inside it, the path of each file is the path it will have on the installed system, and one extra folder called DEBIAN holds the package's control information. The folder name is by convention name_version_architecture. This game is plain text that runs anywhere Python runs, so its architecture is "all" (as opposed to amd64 or arm64 for compiled programs).

Create the folder with the program in place. install -D makes the missing folders on the way, and -m755 sets the permissions the file will have: the owner may write, everyone may read and run:

$ mkdir cmd-hangman_1.0.0_all
$ install -D -m755 cmd-hangman cmd-hangman_1.0.0_all/usr/bin/cmd-hangman
$ mkdir cmd-hangman_1.0.0_all/DEBIAN
$ tree cmd-hangman_1.0.0_all
cmd-hangman_1.0.0_all
|-- DEBIAN
`-- usr
    `-- bin
        `-- cmd-hangman

4 directories, 1 file

/usr/bin is on every user's PATH, so the game will start by typing cmd-hangman. (Debian's convention for games is /usr/games, but that folder is not on the PATH of every distribution, and the same game is packaged for Rocky Linux in the next guide, where it is not. Keeping one path makes both guides the same.)

The control file

The control file is the package's identity card. Create it in the DEBIAN folder:

$ cat cmd-hangman_1.0.0_all/DEBIAN/control
Package: cmd-hangman
Version: 1.0.0
Section: games
Priority: optional
Architecture: all
Depends: python3
Maintainer: TechCommune <admin@techcommune.org>
Description: Linux command hangman for the terminal
 Guess the Linux command one letter at a time before the
 stick figure is finished. Built as a packaging tutorial
 for techcommune.org.

The fields, one by one:

Package        the name. Lower case letters, digits, plus, minus, dot; at least two characters
Version        the version. Anything after it is compared by dpkg when you upgrade: 1.0.1 is newer than 1.0.0
Section        the group in the package lists (games, utils, net, ...)
Priority       optional is right for nearly everything
Architecture   all for text that runs anywhere, amd64 or arm64 for compiled files
Depends        what must be installed first. python3 is all this game needs, because #!/usr/bin/env python3 runs it
Maintainer     name and a working address of whoever answers questions
Description    first line: a short summary. The following lines: a longer text, each starting with ONE space

Two format rules cost newcomers the most time: the long description lines must begin with a space (a line with no space starts a new field and breaks the file), and a blank line must not appear inside the file (it ends the file). The file must end with a newline.

The same fields can be read back from any built package; the guide does that after the build.

Build the package

One command builds it. --root-owner-group records every file as owned by root, as a package must be (without it the files would carry your own user name, which does not exist on other machines). The last argument is the folder:

$ dpkg-deb --root-owner-group --build cmd-hangman_1.0.0_all
dpkg-deb: building package 'cmd-hangman' in 'cmd-hangman_1.0.0_all.deb'.
$ ls -l *.deb
-rw-r--r-- 1 tc tc 1526 Oct 30 22:21 cmd-hangman_1.0.0_all.deb

The result is cmd-hangman_1.0.0_all.deb next to the folder. Look inside before installing anything. -I (information) prints the control file as dpkg sees it, -c (contents) lists the files, in the style of ls -l:

$ dpkg-deb -I cmd-hangman_1.0.0_all.deb
 new Debian package, version 2.0.
 size 1526 bytes: control archive=334 bytes.
     341 bytes,    11 lines      control
 Package: cmd-hangman
 Version: 1.0.0
 Section: games
 Priority: optional
 Architecture: all
 Depends: python3
 Maintainer: TechCommune <admin@techcommune.org>
 Description: Linux command hangman for the terminal
  Guess the Linux command one letter at a time before the
  stick figure is finished. Built as a packaging tutorial
  for techcommune.org.
$ dpkg-deb -c cmd-hangman_1.0.0_all.deb
drwxr-xr-x root/root         0 2026-10-30 22:21 ./
drwxr-xr-x root/root         0 2026-10-30 22:21 ./usr/
drwxr-xr-x root/root         0 2026-10-30 22:21 ./usr/bin/
-rwxr-xr-x root/root      2185 2026-10-30 22:21 ./usr/bin/cmd-hangman

and the raw structure, which really is just an ar archive of three files:

$ ar t cmd-hangman_1.0.0_all.deb
debian-binary
control.tar.zst
data.tar.zst

(Ubuntu 24.04 compresses the two inner archives with zstd; older releases used xz. dpkg reads either.) To see the real file names inside, and to read the control file without dpkg at all:

$ dpkg-deb -f cmd-hangman_1.0.0_all.deb Package Version Depends
Package: cmd-hangman
Version: 1.0.0
Depends: python3

That is a working package. The next sections install it and then improve it until the checker has no complaints.

Install, run, remove

Install a local file with apt, giving a path that contains a slash (./ is enough). apt, unlike dpkg -i, also fetches whatever the package depends on. On this machine python3 is already there, so it only installs the game:

$ sudo apt-get update -qq
$ sudo apt-get install -y ./cmd-hangman_1.0.0_all.deb
Reading package lists...
Building dependency tree...
Reading state information...
The following NEW packages will be installed:
  cmd-hangman
0 upgraded, 1 newly installed, 0 to remove and 5 not upgraded.
Need to get 0 B/1526 B of archives.
After this operation, 0 B of additional disk space will be used.
Get:1 /home/tc/deb/cmd-hangman_1.0.0_all.deb cmd-hangman all 1.0.0 [1526 B]
Selecting previously unselected package cmd-hangman.
(Reading database ... 23342 files and directories currently installed.)
Preparing to unpack .../deb/cmd-hangman_1.0.0_all.deb ...
Unpacking cmd-hangman (1.0.0) ...
Setting up cmd-hangman (1.0.0) ...
$ which cmd-hangman
/usr/bin/cmd-hangman

The game is now a command. Play one round (the word is fixed here so that this page is repeatable; you will get a random one):

$ cmd-hangman
=== cmd-hangman by TechCommune ===





_______

Command: _ _ _ _
Hint:    search text for a pattern
Guess a letter: g





_______

Command: g _ _ _
Hint:    search text for a pattern
Guess a letter: r





_______

Command: g r _ _
Hint:    search text for a pattern
Guess a letter: e





_______

Command: g r e _
Hint:    search text for a pattern
Guess a letter: p





_______

Command: g r e p
Hint:    search text for a pattern

You got it! The command was: grep

Play again? (y/n) n
Happy hacking!

Ask dpkg what it knows about the package. -l lists it, -L lists its files, -s shows its status record, and -S answers the opposite question, which package owns a file:

$ dpkg -l cmd-hangman
Desired=Unknown/Install/Remove/Purge/Hold
| Status=Not/Inst/Conf-files/Unpacked/halF-conf/Half-inst/trig-aWait/Trig-pend
|/ Err?=(none)/Reinst-required (Status,Err: uppercase=bad)
||/ Name           Version      Architecture Description
+++-==============-============-============-======================================
ii  cmd-hangman    1.0.0        all          Linux command hangman for the terminal
$ dpkg -L cmd-hangman
/.
/usr
/usr/bin
/usr/bin/cmd-hangman
$ dpkg -S /usr/bin/cmd-hangman
cmd-hangman: /usr/bin/cmd-hangman
$ which cmd-hangman
/usr/bin/cmd-hangman

Remove it. apt remove deletes the files; apt purge also deletes the configuration files that the package owns. This game has no configuration files, so a plain remove leaves nothing behind and dpkg forgets the package completely:

$ sudo apt-get remove -y cmd-hangman
Reading package lists...
Building dependency tree...
Reading state information...
The following packages will be REMOVED:
  cmd-hangman
0 upgraded, 0 newly installed, 1 to remove and 5 not upgraded.
After this operation, 0 B of additional disk space will be used.
(Reading database ... 23343 files and directories currently installed.)
Removing cmd-hangman (1.0.0) ...
$ which cmd-hangman
(exit status 1)
$ dpkg -l cmd-hangman
dpkg-query: no packages found matching cmd-hangman
(exit status 1)

For a package that does own configuration files (files under /etc marked in a conffiles list), remove would leave them and dpkg -l would show the state "rc" (removed, configuration remains) until you run apt purge NAME.

Check it with lintian

lintian is the Debian project's package checker. It knows the Debian policy and complains about what is missing or wrong. Run it on the .deb:

$ lintian cmd-hangman_1.0.0_all.deb
E: cmd-hangman: no-changelog usr/share/doc/cmd-hangman/changelog.gz (native package)
E: cmd-hangman: no-copyright-file
E: cmd-hangman: package-section-games-but-contains-no-game
W: cmd-hangman: no-manual-page [usr/bin/cmd-hangman]

Each line is a letter for the severity (E error, W warning, I info, P pedantic), the package, the tag that names the problem, and the file. lintian exits with 2 when there are errors. The four findings, and how each is fixed:

no-copyright-file          a package must say under which licence it is distributed
no-changelog               a package must carry a changelog (a "native" package, one made by its own author, has one file)
package-section-games...   lintian expects a game in /usr/games; ours is in /usr/bin, so the section is a lie. Use another section
no-manual-page             every command in /usr/bin should have a man page

Take them in turn. First the copyright file, in the format Debian uses (the licence of the game is CC0, "do what you like", whose full text is already on every Debian system, so the file can refer to it):

$ cat cmd-hangman_1.0.0_all/usr/share/doc/cmd-hangman/copyright
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: cmd-hangman
Source: https://techcommune.org

Files: *
Copyright: 2026 TechCommune
License: CC0-1.0
 On Debian systems, the complete text of the CC0 1.0 Universal
 license can be found in /usr/share/common-licenses/CC0-1.0.

Then the changelog. Its format is strict (a header line, a bullet, a signature line with two spaces, a name, an e-mail and a date in RFC 2822 format) and it is stored compressed. gzip -9n compresses hard and leaves the file's time stamp out, so the package is the same each time you build it:

$ cat changelog-tmp
cmd-hangman (1.0.0) unstable; urgency=low

  * First release.

 -- TechCommune <admin@techcommune.org>  Fri, 30 Oct 2026 12:00:00 +0000
$ gzip -9n -c changelog-tmp > cmd-hangman_1.0.0_all/usr/share/doc/cmd-hangman/changelog.gz
$ rm changelog-tmp

Then the manual page, written in the old roff markup that man understands (.TH sets the title and section 1, .SH starts a section, .B is bold). It is stored compressed too:

$ cat cmd-hangman.1
.TH CMD-HANGMAN 1 "October 2026" "cmd-hangman 1.0.0" "TechCommune"
.SH NAME
cmd-hangman \- guess the Linux command before the stick figure is finished
.SH SYNOPSIS
.B cmd-hangman
.SH DESCRIPTION
.B cmd-hangman
picks a Linux command at random, shows a hint and one underscore per letter.
Type one letter at a time. Six wrong letters finish the stick figure and end
the round. At the end of a round the game asks whether to play again.
.SH AUTHOR
TechCommune, https://techcommune.org
$ install -d cmd-hangman_1.0.0_all/usr/share/man/man1
$ gzip -9n -c cmd-hangman.1 > cmd-hangman_1.0.0_all/usr/share/man/man1/cmd-hangman.1.gz
$ rm cmd-hangman.1

And the section: games is only for programs in /usr/games, so use utils (a miscellaneous program):

$ sed -i 's/^Section: games/Section: utils/' cmd-hangman_1.0.0_all/DEBIAN/control
$ grep Section cmd-hangman_1.0.0_all/DEBIAN/control
Section: utils

Rebuild and get a clean report

Folders created with the default umask of many desktop Ubuntu systems (0002) come out group-writable, and dpkg-deb refuses permissions it considers unsafe in the folders it puts into the package. To be safe, make the permissions explicit before every build. The tree should be 755 for folders, 644 for ordinary files and 755 for the program:

$ find cmd-hangman_1.0.0_all -type d -exec chmod 755 {} +
$ find cmd-hangman_1.0.0_all -type f -exec chmod 644 {} +
$ chmod 755 cmd-hangman_1.0.0_all/usr/bin/cmd-hangman
$ dpkg-deb --root-owner-group --build cmd-hangman_1.0.0_all
dpkg-deb: building package 'cmd-hangman' in 'cmd-hangman_1.0.0_all.deb'.
$ lintian cmd-hangman_1.0.0_all.deb && echo "lintian: no complaints"
lintian: no complaints

Nothing printed means lintian has no complaint. The package is now one you could offer to others. Look at the manual page that came with it:

$ sudo apt-get install -y ./cmd-hangman_1.0.0_all.deb
Reading package lists...
Building dependency tree...
Reading state information...
The following NEW packages will be installed:
  cmd-hangman
0 upgraded, 1 newly installed, 0 to remove and 5 not upgraded.
Need to get 0 B/2410 B of archives.
After this operation, 0 B of additional disk space will be used.
Get:1 /home/tc/deb/cmd-hangman_1.0.0_all.deb cmd-hangman all 1.0.0 [2410 B]
Selecting previously unselected package cmd-hangman.
(Reading database ... 23342 files and directories currently installed.)
Preparing to unpack .../deb/cmd-hangman_1.0.0_all.deb ...
Unpacking cmd-hangman (1.0.0) ...
Setting up cmd-hangman (1.0.0) ...
Processing triggers for man-db (2.12.0-4build2) ...
$ dpkg -L cmd-hangman | grep man
/usr/bin/cmd-hangman
/usr/share/doc/cmd-hangman
/usr/share/doc/cmd-hangman/changelog.gz
/usr/share/doc/cmd-hangman/copyright
/usr/share/man
/usr/share/man/man1
/usr/share/man/man1/cmd-hangman.1.gz
$ dpkg-deb --fsys-tarfile cmd-hangman_1.0.0_all.deb | tar -xOf - ./usr/share/man/man1/cmd-hangman.1.gz | zcat | nroff -man | col -b | head -14
4mCMD-HANGMAN24m(1)		       TechCommune		     4mCMD-HANGMAN24m(1)

1mNAME0m
       cmd-hangman  -  guess the Linux command before the stick figure is fin-
       ished

1mSYNOPSIS0m
       1mcmd-hangman0m

1mDESCRIPTION0m
       1mcmd-hangman 22mpicks a Linux command at random, shows a hint and  one	un-
       derscore per letter.  Type one letter at a time. Six wrong letters fin-
       ish  the stick figure and end the round. At the end of a round the game
       asks whether to play again.

On a normal Ubuntu you simply type man cmd-hangman. The lab machine is a stripped-down container image whose dpkg is set to skip man pages when installing (that is why the file is listed by dpkg -L but is not on the disk), so the page is taken out of the .deb and formatted by hand with nroff, the program that man itself uses.

Test on a clean machine

A package that works on the machine where you built it proves little: the machine already has Python, your folders and your settings. The real test is a fresh system. This section uses a second, clean Ubuntu 24.04 machine that has nothing installed except the base system, and no Python.

Copy the .deb there (scp in real life; here the file is placed straight into the machine):

$ which python3 cmd-hangman
(exit status 1)
$ ls
cmd-hangman_1.0.0_all.deb
$ sudo apt-get update -qq
$ sudo apt-get install -y ./cmd-hangman_1.0.0_all.deb 2>&1 | sed -n '/NEW packages/,/^[0-9]* upgraded/p'
The following NEW packages will be installed:
  ca-certificates cmd-hangman libpython3-stdlib libpython3.12-minimal
  libpython3.12-stdlib media-types openssl python3 python3-minimal python3.12
  python3.12-minimal tzdata
The following packages will be upgraded:
  libssl3t64
1 upgraded, 12 newly installed, 0 to remove and 5 not upgraded.

apt saw the Depends: python3 line, installed Python first, then the game. That is the whole point of the control file. Run it:

$ which cmd-hangman
/usr/bin/cmd-hangman
$ cmd-hangman
=== cmd-hangman by TechCommune ===





_______

Command: _ _ _ _
Hint:    search text for a pattern
Guess a letter: g





_______

Command: g _ _ _
Hint:    search text for a pattern
Guess a letter: r





_______

Command: g r _ _
Hint:    search text for a pattern
Guess a letter: e





_______

Command: g r e _
Hint:    search text for a pattern
Guess a letter: p





_______

Command: g r e p
Hint:    search text for a pattern

You got it! The command was: grep

Play again? (y/n) n
Happy hacking!
$ dpkg -s cmd-hangman | grep -E "^(Status|Version|Depends)"
Status: install ok installed
Version: 1.0.0
Depends: python3

Now the mistake that Depends prevents. If the control file had no Depends line, the install would have worked and the game would have died the first time someone ran it on a machine without Python. A missing dependency is the most common packaging error, and lintian cannot see it, so always do a clean-machine test.

A new version: upgrade the package

Releasing an update is the same build with a bigger version number. Back on the build machine: add a command to the game, change the version in the control file, add a new entry on top of the changelog, rebuild:

$ sed -i 's/    "df": "show free disk space",/    "df": "show free disk space",\n    "tar": "bundle files into an archive",/' cmd-hangman
$ grep -n -B1 -A1 '"tar"' cmd-hangman
20-    "df": "show free disk space",
21:    "tar": "bundle files into an archive",
22-}
$ install -m755 cmd-hangman cmd-hangman_1.0.0_all/usr/bin/cmd-hangman
$ mv cmd-hangman_1.0.0_all cmd-hangman_1.0.1_all
$ sed -i 's/^Version: 1.0.0/Version: 1.0.1/' cmd-hangman_1.0.1_all/DEBIAN/control
$ cat changelog-tmp
cmd-hangman (1.0.1) unstable; urgency=low

  * Add the tar command.

 -- TechCommune <admin@techcommune.org>  Fri, 30 Oct 2026 13:00:00 +0000

cmd-hangman (1.0.0) unstable; urgency=low

  * First release.

 -- TechCommune <admin@techcommune.org>  Fri, 30 Oct 2026 12:00:00 +0000
$ gzip -9n -c changelog-tmp > cmd-hangman_1.0.1_all/usr/share/doc/cmd-hangman/changelog.gz
$ rm changelog-tmp
$ dpkg-deb --root-owner-group --build cmd-hangman_1.0.1_all
dpkg-deb: building package 'cmd-hangman' in 'cmd-hangman_1.0.1_all.deb'.
$ lintian cmd-hangman_1.0.1_all.deb && echo "lintian: no complaints"
lintian: no complaints

Installing the new file on top of the old one is an upgrade: dpkg compares the versions, removes the old files and puts the new ones in place. Nothing needs removing first:

$ sudo apt-get install -y ./cmd-hangman_1.0.1_all.deb
Reading package lists...
Building dependency tree...
Reading state information...
The following packages will be upgraded:
  cmd-hangman
1 upgraded, 0 newly installed, 0 to remove and 5 not upgraded.
Need to get 0 B/2478 B of archives.
After this operation, 0 B of additional disk space will be used.
Get:1 /home/tc/deb/cmd-hangman_1.0.1_all.deb cmd-hangman all 1.0.1 [2478 B]
(Reading database ... 23347 files and directories currently installed.)
Preparing to unpack .../deb/cmd-hangman_1.0.1_all.deb ...
Unpacking cmd-hangman (1.0.1) over (1.0.0) ...
Setting up cmd-hangman (1.0.1) ...
Processing triggers for man-db (2.12.0-4build2) ...
$ dpkg -s cmd-hangman | grep ^Version
Version: 1.0.1
$ grep -c '"tar"' /usr/bin/cmd-hangman
1

If you try to install an older version over a newer one, apt refuses to downgrade without being asked:

$ sudo apt-get install -y ./cmd-hangman_1.0.0_all.deb 2>&1 | tail -3
  cmd-hangman
0 upgraded, 0 newly installed, 1 downgraded, 0 to remove and 5 not upgraded.
E: Packages were downgraded and -y was used without --allow-downgrades.

Keep the build in a script

Typing the steps again for every release is how mistakes get in. A small script does the same work every time. It builds the folder from the sources, sets the permissions, builds the package and checks it. Save it as build-deb.sh next to cmd-hangman, cmd-hangman.1 and a control file:

$ cat build-deb.sh
#!/bin/sh
# build-deb.sh VERSION - build cmd-hangman_VERSION_all.deb from this folder
set -e
V=${1:?usage: build-deb.sh VERSION}
D=cmd-hangman_${V}_all
rm -rf "$D"
install -D -m755 cmd-hangman "$D/usr/bin/cmd-hangman"
install -D -m644 copyright "$D/usr/share/doc/cmd-hangman/copyright"
install -d "$D/usr/share/man/man1" "$D/DEBIAN"
gzip -9n -c cmd-hangman.1 > "$D/usr/share/man/man1/cmd-hangman.1.gz"
gzip -9n -c changelog > "$D/usr/share/doc/cmd-hangman/changelog.gz"
sed "s/^Version:.*/Version: $V/" control > "$D/DEBIAN/control"
find "$D" -type d -exec chmod 755 {} +
find "$D" -type f ! -name cmd-hangman -exec chmod 644 {} +
dpkg-deb --root-owner-group --build "$D"
lintian "$D.deb"

The script expects the loose files (copyright, changelog, cmd-hangman.1, control) beside it. Pull them out of the tree you built, then run it for version 1.0.2:

$ cp cmd-hangman_1.0.1_all/usr/share/doc/cmd-hangman/copyright copyright
$ zcat cmd-hangman_1.0.1_all/usr/share/doc/cmd-hangman/changelog.gz > changelog
$ zcat cmd-hangman_1.0.1_all/usr/share/man/man1/cmd-hangman.1.gz > cmd-hangman.1
$ cp cmd-hangman_1.0.1_all/DEBIAN/control control
$ chmod +x build-deb.sh
$ ./build-deb.sh 1.0.2
dpkg-deb: building package 'cmd-hangman' in 'cmd-hangman_1.0.2_all.deb'.
$ ls *.deb
cmd-hangman_1.0.0_all.deb
cmd-hangman_1.0.1_all.deb
cmd-hangman_1.0.2_all.deb

(The package version, 1.0.2, comes from the command line; the changelog still says 1.0.1 at the top. lintian does not mind, but a real release would add the changelog entry first, so the two agree.)

A tiny repository of your own

A single .deb is fine for one machine. For several machines, a repository lets apt install and upgrade the package like any other. The smallest repository is a folder of .deb files and an index called Packages. dpkg-dev, which has the tool, is installed on the clean machine:

$ sudo apt-get remove -y cmd-hangman 2>&1 | tail -2
(Reading database ... 15787 files and directories currently installed.)
Removing cmd-hangman (1.0.0) ...
$ sudo mkdir -p /srv/repo
$ sudo chown tc: /srv/repo
$ ls /srv/repo
cmd-hangman_1.0.2_all.deb
$ dpkg-scanpackages --multiversion . 2>/dev/null | gzip -9 > Packages.gz
$ ls
Packages.gz
cmd-hangman_1.0.2_all.deb
$ zcat Packages.gz | head -8
Package: cmd-hangman
Version: 1.0.2
Architecture: all
Maintainer: TechCommune <admin@techcommune.org>
Depends: python3
Filename: ./cmd-hangman_1.0.2_all.deb
Size: 2468
MD5sum: 07377b96c965d1e2eff712d93cf7fa4f

Tell apt about the folder with a source line. [trusted=yes] says: do not demand a signature (right for a test, wrong for a real repository, which needs a signing key); "file:/srv/repo ./" means a flat repository in that folder:

$ sudo sh -c 'echo "deb [trusted=yes] file:/srv/repo ./" > /etc/apt/sources.list.d/tc-local.list'
$ sudo apt-get update 2>&1 | grep -E "srv/repo"
Ign:1 file:/srv/repo ./ InRelease
Ign:2 file:/srv/repo ./ Release
Get:3 file:/srv/repo ./ Packages [395 B]
$ apt-cache policy cmd-hangman | head -4
cmd-hangman:
  Installed: (none)
  Candidate: 1.0.2
  Version table:
$ sudo apt-get install -y cmd-hangman 2>&1 | tail -2
Unpacking cmd-hangman (1.0.2) ...
Setting up cmd-hangman (1.0.2) ...

apt now installs the game by its name, and a newer version dropped into the folder (and the index rebuilt) is offered by apt upgrade. To serve the folder to other machines, put it behind any web server and use an http:// address in the source line. A real public repository adds a Release file signed with a GPG key; the guide Airgap repositories shows that part for a whole network.

When the build or install goes wrong

The errors that do happen, each produced here on purpose.

A field missing from the control file (here the Maintainer line was left out). dpkg-deb only warns, names the field, and builds the package anyway; lintian then calls it an error:

$ dpkg-deb --root-owner-group --build broken
dpkg-deb: warning: parsing file 'broken/DEBIAN/control' near line 11 package 'cmd-hangman':
 missing 'Maintainer' field
dpkg-deb: warning: ignoring 1 warning about the control file(s)
dpkg-deb: building package 'cmd-hangman' in 'broken.deb'.
$ lintian broken.deb
E: cmd-hangman: required-field broken.deb Maintainer

A version that dpkg does not accept (a version must start with a digit):

$ dpkg-deb --root-owner-group --build broken
dpkg-deb: error: parsing file 'broken/DEBIAN/control' near line 2 package 'cmd-hangman':
 'Version' field value 'v1.0': version number does not start with digit

A file mode that is too open for the control folder:

$ dpkg-deb --root-owner-group --build broken
dpkg-deb: error: control directory has bad permissions 777 (must be >=0755 and <=0775)

Installing with dpkg when a dependency is missing. On the clean machine, with Python removed first, dpkg -i unpacks the files but cannot configure the package; apt then repairs it:

$ which python3
(exit status 1)
$ sudo dpkg -i /srv/repo/cmd-hangman_1.0.2_all.deb
Selecting previously unselected package cmd-hangman.
(Reading database ... 14490 files and directories currently installed.)
Preparing to unpack .../repo/cmd-hangman_1.0.2_all.deb ...
Unpacking cmd-hangman (1.0.2) ...
dpkg: dependency problems prevent configuration of cmd-hangman:
 cmd-hangman depends on python3; however:
  Package python3 is not installed.

dpkg: error processing package cmd-hangman (--install):
 dependency problems - leaving unconfigured
Errors were encountered while processing:
 cmd-hangman
(exit status 1)
$ sudo apt-get install -f -y 2>&1 | tail -4
Setting up python3 (3.12.3-0ubuntu2.1) ...
running python rtupdate hooks for python3.12...
running python post-rtupdate hooks for python3.12...
Setting up cmd-hangman (1.0.2) ...
$ dpkg -s cmd-hangman | grep ^Status
Status: install ok installed

The rule that follows: install .deb files with apt install ./file.deb, not dpkg -i; apt reads the Depends line and installs what is missing. If two packages contain the same file, dpkg refuses ("trying to overwrite ... which is also in package ..."); the fix is to give your file a name of its own, not to force it.

Cheat sheet

Layout of the package folder, as built in this guide:

cmd-hangman_1.0.0_all/
  DEBIAN/control                               the package's identity card
  usr/bin/cmd-hangman                          the program, mode 755
  usr/share/doc/cmd-hangman/copyright
  usr/share/doc/cmd-hangman/changelog.gz
  usr/share/man/man1/cmd-hangman.1.gz

The commands:

dpkg-deb --root-owner-group --build DIR     build DIR into DIR.deb
dpkg-deb -I FILE.deb                        show the control information
dpkg-deb -c FILE.deb                        list the files inside
dpkg-deb -f FILE.deb Depends                print one control field
lintian FILE.deb                            check against Debian policy
sudo apt install ./FILE.deb                 install, with dependencies
dpkg -l NAME / dpkg -L NAME / dpkg -S PATH  what is installed / its files / who owns a file
sudo apt remove NAME / purge NAME           remove / remove with configuration
dpkg-scanpackages . | gzip -9 > Packages.gz  index a folder of .deb files as a flat repository

Before you hand the file to anyone: right permissions (find ... -exec chmod), clean lintian report, a Depends line, and an install on a clean machine.

Next in the series: Build an .rpm package for Rocky Linux, the same game for the other family of distributions.