In Module 3 you learned to look at the network: ip a, ping, dig, ss -tulpn, curl -v and a layered methodology for working out why something does not answer. That was diagnosis, and diagnosis is reading. This lesson takes the next and considerably more committing step: writing. You are going to decide the address of srv-tramontana, its gateway, its DNS servers and its routes, and you are going to leave it written down in a way that survives a reboot.

Committing is exactly the right word: configuring the network of a machine you reach over the network is the one administration task where a one-letter mistake locks you out instantly and without warning. That is why, on top of netplan's syntax, this lesson teaches you the procedure: validate before applying, have an automatic rollback and never let go of the VM console. srv-tramontana is still running the configuration the installer left — pure DHCP — so its IP depends on a server you do not control. A bookings application whose service name points at 10.0.2.15 cannot afford that address changing on a Tuesday night.

Contents

  1. An operational refresher: IPv4, netmask, CIDR and private networks
  2. IPv6 today: link-local, SLAAC and why you do not disable it
  3. Ephemeral configuration with ip and why it is lost
  4. Netplan: the renderer model in Ubuntu 24.04
  5. Netplan's YAML, field by field
  6. netplan try and mode 600
  7. Name resolution: systemd-resolved and the 127.0.0.53 stub
  8. The machine's name: hostnamectl and /etc/hosts
  9. Interfaces: predictable names, aliases, VLANs, bonding and bridges
  10. Routes, metrics and ip route get
  11. Packet forwarding and NAT as concepts
  12. The Tramontana case: from DHCP to a static IP, verified

  1. An operational refresher: IPv4, netmask, CIDR and private networks

An IPv4 address is 32 bits in four octets. The netmask splits them in two: the network part (bits set to 1) and the host part (bits set to 0). CIDR notation writes the mask as the number of bits set to 1.

CIDR Netmask Usable hosts Typical use
/32 255.255.255.255 1 One specific IP (firewall rules)
/30 255.255.255.252 2 A point-to-point link between routers
/24 255.255.255.0 254 The classic LAN: your lab's
/16 255.255.0.0 65,534 A large network or a container range

Two addresses in every network cannot be assigned: the network address (all host bits at 0, 10.0.2.0) and the broadcast address (all at 1, 10.0.2.255). Hence the "−2".

The private networks of RFC 1918 are not routed on the Internet: 10.0.0.0/8 (companies, VirtualBox NAT, clouds), 172.16.0.0/12 (Docker uses 172.17.0.0/16) and 192.168.0.0/16 (home routers). The gateway is the router's address inside your own network, the one everything non-local is sent to: srv-tramontana sits at 10.0.2.15/24, talks directly to any 10.0.2.x and hands the rest over to 10.0.2.2.

For external IPs we always use RFC 5737's documentation ranges: 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24. Never use somebody else's real addresses in examples or in tests.

  1. IPv6 today: link-local, SLAAC and why you do not disable it

IPv6 stopped being "the future" more than a decade ago, and Ubuntu 24.04 ships it enabled.

  • Every operational interface has a link-local address in fe80::/10. It is not routed off the segment, but neighbour discovery (NDP, the equivalent of ARP) and router advertisements use it.
  • SLAAC (stateless autoconfiguration) lets a host build its own global address from the prefix the router advertises, with no DHCP. That is the norm in IPv6; DHCPv6 exists and coexists with it.
$ ip -6 addr show dev enp0s3
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP
    inet6 fe80::a00:27ff:fe4b:1c93/64 scope link

There is only a link-local address because VirtualBox's NAT network advertises no IPv6 prefix. Disabling IPv6 "just in case" is a bad idea, even though half the Internet recommends it: you do not reduce the attack surface, you reduce it badly. If a service listens on :: and you half-disable IPv6, you end up with firewall rules that do not cover a path which still exists. On top of that you break software that assumes ::1 — systemd-resolved included — with timeouts that are hard to diagnose. The correct stance is the same one as with IPv4: if it is enabled, protect it; that is why the firewall in 06-03 will use table inet, which covers both families. And if you genuinely do not need it on a segment, disable it explicitly in netplan (accept-ra: false, link-local: []), not with a half-finished patch.

  1. Ephemeral configuration with ip and why it is lost

The ip command does not only read: it also writes. And what it writes lives only in the kernel, in memory.

$ sudo ip addr add 10.0.2.99/24 dev enp0s3
$ ip -brief addr show dev enp0s3
enp0s3           UP    10.0.2.15/24 10.0.2.99/24 fe80::a00:27ff:fe4b:1c93/64
$ sudo ip route add 198.51.100.0/24 via 10.0.2.2 dev enp0s3
$ sudo ip link set enp0s3 mtu 1400
$ sudo ip addr del 10.0.2.99/24 dev enp0s3

Notice that ip addr add adds, it does not replace: an interface can hold several addresses at once. And all of this disappears on reboot, and also as soon as systemd-networkd reconfigures the interface after a netplan apply. No file is involved: the kernel's network state is volatile by design. That makes ip the perfect tool for testing a hypothesis and the worst possible one for configuring a server.

  1. Netplan: the renderer model in Ubuntu 24.04

Ubuntu unified network configuration in netplan: you write declarative YAML and netplan generates the configuration for whichever backend applies.

flowchart LR
    A["/etc/netplan/*.yaml"] --> B["netplan generate"]
    B --> C["/run/systemd/network/*"]
    B --> D["NetworkManager<br/>(desktop)"]
    C --> E["systemd-networkd<br/>(server)"]
    E --> F["Kernel:<br/>addresses, routes, DNS"]
    D --> F

The networkd renderer is the Ubuntu Server one — fixed machines, servers, cloud — and NetworkManager is the Ubuntu Desktop one, designed for laptops with wifi, roaming and user VPNs. On srv-tramontana it is networkd.

The files live in /etc/netplan/ with a .yaml extension, are read in alphabetical order and the later ones win over the earlier ones. That is why they are numbered:

$ ls -l /etc/netplan/
-rw------- 1 root root 116 Aug 18 09:14 50-cloud-init.yaml

That file was written by cloud-init during the installation and it may rewrite it on the next boot. The clean way to take control is to disable that part:

$ echo 'network: {config: disabled}' | sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg

  1. Netplan's YAML, field by field

network:
  version: 2
  renderer: networkd
  ethernets:
    enp0s3:
      dhcp4: false
      dhcp6: false
      addresses:
        - 10.0.2.15/24
      routes:
        - to: default
          via: 10.0.2.2
          metric: 100
      nameservers:
        addresses: [10.0.2.2, 192.0.2.53]
        search: [tramontana.example]
      mtu: 1500
      optional: false
Field What it does The detail that matters
version: 2 Format version Always 2; there is no other in use
renderer Backend networkd on a server
ethernets Device type Also wifis, bridges, bonds, vlans
enp0s3 The interface name It must match ip -brief link
dhcp4/dhcp6 DHCP client Set them to false explicitly with a fixed IP
addresses Addresses with CIDR 10.0.2.15 without /24 is a frequent mistake
routes Static routes to: default replaces the old gateway4
metric The route's cost The lowest wins among several default routes
nameservers DNS and search domains They are handed over to systemd-resolved
mtu Maximum transmission unit 1500 is standard; 1450 is typical inside a tunnel
optional: true Do not wait for the interface at boot Avoids a 2-minute wait if there is no cable

The YAML syntax that breaks everybody: indentation uses spaces, never tabs, and two spaces per level is the convention. A tab produces an error as cryptic as found character '\t' that cannot start any token. Three other classics: colons need a space after them (dhcp4: false, not dhcp4:false); lists are written with - or in square brackets, but not half and half; and yes/no are interpreted as booleans, so always use true/false.

netplan generate translates the YAML into systemd-networkd files under /run without activating them. If it fails, you have not touched the network:

$ sudo netplan generate && ls /run/systemd/network/
10-netplan-enp0s3.link  10-netplan-enp0s3.network

  1. netplan try and mode 600

This is the command that separates the people who have lost a server from the people who have not.

$ sudo netplan try
Do you want to keep these settings?
Press ENTER before the timeout to accept the new configuration
Changes will revert in 120 seconds
Configuration accepted.

netplan try applies the configuration, starts a 120-second countdown and waits for your ENTER. If you have broken the network and your SSH session is cut off, you cannot press anything: when the deadline runs out, netplan restores the previous configuration. Adjust the deadline with --timeout 300 if the change is a delicate one.

Command What it does Reversible
netplan generate Validates and generates files under /run It applies nothing
netplan try Applies with an automatic rollback Yes, automatically
netplan apply Applies, full stop No
netplan status --all The effective network and DNS state Read only

netplan try does not cover every case: it reverts properly if the process is still alive waiting for your ENTER, but not if the system hangs. That is why the complete rule is try plus the VM console open in another window; in VirtualBox that console does not go over the network.

Since 24.04, netplan warns if its files are readable by others: Permissions for /etc/netplan/50-cloud-init.yaml are too open. The reason is straightforward: a netplan file can contain a wifi network's password or 802.1X credentials in the clear, and with 644 any user could read them, intern included. Apply umask 027 and leave them at 600 root:root.

  1. Name resolution: systemd-resolved and the 127.0.0.53 stub

$ ls -l /etc/resolv.conf
lrwxrwxrwx 1 root root 39 Aug 18 09:14 /etc/resolv.conf -> ../run/systemd/resolve/stub-resolv.conf
$ cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search tramontana.example

/etc/resolv.conf is a symbolic link to a generated file, and the only server that appears is 127.0.0.53: systemd-resolved's local stub. Applications ask the stub and the stub forwards to the real DNS servers netplan handed it. Editing it by hand achieves nothing: it is regenerated on the next boot or netplan apply.

$ resolvectl status
Link 2 (enp0s3)
    Current Scopes: DNS
         Protocols: +DefaultRoute -LLMNR -mDNS -DNSOverTLS
Current DNS Server: 10.0.2.2
       DNS Servers: 10.0.2.2 192.0.2.53
        DNS Domain: tramontana.example
$ resolvectl query bookings.tramontana.example
bookings.tramontana.example: 10.0.2.15         -- link: enp0s3
-- Information acquired via protocol DNS in 1.2ms.

Three things matter in that output:

  • DNS is configured per interface. A machine with two interfaces can have different DNS servers on each one, and +DefaultRoute marks which one is used for anything that does not match a specific domain.
  • DNS Domain is the search domain: with it, resolvectl query bookings completes to bookings.tramontana.example.
  • resolvectl takes /etc/hosts into account; dig does not, because it talks straight to the server. That discrepancy explains 90% of the "but dig resolves it fine" cases. And resolvectl flush-caches empties the cache when you have just changed a record and are still seeing the old one.

  1. The machine's name: hostnamectl and /etc/hosts

$ hostnamectl
 Static hostname: srv-tramontana
         Chassis: vm
   Virtualization: oracle
Operating System: Ubuntu 24.04.1 LTS
          Kernel: Linux 6.8.0-41-generic
$ sudo hostnamectl set-hostname srv-tramontana

There are three names: the static one (the one in /etc/hostname, the one that persists), the transient one (the one DHCP can impose) and the pretty one (--pretty, free text). /etc/hosts has to be consistent, because many programs resolve their own name at start-up:

127.0.0.1       localhost
127.0.1.1       srv-tramontana
10.0.2.15       srv-tramontana bookings.tramontana.example
::1             localhost ip6-localhost ip6-loopback

The 127.0.1.1 line is a Debian/Ubuntu convention so that the name resolves even with no network.

  1. Interfaces: predictable names, aliases, VLANs, bonding and bridges

enp0s3 is not arbitrary: it is a predictable name generated by systemd from the device's physical position — en (ethernet) + p0 (PCI bus 0) + s3 (slot 3). The old scheme (eth0, eth1) numbered interfaces by the kernel's detection order, which could change between boots and leave your management cable on the wrong interface.

An IP alias is simply a second address on the same interface: it is enough to add it to addresses. It is useful for migrating a service from one IP to another without a cut. And this is the general view of the virtual interfaces you will meet as soon as you leave the lab, without building them here:

Type What it is for Netplan key When you need it
VLAN Separating logical networks over one cable (802.1Q) vlans: with id and link A switch delivers several networks over a trunk port
Bonding Aggregating several NICs: redundancy or more bandwidth bonds: with interfaces and parameters.mode A physical server with two cards and two switches
Bridge A software switch in the same broadcast domain bridges: with interfaces VMs or containers on the physical LAN
Tunnel / VPN A private network over a public one tunnels: Joining sites or remote working → project 08-04

The VPN appears here only as a networking concept: the complete WireGuard server is built in project 08-04.

  1. Routes, metrics and ip route get

$ ip route
default via 10.0.2.2 dev enp0s3 proto static metric 100
10.0.2.0/24 dev enp0s3 proto kernel scope link src 10.0.2.15

Two entries: the default route and the network's own link route, which the kernel creates by itself when the address is assigned. The kernel always picks the most specific route (the longest prefix) and, where those tie, the one with the lowest metric. A persistent static route is added to the YAML's routes block with to: 198.51.100.0/24 and via: 10.0.2.9.

The golden tool for ending an argument is to ask the kernel what it will do with one specific packet, without sending anything:

$ ip route get 198.51.100.7
198.51.100.7 via 10.0.2.9 dev enp0s3 src 10.0.2.15 uid 1000
$ ip route get 10.0.2.77
10.0.2.77 dev enp0s3 src 10.0.2.15 uid 1000

The first one leaves via router 10.0.2.9 following the static route; the second is local and goes through no router at all. When somebody claims "the traffic isn't going the way it should", ip route get closes the conversation in a second.

  1. Packet forwarding and NAT as concepts

By default, Linux does not forward packets between interfaces: if one arrives that is not addressed to it, it drops it. A kernel parameter controls this, and enabling it turns the machine into a router:

$ sysctl net.ipv4.ip_forward
net.ipv4.ip_forward = 0
$ echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-forward.conf
$ sudo sysctl --system

It is made persistent under /etc/sysctl.d/, never by editing /etc/sysctl.conf by hand. NAT is what your home router does and what VirtualBox does with the 10.0.2.0/24 network: it rewrites the source address of outgoing packets so that they all appear to come from a single IP, and it keeps a table to return the replies. NAT is not security, however much it looks like it: it is a workaround for the shortage of IPv4 which, as a bonus, means incoming connections reach nobody by default.

srv-tramontana needs neither forwarding nor NAT: it is an end server, and leaving ip_forward = 0 is the right stance. You will enable it with the VPN in project 08-04. Filtering and NAT rules are written with ufw/nftables, and that is lesson 06-03.

  1. The Tramontana case: from DHCP to a static IP, verified

Step 0 — measure first and take a copy, the course's convention:

$ { ip -brief a; echo; ip r; echo; resolvectl status; echo; ss -tulpn; } \
    | sudo tee /root/network-reference.txt > /dev/null
$ sudo cp /etc/netplan/50-cloud-init.yaml /etc/netplan/50-cloud-init.yaml.bak-$(date +%F)

Step 1 — open the VM console in VirtualBox and leave a second SSH session open. Never close the door you are coming in through.

Step 2 — write the configuration:

$ sudo install -m 600 -o root -g root /dev/null /etc/netplan/60-tramontana.yaml
$ sudo tee /etc/netplan/60-tramontana.yaml > /dev/null <<'YAML'
network:
  version: 2
  renderer: networkd
  ethernets:
    enp0s3:
      dhcp4: false
      dhcp6: false
      accept-ra: false
      addresses:
        - 10.0.2.15/24
      routes:
        - to: default
          via: 10.0.2.2
          metric: 100
      nameservers:
        addresses: [10.0.2.2]
        search: [tramontana.example]
      optional: false
YAML
$ sudo mv /etc/netplan/50-cloud-init.yaml /root/50-cloud-init.yaml.retired

The cloud-init file is moved out of /etc/netplan/: renaming it inside the directory is not enough, because netplan reads any .yaml.

Step 3 — validate without applying: sudo netplan generate and check that it returns 0.

Step 4 — apply with a safety net: sudo netplan try. Before pressing ENTER, check from the other session that you still have access and that the application answers. Only then do you accept.

Step 5 — measure afterwards and compare:

$ { ip -brief a; echo; ip r; echo; resolvectl status; echo; ss -tulpn; } > /tmp/network-current.txt
$ sudo diff -u /root/network-reference.txt /tmp/network-current.txt
-default via 10.0.2.2 dev enp0s3 proto dhcp src 10.0.2.15 metric 100
+default via 10.0.2.2 dev enp0s3 proto static metric 100
-       DNS Domain: ~.
+       DNS Domain: tramontana.example

The only thing that has changed is what we wanted: the route goes from proto dhcp to proto static and the search domain appears. The IP is the same and the services listen just as before. If the diff showed anything else, you would have a problem to investigate now, not in three weeks' time.

Step 6 — the acid test, rebooting:

$ sudo reboot
$ ssh [email protected] 'ip -brief a show enp0s3; curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:8080/health'
enp0s3           UP    10.0.2.15/24 fe80::a00:27ff:fe4b:1c93/64
200

A network configuration is not finished until it survives a reboot. Document the change in /opt/tramontana/HISTORY, like everything else.

Common Mistakes and Tips

  • Tabs in the YAML. It is, by a distance, mistake number one. Configure your editor so that it inserts spaces in .yaml files (:set expandtab in vim) and always run netplan generate before applying.
  • Forgetting the CIDR in addresses. - 10.0.2.15 without /24 gives an unclear validation error. The address always carries its mask.
  • Using netplan apply on a remote machine. If you get it wrong, the only way back is the physical or hypervisor console. netplan try costs the same and forgives.
  • Editing /etc/resolv.conf. It is a link to a generated file: your change lasts until the next boot. DNS is changed in netplan and checked with resolvectl status.
  • Leaving the cloud-init file active. It may rewrite your configuration at boot; disable it with 99-disable-network-config.cfg.
  • Assigning a static IP inside the DHCP range. Sooner or later the DHCP server hands it to somebody else and you have an intermittent, maddening conflict. Ask for a reservation or an excluded range.
  • Tip: keep /etc/netplan/ in git alongside your scripts. It is production configuration and it deserves a history, just like the systemd units.

Security and compliance warning: an addressing change on a server that processes guests' personal data affects firewall rules, access control lists and audit records. In a real environment it is planned with a maintenance window, communicated to the people responsible and reviewed by the security officer. Never reconfigure or scan networks that are not yours: everything here is done on srv-tramontana, your own VM.

Exercises

  1. Double-checking before applying. Write the exact sequence of commands to change srv-tramontana's DNS from 10.0.2.2 to 192.0.2.53, from an SSH session, without risking the loss of name resolution. Include the subsequent verification.
  2. A second address and a static route. Add the address 10.0.2.16/24 and a route towards 198.51.100.0/24 through 10.0.2.9 to enp0s3, persistently. Then prove, without generating any real traffic, that a packet to 198.51.100.7 will leave by that route and not by the default one.
  3. Diagnosing a broken YAML. When you apply your configuration you get Error in network definition: enp0s3: unknown key 'gateway4'. Explain what has happened, fix it and say why netplan has not left the machine without a network.

Solutions

1. A DNS server that does not answer leaves the machine "working and mute", so it is handled with the same procedure as the IP:

$ sudo cp /etc/netplan/60-tramontana.yaml /root/60-tramontana.yaml.bak-$(date +%F)
$ sudo sed -i 's/addresses: \[10\.0\.2\.2\]/addresses: [192.0.2.53]/' /etc/netplan/60-tramontana.yaml
$ sudo diff -u /root/60-tramontana.yaml.bak-$(date +%F) /etc/netplan/60-tramontana.yaml
-        addresses: [10.0.2.2]
+        addresses: [192.0.2.53]
$ sudo netplan generate && sudo netplan try

Verification from the safety session, before pressing ENTER:

$ resolvectl status | grep 'Current DNS'
Current DNS Server: 192.0.2.53
$ resolvectl flush-caches && resolvectl query bookings.tramontana.example
bookings.tramontana.example: 10.0.2.15         -- link: enp0s3

Two details: the copy goes to /root and not to /etc/netplan/, because any .yaml file in that directory is read (with .bak-date it would not be, but it is better not to depend on that); and if the new DNS server did not answer, netplan try reverts on its own after 120 seconds.

2. The address is added to the list and the route to the routes block:

      addresses:
        - 10.0.2.15/24
        - 10.0.2.16/24
      routes:
        - to: default
          via: 10.0.2.2
          metric: 100
        - to: 198.51.100.0/24
          via: 10.0.2.9
$ sudo netplan generate && sudo netplan try
$ ip -brief a show enp0s3
enp0s3           UP    10.0.2.15/24 10.0.2.16/24 fe80::a00:27ff:fe4b:1c93/64
$ ip route get 198.51.100.7
198.51.100.7 via 10.0.2.9 dev enp0s3 src 10.0.2.15 uid 1000

ip route get interrogates the kernel's routing table without sending a single packet: it is exactly the demonstration the exercise asked for. Had it returned via 10.0.2.2, the static route would not have been applied.

3. gateway4 was the classic key for the gateway and it has been removed in the netplan versions shipped with Ubuntu 24.04. Its replacement is an entry in routes:

      routes:
        - to: default
          via: 10.0.2.2

The change was made because gateway4 allowed only one gateway and supported neither metrics nor alternative tables; routes covers every case with the same syntax. And the machine has not been left without a network because netplan's parser caught the error in the generate phase, before touching anything: the configuration only reaches the kernel if the YAML is valid in its entirety. That is exactly why netplan generate always comes before netplan try: it separates syntax errors (cheap) from content errors (expensive).

Conclusion

srv-tramontana no longer depends on a DHCP server for the address its own service name advertises. You have left the network written, versioned and verified: a netplan file with mode 600, static IP 10.0.2.15/24, gateway 10.0.2.2, DNS servers and a search domain declared, resolution through systemd-resolved and a procedure — measure, generate, try, compare with diff -u, reboot — that you can repeat on any machine without breaking a sweat. Along the way you have seen why ip is for testing and not for configuring, why IPv6 is protected instead of disabled, and how to ask the kernel which way a specific packet will go.

But look at what you have just done: you have fixed a stable, well-known address for a server that accepts SSH connections with passwords, allows root logins and has already racked up 47 failed attempts from 203.0.113.44 in /var/log/btmp. You have put a permanent letterbox on a door with no lock. In the next lesson, 06-02: SSH and Remote Access, you fix precisely that: you will understand the protocol's three phases and what the fingerprint you are asked to accept the first time really means, you will generate ed25519 keys, you will banish password authentication, you will harden sshd_config directive by directive without locking yourself out of the machine, you will build tunnels to reach Tramontana's database without exposing it, and you will read with lastb who has been knocking on your door 47 times.

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