← Back to posts
February 10, 2025
infrastructuredockerhetznerdevopsserver-setup

Phase 1: Server Setup and Where to Start

A step-by-step implementation guide for the Phase 1 foundation: Hetzner server, security hardening, Docker, and the tools that make server life easier.

This is a follow-up to One Server, One Repo, Multiple Full-Stack Apps. That article covers the architecture; this one covers Phase 1 implementation: getting the server up, securing it, and preparing it for Docker.

Phase 1

Hetzner server creation

When creating the server:

  • Type: Regular Performance, x86 (AMD)
  • Location: Falkenstein, Nuremberg, or Helsinki (latency to Italy is similar behind Cloudflare)
  • Networking: Public IPv4 + IPv6
  • SSH Keys: Add one before creating. Do not skip.
  • Volumes: Skip (built-in disk is enough)
  • Firewalls: Skip (we configure UFW on the server)
  • Backups: Skip (we use our own pg_dump strategy)
  • Cloud config: Leave empty
  • Name: platform-1 or platform-prod

SSH keys

On Ubuntu:

ssh-keygen -t ed25519 -f ~/.ssh/hetzner-platform

The -f flag lets you choose a path. Use a unique name if you already have other keys (e.g. hetzner-platform instead of overwriting hetzner).

Then copy the public key:

cat ~/.ssh/hetzner-platform.pub

Paste it into Hetzner → "+ Add SSH key" → save.

Connecting after creation

Once the server is up:

ssh -i ~/.ssh/hetzner-platform -o IdentitiesOnly=yes root@YOUR_SERVER_IP

If you have many keys in your ssh-agent, IdentitiesOnly=yes prevents "too many authentication failures."


Server setup script

Run a single script that does everything: security hardening, Docker, and quality-of-life tools.

Security

  • Create deploy user with sudo
  • SSH on port 2222, key-only, root login disabled
  • UFW firewall (ports 80, 443, 2222)
  • Fail2ban for SSH brute-force protection
  • Unattended security updates

Docker

  • Docker CE + Compose plugin
  • deploy user in docker group

Shell and tools

  • zsh + Oh My Zsh (agnoster theme, autosuggestions, syntax highlighting)
  • htop, ncdu, fzf, ripgrep, eza, bat, fd, jq, tree

Aliases

  • dc for docker compose
  • dps for container status
  • ll for eza -la
  • cat for bat

Run it on the server:

# From your local machine (port 22, before the script runs)
scp -i ~/.ssh/hetzner-platform -o IdentitiesOnly=yes server-setup.sh root@YOUR_SERVER_IP:/root/

# On the server
chmod +x /root/server-setup.sh
/root/server-setup.sh

Important: Before closing the root session, open a new terminal and test:

ssh -i ~/.ssh/hetzner-platform -o IdentitiesOnly=yes -p 2222 deploy@YOUR_SERVER_IP

If that works, you're set. If not, fix it from the still-open root session.


What those tools do

ToolPurposeReplaces
htopInteractive process monitor (CPU, RAM)top
ncduVisual disk usage analyzerdu -sh
fzfFuzzy finder for files, history, anythingmanual grep
ripgrepFast text search in filesgrep -r
ezaModern ls with colors, git status, iconsls
batcat with syntax highlightingcat
fdFast file finder by namefind
jqParse and query JSON from the command linemanual parsing
treeDirectory structure as a treenested ls

All lightweight, zero config. You'll use htop and ncdu the most for server management.


CI/CD: what the server needs

The server does not need the repo. It does not need git, git clone, or build tools.

Here's the flow:

  1. GitHub Actions builds Docker images
  2. Images are pushed to GHCR (GitHub Container Registry)
  3. Actions SSHs into the server
  4. Server runs docker compose pull + docker compose up -d

The server only needs:

  • Docker (from the setup script)
  • docker-compose.yml + .env on the server (deployed once, updated when they change)
  • GHCR login so it can pull private images

One-time step on the server as deploy:

echo "YOUR_GITHUB_TOKEN" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin

That's it. The server is a Docker runtime: it pulls pre-built images and runs them.


What is GHCR?

GitHub Container Registry is where Docker images are stored. Like Docker Hub, but built into GitHub.

Flow: GitHub Actions builds your image → pushes to ghcr.io/your-username/site-1-frontend:latest → server pulls and runs it.

It's free for public repos and included in GitHub paid plans for private repos. No extra signup.

Cost for private repos: Currently free. GitHub has said they'll give at least 30 days notice beforecharging. If they do, standard Packages rates are roughly $0.25/GB storage + $0.50/GB egress. For 5 small apps you'd be in the 2–5 GB range. Negligible.


Server setup script (full)

Save this as server-setup.sh and run it on the server:

#!/bin/bash
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive

# === CONFIG ===
SSH_PORT=2222
USERNAME=deploy

echo "=== 1. System update ==="
apt update && apt upgrade -y

echo "=== 2. Essential packages ==="
apt install -y \
  curl wget git htop ncdu tree jq unzip \
  zsh fzf bat ripgrep eza fd-find \
  ufw fail2ban \
  ca-certificates gnupg lsb-release \
  unattended-upgrades apt-listchanges

echo "=== 3. Create deploy user ==="
if ! id "$USERNAME" &>/dev/null; then
  adduser --disabled-password --gecos "" $USERNAME
  usermod -aG sudo $USERNAME
  echo "$USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$USERNAME
  mkdir -p /home/$USERNAME/.ssh
  cp ~/.ssh/authorized_keys /home/$USERNAME/.ssh/
  chown -R $USERNAME:$USERNAME /home/$USERNAME/.ssh
  chmod 700 /home/$USERNAME/.ssh
  chmod 600 /home/$USERNAME/.ssh/authorized_keys
fi

echo "=== 4. SSH hardening ==="
mkdir -p /etc/ssh/sshd_config.d
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
sed -i 's/^#* *Port 22 *$/Port '"$SSH_PORT"'/' /etc/ssh/sshd_config
grep -q "^Port $SSH_PORT" /etc/ssh/sshd_config || echo "Port $SSH_PORT" >> /etc/ssh/sshd_config
cat > /etc/ssh/sshd_config.d/hardened.conf <<EOF
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AuthenticationMethods publickey
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sshd -t && systemctl restart ssh
sleep 2
ss -tlnp | grep -q ":$SSH_PORT" || { echo "ERROR: SSH not listening on port $SSH_PORT"; ss -tlnp; exit 1; }

echo "=== 5. Firewall (UFW) ==="
ufw default deny incoming
ufw default allow outgoing
ufw allow $SSH_PORT/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable

echo "=== 6. Fail2ban ==="
cat > /etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3

[sshd]
enabled = true
port = $SSH_PORT
logpath = /var/log/auth.log
EOF
systemctl enable fail2ban
systemctl restart fail2ban

echo "=== 7. Unattended security updates ==="
cat > /etc/apt/apt.conf.d/20auto-upgrades <<EOF
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF

echo "=== 8. Docker ==="
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
usermod -aG docker $USERNAME

echo "=== 9. Zsh + Oh My Zsh for $USERNAME ==="
curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o /tmp/ohmyzsh-install.sh
chmod +x /tmp/ohmyzsh-install.sh
runuser -u $USERNAME -l -c "RUNZSH=no CHSH=no sh /tmp/ohmyzsh-install.sh"
rm -f /tmp/ohmyzsh-install.sh

sudo -u $USERNAME git clone https://github.com/zsh-users/zsh-autosuggestions /home/$USERNAME/.oh-my-zsh/custom/plugins/zsh-autosuggestions
sudo -u $USERNAME git clone https://github.com/zsh-users/zsh-syntax-highlighting /home/$USERNAME/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting

sudo -u $USERNAME cat > /home/$USERNAME/.zshrc <<'ZSHEOF'
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="agnoster"
plugins=(git docker docker-compose zsh-autosuggestions zsh-syntax-highlighting fzf sudo history)
source $ZSH/oh-my-zsh.sh

alias ll="eza -la --git --icons"
alias lt="eza -la --tree --level=2"
alias cat="batcat"
alias fd="fdfind"
alias dc="docker compose"
alias dps="docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
alias dlogs="docker compose logs -f"
alias dres="docker compose restart"
alias ports="ss -tulnp"

export PATH="$HOME/.local/bin:$PATH"
ZSHEOF
chown $USERNAME:$USERNAME /home/$USERNAME/.zshrc
chsh -s $(which zsh) $USERNAME

echo "=== 10. Zsh + Oh My Zsh for root ==="
curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -o /tmp/ohmyzsh-install.sh
chmod +x /tmp/ohmyzsh-install.sh
HOME=/root RUNZSH=no CHSH=no sh /tmp/ohmyzsh-install.sh
rm -f /tmp/ohmyzsh-install.sh
git clone https://github.com/zsh-users/zsh-autosuggestions ~/.oh-my-zsh/custom/plugins/zsh-autosuggestions
git clone https://github.com/zsh-users/zsh-syntax-highlighting ~/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting
cat > ~/.zshrc <<'ZSHEOF'
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="agnoster"
plugins=(git docker docker-compose zsh-autosuggestions zsh-syntax-highlighting fzf sudo history)
source $ZSH/oh-my-zsh.sh
alias ll="eza -la --git --icons"
alias lt="eza -la --tree --level=2"
alias cat="batcat"
alias fd="fdfind"
alias dc="docker compose"
alias dps="docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
alias dlogs="docker compose logs -f"
alias dres="docker compose restart"
alias ports="ss -tulnp"
export PATH="$HOME/.local/bin:$PATH"
ZSHEOF
chsh -s $(which zsh) root

echo ""
echo "============================================"
echo "  SETUP COMPLETE"
echo "============================================"
echo ""
echo "  SSH port: $SSH_PORT"
echo "  Deploy user: $USERNAME"
echo ""
echo "  IMPORTANT: Before closing this session,"
echo "  open a NEW terminal and test:"
echo ""
echo "    ssh -i ~/.ssh/hetzner-platform -o IdentitiesOnly=yes -p $SSH_PORT $USERNAME@YOUR_SERVER_IP"
echo ""
echo "  If it works, you're good. If not, root"
echo "  session is still open to fix it."
echo ""
echo "  If apt upgraded the kernel, reboot when"
echo "  convenient: reboot"
echo "============================================"

Note: If there's a pending kernel upgrade, reboot first, then run the script:

reboot
# Wait 30 seconds, then reconnect
ssh -i ~/.ssh/hetzner-platform -o IdentitiesOnly=yes root@YOUR_SERVER_IP
chmod +x /root/server-setup.sh
/root/server-setup.sh

Run the script as a file, not line by line. That ensures all steps run in order.


What's next

With Phase 1 done, you have a hardened server with Docker and a usable shell. Next: monorepo scaffold, Traefik, and the first site. I'll cover that in a future post.

Command Palette

Search for a command to run...