Bastion host a.k.a. jumphost. This is the entry point for the whole thing. If you want to get into any host, you have to go through the bastion. To get to a host, you need to get into the bastion first and then you can get to where you want to go. Thus you'll need two keys to get to the target. It also means the bastion has to be watertight.

Create container

The bastion is going to run in a small Alpine container within Proxmox. The only thing running here will be OpenSSH, so it's not going to need much in the way of resources. If I'm wrong, it can be changed later. The main guide followed was this one. Also this.
pct create 100 local:vztmpl/alpine-3.23-default_20260116_amd64.tar.xz \
    --hostname bastion \
    --memory 64 \
    --cores 1 \
    --rootfs local-lvm:1 \
    --net0 name=eth0,bridge=vmbr0,gw=192.168.1.1,ip=192.168.1.14/24 \
    --ostype alpine \
    --unprivileged 1
pct set 100 --onboot 1
pct start 100
pct enter 100
apk update
apk upgrade

Enable automatic updates

crontab -e
Add the line:
0    2    *    *    *    apk update && apk upgrade

Install SSH server

pct exec 100 -- apk add openssh
pct exec 100 -- rc-service sshd start

Disable root login

vi /etc/ssh/sshd_config
Change #PermitRootLogin prohibit-password to PermitRootLogin no

Create/configure admin user

From here.
setup-user -a admin # Creates a locked admin user
grep admin /etc/passwd
# admin:x:1000:1000::/home/admin:/bin/bash # Alpine uses ash not bash
apk add shadow
chsh -s /bin/ash admin
grep admin /etc/passwd
# admin:x:1000:1000::/home/admin:/bin/ash # Fixed!
passwd admin # Set a password and unlock user
su - admin
doas pwd
# doas: /etc/doas.d/20-wheel.conf is writable by group or other
exit
ls -l /etc/doas.d/20-wheel.conf 
# -rw-rw-r--    1 root     root            22 Jul  4 13:17 /etc/doas.d/20-wheel.conf
chmod 640 /etc/doas.d/20-wheel.conf 
ls -l /etc/doas.d/20-wheel.conf 
# -rw-r-----    1 root     root            22 Jul  4 13:17 /etc/doas.d/20-wheel.conf
su - admin
doas pwd
# doas (admin@bastion) password: 
# /home/admin

Configure ssh

Create key pair on dev machine ssh-keygen -t ed25519. Then on bastion:
mkdir -p /home/admin/.ssh
chmod 700 /home/admin/.ssh/
echo "ssh-ed25519 XXX damo@thinkpad" > /home/admin/.ssh/authorized_keys
chmod 600 /home/admin/.ssh/authorized_keys 
Swap out /etc/ssh/sshd_config as per the guide linked at the top.

Configure firewall

doas apk add ufw Follow config / rule creation as per linked guide. More info here.
doas ufw status verbose
# Status: active
# Logging: on (low)
# Default: deny (incoming), deny (outgoing), disabled (routed)
# New profiles: skip
# 
# To                         Action      From
# --                         ------      ----
# 22/tcp                     ALLOW IN    Anywhere                  
# 22/tcp (v6)                ALLOW IN    Anywhere (v6)             
# 
# 22/tcp                     ALLOW OUT   Anywhere                  
# 53/udp                     ALLOW OUT   Anywhere                  
# 80/tcp                     ALLOW OUT   Anywhere                  
# 443/tcp                    ALLOW OUT   Anywhere                  
# 123/udp                    ALLOW OUT   Anywhere                  
# 22/tcp (v6)                ALLOW OUT   Anywhere (v6)             
# 53/udp (v6)                ALLOW OUT   Anywhere (v6)             
# 80/tcp (v6)                ALLOW OUT   Anywhere (v6)             
# 443/tcp (v6)               ALLOW OUT   Anywhere (v6)             
# 123/udp (v6)               ALLOW OUT   Anywhere (v6) 
After this has been installed run the following commands:
doas ufw enable     # enable the firewall
doas rc-update add ufw    # add UFW init scripts

Configure fail2ban

doas apk add fail2ban Follow config / rule creation as per linked guide. EXCEPT The guide uses logpath = /var/log/auth.log. Alpine does not store logs here and instead uses /var/log/messaes. Thus, in /etc/fail2ban/jail.local, specify logpath = /var/log/messages. Useful link.
/ # rc-service fail2ban start
* Starting fail2ban ...
Server ready                                                                                                                                                              [ ok ]
/ # fail2ban-client status sshd
Status for the jail: sshd
|- Filter
|  |- Currently failed:	0
|  |- Total failed:	0
|  `- File list:	/var/log/messages
`- Actions
|- Currently banned:	0
|- Total banned:	0
`- Banned IP list:	
/ # rc-status
Runlevel: default
crond                   [  started  ]
networking              [  started  ]
Dynamic Runlevel: hotplugged
Dynamic Runlevel: needed/wanted
localmount              [  started  ]
Dynamic Runlevel: manual
sshd                    [  started  ]
fail2ban                [  started  ]
Once this has been installed etc., run the following: doas rc-update add fail2ban # start fail2ban server on boot

ProxyJump

On dev machine, edit ~/.ssh/config. Add in ProxyJump bastion to the config of each host on the proxmox server.

Lock down hosts

Again, following the guide.
iptables -A INPUT -p tcp --dport 22 -s 192.168.1.14 -j ACCEPT
iptables -A INPUT -p tcp --dport 22 -j DROP
Install iptables-persistent on proxmox host. This reloads iptables rules after a reboot. Rules are saved on install. If rules are modified after this, the command iptables-save must be used. Can also use netfilter-persistent save.

Problems

# Enable automatic security updates — this is non-negotiable for an internet-facing host

Different for Alpine apk-autoupdate is not available in latest Alpine version

Solution

Add a cronjob into the crontable:
/ # crontab -l
# do daily/weekly/monthly maintenance
# min	hour	day	month	weekday	command
*/15	*	*	*	*	run-parts /etc/periodic/15min
0	*	*	*	*	run-parts /etc/periodic/hourly
0	2	*	*	*	run-parts /etc/periodic/daily
0	3	*	*	6	run-parts /etc/periodic/weekly
0	5	1	*	*	run-parts /etc/periodic/monthly
0	2	*	*	*	apk update && apk upgrade

“Create a dedicated admin user and disable root login”

Alpine uses adduser instead of useradd. Edited summary of troubleshooting from duck.ai (GPT-5.4 nano)

“Configuring fail2ban”

/ # rc-service fail2ban start
* Starting fail2ban ...
2026-07-04 14:55:32,481 fail2ban                [1545]: ERROR   Failed during configuration: Have not found any log file for sshd jail
* start-stop-daemon: failed to start `/usr/bin/fail2ban-client'
* Failed to start fail2ban                                                                                                                                               [ !! ]
* ERROR: fail2ban failed to start

https://wiki.alpinelinux.org/wiki/Fail2ban

Solution

File jail.local in the guide specifies /var/log/auth.log for logpath. Alpine uses /var/log/messages.