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 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.
-
ListenAddress - IP of the container
-
AllowUsers - only the admin user Test
connection from dev machine
-
ssh admin@192.168.1.14 If successful,
add it to ~/.ssh/config on dev
machine. Once this has been installed etc., run the following:
doas rc-update add sshd # start SSH server on boot
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.
-
On proxmox host
AllowUsers has been
added to /etc/ssh/sshd_config. The
allowed user will be one that exists on the host, not the bastion.
-
Also,
iptables rules added as per
guide to accept only SSH from the bastion
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 -e to edit
crontab -l to show table
/ # 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)
-
Tightened permissions on
/etc/doas.d/20-wheel.conf to make it
no longer group/other-writable (chmod 640, later verified as -rw-r-----).
-
Discovered the initial config in
/etc/doas.conf was commented out and
ensured the active policy was present (placed the rule into
/etc/doas.d/20-wheel.conf, and also
briefly wrote it into /etc/doas.conf).
-
Ensured the
admin user could log in
with a valid shell (installed shadow,
then set admin’s shell using chsh).
-
Set the rule to allow no-password prompts for
admin by using:
permit nopass :wheel in
/etc/doas.d/20-wheel.conf.
-
Confirmed
admin is in the
wheel group and verified with
doas pwd
(success).
“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.