Entry 006

Building a Mini Digital Archive Server in Docker: A Beginner Linux Admin Tutorial

A desk in a dimly lit archive with a monitor showing ls -ld output for /archive, /archive/masters and /archive/access, next to a diagram of the archive folder splitting into masters and access

This tutorial walks through the basics of Linux system administration inside a Docker container. Instead of running commands one at a time with no context, you’ll build something with a purpose: a small digital archive server, set up the way a real archive or special collections department might run a shared machine.

  • Archivists can create and edit the master files.
  • Interns can log in but can’t touch the masters.
  • The public gets a read-only web page served by nginx.

The examples were run in an Ubuntu 24.04 container using Docker Desktop on a Mac, but they work with Docker on any OS.

AI-assistance disclosure: this post was drafted by Claude Opus 5.5, an AI model made by Anthropic, based on a lab session from Luis’s CIS 3850 Systems & Network Admin class. Some explanations were adapted from an earlier draft made with GPT-6. Luis ran and tested every command in his own container, including the bugs. If something doesn’t work for you, message Luis on Instagram at @pro.sumer.

What you will learn

  • How to start an interactive Ubuntu container with a port mapped to your computer
  • How to create users and groups, and how to read /etc/passwd and /etc/group
  • Why supplementary groups are the right tool for job roles
  • How Linux permissions work on files and on folders, including the setgid bit and umask
  • How to install software with apt
  • How to start, inspect and stop processes with ps, pgrep, top and kill
  • How to debug two real problems: a redirect that drops your port, and a 403 Forbidden

Part 1: Spin up the box

Open a terminal on your computer and run:

docker run -it --name archive-box -p 8080:80 ubuntu:24.04 bash
  • -it: interactive mode with a terminal, so you get a shell prompt inside the container
  • --name archive-box: a readable name, so you don’t have to type the container ID
  • -p 8080:80: maps port 8080 on your computer to port 80 inside the container. This is how your browser will reach the web server later. You can’t add a port mapping after the container starts, so don’t skip it.
  • ubuntu:24.04: the image to run
  • bash: the program to start inside it

Your prompt changes to something like root@c2c174374dee:/#. You’re now root inside the container, and the string of letters and numbers is the container ID. Because you’re already root, none of the commands below need sudo. On a normal Linux machine, you’d put sudo in front of the admin commands.

Take a look around:

whoami              # root
hostname            # the container ID
cat /etc/os-release # Ubuntu 24.04
uname -r            # kernel version
df -h               # disk usage

A detail worth noticing: uname -r doesn’t report an Ubuntu kernel. It reports the kernel of the machine Docker is running on (on a Mac, the Linux VM Docker Desktop runs in the background). Containers share the host’s kernel. They get their own user space (files, users, processes), but the kernel space (CPU, memory, devices) belongs to the host. That’s the main difference between a container and a full virtual machine.

Part 2: Users and groups

First, create two groups for the two job roles:

groupadd archivists
groupadd interns

Then create three people: two archivists and one intern. You need two archivists so you can test whether they can edit each other’s files later.

useradd -m -s /bin/bash -G archivists isabella
useradd -m -s /bin/bash -G archivists maria
useradd -m -s /bin/bash -G interns anthony
  • -m: create a home directory (/home/isabella)
  • -s /bin/bash: give the user Bash as their login shell
  • -G archivists: add the user to archivists as a supplementary group

Passwords are optional here. Root can switch to any account with su without a password, so you can skip passwd in this lab. On a real server, you’d set one with passwd isabella.

Check your work:

id isabella
uid=1001(isabella) gid=1003(isabella) groups=1003(isabella),1001(archivists)

Your numbers may be different. What matters is the shape:

  • uid: Isabella’s user ID. Linux tracks users by number, not by name.
  • gid: her primary group, a private group also called isabella. Ubuntu creates it automatically.
  • groups: every group she belongs to, including archivists.

The user isabella and the group isabella are two separate things that happen to share a name.

Reading /etc/passwd and /etc/group

Linux stores local accounts in plain text files. Look at the newest entries:

tail -n 3 /etc/passwd /etc/group

Gotcha: Use tail -n 3, not tail -3. The short form is an old syntax that GNU tail only accepts with a single file. With two files, it fails with invalid option -- '3'.

A line from /etc/passwd looks like this:

isabella:x:1001:1003::/home/isabella:/bin/bash

Each colon separates a field:

Field Example Meaning
Username isabella Account name
Password x The real password hash is in /etc/shadow, which only root can read
UID 1001 User ID
GID 1003 Primary group ID
Comment (empty) Optional full name or notes
Home /home/isabella Home directory
Shell /bin/bash Login shell

And from /etc/group:

archivists:x:1001:isabella,maria
interns:x:1002:anthony
isabella:x:1003:

The last field lists the group’s members. The isabella group shows an empty member list, but she’s still in it. Primary group membership is recorded in the GID field of /etc/passwd, not here. Only supplementary members are listed in /etc/group.

Quick tip: To find one account instead of scrolling, use grep:

grep '^isabella:' /etc/passwd

The ^ means “starts with”, so this matches only the line for the isabella account and not some other line that happens to contain the name.

Why a supplementary group instead of a primary group?

Using -G (capital) keeps Isabella’s private primary group and adds archivists as an extra membership. Using -g (lowercase) would make archivists her primary group instead. There are four reasons to prefer -G:

  1. Personal files stay personal. Every file a user creates gets their primary group by default. If archivists were Isabella’s primary group, every file in her home folder would belong to the whole team.
  2. Roles aren’t identity. Being an archivist is a job. You can add or remove the role without changing any existing files:
    usermod -aG archivists anthony   # promote Anthony (-a means append)
    gpasswd -d anthony archivists    # take it back
  3. People can hold several roles. A user has one primary group but can have many supplementary groups.
  4. The shared folder takes care of itself. In the next part, the setgid bit makes new files in the masters folder belong to archivists automatically.

Gotcha: usermod -G without -a replaces all of the user’s supplementary groups. Always use -aG.

Part 3: Permissions

Quick reference: permission numbers

Number Permission Symbol
4 read r
2 write w
1 execute x

Add the numbers together for each of the three groups of people: owner, group and others. For example, 755 means the owner gets 7 (4+2+1 = rwx), and the group and everyone else get 5 (4+1 = r-x).

Folders aren’t files

The same three letters mean different things on a folder, and this trips up almost everyone:

Permission On a file On a folder
r Read the contents List what’s inside (ls)
w Change the contents Create, delete or rename things inside (needs x too)
x Run it as a program Enter it and reach anything inside

x on a folder has nothing to do with running programs. It’s the key to the door. Without it, you can’t get to anything inside, even a file that’s open to everyone. Keep this in mind for the 403 error later.

Create the folders

mkdir -p /archive/{masters,access}
  • -p: creates /archive first if it doesn’t exist
  • {masters,access}: brace expansion. The shell turns it into /archive/masters /archive/access before mkdir runs.

The masters folder: team only

chown -R root:archivists /archive/masters
chmod 2770 /archive/masters
  • chown root:archivists: root is the user owner, and archivists is the group owner
  • 2770 breaks down like this:
    • 2: the setgid bit. New files created inside inherit the folder’s group (archivists).
    • 7: owner (root) gets rwx
    • 7: group (archivists) gets rwx
    • 0: everyone else gets nothing, not even a look inside

Check it:

ls -ld /archive/masters
# drwxrws--- 2 root archivists 4096 Oct 22 20:30 /archive/masters

(-d shows the folder itself instead of what’s inside it.) See the lowercase s where the group’s x would normally be? That’s setgid, switched on.

Does root need to be in archivists for this to work? No. The folder just records two labels, a user owner and a group owner, and they don’t have to be related. When Isabella tries to get in, Linux checks:

  1. Is she the owner (root)? No.
  2. Is she in the owning group (archivists)? Yes, so the group’s rwx applies.

Group membership decides which set of permissions applies to you. The bits decide what you’re allowed to do.

Test it by running a command as each user:

su - anthony -c "touch /archive/masters/test.txt"
# touch: cannot touch '/archive/masters/test.txt': Permission denied

su - isabella -c "touch /archive/masters/interview.txt"
# no output means it worked

ls -l /archive/masters
# -rw-r--r-- 1 isabella archivists 0 Oct 22 interview.txt

The file’s user owner is isabella, but its group owner is archivists, not isabella. That’s setgid at work. Without the 2, the file would belong to Isabella’s private group.

su - username starts a fresh login as that user, so group changes take effect right away. A shell that was already open wouldn’t see new group memberships until the user logged in again.

The umask problem: setgid isn’t enough

Look at that file again: -rw-r--r--. The group is archivists, but the group only has read. Try having Maria edit Isabella’s file:

su - maria -c "echo 'transcript notes' >> /archive/masters/interview.txt"
# Permission denied

Setgid gave the file the right group, but it didn’t give that group write access. That’s controlled by the umask, a mask that removes permissions from every new file a user creates:

su - isabella -c umask
# 0022

New files start as 666 (rw-rw-rw-), and the umask takes bits away:

umask New file New folder Who can write
022 644 (rw-r--r--) 755 Only the owner (the default)
002 664 (rw-rw-r--) 775 Owner and group
077 600 (rw-------) 700 Only the owner, and no one else can even read

For a team folder, archivists need 002. Set it in each archivist’s login profile so it sticks:

echo "umask 002" >> /home/isabella/.profile
echo "umask 002" >> /home/maria/.profile

Fix the file that already exists (umask only affects new files), then test again:

chmod g+w /archive/masters/interview.txt

su - isabella -c "touch /archive/masters/interview2.txt"
ls -l /archive/masters/interview2.txt
# -rw-rw-r-- 1 isabella archivists 0 Oct 22 interview2.txt

su - maria -c "echo 'transcript notes' >> /archive/masters/interview2.txt"
# works

chmod g+w is symbolic notation: group, add (+) write. It’s handy when you want to change one bit without retyping the whole number.

So the full recipe for a shared folder is:

  1. chown: who owns it
  2. chmod 2770: who can get in, and new files inherit the group
  3. umask 002: new files are writable by that group

Why was the umask 022 and not 002? Ubuntu can automatically give 002 to users whose private group matches their user ID. Run grep -E '^(USERGROUPS_ENAB|UMASK)' /etc/login.defs to see the settings. Here, the groups were created first, so Isabella’s UID and GID don’t match, and she gets the plain 022. Your results may be different, so always check with umask.

Notice that 664 still gives others read access to the file itself. Nobody outside the group can get to it, though, because the 0 on the masters folder locks the door.

The access folder: only root publishes

chmod 755 /archive/access
ls -ld /archive/access
# drwxr-xr-x 2 root root ... /archive/access

This folder is the public copy that the web server reads from. It’s owned by root:root with 755, so only root can write to it. Archivists, interns and the web server can read it but can’t change it.

This is deliberate. It’s the principle of least privilege: every account gets only the access its job needs. The web server only needs to read, so if someone ever finds a hole in the site, they can’t use the web server to drop files into the folder. Publishing becomes a deliberate step that only an admin can take, much like a reading room that gets copies while the originals stay locked away.

Part 4: Software

The Ubuntu Docker image is stripped down. It doesn’t even include ps. Install what the archive needs:

apt update && apt install -y nginx libimage-exiftool-perl procps curl
  • apt update: refreshes the list of available packages. Always run it first in a new container.
  • nginx: the web server
  • libimage-exiftool-perl: provides exiftool, a metadata reader used in real digital preservation work
  • procps: provides ps, top and pgrep
  • curl: makes web requests from the command line, which you’ll need for debugging later

Confirm everything installed:

dpkg -l | grep -E "nginx|exiftool|procps"
exiftool -ver

Try exiftool on a file in the masters folder:

exiftool /archive/masters/interview2.txt

The output includes the file’s type, size, modification date and permissions. That’s the same information you’ve been setting by hand, read back as metadata.

Part 5: Processes

Publish a page and start the server

As root, create a home page in the access folder and link it into nginx’s web root:

echo "<h1>Collection Home</h1>" > /archive/access/index.html
ln -sf /archive/access /var/www/html/archive
nginx
  • ln -sf: creates a symbolic link, a shortcut. nginx serves files from /var/www/html, so /var/www/html/archive now points to your access folder.
  • nginx: starts the server in the background

Inspect the processes

ps aux | grep nginx
pgrep nginx
top                 # press q to quit

You’ll see something like this:

root      2961  ... nginx: master process nginx
www-data  2962  ... nginx: worker process
www-data  2963  ... nginx: worker process
...

A few things to notice:

  • One master and many workers. The master runs as root because it needs root to bind to port 80. The workers, which handle the actual web requests, run as www-data, a low-privilege account. Least privilege again.
  • The number of workers matches your CPU cores. nginx’s default setting is worker_processes auto. Run nproc to see how many cores Docker gives the container.
  • The master is the parent. You can see the tree with:
    ps -ef --forest | grep nginx

Visit the site

In your browser, go to:

http://localhost:8080/archive/

You should see Collection Home.

Debugging 1: The page won’t load without a trailing slash

When Luis first visited localhost:8080/archive (no slash at the end), nothing loaded. nginx was running fine, so the problem was somewhere else. curl shows what’s going on:

curl -I localhost/archive
HTTP/1.1 301 Moved Permanently
Location: http://localhost/archive/

/archive is a folder, so nginx redirects to /archive/. But nginx thinks it’s on port 80, because that’s true inside the container, so it builds the redirect without your port. The browser follows it to localhost:80 on your computer, where nothing is listening.

Quick fix: always type the trailing slash.

Real fix: tell nginx to send relative redirects, which leave out the host and port:

sed -i 's/server_name _;/server_name _;\n\tabsolute_redirect off;/' /etc/nginx/sites-available/default
nginx -t && nginx -s reload
curl -I localhost/archive
# Location: /archive/
  • sed -i: edits the file in place, adding absolute_redirect off; under the server_name line
  • nginx -t: tests the configuration before applying it, so a typo doesn’t take the server down
  • nginx -s reload: applies the new configuration without stopping the server

Now localhost:8080/archive works with or without the slash.

Debugging 2: Break it on purpose with permissions

This is where permissions and processes meet. Remove the “others” permissions from the access folder:

chmod 700 /archive/access

Refresh the browser and you get:

403 Forbidden
nginx/1.24.0 (Ubuntu)

Why? The worker processes run as www-data. That account isn’t the owner (root) and isn’t in the group (root), so it falls under others, and 700 gives others nothing. Without x on the folder, www-data can’t even get in, so it never reaches index.html, even though the file itself is readable.

If you didn’t break it on purpose, the nginx error log tells you exactly where it was blocked:

tail -n 5 /var/log/nginx/error.log
# ... "/var/www/html/archive/index.html" is forbidden (13: Permission denied)

Fix it:

chmod 755 /archive/access

Refresh, and Collection Home is back.

Bonus hardening: stop advertising your version

That 403 page told the world the exact server version, nginx/1.24.0 (Ubuntu). An attacker can use that to look up known vulnerabilities. Hide it:

sed -i 's/# server_tokens off;/server_tokens off;/' /etc/nginx/nginx.conf
nginx -t && nginx -s reload

Now error pages just say nginx.

Part 6: Stopping processes

Kill a worker and watch it come back

Pick any worker PID from pgrep nginx (use your own numbers, since PIDs change every time nginx starts):

kill 2962
pgrep nginx

PID 2962 is gone, but a new PID has taken its place. The master noticed a dead child and started a replacement. That’s why killing a worker doesn’t stop a web server.

Kill the master

To actually stop nginx, kill the master process. That’s the one owned by root, and it has the lowest nginx PID:

kill 2961
pgrep nginx         # no output; everything is gone

Refresh the browser and you get a connection error. Compare that with the 403 from earlier:

What you see What it means
403 Forbidden A server is running but refused the request
Connection error Nothing is listening at all

Telling those two apart is half of troubleshooting a web server.

Save your work

Everything you built lives only in this container. Nothing is saved to your computer unless you set up a volume. To keep a snapshot, run this from your computer’s terminal, not inside the container:

docker commit archive-box archive-box:v1

To come back to the same container later:

docker start -ai archive-box
nginx               # nginx doesn't restart on its own

exit inside the container stops it, because bash was the container’s main process.

Quick reference

Task Command
Create a group groupadd archivists
Create a user with a supplementary group useradd -m -s /bin/bash -G archivists isabella
Add a user to another group usermod -aG interns isabella
Show a user’s IDs and groups id isabella
Find one account grep '^isabella:' /etc/passwd
Change owner and group chown root:archivists /archive/masters
Team-only folder with setgid chmod 2770 /archive/masters
Public read-only folder chmod 755 /archive/access
Add group write to one file chmod g+w file.txt
Show a folder’s own permissions ls -ld /archive/masters
Check the umask umask
Run a command as another user su - anthony -c "command"
Install software apt update && apt install -y nginx
Find processes ps aux | grep nginx or pgrep nginx
Show the process tree ps -ef --forest
Live process view top (press q to quit)
Test and reload nginx nginx -t && nginx -s reload
Check HTTP headers curl -I localhost/archive
Stop a process kill <PID>

What’s next

This container is the first piece of a bigger lab. The next step is capturing the traffic between the browser and this server with Wireshark, to watch the port mapping, the 301 redirect and the HTTP requests from the network’s point of view.