SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Linux symlinks: ln -s, readlink and ln -sfn

Learn how ln -s links work on Linux: absolute versus relative targets, readlink and realpath, and the ln -sfn and mv -T release switch for VPS deploys.

Verified Every command ran end-to-end on a fresh Ubuntu 24.04 server, October 14, 2026.

A Linux symlink, made with ln -s, is a small file that holds a path as text. When a program opens the link, the kernel reads that text and opens the file it names instead. The link holds no copy of the data. If the target goes away, the link stays behind and points at nothing. The symlink(7) man page calls that a dangling link.

This guide is a lab. You build every case in a scratch directory and look at the result yourself. Then you apply the same rules to a release switch on a VPS (virtual private server) and to the nginx sites-enabled directory. Every command runs as a normal user, except the chown and nginx steps, which need sudo.

ln makes hard links by default. You only get a symbolic link with -s (long form --symbolic). A hard link is a second name for the same file on disk, so there is no separate target that can go missing. Forget the -s and you get a hard link, which behaves very differently when you move or delete things.

ls -li prints each file's inode number in the first column. The inode is the number the filesystem uses to identify a file. Two names with the same inode number are one file. Hard links are the trick behind hard links in an arr stack folder layout, where a download and a library entry share one copy of the data. Everything below is about symbolic links.

Set up the lab

mkdir -p ~/symlink-lab
cd ~/symlink-lab
echo "hello" > target.txt
ln -s "$PWD/target.txt" abs-link
ln -s target.txt rel-link
ls -l abs-link rel-link

In ls -l output, the first character of the mode column is the file type. POSIX defines l there as a symbolic link. The two links hold different text: abs-link holds a full path that starts at /, and rel-link holds only target.txt.

The ln man page says a symlink "can hold arbitrary text". It also says that when the link is used, a relative link is interpreted in relation to its parent directory. The parent directory is the directory the link file sits in. It is not the directory you were in when you ran ln, and it is not the directory you are in when you use the link.

That one rule explains most broken links. Try it:

mkdir -p links
ln -s target.txt links/wrong
ln -sr target.txt links/right
readlink links/wrong
readlink links/right
cat links/right
cat links/wrong

links/wrong stores the text target.txt, so the kernel looks for links/target.txt. That file does not exist, so the last cat fails. The -r option (--relative) works only together with -s. The man page says it creates links "relative to link location". In other words, ln rewrites the path you typed so that it starts from the directory where the link will live. That is why links/right reaches the real file.

Now move the whole tree and use both links again:

cd ~
mv symlink-lab symlink-lab-moved
cat symlink-lab-moved/rel-link
cat symlink-lab-moved/abs-link
mv symlink-lab-moved symlink-lab
cd ~/symlink-lab

Work out which cat fails before you run it. abs-link still holds the old full path, and nothing lives at that path after the move. rel-link holds target.txt, which is resolved inside whatever directory the link sits in now, so it moves with the tree.

The practical rule: use relative links inside a tree that you will move or copy to another machine. Use absolute links when the target sits at a fixed system path that never moves, such as a file under /etc.

There are three tools, and they answer different questions:

ln -s rel-link chain
readlink chain
readlink -f chain
realpath chain
realpath --relative-to="$HOME" chain

Plain readlink prints the value stored in the link, exactly as written. A relative link prints a relative path. A link to another link prints only the next step of the chain.

readlink -f follows every symlink in every part of the path, recursively, and prints the canonical name. All parts except the last must exist. readlink -e needs every part to exist, and readlink -m needs none of them to exist.

realpath prints the resolved absolute file name. The readlink man page itself says realpath is the better command for this job. realpath --relative-to=DIR prints the result relative to a directory you choose, which is useful in scripts that build relative links by hand.

One trap in scripts: GNU readlink suppresses most error messages by default. Do not wait for an error message when you run it on a path that is not a link. Check the exit status instead:

readlink target.txt; echo "exit status: $?"
readlink rel-link; echo "exit status: $?"
stat -c '%A %a %n' rel-link target.txt
ls -l rel-link target.txt

Compare the mode string of the link with the mode string of the target. The symlink(7) man page says that on Linux, the permissions of an ordinary symbolic link "are not used in any operations". So the mode you see on the link tells you nothing about who can read the data. Read the target's mode instead. If you need a refresher on those strings, see how to read a Linux mode string like drwxr-xr-x.

chown follows symlinks by default. The man page names this behavior --dereference and marks it as the default. So chown user link changes the owner of the target, not the link. The -h option (--no-dereference) changes the link itself. This demo needs root, because only root can give a file to another user:

ls -l target.txt rel-link
sudo chown nobody rel-link
ls -l target.txt rel-link
sudo chown -h nobody rel-link
ls -l target.txt rel-link
sudo chown -h "$(id -un)" rel-link
sudo chown "$(id -un)" target.txt

After each chown, check which of the two lines changed. The last two commands give both files back to you.

If permissions on a link are ignored, why would its owner matter? symlink(7) describes the cases. One example is removing or renaming a link inside a sticky-bit directory such as /tmp. With chown -R, three more options decide whether the recursive walk enters linked directories: -H, -L and -P, where -P (never follow) is the default. The full rules are in the chown command and how it handles ownership.

Build a tiny app with two releases and a current link:

mkdir -p ~/symlink-lab/app/releases/v1 ~/symlink-lab/app/releases/v2
cd ~/symlink-lab/app
echo "v1" > releases/v1/version.txt
echo "v2" > releases/v2/version.txt
ln -s releases/v1 current
cat current/version.txt

Now try to point current at v2 the obvious way:

ln -sf releases/v2 current
readlink current
ls -la releases/v1

Check what readlink current prints, and read the listing of releases/v1 carefully. The ln man page lists a form ln TARGET... DIRECTORY, which creates links to each TARGET "in DIRECTORY". current is a link to a directory, so ln can treat it as that directory. If the listing shows a new v2 entry inside releases/v1, that form is what ran. That stray link holds the text releases/v2, resolved from inside releases/v1, so it is dangling as well. List any dangling links, then remove the stray one if it is there:

find . -xtype l
rm -f releases/v1/v2

Now add -n:

ln -sfn releases/v2 current
readlink current
cat current/version.txt

The man page defines -n (--no-dereference) as: "treat LINK_NAME as a normal file if it is a symbolic link to a directory". -f (--force) removes an existing destination, because by default the name of the new link must not already exist. Together, -sfn replaces the link named current instead of looking inside it. The related -T option treats LINK_NAME as a normal file always.

ln -sfn is fine at a prompt. For a deploy, a common pattern splits the switch into two steps, so that the final step is a single rename:

cd ~/symlink-lab/app
ln -sfn releases/v1 current.tmp
mv -T current.tmp current
readlink current
cat current/version.txt

Step one builds the new link under a temporary name, next to current. The -n matters here too. If a failed deploy left an old current.tmp that points at a directory, plain -sf could put the new link inside that directory. Step two renames the temporary link onto current. The mv man page says -T treats the destination as a normal file. So mv replaces current itself, instead of moving current.tmp into the directory that current points at.

Why you can call this switch atomic: mv renames the link, and the rename(2) man page gives a guarantee for that call. If the new path already exists, "it will be atomically replaced, so that there is no point at which another process attempting to access newpath will find it missing." The same page says that when the new path is a symlink, the link is overwritten, not followed. So the release directories are never touched.

The guarantee only holds if you respect the conditions in the rename(2) man page:

  • Keep current.tmp in the same directory as current. rename(2) fails with EXDEV when the two paths are not on the same mounted filesystem. The same directory means the same filesystem.
  • current must be a link, not a real directory. rename(2) fails with EISDIR when the new path is an existing directory and the old path is not. If an old deploy left a real directory there, move it aside once by hand.
  • Atomic means the name is never missing. A program that already resolved current keeps the files it opened from the old release until it looks up the path again. Reload or restart the service after the switch.
  • Keep the previous release directory. A rollback is the same two commands, pointed at the old directory.

A real deploy names each release by time, so every build gets its own directory:

cd ~/symlink-lab/app
release="releases/$(date +%Y%m%d%H%M%S)"
mkdir -p "$release"
echo "new build" > "$release/version.txt"
ln -sfn "$release" current.tmp
mv -T current.tmp current
cat current/version.txt

Point your web server or systemd unit at the path that ends in current, never at a single release directory. Then a deploy never needs a config edit.

Build a small tree with one relative link and one absolute link:

cd ~/symlink-lab
mkdir -p tree/data
echo "data" > tree/data/file.txt
ln -s data/file.txt tree/rel
ln -s "$PWD/tree/data/file.txt" tree/abs

Copy it four ways, then list the links in each copy:

sudo apt install -y rsync
cp -r tree copy-r
cp -a tree copy-a
rsync -a tree/ copy-rsync/
rsync -a --safe-links tree/ copy-safe/
find copy-r copy-a copy-rsync copy-safe -type l -ls

Here is what each man page promises, so you can check it against the output.

cp -a is the same as -dR --preserve=all, and -d means --no-dereference --preserve=links. So cp -a copies links as links. It copies the stored text unchanged, which means copy-a/abs still points into the original tree, not into the copy. The man page does not spell out what plain cp -r does with a link inside the tree, so compare copy-r with copy-a in the find output. If you want a fixed behavior, say so: -L always follows links, and -P never does.

rsync -a is equivalent to -rlptgoD. The -l in that list recreates each symlink's unchanged value on the receiving side. --safe-links tells rsync to ignore links that point outside the copied tree, and the man page adds that all absolute links are ignored too. Check whether abs exists in copy-safe. rsync -L turns each link into the file it points at instead. Note that -a does not include -H, so hard links are not preserved unless you add it.

For tar, compare two archives:

tar -cf tree.tar tree
tar -tvf tree.tar
tar -chf tree-h.tar tree
tar -tvf tree-h.tar

The man page defines -h (--dereference) as: follow symlinks and archive the files they point to. Read the mode column of the two listings the same way you read ls -l.

The lesson from all four tools is the same. A relative link that points inside the tree still works in any copy that keeps links. An absolute link keeps pointing at the original location. That is the practical argument for ln -sr in anything you back up or deploy by copying.

find ~/symlink-lab -type l
find ~/symlink-lab -xtype l

-type l matches every symlink. -xtype checks the type of the file that the link points at, which is the file -type does not check. The find man page adds that when a link is broken, -xtype behaves the same as -type, and for a link that type is l. So -xtype l lists the links whose target is missing. A link that points at itself is not in that list: find prints an error about it on stderr and exits with a nonzero status. Read the error output as well as the list. Do not add -L to this command, because it changes how find treats links.

Print the list first and read it. Delete only after you have checked it:

find ~/symlink-lab -xtype l -delete

Real-world example: nginx sites-enabled

The nginx docs describe one mechanism: the include directive "includes another file, or files matching the specified mask". The sites-available and sites-enabled layout does not come from nginx itself. It comes from Debian's nginx package, whose nginx.conf includes /etc/nginx/sites-enabled/* inside the http block, next to /etc/nginx/conf.d/*.conf. Check what your own package does:

sudo apt install -y nginx
grep -n 'include' /etc/nginx/nginx.conf
ls -l /etc/nginx/sites-enabled/

The idea is simple. Every site config lives in sites-available. A site is on when a link to its file sits in sites-enabled, and off when that link is gone. The config file itself never moves. Create a test site on port 8080:

sudo tee /etc/nginx/sites-available/lab.conf > /dev/null <<'EOF'
server {
    listen 8080;
    default_type text/plain;
    return 200 "symlink lab\n";
}
EOF
sudo ln -sr /etc/nginx/sites-available/lab.conf /etc/nginx/sites-enabled/lab.conf
readlink /etc/nginx/sites-enabled/lab.conf
sudo nginx -t
sudo nginx -s reload
curl -s http://127.0.0.1:8080/

nginx -t checks the configuration for correct syntax, and then tries to open the files the configuration refers to. Run it before every reload. The nginx docs say changes are not applied until you send the reload command or restart nginx, so a new link does nothing on its own.

To turn the site off, remove the link, test again, and reload:

sudo rm /etc/nginx/sites-enabled/lab.conf
sudo nginx -t
sudo nginx -s reload
ls -l /etc/nginx/sites-available/lab.conf

The last line shows that the config file is still there. A common leftover is the opposite case: someone deletes the file in sites-available and forgets the link. sudo find /etc/nginx/sites-enabled -xtype l finds those before they surprise you. For what goes inside a real site file, see an nginx reverse proxy config explained block by block.

When you are done with the lab, remove it with rm -r ~/symlink-lab and delete /etc/nginx/sites-available/lab.conf.

FAQ

What is the difference between ln and ln -s?

Plain ln creates a hard link, which is a second name for the same file on disk. ln -s creates a symbolic link, which is a small file that stores a path as text. A symlink can point at a directory or at a file on another filesystem, and it breaks when its target is removed. ls -li shows the difference: hard links share one inode number, while the first character of a symlink's mode is l.

The link probably holds an absolute path, and nothing exists at that path after the move. Run readlink on the link to see the stored text. A relative link is resolved from the directory the link sits in, so it keeps working when the whole tree moves together. Recreate the link with ln -sr target linkname and ln writes the relative path for you.

Use ln -sfn new-target linkname. Without -n, if the existing link points at a directory, ln can treat the link as that directory and create the new link inside it. For a live deploy, create the new link under a temporary name in the same directory, then run mv -T linkname.tmp linkname, which replaces the old link with one rename.

mv renames the temporary link onto the old one. The rename(2) man page says an existing new path is atomically replaced, so no other process ever finds the name missing. The guarantee needs both paths on the same filesystem, and the old name must be a link, not a real directory. Running programs keep files they already opened, so reload your service after the switch.

No. rm linkname removes only the link. The symlink(7) man page lists unlink among the system calls that act on the link itself and do not follow it. The target file stays where it was. Check with ls -l on the target after you remove the link.

#linux#symlinks#command-line#filesystem#deployment