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
- What an inode is
- Seeing the inode and the link count
- Hard links with
ln - The two limitations of hard links
- Symbolic links with
ln -s - Absolute versus relative
- Broken links
- Which commands follow the link and which do not
- Hard versus symbolic: the table
- Links in a real Linux system
update-alternatives- Tramontana's deployment pattern
- What an inode is
When you save a file, the file system creates three distinct things:
- The data blocks: the content itself, spread across the disk.
- 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.
- 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.
- 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.txtThe 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/appA count of 4, not 1. The four names pointing at that inode are:
- The
appentry inside/opt/tramontana. - The
.entry inside/opt/tramontana/app. - The
..entry inside/opt/tramontana/app/templates. - 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/vmlinuzThe device:inode pair does identify a file uniquely across the whole machine.
- Hard links with
ln
lnA 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.txtThree things to confirm in that output:
- The same inode (262149) on
accommodation.txtandhouses.txt. - The count went up to 2 on both: there are two names pointing there.
- The size is shown twice (418 and 418), but the file takes up 418 bytes in total, not 836.
lsshows 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;5And 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;5The 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.
- 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 linkThe 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 directoryNot 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:
The immediate consequences:
find,du,tar,rsyncand 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.
- Symbolic links with
ln -s
ln -sA 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.csvEverything 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.00And 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/appIt 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/appThere 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.
- 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.1Both 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:
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.1app-absstill 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-relpoints atreleases/3.2.1inside 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.1You 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.
- 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 directoryls -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.9find -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.
- 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.csvThe 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.txtA 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
- 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:
- Incremental backups with deduplication.
rsync --link-destcreates 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. - When you need the file to survive the deletion of the original name, without leaving a broken link behind.
- 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/sbinThis 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.12python3 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.basicTwo 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.serviceEnabling 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.gzThere are no links here, but it is worth knowing that some services keep a current.log as a link to the day's file.
update-alternatives
update-alternativesDebian 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:
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 30Changing 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 modeoperator@srv-tramontana:~$ ls -l /etc/alternatives/editor
lrwxrwxrwx 1 root root 18 Aug 18 14:20 /etc/alternatives/editor -> /usr/bin/vim.basicThe 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.
- 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 deploymentsflowchart 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 tramontanaThe rollback:
operator@srv-tramontana:/opt/tramontana$ sudo ln -sfn releases/3.2.1 app
operator@srv-tramontana:/opt/tramontana$ sudo systemctl restart tramontanaOne 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:
-ssymbolic, obviously.-fforces the replacement. Without it,lnfails becauseappalready exists.-nis the critical one. Without-n,lnsees thatappis 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.txtIt 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:
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:
- Create
original.txtwith three lines. Note its inode and its link count. - Create a hard link
hard.txtand a symbolic linksoft.txt. Show all three withls -liand explain each column. - Add a line through
hard.txt. What dooriginal.txtandsoft.txtsee? - Delete
original.txt. What happens to each of the other two? Explain why in terms of inodes. - Create a new
original.txtwith different content. What doessoft.txtpoint at now? Andhard.txt?
Exercise 2: setting up the deployment pattern
Set up the complete release structure on srv-tramontana:
- Create
/opt/tramontana/releases/3.2.1with the current contents of/opt/tramontana/app. - Replace
/opt/tramontana/appwith a relative link to that release, without the content ceasing to be accessible at any verifiable moment. - Simulate the deployment of a version 3.3.0 and activate it.
- Simulate it failing and go back to 3.2.1.
- 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:
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.txtInode 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.txtReading it column by column:
hard.txtandoriginal.txtshare inode 393221 and both show a count of 2. They are two names for the same file, with no hierarchy between them.soft.txthas its own inode (393225), typel, permissionslrwxrwxrwx(which mean nothing), size 12 — the characters oforiginal.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 4All 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 directoryhard.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.txthas "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.txtstill 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 $?
0rsync -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-directoryThe 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.1One 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.1The 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 $?
1ls -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.0The 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
mvis instantaneous, why.shares an inode with its directory, why you can delete an open file and whydfanddusometimes disagree. - A hard link is another name for the same inode.
rmunlinks: 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 -srworks them out for you. - Broken links are not detected by
ls: they are detected byreadlink -eandfind -xtype l. - Every command decides whether it follows the link or not, and the serious traps are
cpwithout-a, which undoes links, and the trailing slash in anrm -rf, which goes through the link and deletes the real target. - Links are the structure of the system:
/bin,/usr/bin/python3,/etc/alternativesand enabling a systemd service means creating a link. - And Tramontana now has its deployment pattern: immutable
releases/and anapp -> releases/Nthat turns publishing and reverting into one atomicln -sfn, with-nbecause 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
- What Is Linux?
- History of Linux
- Linux Distributions
- Installing Linux
- First Contact with the System
- The Linux File System Structure
Module 2: Basic Linux Commands
- Introduction to the Command Line
- Getting Help and System Documentation
- Navigating the File System
- File and Directory Operations
- Viewing and Editing Files
- Hard and Symbolic Links
- File Permissions and Ownership
Module 3: Advanced Command-Line Skills
- The Shell Environment: Variables, Aliases and History
- Using Wildcards and Regular Expressions
- Searching Files and Content: find, locate and grep
- Pipes and Redirection
- Text Processing: cut, sort, uniq, sed and awk
- Process Management
- Scheduling Tasks with Cron
- Networking Commands
Module 4: Shell Scripting
- Introduction to Shell Scripting
- Variables and Data Types
- Script Input, Output and Arguments
- Control Structures
- Functions and Libraries
- Debugging and Error Handling
- Production Scripts: Best Practices
Module 5: System Administration
- User and Group Management
- sudo and Special Permissions
- Package Management
- Disk Management
- systemd and Service Management
- System Logs: journald and syslog
- System Monitoring and Performance Tuning
- Backup and Restore
Module 6: Networking and Security
- Network Configuration
- SSH and Remote Access
- Firewalls and Perimeter Security
- Intrusion Detection Systems
- Secrets Management and TLS Certificates
- Securing Linux Systems
Module 7: Advanced Topics
- The Boot Process and System Recovery
- Advanced Diagnostics: strace, perf and eBPF
- Linux Kernel Tuning
- Virtualization with Linux
- Linux Containers and Docker
- Automation with Ansible
- High Availability and Load Balancing
