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-1orplatform-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
deployuser 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
deployuser indockergroup
Shell and tools
- zsh + Oh My Zsh (agnoster theme, autosuggestions, syntax highlighting)
htop,ncdu,fzf,ripgrep,eza,bat,fd,jq,tree
Aliases
dcfordocker composedpsfor container statusllforeza -lacatforbat
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
| Tool | Purpose | Replaces |
|---|---|---|
| htop | Interactive process monitor (CPU, RAM) | top |
| ncdu | Visual disk usage analyzer | du -sh |
| fzf | Fuzzy finder for files, history, anything | manual grep |
| ripgrep | Fast text search in files | grep -r |
| eza | Modern ls with colors, git status, icons | ls |
| bat | cat with syntax highlighting | cat |
| fd | Fast file finder by name | find |
| jq | Parse and query JSON from the command line | manual parsing |
| tree | Directory structure as a tree | nested 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:
- GitHub Actions builds Docker images
- Images are pushed to GHCR (GitHub Container Registry)
- Actions SSHs into the server
- Server runs
docker compose pull+docker compose up -d
The server only needs:
- Docker (from the setup script)
docker-compose.yml+.envon 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.