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:

๐Ÿ–ฅ๏ธ 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:

  1. Language โ€” English
  2. Keyboard โ€” verify with the detect layout prompt
  3. Installation type โ€” choose Ubuntu Server (not minimized; you want man pages and common tools)
  4. Network โ€” note the interface name (usually enp0s3, ens18 on Proxmox, or eth0) and whether it got a DHCP lease
  5. Proxy โ€” leave blank unless your network requires one
  6. Storage โ€” "Use entire disk" is fine for a lab; watch the partition summary before continuing
  7. Profile setup โ€” this creates your first user:
Your name:                lab admin
Your server's name:       web01
Pick a username:          labadmin
Choose a password:        ********
  1. SSH Setup โ€” โœ… check "Install OpenSSH server". You will almost always manage servers over SSH.
  2. 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/*.yaml silences 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:

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:80 is 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

๐Ÿ“š Sources & Related

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse