Manual Server Setup - Complete Hands-On Provisioning
Status: Active
Last Updated: 2026-08-14
Category: Infrastructure - Phase 1: Manual Infrastructure
Prerequisites: linux-fundamentals, ssh-basics
Time: 4-6 hours
Tags: provisioning, ubuntu-server, manual-setup, networking, systemd, fundamentals
Summary
Provision a production-style server completely by hand: install Ubuntu Server, configure networking, harden access, create users, install packages, and bring services up under systemd. This is deliberately manual โ every step you type here is a step you will later automate with scripts, Ansible, and Terraform, and understanding it is what makes that automation possible to reason about.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Install Ubuntu Server on a VM from ISO
- โ Configure static and DHCP networking manually
- โ Create and manage users, groups, and sudo access
- โ Install packages and manage repositories by hand
- โ Configure and enable services with systemd
- โ Set up SSH access and a firewall
- โ Document what you did as you go
- โ Feel every pain point that motivates Infrastructure as Code
๐ฅ๏ธ Why Do This Manually First?
The learning path for infrastructure automation is:
Manual Setup โ Scripts โ Ansible โ Terraform โ GitOps
(Understand) (Repeat) (Config) (Infra) (Production)
You cannot automate a process you don't understand. When your Ansible playbook fails at "configure netplan," you need to know what netplan does and what a correct file looks like โ because you've written one by hand.
What you'll build in this article:
| Step | Task | Time |
|---|---|---|
| 1 | Install Ubuntu Server 24.04 LTS on a test VM | 30-45 min |
| 2 | Verify and adjust network configuration | 20 min |
| 3 | Create admin user + lock down root SSH | 20 min |
| 4 | Update packages, add repositories | 15 min |
| 5 | Install Docker + a sample service | 30 min |
| 6 | Configure UFW firewall | 15 min |
| 7 | Write the runbook as you go | ongoing |
Lab requirements: A hypervisor (Proxmox, VirtualBox, VMware, or libvirt), 2 vCPU / 4GB RAM / 20GB disk for the VM, and the Ubuntu Server 24.04 LTS ISO.
๐ฟ Step 1: Install Ubuntu Server
Boot the Installer
Attach the ISO and boot the VM. The Ubuntu Server installer (Subiquity) walks you through:
- Language โ English
- Keyboard โ verify with the detect layout prompt
- Installation type โ choose Ubuntu Server (not minimized; you want man pages and common tools)
- Network โ note the interface name (usually
enp0s3,ens18on Proxmox, oreth0) and whether it got a DHCP lease - Proxy โ leave blank unless your network requires one
- Storage โ "Use entire disk" is fine for a lab; watch the partition summary before continuing
- Profile setup โ this creates your first user:
Your name: lab admin
Your server's name: web01
Pick a username: labadmin
Choose a password: ********
- SSH Setup โ โ check "Install OpenSSH server". You will almost always manage servers over SSH.
- Featured snaps โ skip everything; we'll install what we need explicitly
What Happens During Install
Behind the pretty TUI, Subiquity:
1. Partitions the disk (typically: EFI partition + boot + LVM root)
2. Copies the squashfs base system to disk
3. Writes your choices to /etc/netplan/, /etc/ssh/, user accounts
4. Installs grub to the boot device
5. Enables cloud-init (remember this โ it matters later)
Reboot when prompted and remove the ISO.
๐ Step 2: Network Configuration
Ubuntu Server 24.04 uses netplan, which renders configuration down to systemd-networkd (server) or NetworkManager (desktop).
Check Current State
# What interfaces exist and their state?
ip link show
What Happens: Lists all network interfaces with their MAC addresses and state (UP/DOWN). Loopback (lo) is always there; your NIC will have a name like ens18.
# What addresses are assigned?
ip addr show ens18
What Happens: Shows IPv4/IPv6 addresses. If DHCP worked during install, you'll see something like inet 192.168.1.50/24.
# What's the routing table?
ip route
What Happens: Shows default via 192.168.1.1 dev ens18 โ your gateway โ plus directly connected networks.
# Can we resolve names and reach the internet?
resolvectl status | head -20
ping -c 3 archive.ubuntu.com
Read the Netplan Config
The installer wrote a file for you:
cat /etc/netplan/50-cloud-init.yaml
Typical contents:
network:
version: 2
ethernets:
ens18:
dhcp4: true
โ ๏ธ Note the filename:
50-cloud-init.yaml. Cloud-init generated it. This becomes important when we automate VMs later โ cloud-init will regenerate these files unless told otherwise.
Set a Static IP Manually
For a server, you usually want a predictable address. Edit netplan config:
sudo nano /etc/netplan/01-static.yaml
network:
version: 2
ethernets:
ens18:
addresses:
- 192.168.1.10/24
routes:
- to: default
via: 192.168.1.1
nameservers:
addresses: [192.168.1.1, 1.1.1.1]
Apply it:
sudo netplan apply
ip addr show ens18 # verify new address
ping -c 3 1.1.1.1 # verify routing still works
What Happens: netplan apply renders your YAML into systemd-networkd config files under /run/systemd/network/ and restarts networking. If you're connected over SSH with a changed IP, your session drops โ reconnect to the new address.
๐ก Netplan gotcha: File permissions matter. Ubuntu 24.04 warns if netplan configs are world-readable when they contain sensitive data.
sudo chmod 600 /etc/netplan/*.yamlsilences it.
๐ค Step 3: User Management
Create an Admin User
If you skipped user creation in the installer, or want a second admin:
# Create user with home directory and bash shell
sudo adduser deploy
What Happens: Creates /home/deploy, copies skel files (.bashrc, .profile), prompts for a password, and creates group deploy with the same name.
Grant Sudo Access
# Add to the sudo group
sudo usermod -aG sudo deploy
# Verify
groups deploy
# deploy : deploy sudo
# Test it
su - deploy
sudo -v # should prompt for deploy's password and succeed
Passwordless Sudo (Optional, Lab Only)
For automation targets you often want NOPASSWD sudo:
sudo visudo -f /etc/sudoers.d/deploy
deploy ALL=(ALL) NOPASSWD:ALL
What Happens: Drop-in sudoers fragment. visudo -f syntax-checks before saving โ never edit sudoers with a plain editor, a typo can lock you out of root entirely.
Harden SSH: Disable Root Login and Password Auth
Edit the SSH daemon config:
sudo nano /etc/ssh/sshd_config.d/99-hardening.conf
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
Deploy your public key first:
# From YOUR workstation, not the server
ssh-copy-id deploy@192.168.1.10
What Happens: Appends your ~/.ssh/id_ed25519.pub to /home/deploy/.ssh/authorized_keys with correct permissions (.ssh = 700, authorized_keys = 600).
Then validate and restart:
sudo sshd -t # syntax check โ run this ALWAYS
sudo systemctl restart ssh
Test before closing your current session! Keep your existing SSH connection open while verifying login from a new terminal. If key auth fails, you still have the old session to fix things.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
Permission denied (publickey) after restart |
Wrong perms on .ssh or authorized_keys |
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys |
| Locked out entirely | Disabled password auth before deploying key | Use hypervisor console to revert config |
| Still prompted for password | Key not offered / wrong user | Run ssh -v deploy@host and read the negotiation |
๐ฆ Step 4: Packages and Repositories
Update Everything
sudo apt update
What Happens: Refreshes package indexes from configured repositories in /etc/apt/sources.list.d/. Downloads nothing but metadata.
sudo apt upgrade -y
What Happens: Installs available updates. On a fresh image this can take several minutes and may install a new kernel โ reboot afterwards if so (sudo reboot, then check uname -r).
Add a Third-Party Repository (Docker)
Docker isn't in Ubuntu's default repos at current versions. Add Docker's official repo:
# Install prerequisites
sudo apt install -y ca-certificates curl gnupg
# Trust Docker's signing key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
apt-cache policy docker-ce # confirm the repo is now an install source
What Happens: You've just done five discrete operations โ keyring setup, GPG dearmoring, permissions, source list entry, index refresh โ that any future automation must reproduce exactly. Count them. This single block is why people love Ansible.
Install Packages
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin \
htop jq unzip tree
What Happens: Resolves dependencies, downloads, unpacks, and runs maintainer scripts. Docker's post-install enables and starts docker.service automatically via systemd.
# Enable docker without sudo for your user (lab convenience)
sudo usermod -aG docker deploy
# Log out and back in for the group change to take effect
docker run hello-world
โ๏ธ Step 5: Service Setup with systemd
Inspect a Service
systemctl status docker
What Happens: Shows loaded state, enabled/disabled (boot behavior), active state (running/stopped), recent log lines, and the exact unit file path.
systemctl cat docker # read the unit file
journalctl -u docker -f # follow logs
Run a Sample Service: Nginx Container
Create a tiny service to manage:
mkdir -p ~/apps/web && cd ~/apps/web
cat > docker-compose.yml <<'EOF'
services:
web:
image: nginx:alpine
ports:
- "8080:80"
restart: unless-stopped
EOF
docker compose up -d
curl -s http://localhost:8080 | head -5
What Happens: Compose pulls the image, creates a container, publishes port 8080โ80 inside the container, and restart: unless-stopped makes Docker's own restart policy keep it alive across daemon restarts.
Make It a Boot-Persistent System Service
Docker itself starts at boot, but compose apps started by hand don't come back. Wrap it in a systemd unit:
sudo tee /etc/systemd/system/webapp.service > /dev/null <<'EOF'
[Unit]
Description=Sample nginx web app
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/deploy/apps/web
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload # pick up the new unit file
sudo systemctl enable --now webapp.service
systemctl status webapp.service
What Happens:
daemon-reloadre-scans unit directories โ required whenever you add/edit unitsenablecreates the symlink intomulti-user.target.wants(starts at boot)--nowalso starts it immediatelyType=oneshot+RemainAfterExit=yesfits compose: the command exits, but the service should show "active"
Verify persistence semantics:
sudo reboot
# After reboot:
systemctl status webapp docker
curl -s http://localhost:8080 >/dev/null && echo "web is UP"
๐ฅ Step 6: Firewall with UFW
UFW is a friendly frontend to nftables:
sudo ufw default deny incoming
sudo ufw default allow outgoing
# RULE ORDER MATTERS: allow SSH BEFORE enabling the firewall
sudo ufw allow 22/tcp comment 'SSH'
sudo ufw allow 8080/tcp comment 'web app'
sudo ufw enable
sudo ufw status verbose
What Happens: ufw enable starts filtering immediately. Without the SSH rule first, you'd cut off your own remote session โ the classic self-lockout.
What Happens (under the hood): UFW writes nftables rules; inspect them with sudo nft list ruleset | head -40 to see what "simple" actually generated.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
SSH dies on ufw enable |
No rule for 22/tcp | Always add SSH rule before enabling |
| Port unreachable externally despite rule | App binds to 127.0.0.1 only |
Check with ss -tlnp; bind to 0.0.0.0 |
| Docker published port ignores UFW | Docker manipulates iptables/nftables directly, bypassing UFW input rules | Restrict publish IPs (127.0.0.1:8080:80) or use DOCKER-USER chain |
ufw: command not found |
Not installed on minimal images | sudo apt install ufw |
โ ๏ธ Docker + UFW is a famous footgun: a container with
-p 8080:80is reachable from outside regardless of UFW rules, because Docker inserts NAT rules ahead of the input chain. Know this before you trust a firewall.
๐ Step 7: Document As You Go
While you work, capture what you did in SETUP-NOTES.md on your workstation:
# web01 setup notes โ 2026-08-14
- Ubuntu 24.04.1 LTS, entire-disk install, LVM default
- Static IP 192.168.1.10/24 via netplan (/etc/netplan/01-static.yaml)
- User: deploy (sudo), key-only SSH, root login disabled
- Config: /etc/ssh/sshd_config.d/99-hardening.conf
- Packages: docker-ce from download.docker.com repo (keyring /etc/apt/keyrings/docker.gpg)
- App: ~/apps/web (nginx:alpine on :8080)
- systemd wrapper: /etc/systemd/system/webapp.service (enable --now)
- Firewall: UFW, deny incoming / allow outgoing; 22, 8080/tcp
Gotchas hit:
- Had to chmod 600 netplan yaml (24.04 warning)
- Docker port bypasses UFW โ noted for prod
Every line here is a requirement for the automation you build next. When your script misses "chmod 600 the netplan file" because your notes didn't mention it, you'll learn why documentation-as-code exists โ which is exactly the next lesson.
โฑ๏ธ The Pain Points (Pay Attention Here)
Keep a tally while working. Typical counts for this exact walkthrough:
| Pain Point | Where You Felt It |
|---|---|
| ~60 individual commands | Whole process |
| 3 places order mattered (SSH key โ disable passwords, UFW rule โ enable, daemon-reload โ status) | Steps 3, 6, 5 |
| 2 things silently persisted differently than expected (cloud-init netplan, Docker vs UFW) | Steps 2, 6 |
| Zero reuse for server #2 | Everything |
| Drift begins the moment you finish | Ongoing |
Second server = repeating all of it, slightly wrong in different places. That divergence โ configuration drift โ is the disease the rest of this course cures.
โ Validation Checklist
Before moving on, confirm:
# Networking
ip -brief addr # expected IP present
resolvectl query google.com # DNS works
# Users & SSH
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication' # both "no"
ssh deploy@localhost echo ok # key auth works
# Services
systemctl is-active docker webapp
systemctl is-enabled docker webapp
# Firewall
sudo ufw status numbered
# Reboot survival
sudo reboot # then re-check all of the above
๐ Related
- Next: documentation-as-code โ turn those setup notes into real runbooks
- Then: why-infrastructure-as-code โ the case for automating everything you just did
- linux-fundamentals โ deeper Linux grounding
- cloud-init-basics โ how most of this gets automated at boot time
- ansible-basics โ where these steps become idempotent tasks
๐ Sources & Related
- Ubuntu Server documentation โ netplan, sshd, UFW guides
- Docker Docs โ repository setup for Ubuntu
- KB: kb/sysadmin/system-admin-basics