Throughout this module you have worked with an implicit assumption: that each name corresponds to one file and each file has one name. That assumption is false, and understanding why is what separates somebody who uses Linux from somebody who understands it.

In a Unix file system, the name and the content are separate things. The content lives in a structure called an inode; the name is simply an entry in a directory pointing at that inode. Nothing stops two entries pointing at the same place, nor stops a file existing whose content is the path of another. That is where the two kinds of link in this lesson come from.

This is not file system theory for curiosity's sake. Links are everywhere on a Linux system: /bin is a link, your python3 is a link, and the system with which Debian and Ubuntu decide which version of a tool gets used is built on links. And at the end of the lesson you will design the pattern with which Tramontana Bookings will turn a deployment — and a rollback — into an instantaneous change of link, with no service outage window.

Contents

  1. What an inode is
  2. Seeing the inode and the link count
  3. Hard links with ln
  4. The two limitations of hard links
  5. Symbolic links with ln -s
  6. Absolute versus relative
  7. Broken links
  8. Which commands follow the link and which do not
  9. Hard versus symbolic: the table
  10. Links in a real Linux system
  11. update-alternatives
  12. Tramontana's deployment pattern

  1. What an inode is

When you save a file, the file system creates three distinct things:

  1. The data blocks: the content itself, spread across the disk.
  2. The inode: a structure with all the file's metadata — permissions, owner, group, size, timestamps, link count and the addresses of the data blocks. Each inode has a unique number within its file system.
  3. The directory entry: a pair (name, inode number) stored in the directory.

Notice what the inode does not contain: the file's name. The name is not a property of the file; it is a property of the directory containing it.

flowchart LR
    subgraph DIR["Directory /home/operator/data"]
        E1["'houses.txt' -> 262149"]
        E2["'bookings.csv' -> 262151"]
    end
    subgraph INO["Inode table"]
        I1["Inode 262149<br/>permissions, owner, dates<br/>links: 1<br/>block pointers"]
        I2["Inode 262151<br/>permissions, owner, dates<br/>links: 1<br/>block pointers"]
    end
    subgraph BLK["Data blocks"]
        B1["mas-figueres;Mas Figueres..."]
        B2["id;date;house;guest..."]
    end
    E1 --> I1 --> B1
    E2 --> I2 --> B2

A directory, seen this way, does not contain files: it contains a list of names with inode numbers. It is an index.

This architecture explains at a stroke several things that until now were loose facts:

Fact The inode explanation
mv within the same disk is instantaneous It only changes the directory entry; the inode and the blocks are untouched
. has the same number as its directory They are two names for the same inode (you checked this in 02-03)
The name is not in stat Because it is not in the inode
You can delete a file another process is reading The name is removed, but the inode survives while somebody has it open
df -i can run out with free space available Inodes are a finite resource, fixed when you format

That second-to-last point is the definitive explanation of the mystery from lesson 02-03: you delete a 2 GB log, du stops seeing it, but df still says the space is occupied. The name has gone; the inode is still alive because the process has it open, and with it its blocks. Until the process closes the file — or you restart it — nothing is freed.

  1. Seeing the inode and the link count

Two tools you already know, now with links in mind.

operator@srv-tramontana:~$ ls -li data/
total 8
262151 -rw-r--r-- 1 operator operator 1204 Aug 18 07:55 bookings.csv
262149 -rw-r----- 1 operator operator  418 Aug 17 19:40 houses.txt

The first column, thanks to -i, is the inode number. The column immediately after the permissions — the 1 — is the link count: how many names point to this inode.

stat spells it out:

operator@srv-tramontana:~$ stat data/houses.txt
  File: data/houses.txt
  Size: 418       	Blocks: 8          IO Block: 4096   regular file
Device: 8,2	Inode: 262149      Links: 1
Access: (0640/-rw-r-----)  Uid: ( 1001/operator)   Gid: ( 1001/operator)

Inode: 262149 and Links: 1. That counter is what governs when a file's space is released, as you will see in the next section.

And now the behaviour of directories that was left pending in lesson 02-03 makes sense:

operator@srv-tramontana:~$ ls -ldi /opt/tramontana/app
131074 drwxr-xr-x 4 root root 4096 Aug 18 08:30 /opt/tramontana/app

A count of 4, not 1. The four names pointing at that inode are:

  1. The app entry inside /opt/tramontana.
  2. The . entry inside /opt/tramontana/app.
  3. The .. entry inside /opt/tramontana/app/templates.
  4. The .. entry inside another subdirectory.

Hence the formula: a directory's links = 2 + number of subdirectories. A directory with no subdirectories has 2; one with seven subdirectories has 9.

An inode number only makes sense within its own file system. Two files on different disks can have the same number with no relationship whatsoever. That is why, to compare, you also have to look at the device:

operator@srv-tramontana:~$ stat -c '%d:%i  %n' data/houses.txt /boot/vmlinuz
2049:262149  data/houses.txt
2049:131331  /boot/vmlinuz

The device:inode pair does identify a file uniquely across the whole machine.

  1. Hard links with ln

A hard link is, quite simply, another directory entry pointing at the same inode. It is not a copy nor a shortcut: it is another, equally legitimate name for the same file.

operator@srv-tramontana:~$ ln data/houses.txt data/accommodation.txt

operator@srv-tramontana:~$ ls -li data/
total 12
262149 -rw-r----- 2 operator operator  418 Aug 17 19:40 accommodation.txt
262151 -rw-r--r-- 1 operator operator 1204 Aug 18 07:55 bookings.csv
262149 -rw-r----- 2 operator operator  418 Aug 17 19:40 houses.txt

Three things to confirm in that output:

  1. The same inode (262149) on accommodation.txt and houses.txt.
  2. The count went up to 2 on both: there are two names pointing there.
  3. The size is shown twice (418 and 418), but the file takes up 418 bytes in total, not 836. ls shows the inode's size, and the inode is the same one.

That last point has a practical consequence: du counts the space only once if it finds both names in the same walk, but it can count it twice if they are in different walks. It is a classic source of discrepancies when working out the size of backups that use hard links.

There is no original and no copy. The two names are exactly equivalent; the system does not record which was created first. Modifying one modifies the other, because they are the same file:

operator@srv-tramontana:~$ echo "el-moli;El Molí;Ripollès;5" >> data/accommodation.txt
operator@srv-tramontana:~$ tail -n 1 data/houses.txt
el-moli;El Molí;Ripollès;5

And the same goes for the metadata: chmod on one changes the permissions of the other, because the permissions live in the inode.

What happens when you delete

Here is the behaviour you have to understand properly:

operator@srv-tramontana:~$ rm data/houses.txt

operator@srv-tramontana:~$ ls -li data/
262149 -rw-r----- 1 operator operator  446 Aug 18 13:02 accommodation.txt
262151 -rw-r--r-- 1 operator operator 1204 Aug 18 07:55 bookings.csv

operator@srv-tramontana:~$ cat data/accommodation.txt
mas-figueres;Mas Figueres;Girona;6
...
el-moli;El Molí;Ripollès;5

The content is still intact. rm does not delete files: it decrements the link count and removes the name. The inode and its blocks are only released when two things happen at once:

  • The link count reaches 0.
  • No process has the file open.

That is why the low-level command is called unlink(), not delete(). rm literally means "unlink".

The three possible outcomes of an rm:

Situation after decrementing the count What happens to the data
Count > 0 Still reachable through the other names. Nothing is released
Count = 0 and nobody has it open The inode and the blocks are released. The file disappears
Count = 0 but some process has it open Still on disk, nameless but alive: du cannot see them, df does count them

The third row is the one behind the mystery of the space that is not released. It is also a mechanism used on purpose: a program that needs a temporary file can create it, open it and delete it immediately. The file carries on working while the program has it open, and disappears automatically when the program finishes, even if it terminates abnormally. There is nothing to clean up.

  1. The two limitations of hard links

They cannot cross file systems

operator@srv-tramontana:~$ ln data/accommodation.txt /boot/test.txt
ln: failed to create hard link '/boot/test.txt' => 'data/accommodation.txt':
 Invalid cross-device link

The reason: a hard link is a (name, inode number) entry. The inode number only has meaning within its own file system: inode 262149 on /dev/sda2 has nothing to do with inode 262149 on /dev/sda1. An entry in a directory on /boot saying "262149" would point at inode 262149 of /boot, which is a completely different file.

It is not an arbitrary restriction: it is that the reference means nothing outside its own scope.

How to find out whether two paths are on the same file system, before you try:

operator@srv-tramontana:~$ df --output=source /home/operator /opt/tramontana /boot
Filesystem
/dev/sda2
/dev/sda2
/dev/sda1

/home and /opt share /dev/sda2: you can link between them. /boot is on /dev/sda1: you cannot.

They cannot be made to directories

operator@srv-tramontana:~$ ln /opt/tramontana/app /tmp/app-link
ln: /opt/tramontana/app: hard link not allowed for directory

Not even root can on Linux. The reason is more interesting:

The Unix directory tree is, by design, an acyclic graph: you can go down and up, but not round in circles. If you could create a hard link to a directory, you could build a loop:

/a/b/c/  ->  hard link to  /a

The immediate consequences:

  • find, du, tar, rsync and any recursive walk would enter an infinite loop, because there would be no way of detecting that they had already been there without keeping a record of every inode visited.
  • The link count would stop being useful for knowing when to release the space: a cycle of directories referencing each other would have counts greater than zero even though nobody could reach them from the root. It would be unreachable garbage, impossible to collect.
  • .. would stop having a single meaning: a directory with two parents cannot have a single ...

The . and .. entries are the exception: they are hard links to directories, created by the kernel, and that is why directory counts are greater than 1. But they are controlled and their topology is known, so they break nothing.

Both limitations disappear with the other kind of link.

  1. Symbolic links with ln -s

A symbolic link (or symlink, or soft link) is a file in its own right, with its own inode, whose content is a text path. When something opens it, the kernel reads that path and redirects the operation.

operator@srv-tramontana:~$ ln -s /home/operator/data/bookings.csv ~/bookings-current.csv

operator@srv-tramontana:~$ ls -li ~/bookings-current.csv
262180 lrwxrwxrwx 1 operator operator 32 Aug 18 13:20 bookings-current.csv -> /home/operator/data/bookings.csv

Everything you need to read there:

Element What it means
262180 Its own inode, different from the target's (262151)
The leading l Type symbolic link, the l you saw in Module 1
rwxrwxrwx The link's permissions are always these and mean nothing: the ones that count are the target's
1 The link count of the link itself
32 The size is the length of the path, 32 characters
-> /home/... The path it contains

That size confirms what a symlink is: a file whose content is a text string.

When you use it, everything works as if it were the target:

operator@srv-tramontana:~$ head -n 2 ~/bookings-current.csv
id;date;house;guest;nights;amount
1001;2026-07-03;mas-figueres;Nuria Prat;4;620.00

And the two limitations of the hard link do not exist:

operator@srv-tramontana:~$ ln -s /boot/vmlinuz ~/kernel-current
operator@srv-tramontana:~$ ls -l ~/kernel-current
lrwxrwxrwx 1 operator operator 13 Aug 18 13:24 kernel-current -> /boot/vmlinuz

operator@srv-tramontana:~$ ln -s /opt/tramontana/app ~/app-production
operator@srv-tramontana:~$ ls -ld ~/app-production
lrwxrwxrwx 1 operator operator 19 Aug 18 13:25 app-production -> /opt/tramontana/app

It crosses file systems and points at directories without any trouble, because it does not reference an inode: it references a path, and a path is text that gets resolved at the moment of use.

A link to a directory behaves like the directory:

operator@srv-tramontana:~$ ls ~/app-production/
executable  templates  version.txt

operator@srv-tramontana:~$ cd ~/app-production
operator@srv-tramontana:~/app-production$ pwd
/home/operator/app-production
operator@srv-tramontana:~/app-production$ pwd -P
/opt/tramontana/app

There you have the explanation of pwd -P that was left pending in lesson 02-03: pwd shows the logical path, the one you used to get there; pwd -P shows the physical one, resolving the links.

Options of ln worth knowing:

Option What it does
-s Creates a symbolic link (without it, a hard one)
-f Force: replaces the link if it already exists
-n Treats an existing link to a directory as a file, does not descend into it
-r Creates the link with an automatically calculated relative path
-v Reports what it does
-T Always treats the destination as a name, never as a directory

The combination -sfn is the deployment one and you will see it in action in section 12. Without -n, replacing a link that points at a directory creates the new link inside that directory instead of replacing it, which is a very easy mistake to make.

  1. Absolute versus relative

The content of a symbolic link is a path, and that path can be of either kind.

operator@srv-tramontana:~$ cd /opt/tramontana

# Absolute
operator@srv-tramontana:/opt/tramontana$ sudo ln -s /opt/tramontana/releases/3.2.1 app-abs

# Relative
operator@srv-tramontana:/opt/tramontana$ sudo ln -s releases/3.2.1 app-rel

operator@srv-tramontana:/opt/tramontana$ ls -l app-*
lrwxrwxrwx 1 root root 30 Aug 18 13:40 app-abs -> /opt/tramontana/releases/3.2.1
lrwxrwxrwx 1 root root 16 Aug 18 13:40 app-rel -> releases/3.2.1

Both work exactly the same right now. They differ in what happens when something moves.

A relative link is resolved from the directory where the link lives, not from where you are:

operator@srv-tramontana:~$ ls /opt/tramontana/app-rel/
executable  templates  version.txt

It works from your home directory because releases/3.2.1 is resolved relative to /opt/tramontana/, which is where the link lives.

Now, the test that tells them apart: copying the whole tree somewhere else.

operator@srv-tramontana:~$ sudo cp -a /opt/tramontana /srv/tramontana/full-copy

operator@srv-tramontana:~$ ls -l /srv/tramontana/full-copy/app-*
lrwxrwxrwx 1 root root 30 Aug 18 13:40 app-abs -> /opt/tramontana/releases/3.2.1
lrwxrwxrwx 1 root root 16 Aug 18 13:40 app-rel -> releases/3.2.1
  • app-abs still points at /opt/tramontana/..., that is, at the original tree. The copy is not self-contained: it depends on the original. If you delete /opt/tramontana, the copy is left broken.
  • app-rel points at releases/3.2.1 inside the copy. The copied tree is self-sufficient and consistent.
Criterion Absolute Relative
Survives moving the link No Yes, if the whole set is moved
Survives moving the target No No
Copying the complete tree Independence is broken Stays coherent
Works inside a chroot or container No (the root changes) Yes
Readability with ls -l Very clear You have to work it out
Robust against being mounted elsewhere No Yes

The rule of use:

  • Relative within a single application tree (/opt/tramontana/app → releases/...), because the whole set is copied, backed up and restored as one unit.
  • Absolute when the target is somewhere else in the system and has no structural relationship with the link (for example, a link in your home directory to /var/log/tramontana).

ln -r works out the relative path for you:

operator@srv-tramontana:~$ cd /opt/tramontana
operator@srv-tramontana:/opt/tramontana$ sudo ln -sr /opt/tramontana/releases/3.2.1 app-auto
operator@srv-tramontana:/opt/tramontana$ ls -l app-auto
lrwxrwxrwx 1 root root 16 Aug 18 13:47 app-auto -> releases/3.2.1

You give it absolute paths, which are easy to type with Tab without making a mistake, and it generates the correct relative link. It is the best of both worlds.

  1. Broken links

Because a symlink stores a path and not a reference to an inode, nothing guarantees that the target exists. You can create a link to something that does not exist yet, and the target can disappear afterwards.

operator@srv-tramontana:~$ ln -s /opt/tramontana/releases/9.9.9 ~/future
operator@srv-tramontana:~$ ls -l ~/future
lrwxrwxrwx 1 operator operator 33 Aug 18 13:52 future -> /opt/tramontana/releases/9.9.9

operator@srv-tramontana:~$ cat ~/future
cat: /home/operator/future: No such file or directory

ls -l shows the link without complaining, because the link exists perfectly well: it is the target that does not. The error only appears when you try to use it, and the message mentions the name of the link, which is confusing: it looks as if ~/future does not exist when ~/future is right there.

With colour enabled, ls shows broken links in flashing red, which is hard to miss. To detect them reliably:

# A specific link
operator@srv-tramontana:~$ ls -lL ~/future
ls: cannot access '/home/operator/future': No such file or directory

# Every broken link in a tree
operator@srv-tramontana:~$ find ~ -xtype l
/home/operator/future

# With more context
operator@srv-tramontana:~$ find ~ -xtype l -exec ls -l {} \;
lrwxrwxrwx 1 operator operator 33 Aug 18 13:52 /home/operator/future -> /opt/tramontana/releases/9.9.9

find -xtype l means "items that, once the link is resolved, are of type link", which only happens if the link cannot be resolved. find is studied in depth in lesson 03-03; you can adopt this invocation now as a recipe.

And readlink is the specific tool for inspecting links:

operator@srv-tramontana:~$ readlink ~/future
/opt/tramontana/releases/9.9.9

operator@srv-tramontana:~$ readlink -f ~/bookings-current.csv
/home/operator/data/bookings.csv

operator@srv-tramontana:~$ readlink -e ~/future
operator@srv-tramontana:~$ echo $?
1
Option What it does
(none) Shows the literal content of the link
-f Resolves the whole chain of links; does not fail if the last one does not exist
-e Like -f, but fails if the target does not exist

readlink -e is the checker: code 0 if the link leads to something real, non-zero if it is broken. It is exactly what you will use in the deployment script to check that the app link points at an existing release before starting the service.

A broken link is not always a mistake. It is a normal situation when the target will be created later, or when it points at a removable device that is not mounted right now. What there must not be are broken links that nobody knows why they are there.

  1. Which commands follow the link and which do not

This is the part that causes the most mistakes in practice: each command decides for itself whether it acts on the link or on its target.

Command Default behaviour How to change it
cat, less, editors Follow the link: they read and write the target —
ls Shows the link -L to show the target
ls -l of a linked directory Lists the target's contents -d to see the link
cp Follows: copies the target's content -P or -d copies the link as such
cp -a Does not follow: copies links as links -L to follow them
mv Moves the link, not the target —
rm Deletes the link, never the target —
chmod, chown Follow: they change the target chown -h changes the link
du Counts the link (a few bytes) -L to count the target
find Does not follow -L to follow
tar Stores links as links -h to store the content
rsync -a Copies links as links -L to copy the content

The three cases you must be clear about:

rm on a link deletes the link. Always. It is safe:

operator@srv-tramontana:~$ rm ~/bookings-current.csv
operator@srv-tramontana:~$ ls data/bookings.csv
data/bookings.csv

The target is still intact. This is the behaviour people fear and that in reality never lets you down.

cp without -a undoes links:

operator@srv-tramontana:~$ ln -s /opt/tramontana/app/version.txt ~/v.txt

operator@srv-tramontana:~$ cp ~/v.txt /tmp/v-copy.txt
operator@srv-tramontana:~$ ls -l /tmp/v-copy.txt
-rw-rw-r-- 1 operator operator 26 Aug 18 14:02 /tmp/v-copy.txt

operator@srv-tramontana:~$ cp -P ~/v.txt /tmp/v-link.txt
operator@srv-tramontana:~$ ls -l /tmp/v-link.txt
lrwxrwxrwx 1 operator operator 36 Aug 18 14:02 /tmp/v-link.txt -> /opt/tramontana/app/version.txt

A normal cp produced a real file with the content; cp -P produced a link. When copying a tree containing links, this difference decides whether you get a faithful copy or a bloated one in which every link has turned into a complete copy of the file.

The trailing-slash trap with links to directories. This one bites everybody:

operator@srv-tramontana:~$ ln -s /opt/tramontana/app ~/app-link

# WITHOUT a slash: the link itself
operator@srv-tramontana:~$ ls -ld ~/app-link
lrwxrwxrwx 1 operator operator 19 Aug 18 14:05 /home/operator/app-link -> /opt/tramontana/app

# WITH a slash: the DIRECTORY it points at
operator@srv-tramontana:~$ ls -ld ~/app-link/
drwxr-xr-x 4 root root 4096 Aug 18 08:30 /home/operator/app-link/

The trailing slash means "treat it as a directory", and that forces the kernel to resolve the link. And now the dangerous part:

# Deletes ONLY the link. The target is untouched.
operator@srv-tramontana:~$ rm ~/app-link

# With a trailing slash: an error, because rm does not delete directories without -r
operator@srv-tramontana:~$ rm ~/app-link/
rm: cannot remove '/home/operator/app-link/': Is a directory

# THIS ONE REALLY DOES DELETE THE CONTENTS OF THE REAL TARGET
operator@srv-tramontana:~$ rm -rf ~/app-link/

That last line, with -rf and a trailing slash, goes through the link and deletes the contents of /opt/tramontana/app. The production application, wiped out by one slash too many.

The golden rule: never put a trailing slash after the name of a link in a destructive command. And in general, before an rm -rf on something that might be a link, check:

operator@srv-tramontana:~$ ls -ld ~/app-link
operator@srv-tramontana:~$ readlink -f ~/app-link
/opt/tramontana/app

  1. Hard versus symbolic: the table

Characteristic Hard link Symbolic link
What it is Another directory entry to the same inode A file whose content is a path
Inode The same as the target Its own, different one
Created with ln source link ln -s source link
In ls -l Indistinguishable from a normal file Starts with l, with a ->
Size That of the file The length of the path
Target's link count Increases Does not change
Crosses file systems No Yes
Points at directories No Yes
If the target is deleted The data stays reachable The link is left broken
If the target is moved Carries on working It breaks (except coherent relative ones)
Permissions Those of the shared inode lrwxrwxrwx, irrelevant
Finding the target There is no way to know which was "the original" readlink
Can point at something non-existent No Yes
Cost on disk Zero (just the entry) An inode and a few bytes
Typical use Backups with rsync --link-dest, deduplication Everything else

When to choose each, in practice:

Use symbolic links by default. They are visible in ls -l, they tell you where they point, they cross file systems, they work with directories and they can be replaced atomically. 95% of the links you create and that you find on a Linux system are symbolic.

Use hard links in two specific cases:

  1. Incremental backups with deduplication. rsync --link-dest creates what looks like a full copy every day, but the files that have not changed are hard links to the previous day's: they take up zero extra space. It is the basis of Time Machine-style backups and you will see it in lesson 05-08.
  2. When you need the file to survive the deletion of the original name, without leaving a broken link behind.

  1. Links in a real Linux system

Links are not a curiosity: they are the structure of the system.

The root itself:

operator@srv-tramontana:~$ ls -l / | grep '^l'
lrwxrwxrwx   1 root root    7 Apr 22  2024 bin -> usr/bin
lrwxrwxrwx   1 root root    7 Apr 22  2024 lib -> usr/lib
lrwxrwxrwx   1 root root    9 Apr 22  2024 lib64 -> usr/lib64
lrwxrwxrwx   1 root root    8 Apr 22  2024 sbin -> usr/sbin

This is the reorganisation known as the /usr merge: the binaries were consolidated under /usr and links were left at the root so that the thousands of existing references to /bin/bash would carry on working. This is what you saw with the arrow in tree -L 1 / in lesson 02-03.

Notice that they are relative (usr/bin, not /usr/bin). Deliberately so: that way they stay valid if the tree is mounted somewhere else, for example when repairing the system from a rescue disk mounted at /mnt.

The Python interpreter:

operator@srv-tramontana:~$ ls -l /usr/bin/python3
lrwxrwxrwx 1 root root 10 Aug  2 12:04 /usr/bin/python3 -> python3.12

python3 is a link to the specific version. Upgrading Python to 3.13 is, in essence, changing that link: every script beginning with #!/usr/bin/python3 starts using the new version without touching a single one.

Chains of links:

operator@srv-tramontana:~$ ls -l /usr/bin/vi
lrwxrwxrwx 1 root root 20 Aug 18 09:00 /usr/bin/vi -> /etc/alternatives/vi
operator@srv-tramontana:~$ ls -l /etc/alternatives/vi
lrwxrwxrwx 1 root root 17 Aug 18 09:00 /etc/alternatives/vi -> /usr/bin/vim.basic
operator@srv-tramontana:~$ readlink -f /usr/bin/vi
/usr/bin/vim.basic

Two hops. readlink -f follows the whole chain to the end. The kernel allows about 40 levels of nesting before giving Too many levels of symbolic links.

systemd services:

operator@srv-tramontana:~$ ls -l /etc/systemd/system/multi-user.target.wants/ | head -n 4
lrwxrwxrwx 1 root root 44 Jul  1 10:22 ssh.service -> /lib/systemd/system/ssh.service
lrwxrwxrwx 1 root root 48 Jul  1 10:22 cron.service -> /lib/systemd/system/cron.service

Enabling a service in systemd is literally creating a symbolic link, and disabling it is deleting it. When you run systemctl enable in lesson 05-05, what happens underneath is an ln -s.

Rotated logs:

operator@srv-tramontana:~$ ls -l /var/log/tramontana/
-rw-r----- 1 root adm  18K Aug 18 09:14 access.log
-rw-r----- 1 root adm 2.1K Aug 17 23:59 access.log.1.gz

There are no links here, but it is worth knowing that some services keep a current.log as a link to the day's file.

  1. update-alternatives

Debian and Ubuntu use links to solve a specific problem: several packages providing the same function. Which editor does sudo editor open? Which java runs if three are installed? What is vi if there are vim, nvi and elvis?

The mechanism is a double layer of symbolic links:

/usr/bin/editor  ->  /etc/alternatives/editor  ->  /bin/nano

The first layer is fixed and is on the PATH. The second one, in /etc/alternatives/, is the one that gets changed. That way the administrator never touches /usr/bin, which belongs to the packages.

operator@srv-tramontana:~$ update-alternatives --display editor
editor - auto mode
  link best version is /bin/nano
  link currently points to /bin/nano
  link editor is /usr/bin/editor
/bin/nano - priority 40
/usr/bin/vim.basic - priority 30

Changing it:

operator@srv-tramontana:~$ sudo update-alternatives --config editor
There are 2 choices for the alternative editor (providing /usr/bin/editor).

  Selection    Path                Priority   Status
------------------------------------------------------------
* 0            /bin/nano            40        auto mode
  1            /bin/nano            40        manual mode
  2            /usr/bin/vim.basic   30        manual mode

Press <enter> to keep the current choice[*], or type selection number: 2
update-alternatives: using /usr/bin/vim.basic to provide /usr/bin/editor (editor) in manual mode
operator@srv-tramontana:~$ ls -l /etc/alternatives/editor
lrwxrwxrwx 1 root root 18 Aug 18 14:20 /etc/alternatives/editor -> /usr/bin/vim.basic

The second-layer link has changed. The main commands:

Command What it does
--display <name> Shows the state and the options
--config <name> Choose interactively
--list <name> Only the available paths
--set <name> <path> Set it without interaction (for scripts)
--auto <name> Go back to automatic mode by priority

Why this matters for your session's EDITOR: when you run sudo visudo or crontab -e, the editor that opens is decided by the EDITOR environment variable and, if it is not defined, by the editor alternative. If you have never configured anything and nano appears, you now know why and how to change it. The EDITOR variable belongs to lesson 03-01.

  1. Tramontana's deployment pattern

This is where everything above becomes an architectural decision. It is the pattern Tramontana Bookings is going to adopt and which will reappear in lesson 04-07 and in Module 8.

The problem

Today, deploying a new version would mean: stop the service, delete or move /opt/tramontana/app, copy the new version, start it. The problems:

  • A window of unavailability proportional to the copy time. Copying 48 MB takes seconds; in production, copying a large release can take minutes.
  • An inconsistent intermediate state: during the copy, the directory contains half the old version and half the new one. If the service starts at that moment, it behaves unpredictably.
  • Rolling back is another complete deployment, with its own window and its own risks, carried out under pressure because something has failed.
  • The previous version is no longer there, so rolling back means recovering it from a backup.

The solution

Separate the content (the releases, each in its own immutable directory) from the pointer (a symbolic link saying which one is active).

/opt/tramontana/
├── releases/
│   ├── 3.1.0/          <- previous version, untouched
│   ├── 3.2.0/          <- previous version, untouched
│   └── 3.2.1/          <- new version
├── app -> releases/3.2.1     <- THE POINTER
└── shared/
    └── uploads/        <- data that survives deployments
flowchart TD
    S["Tramontana service<br/>reads /opt/tramontana/app"] --> L["app<br/>(symbolic link)"]
    L -.->|"points at"| R321["releases/3.2.1<br/>ACTIVE"]
    R320["releases/3.2.0<br/>in reserve"]
    R310["releases/3.1.0<br/>in reserve"]
    L -.->|"one ln -sfn<br/>and it points here"| R320

The complete deployment:

# 1. Upload the new version into its own directory. The service carries on
#    running on 3.2.1 and never notices a thing.
operator@srv-tramontana:~$ sudo mkdir -p /opt/tramontana/releases/3.3.0
operator@srv-tramontana:~$ sudo tar -xzf /srv/tramontana/outgoing/app-3.3.0.tar.gz \
    -C /opt/tramontana/releases/3.3.0 --strip-components=1

# 2. Verify the new version BEFORE activating it
operator@srv-tramontana:~$ cat /opt/tramontana/releases/3.3.0/version.txt
Tramontana Bookings 3.3.0
operator@srv-tramontana:~$ ls -l /opt/tramontana/releases/3.3.0/executable
-rwxr-xr-x 1 root root 51203584 Aug 18 14:40 executable

# 3. Put on record what was there before
operator@srv-tramontana:~$ readlink /opt/tramontana/app
releases/3.2.1

# 4. THE DEPLOYMENT: a single command
operator@srv-tramontana:~$ cd /opt/tramontana
operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.3.0 app

# 5. Verify
operator@srv-tramontana:/opt/tramontana$ ls -l app
lrwxrwxrwx 1 root root 16 Aug 18 14:41 app -> releases/3.3.0
operator@srv-tramontana:/opt/tramontana$ cat app/version.txt
Tramontana Bookings 3.3.0

# 6. Reload the service
operator@srv-tramontana:/opt/tramontana$ sudo systemctl restart tramontana

The rollback:

operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.2.1 app
operator@srv-tramontana:/opt/tramontana$ sudo systemctl restart tramontana

One command. That is the whole rollback. Nothing copied, nothing recovered from a backup, no maintenance window. And it can be run at three in the morning by somebody who is nervous, with no risk of getting it wrong.

Why ln -sfn and nothing else

The three letters matter:

  • -s symbolic, obviously.
  • -f forces the replacement. Without it, ln fails because app already exists.
  • -n is the critical one. Without -n, ln sees that app is a link to a directory, goes inside it and creates the link there:
# WITHOUT -n: the silent disaster
operator@srv-tramontana:/opt/tramontana$ sudo ln -sf releases/3.3.0 app
operator@srv-tramontana:/opt/tramontana$ ls -l app/
lrwxrwxrwx 1 root root 15 Aug 18 14:45 3.3.0 -> releases/3.3.0
executable  templates  version.txt

It has created releases/3.2.1/3.3.0 instead of changing app. The app link still points at the old version, the service is not updated, and on top of that you have dirtied the previous release's directory, which was supposed to be immutable. Always -n.

Why it is "atomic"

Replacing a symbolic link is done, underneath, with the rename() system call, which the kernel guarantees to be atomic: there is no instant at which app does not exist or points half-way. Any process opening /opt/tramontana/app at any moment will see either the complete old version or the complete new one, never an intermediate state.

Compare that with the naive alternative of rm app && ln -s releases/3.3.0 app: between the two commands there is a window, brief but real, in which app does not exist. A process trying to open a file just then would get an error. That is why the pattern is ln -sfn, in a single command.

The advantages of the pattern, summarised

Aspect Classic deployment Link-based pattern
Downtime However long the copy takes The service restart
Intermediate state Exists and is inconsistent Does not exist
Rolling back Another complete deployment One command
Previous version Overwritten Untouched on disk
Verifying before activating Impossible Yes, at your own pace
Disk space One copy N copies (rotation needed)

The trade-off is in the last row: you have to remove the old releases or /opt will grow without limit. The usual policy is to keep the last three or five, and you will write the script that does it in Module 4.

Another detail of the scheme: the shared/ directory. Data that must survive deployments — files uploaded by users, persistent caches — cannot live inside the release, because the next release would not have it. It goes in shared/ and each release links to it:

operator@srv-tramontana:~$ sudo ln -s ../../shared/uploads /opt/tramontana/releases/3.3.0/uploads

This pattern, which you are assembling here by hand, is exactly the one implemented by professional deployment tools (Capistrano popularised it, and the deployment structures of many PaaS platforms replicate it). You will automate it with a script in lesson 04-07 and put it into production in Module 8.

Common Mistakes and Tips

Believing a hard link is a copy. You modify one and you change both, because they are the same file.

Believing that deleting a hard link deletes the data. Only if it was the last name.

Running ln -sf without -n on a link to a directory. It creates the link inside instead of replacing it, silently.

Putting a trailing slash after a link in an rm -rf. It goes through the link and deletes the real target. The most expensive mistake in this lesson.

Copying trees containing links with cp -r. It turns the links into real copies and bloats the result. Use cp -a or rsync -a.

Using absolute links inside a tree that is going to be copied or moved. The copy ends up depending on the original. ln -sr.

Trusting ls -l to tell you whether a link works. It shows the broken ones exactly like the good ones. readlink -e or find -xtype l.

Tip: readlink -f before any destructive operation on a path that might contain links. It tells you where it really points.

Tip: use relative links within a single tree and absolute ones between different trees. It is the rule that avoids 90% of broken links.

Tip: run find /path -xtype l from time to time. Broken links are a symptom: something moved or was deleted and nobody updated what pointed at it.

Tip: document your structural links. A README in /opt/tramontana explaining the release pattern stops the next administrator deleting releases/ thinking it is surplus.

Exercises

Exercise 1: checking inode behaviour

In /tmp, without looking back at the theory:

  1. Create original.txt with three lines. Note its inode and its link count.
  2. Create a hard link hard.txt and a symbolic link soft.txt. Show all three with ls -li and explain each column.
  3. Add a line through hard.txt. What do original.txt and soft.txt see?
  4. Delete original.txt. What happens to each of the other two? Explain why in terms of inodes.
  5. Create a new original.txt with different content. What does soft.txt point at now? And hard.txt?

Exercise 2: setting up the deployment pattern

Set up the complete release structure on srv-tramontana:

  1. Create /opt/tramontana/releases/3.2.1 with the current contents of /opt/tramontana/app.
  2. Replace /opt/tramontana/app with a relative link to that release, without the content ceasing to be accessible at any verifiable moment.
  3. Simulate the deployment of a version 3.3.0 and activate it.
  4. Simulate it failing and go back to 3.2.1.
  5. Write a one-line check that verifies that the link points at a release that really exists.

Exercise 3: Luis's link

Luis has set up a shared working directory on his laptop and sends you this to replicate on the server:

cd /opt/tramontana
sudo rm app
sudo ln -s /home/luis/projects/tramontana/build app

Marta asks you whether she can approve it. Analyse the proposal, list the problems and propose the correct alternative with your reasoning.

Solutions

Solution 1

operator@srv-tramontana:~$ cd /tmp
operator@srv-tramontana:/tmp$ printf 'line 1\nline 2\nline 3\n' > original.txt
operator@srv-tramontana:/tmp$ ls -li original.txt
393221 -rw-rw-r-- 1 operator operator 21 Aug 18 15:02 original.txt

Inode 393221, count 1: a single name points there.

operator@srv-tramontana:/tmp$ ln original.txt hard.txt
operator@srv-tramontana:/tmp$ ln -s original.txt soft.txt
operator@srv-tramontana:/tmp$ ls -li original.txt hard.txt soft.txt
393221 -rw-rw-r-- 2 operator operator 21 Aug 18 15:02 hard.txt
393221 -rw-rw-r-- 2 operator operator 21 Aug 18 15:02 original.txt
393225 lrwxrwxrwx 1 operator operator 12 Aug 18 15:03 soft.txt -> original.txt

Reading it column by column:

  • hard.txt and original.txt share inode 393221 and both show a count of 2. They are two names for the same file, with no hierarchy between them.
  • soft.txt has its own inode (393225), type l, permissions lrwxrwxrwx (which mean nothing), size 12 — the characters of original.txt — and the arrow to the target.
operator@srv-tramontana:/tmp$ echo "line 4" >> hard.txt
operator@srv-tramontana:/tmp$ cat original.txt
line 1
line 2
line 3
line 4
operator@srv-tramontana:/tmp$ cat soft.txt
line 1
line 2
line 3
line 4

All three see the same thing. hard.txt and original.txt because they are the same inode; soft.txt because it resolves to original.txt, which still exists.

operator@srv-tramontana:/tmp$ rm original.txt
operator@srv-tramontana:/tmp$ ls -li hard.txt soft.txt
393221 -rw-rw-r-- 1 operator operator 28 Aug 18 15:05 hard.txt
393225 lrwxrwxrwx 1 operator operator 12 Aug 18 15:03 soft.txt -> original.txt

operator@srv-tramontana:/tmp$ cat hard.txt
line 1
line 2
line 3
line 4

operator@srv-tramontana:/tmp$ cat soft.txt
cat: soft.txt: No such file or directory

hard.txt works perfectly and its count has dropped from 2 to 1. rm removed a directory entry and decremented the count; since it did not reach 0, the inode and its data are still there.

soft.txt is broken. It contained the text original.txt, and that name no longer exists in the directory. The link held no reference to the inode, only a string.

operator@srv-tramontana:/tmp$ echo "completely different content" > original.txt
operator@srv-tramontana:/tmp$ ls -li original.txt hard.txt soft.txt
393221 -rw-rw-r-- 1 operator operator 28 Aug 18 15:05 hard.txt
393230 -rw-rw-r-- 1 operator operator 29 Aug 18 15:08 original.txt
393225 lrwxrwxrwx 1 operator operator 12 Aug 18 15:03 soft.txt -> original.txt

operator@srv-tramontana:/tmp$ cat soft.txt
completely different content

operator@srv-tramontana:/tmp$ cat hard.txt
line 1
...

This last step is the one that fixes the concept:

  • soft.txt has "repaired" itself and now points at the new file, which has a different inode (393230). A symbolic link is resolved by name, at the moment of use, so it follows whatever file occupies that name. That is its virtue and its danger: it can end up pointing at something with no relation at all to the original.
  • hard.txt still has the old content in inode 393221. It never depended on the name.

A one-sentence summary: the hard link is tied to the content; the symbolic one is tied to the name.

Solution 2

# 1. Create the release from the current content, preserving everything
operator@srv-tramontana:~$ sudo mkdir -p /opt/tramontana/releases
operator@srv-tramontana:~$ sudo rsync -a /opt/tramontana/app/ /opt/tramontana/releases/3.2.1/

operator@srv-tramontana:~$ sudo diff -rq /opt/tramontana/app /opt/tramontana/releases/3.2.1
operator@srv-tramontana:~$ echo $?
0

rsync -a with a trailing slash on the source copies the contents, not the folder. And diff -rq with no output confirms the copy is identical before you touch anything.

# 2. Replace the directory with the link
operator@srv-tramontana:~$ cd /opt/tramontana

#    Rename first (do not delete): if something goes wrong, the original is still there
operator@srv-tramontana:/opt/tramontana$ sudo mv app app.original-directory

#    Create the relative link
operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.2.1 app

#    Verify that the content is still reachable through the usual path
operator@srv-tramontana:/opt/tramontana$ ls -l app
lrwxrwxrwx 1 root root 16 Aug 18 15:20 app -> releases/3.2.1
operator@srv-tramontana:/opt/tramontana$ cat app/version.txt
Tramontana Bookings 3.2.1
operator@srv-tramontana:/opt/tramontana$ ls app/
executable  templates  version.txt

#    Only once everything is verified, remove the old directory
operator@srv-tramontana:/opt/tramontana$ sudo rm -rI app.original-directory

The important detail is mv instead of rm in the intermediate step. Renaming is instantaneous and reversible; deleting is not. If the link had gone wrong, an mv back would have restored everything.

# 3. Deploy 3.3.0
operator@srv-tramontana:/opt/tramontana$ sudo cp -a releases/3.2.1 releases/3.3.0
operator@srv-tramontana:/opt/tramontana$ echo "Tramontana Bookings 3.3.0" | sudo tee releases/3.3.0/version.txt
Tramontana Bookings 3.3.0

#    Verify BEFORE activating
operator@srv-tramontana:/opt/tramontana$ cat releases/3.3.0/version.txt
Tramontana Bookings 3.3.0

#    Note the current state in case you have to go back
operator@srv-tramontana:/opt/tramontana$ readlink app
releases/3.2.1

#    Activate
operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.3.0 app
operator@srv-tramontana:/opt/tramontana$ cat app/version.txt
Tramontana Bookings 3.3.0
# 4. Rollback
operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.2.1 app
operator@srv-tramontana:/opt/tramontana$ cat app/version.txt
Tramontana Bookings 3.2.1

One command and you are on the previous version. And releases/3.3.0 is still on disk, so retrying the deployment after fixing the problem is another single command.

# 5. Checking the link
operator@srv-tramontana:/opt/tramontana$ readlink -e app > /dev/null && echo "OK: $(readlink app)" || echo "FAILED: broken link"
OK: releases/3.2.1

The breakdown: readlink -e resolves the link and fails with a non-zero code if the target does not exist. With the && and || of lesson 02-01, the line reports in both cases. It is exactly the check that will go at the start of the deployment script in Module 4, so that the service is never started with the link pointing at nothing.

Solution 3

The problems with Luis's proposal, from most to least serious:

1. Production would depend on a developer's personal directory. The link points at /home/luis/projects/tramontana/build. That means the production application runs whatever code Luis has in his working folder at that instant. Every time Luis builds, production changes. Without review, without Marta's approval, without warning. It is exactly the scenario the deployment process exists to prevent.

2. That directory does not exist on srv-tramontana. /home/luis is a path on Luis's laptop. On the server, the link would be born broken:

operator@srv-tramontana:~$ ls -l /opt/tramontana/app
lrwxrwxrwx 1 root root 36 Aug 18 15:40 app -> /home/luis/projects/tramontana/build
operator@srv-tramontana:~$ readlink -e /opt/tramontana/app
operator@srv-tramontana:~$ echo $?
1

ls -l shows it quite happily, but the service would not start. And the error message would be No such file /opt/tramontana/app, when app does exist: it is the target that does not. Diagnosing that without knowing about links takes a good while.

3. sudo rm app destroys the original before you have anything. If the new link goes wrong — and it is going to go wrong — the application is no longer there. There is no way back. It should be mv, and you only delete once the new thing has been verified.

4. It breaks the FHS. Module 1 justified /opt/tramontana/app precisely because /home/user/project is the worst possible place: offboarding an employee brings the service down. Here the link reintroduces that dependency through the back door, with the aggravating factor that in ls -l /opt/tramontana everything looks fine.

5. A permissions and security problem. The production code would be under the control of an unprivileged user, who can modify it at any time without sudo. Anybody who compromises Luis's account controls the production application. Lesson 02-07 will give you the exact vocabulary to explain why this is serious.

6. There is no versioning and no way back. There is no way to know which version is deployed nor how to recover the previous one.

The correct alternative, which is the pattern from section 12:

# Luis produces a versioned package from his build environment
luis@laptop-luis:~$ tar -czf tramontana-app-3.3.0.tar.gz -C build .
luis@laptop-luis:~$ sha256sum tramontana-app-3.3.0.tar.gz > tramontana-app-3.3.0.tar.gz.sha256

# The operator receives it, verifies its integrity and deploys it as a release
operator@srv-tramontana:~$ sha256sum -c /srv/tramontana/outgoing/tramontana-app-3.3.0.tar.gz.sha256
tramontana-app-3.3.0.tar.gz: OK

operator@srv-tramontana:~$ sudo mkdir -p /opt/tramontana/releases/3.3.0
operator@srv-tramontana:~$ sudo tar -xzf /srv/tramontana/outgoing/tramontana-app-3.3.0.tar.gz \
    -C /opt/tramontana/releases/3.3.0

# Verify before activating
operator@srv-tramontana:~$ cat /opt/tramontana/releases/3.3.0/version.txt
Tramontana Bookings 3.3.0

# Activate with a single atomic command
operator@srv-tramontana:~$ cd /opt/tramontana && sudo ln -sfn releases/3.3.0 app
operator@srv-tramontana:/opt/tramontana$ readlink -e app && cat app/version.txt
/opt/tramontana/releases/3.3.0
Tramontana Bookings 3.3.0

The answer for Marta:

The proposal should not be approved as it stands. The underlying idea — being able to switch versions quickly — is a good one, and in fact it is the one we are going to implement; the problem is this particular execution.

As it is framed, the production application would start running Luis's laptop working directory directly. That means three things: that any build of his would change production instantly and without warning, that we would never know which version is running, and that if Luis leaves the company or changes laptop, the service stops working. On top of that, on the server that path does not even exist, so the change would take the application down immediately.

The alternative I propose achieves the same goal with guarantees: each version is installed in its own numbered directory on the server, and a pointer indicates which one is active. Deploying means changing the pointer, and going back to the previous version means the same, in both cases with a single command and without copying anything. The previous version stays untouched on the server, so the rollback is immediate and safe. And at any moment we can look up which version is in production and who approved it.

I will talk to Luis: what we need from him is the version package, not direct access from his machine.

Conclusion

You have dismantled the assumption you started the module with: a file is not a name.

  • An inode holds all the metadata and the block addresses, except the name. Directories are indexes of (name, inode) pairs.
  • That architecture explains why mv is instantaneous, why . shares an inode with its directory, why you can delete an open file and why df and du sometimes disagree.
  • A hard link is another name for the same inode. rm unlinks: the content lives on as long as the count has not reached zero and nobody has it open.
  • Hard links do not cross file systems because the inode number means nothing outside its own, and do not point at directories because they would allow cycles that would break recursive walks and space reclamation.
  • A symbolic link is a file whose content is a path. It crosses file systems, points at directories, can point at something that does not exist, and is resolved by name at the moment of use.
  • Relative within a single tree, absolute between different trees, and ln -sr works them out for you.
  • Broken links are not detected by ls: they are detected by readlink -e and find -xtype l.
  • Every command decides whether it follows the link or not, and the serious traps are cp without -a, which undoes links, and the trailing slash in an rm -rf, which goes through the link and deletes the real target.
  • Links are the structure of the system: /bin, /usr/bin/python3, /etc/alternatives and enabling a systemd service means creating a link.
  • And Tramontana now has its deployment pattern: immutable releases/ and an app -> releases/N that turns publishing and reverting into one atomic ln -sfn, with -n because without it the deployment fails silently.

One last piece of the module remains, and it is the one with the most consequences. You have seen that /etc/tramontana/app.conf is -rw-r----- and belongs to root:tramontana, that the executable is -rwxr-xr-x, and that the permissions of a symbolic link mean nothing. You have used sudo without ever quite asking yourself why it was needed. In the next lesson, File Permissions and Ownership, all of that stops being noise: you will read the ls -l string character by character, understand why x means different things on a file and on a directory, translate between octal and symbolic notation in both directions, work out what the umask does, recognise SUID, SGID and the sticky bit when you see them, and design the correct permissions for every Tramontana path. It is the lesson that separates a server that works from a server that is also secure.

Linux Course: From Beginner to System Administrator

Module 1: Introduction to Linux

Module 2: Basic Linux Commands

Module 3: Advanced Command-Line Skills

Module 4: Shell Scripting

Module 5: System Administration

Module 6: Networking and Security

Module 7: Advanced Topics

Module 8: Practical Projects

© Copyright 2026. All rights reserved