Package Management: apt, Repositories, Updates and Installing .NET and Node on a Server
On Ubuntu 24.04 you install .NET from Ubuntu's own feed and keep packages.microsoft.com out with an apt pin file; apt-cache policy shows where each package comes from and with which priority. We show the basics of apt, deb822 and Signed-By, the backports PPA, installing NodeSource without the script, apt-mark hold, unattended-upgrades, and needrestart restarting Bookstore.Api during a real upgrade.
Version note: The commands in this post were run on a test VM with Ubuntu Server 24.04.5 LTS (apt 2.8, needrestart 3.6, unattended-upgrades 2.9), with ASP.NET Core runtime 10.0.15 from the Ubuntu feed and Node.js 24.22 from NodeSource.
Guess First
On the server of Bookstore, the online bookstore used as the shared example across this site's series, 48 packages are waiting to be upgraded. From an SSH session, inside a script, you run this:
sudo apt-get -y upgrade
You did not touch Bookstore.Api's packages; you even put them on hold. Does anything happen to the application during the upgrade? The answer is in the Updates section, in the output of a real upgrade.
In this post we look at installing software on the server and keeping it up to date: the basic commands of apt, where a package comes from and with which priority, repositories, the deb822 format and signed-by keys, not mixing feeds when installing .NET and Node on a server, automatic security updates, and rebooting after a kernel update. If you have used winget or Chocolatey on Windows the logic will feel familiar; the difference is that on Linux almost every part of the system comes from this package manager.
The Basics of apt: update, upgrade, install, remove, purge
$ sudo apt update
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Hit:1 http://security.ubuntu.com/ubuntu noble-security InRelease
Hit:2 http://archive.ubuntu.com/ubuntu noble InRelease
Hit:3 http://archive.ubuntu.com/ubuntu noble-updates InRelease
Hit:4 http://archive.ubuntu.com/ubuntu noble-backports InRelease
Reading package lists...
Building dependency tree...
Reading state information...
48 packages can be upgraded. Run 'apt list --upgradable' to see them.
$ apt list --upgradable 2>/dev/null | head -n 4
Listing...
apt-utils/noble-updates,noble-security 2.8.4ubuntu0.1 amd64 [upgradable from: 2.8.3]
apt/noble-updates,noble-security 2.8.4ubuntu0.1 amd64 [upgradable from: 2.8.3]
bind9-dnsutils/noble-updates,noble-security 1:9.18.39-0ubuntu0.24.04.9 amd64 [upgradable from: 1:9.18.39-0ubuntu0.24.04.8]
update does not upgrade any package; it only downloads the package lists from the repositories and refreshes the information on "which versions exist". upgrade is what upgrades packages. The two almost always run back to back. The warning in the output matters too: apt is designed for humans and its output may change between versions. In scripts and automation use apt-get and apt-cache; that is why the commands I ran from scripts in the rest of this post use apt-get.
apt-get upgrade stays on the safe side: it installs no new packages and removes none. That is why it leaves behind updates that need new packages, such as a kernel update:
$ sudo apt-get upgrade -s | grep -A4 "kept back"
The following packages have been kept back:
linux-headers-generic linux-headers-virtual linux-image-virtual
linux-virtual
The following packages will be upgraded:
apt apt-utils bind9-dnsutils bind9-host bind9-libs ca-certificates
-s (simulate) shows what would happen without changing anything. To get the kernel packages on the "kept back" line you need apt-get full-upgrade (formerly dist-upgrade); it installs new packages and removes packages when needed, so do not confirm it without reading its output. I also saw a "deferred due to phasing" line in the upgrade output: Ubuntu rolls out some updates to a share of machines first (phased updates) and opens them to everyone if nothing goes wrong.
I saw the difference between remove and purge with a small FTP server package:
$ sudo apt-get remove -y -qq vsftpd >/dev/null; dpkg -l vsftpd | tail -n 1; ls /etc/vsftpd.conf
rc vsftpd 3.0.5-0ubuntu3.1 amd64 lightweight, efficient FTP server written for security
/etc/vsftpd.conf
$ sudo apt-get purge -y -qq vsftpd >/dev/null; dpkg -l vsftpd 2>&1 | tail -n 1; ls /etc/vsftpd.conf
dpkg-query: no packages found matching vsftpd
ls: cannot access '/etc/vsftpd.conf': No such file or directory
remove removes the program but leaves the configuration files in /etc; rc in dpkg -l means "removed, config files remain". If you reinstall the package it comes back with your old settings. purge deletes the config too. If you want to clean out a service, use purge; if you are removing it temporarily, remove. apt autoremove removes dependencies that no package needs any more.
Where a Package Comes From: apt-cache policy, dpkg -L and dpkg -S
$ apt-cache policy aspnetcore-runtime-10.0
aspnetcore-runtime-10.0:
Installed: 10.0.15-0ubuntu1~24.04.1
Candidate: 10.0.15-0ubuntu1~24.04.1
Version table:
*** 10.0.15-0ubuntu1~24.04.1 500
500 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 Packages
500 http://security.ubuntu.com/ubuntu noble-security/main amd64 Packages
100 /var/lib/dpkg/status
$ dpkg -S /usr/bin/dotnet /usr/sbin/nginx
dotnet-host-10.0: /usr/bin/dotnet
nginx: /usr/sbin/nginx
$ dpkg -L aspnetcore-runtime-10.0 | head -n 6
/.
/usr
/usr/lib
/usr/lib/dotnet
/usr/lib/dotnet/shared
/usr/lib/dotnet/shared/Microsoft.AspNetCore.App
apt-cache policy is the most important command in this post. Installed shows the installed version, Candidate the version that apt install would install, and the version table shows which repository each version comes from and with which priority. *** marks the installed one, 100 is the installed package's own record, 500 a normal repository. Among equal priorities apt picks the highest version; to change priorities there are the pin files we will see below. dpkg -S tells which package a file came from, dpkg -L which files a package installed. The answer to "where did this dotnet come from" is a single line.
The record of what was installed when is kept in /var/log/apt/history.log; old ones are compressed and rotated, so the zgrep from the previous post is useful here too: zgrep -h "^Commandline" /var/log/apt/history.log*.
Repositories: deb822, Signed-By and Keys
$ cat /etc/apt/sources.list | grep -v "^$"
# Ubuntu sources have moved to the /etc/apt/sources.list.d/ubuntu.sources
# file, which uses the deb822 format. Use deb822-formatted .sources files
# to manage package sources in the /etc/apt/sources.list.d/ directory.
# See the sources.list(5) manual page for details.
$ cat /etc/apt/sources.list.d/ubuntu.sources | grep -v "^#" | grep -v "^$"
Types: deb
URIs: http://archive.ubuntu.com/ubuntu
Suites: noble noble-updates noble-backports
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
Types: deb
URIs: http://security.ubuntu.com/ubuntu
Suites: noble-security
Components: main universe restricted multiverse
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
On Ubuntu 24.04 the sources live in .sources files in deb822 format instead of the old one-line sources.list. Each block is a repository: the address, the suites (noble the initial release, noble-updates fixes, noble-security security updates), the components and Signed-By. The last line is critical for security: the package list from this repository is only accepted if it is signed with this key. The old apt-key add made a key trusted for all repositories; apt-key is no longer used, and each third-party repository is tied to its own key file.
To see what happens, I deliberately pointed one repository's Signed-By at the wrong key:
$ sudo apt-get update 2>&1 | grep -iE "nodesource|NO_PUBKEY|not signed" | head -n 4
Hit:5 https://deb.nodesource.com/node_24.x nodistro InRelease
Err:5 https://deb.nodesource.com/node_24.x nodistro InRelease
The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 2F59B5F99B1BE0B4
W: An error occurred during the signature verification. The repository is not updated and the previous index files will be used. GPG error: https://deb.nodesource.com/node_24.x nodistro InRelease: The
apt refused to update that repository's list and kept using the old one. When you see NO_PUBKEY, the fix is to download the right key file and tie it to Signed-By; advice found online such as "add it with apt-key" or "write trusted=yes" turns signature checking off.
Installing .NET on a Server: Which Feed?
Bookstore.Api is published framework-dependent (the --self-contained false from the fifth post), so the server only needs the runtime; the SDK lives on the build machine and in CI. The ASP.NET Core runtime package also brings the .NET runtime:
$ dotnet --list-runtimes
Microsoft.AspNetCore.App 10.0.15 [/usr/lib/dotnet/shared/Microsoft.AspNetCore.App]
Microsoft.NETCore.App 10.0.15 [/usr/lib/dotnet/shared/Microsoft.NETCore.App]
On Ubuntu, .NET can come from three places, and Microsoft's install page gives this breakdown for Ubuntu 24.04: .NET 10 and 8 are in Ubuntu's own feed, older versions in Canonical's "dotnet/backports" PPA, and packages.microsoft.com has no .NET packages for 24.04. Our setup matches the first: the runtime comes from noble-updates and noble-security. Ubuntu's SDK is always in the 1xx feature band (dotnet-sdk-10.0 10.0.115); if you need a newer band you install the SDK in CI, not on the server.
For .NET 11 I looked at the backports PPA:
$ sudo add-apt-repository -y ppa:dotnet/backports 2>&1 | tail -n 2
More info: https://launchpad.net/~dotnet/+archive/ubuntu/backports
Adding repository.
$ apt-cache policy aspnetcore-runtime-9.0 aspnetcore-runtime-11.0 | head -n 12
aspnetcore-runtime-9.0:
Installed: (none)
Candidate: 9.0.23-0ubuntu1~24.04.1~ppa1
Version table:
9.0.23-0ubuntu1~24.04.1~ppa1 500
500 https://ppa.launchpadcontent.net/dotnet/backports/ubuntu noble/main amd64 Packages
aspnetcore-runtime-11.0:
Installed: (none)
Candidate: 11.0.0-0ubuntu1~24.04.1~ppa1
Version table:
11.0.0-0ubuntu1~24.04.1~ppa1 500
500 https://ppa.launchpadcontent.net/dotnet/backports/ubuntu noble/main amd64 Packages
Although Microsoft's table only lists older versions in backports, at the time of writing (14 November 2026) the PPA also had aspnetcore-runtime-11.0 11.0.0. This kind of information changes quickly; check it with apt-cache policy, not with the table. On 24.04, add-apt-repository creates a .sources file in deb822 format and embeds the key inside the file. Since Bookstore stays on .NET 10, I removed the PPA with add-apt-repository --remove.
The real danger is two feeds offering the same package in different builds. The Microsoft repository is often added for PowerShell and the SQL Server tools, and it may still be registered on servers upgraded from 22.04. Even without .NET in it, the name overlap is not completely gone:
$ apt-cache policy '?name(dotnet.*)' | grep -B5 packages.microsoft.com
dotnet-apphost-pack-6.0:
Installed: (none)
Candidate: 6.0.30-1
Version table:
6.0.30-1 500
500 https://packages.microsoft.com/ubuntu/24.04/prod noble/main amd64 Packages
When .NET packages from two feeds are mixed, you get errors where dotnet cannot find its own folder ("The folder [/usr/share/dotnet/host/fxr] does not exist" and the like); Microsoft's "package mix ups" page lists them. The recommended fix is an apt preferences file. We added it to the repository as deploy/linux/apt/preferences.d/dotnet-microsoft:
# /etc/apt/preferences.d/dotnet-microsoft
# .NET comes from the Ubuntu feed; never take it from packages.microsoft.com
Package: dotnet* aspnet* netstandard*
Pin: origin "packages.microsoft.com"
Pin-Priority: -10
$ apt-cache policy '?name(dotnet.*)' | grep -B5 packages.microsoft.com
dotnet-apphost-pack-6.0:
Installed: (none)
Candidate: (none)
Version table:
6.0.30-1 -10
500 https://packages.microsoft.com/ubuntu/24.04/prod noble/main amd64 Packages
A negative priority means "never install these packages from this source": the candidate is now (none). The file does nothing while the Microsoft repository is absent, and protects the server when someone adds it later.
Installing Node on a Server
Bookstore's Angular front end (bookstore-web) is built and served by Nginx as static files, so the server does not need Node; the build happens in CI. You would need it if you used Angular SSR. Ubuntu's own package is old:
$ apt-cache policy nodejs
nodejs:
Installed: (none)
Candidate: 18.19.1+dfsg-6ubuntu5
Version table:
18.19.1+dfsg-6ubuntu5 500
500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages
Angular 22 needs a current LTS. The common route is the NodeSource repository. Its setup pages suggest curl ... | sudo bash; I read the script and did what it does by hand, step by step: download the key, write the deb822 file.
$ curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/nodesource.gpg
$ cat /etc/apt/sources.list.d/nodesource.sources
Types: deb
URIs: https://deb.nodesource.com/node_24.x
Suites: nodistro
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/nodesource.gpg
$ sudo apt-get install -y -qq nodejs >/dev/null 2>&1; node --version; apt-cache policy nodejs | head -n 3
v24.22.3
nodejs:
Installed: 24.22.3-1nodesource1
Candidate: 24.22.3-1nodesource1
$ sudo apt-mark hold nodejs
nodejs set on hold.
apt-cache policy now showed two sources (NodeSource's 24.x versions and Ubuntu's 18.19); both have priority 500, so the higher version was picked. NodeSource's own script additionally gives its source priority 600 with a /etc/apt/preferences.d/nodejs file, so that the choice does not change even if Ubuntu later publishes a higher-numbered version. apt-mark hold freezes a package at its current version: upgrade does not touch it, and apt-mark showhold lists the frozen ones. Because the application also needs testing when Node's major version changes, a hold makes sense here. After the experiment I removed Node and the repository from the server.
Updates: unattended-upgrades, needrestart and Reboots
Here is the answer to the guess. I put Bookstore's .NET packages on hold and ran sudo apt-get -y upgrade from a script. The end of the output:
Running kernel seems to be up-to-date.
Restarting services...
/etc/needrestart/restart.d/systemd-manager
systemctl restart bookstore-api.service cron.service multipathd.service nginx.service polkit.service ssh.service systemd-journald.service systemd-networkd.service systemd-resolved.service systemd-timesyncd.service systemd-udevd.service udisks2.service
Service restarts being deferred:
systemctl restart ModemManager.service
/etc/needrestart/restart.d/dbus.service
systemctl restart getty@tty1.service
systemctl restart serial-getty@ttyS0.service
systemctl restart systemd-logind.service
systemctl restart unattended-upgrades.service
$ journalctl -u bookstore-api --since "-20min" -o short-iso --no-pager | grep -E "Stopping|Stopped|started" | tail -n 4
2026-11-14T16:26:25+00:00 bookstore-dev bookstore-api[6062]: Bookstore.Api[0] Stopped cleanly
2026-11-14T16:26:25+00:00 bookstore-dev systemd[1]: Stopped bookstore-api.service - Bookstore API (ASP.NET Core on Kestrel).
2026-11-14T16:26:32+00:00 bookstore-dev bookstore-api[13906]: Microsoft.Hosting.Lifetime[0] Application started. Hosting environment: Production; Content root path: /opt/bookstore/releases/2026-11-14-6
2026-11-14T16:26:32+00:00 bookstore-dev bookstore-api[13906]: Bookstore.Api[0] Bookstore.Api started, PID 13906
Bookstore.Api's package was not upgraded, yet the application was restarted, with a 7-second outage. The reason is needrestart: after an upgrade it finds which running processes still hold old libraries in memory (here the upgraded libc6) and restarts those services. When it runs without a terminal it does so on its own, without asking; Nginx and SSH are on the list too. Thanks to graceful shutdown (fourth post), in-flight requests completed, but doing this outside a deploy window because "I only upgraded packages" means an unexpected outage. If you do not want a particular service restarted automatically, add it to needrestart's $nrconf{override_rc} setting with a file under /etc/needrestart/conf.d/; then the restart is up to you.
Automatic security updates are on by default:
$ cat /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
$ systemctl list-timers "apt-daily*" "unattended*" --no-pager
NEXT LEFT LAST PASSED UNIT ACTIVATES
Sun 2026-11-15 00:37:40 UTC 8h Sat 2026-11-14 14:05:09 UTC - apt-daily.timer apt-daily.service
Sun 2026-11-15 06:32:57 UTC 14h Sat 2026-11-14 14:05:09 UTC - apt-daily-upgrade.timer apt-daily-upgrade.service
2 timers listed.
Pass --all to see loaded but inactive timers, too.
The timers from the fifth post show up here too: apt-daily updates the lists, apt-daily-upgrade runs unattended-upgrades. Allowed-Origins in /etc/apt/apt.conf.d/50unattended-upgrades contains noble and noble-security by default (plus ESM if available); since noble does not change after release, in practice only security updates are installed each day, after which needrestart restarts the services. Automatic-Reboot is off by default: even if the kernel is updated, the server does not restart on its own. The log of installed updates is under /var/log/unattended-upgrades/.
You can tell whether a reboot is needed from two places. After installing the new kernel with full-upgrade:
$ cat /var/run/reboot-required.pkgs
libc6
linux-image-6.8.0-149-generic
linux-base
$ sudo needrestart -b -k
NEEDRESTART-VER: 3.6
NEEDRESTART-KCUR: 6.8.0-147-generic
NEEDRESTART-KEXP: 6.8.0-149-generic
NEEDRESTART-KSTA: 3
$ sudo reboot
...
$ uname -r
6.8.0-149-generic
$ ls /var/run/reboot-required 2>&1
ls: cannot access '/var/run/reboot-required': No such file or directory
If the /var/run/reboot-required file exists, a reboot is needed, and .pkgs says which packages asked for it. In needrestart's -b (batch) output, KCUR is the running kernel and KEXP the expected one; KSTA: 3 means "a newer kernel is installed and waiting for a reboot" (it became 1 after the reboot). These lines are well suited to monitoring: Ubuntu's login message also says "System restart required" when you log in with SSH, but if nobody logs in, the information stays there.
Production Notes
Plan your updates. Leaving security updates to unattended-upgrades is the right default; but know that one of Bookstore's services may restart in the middle of the day, and make your health check tolerant enough not to treat that as a problem. Set a regular reboot window for kernel updates; with several servers, do them one by one behind the load balancer. Try other updates (minor versions of Nginx or the .NET runtime) on a test server first.
Build the server reproducibly, not by hand. Repository files, pins and the installed package list should live in a script or a configuration management tool; trying to remember the commands in this post on every new server means skipping a step one day. Even better is shipping the application as a container: then the runtime comes inside the image and on the server you only update the container runtime (the upcoming Docker series).
Do not treat apt-mark hold as a permanent solution. A held package does not get security updates either. If you put the .NET runtime on hold, write it down and follow Microsoft's monthly Patch Tuesday updates; after the experiment I released Bookstore's packages with apt-mark unhold.
Common Pitfalls
Running an unknown script with curl ... | sudo bash. You give it root without seeing what it does; if the connection drops halfway, half of the script may run. Download it to a file first, read it, then run it; or, as in the NodeSource example, do its two or three steps yourself.
Cluttering the system with PPAs. Each PPA can replace the packages Ubuntu tested with its own versions; on a server with several PPAs it becomes hard to find out why a package is at a given version, and release upgrades (thirteenth post) break. Before adding a PPA, check with apt-cache policy which packages it affects, and remove it when you are done.
Using apt in a script and getting stuck on a question. Use apt-get -y; some packages ask questions during installation, so in automation run with DEBIAN_FRONTEND=noninteractive. Otherwise the script waits forever on a question nobody can see.
Running install without update. If the package list is stale, apt tries to download a version that no longer exists on the server and you get a 404. Always apt-get update before installing.
Installing .NET from two feeds. If a server has both /usr/lib/dotnet and /usr/share/dotnet and the dotnet command gives strange errors, this is usually the reason. Check with apt-cache policy where each .NET package comes from and go back to a single source.
A Small Experiment
★☆☆ On your own server list the pending updates with apt list --upgradable and see which ones stay "kept back" with apt-get upgrade -s. Find the last five operations in /var/log/apt/history.log.
★★☆ Write a file under /etc/needrestart/conf.d/ that turns off needrestart's automatic restart for Bookstore.Api, and after a library update confirm in the needrestart -b output that the service is listed but not restarted.
★★★ On a clean VM, add the Microsoft repository, list with apt-cache policy '?name(dotnet.*)' which packages come from both sources without the pin file, then add the pin file and compare.
We have seen how to install software on the server and keep it up to date; everything we install also consumes disk, memory and CPU. In the next post we look at resource problems: df, du, free, vmstat, iostat, the OOM killer and the midnight "No space left on device" case.