rationale Link to heading
- docker/podman portable for easy migrations
- wireguard full tunnel with unbound dns for browsing clients
- webui for wireguard to add/remove clients easily
- minimize server exposition by exposing ssh/wireguard only
- vaultwarden instance for credentials/mfa
- rustic-server for dedup/encrypted backups
- caddy as reverse proxy handling automatically certificates
service.cloud.domain.tld
handled by caddy
wg-easy webui
context Link to heading
the need of a passwordmanager and backup solution all in one but behind vpn feels way more secure cool
being able to browse internet while vpn connected is mandatory that’s why i choose full tunnel
other designs are possible : split tunnel / full exposure (novpn) / etc …
those are not covered here
here are the basics
- containers use internal network
10.0.0.0/24
- rootless for all except wg-easy needs privileged mode
- ssh port forwarding is failover access to vaultwarden/wg-easy
to avoid a huge detailed post i show only one full podman-compose.yml
chapters are describing containers behaviors/needs
prerequisites Link to heading
- vps/cloud instance
A
dns registrar resolving*.cloud.domain.tld
> publicip- i use rocky9 with selinux enabled but any distro should do
firewall Link to heading
firewalld
is default on rocky but no need defaults ports
need to open ssh/wireguard and authorize traffic from future wireguard’s clients on 10.8.0.0/24
sudo firewall-cmd --permanent --remove-service=cockpit
sudo firewall-cmd --permanent --remove-service=dhcpv6-client
sudo firewall-cmd --permanent --add-port=MYSSHPORT/tcp
sudo firewall-cmd --permanent --add-port=51820/udp
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="10.8.0.0/24" accept'
sudo firewall-cmd --complete-reload
to handle automatic letsencrypt certs ( dns-01
challenge ) one can use caddy’s dns plugins ( ovh , njalla , etc … ) no need to open http
you’ll need to build caddy in this case
but if you need to use http-01
challenge (default in caddy ) open http port temporarly
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --complete-reload
after caddy did the job close it back
sudo firewall-cmd --permanent --remove-service=http
sudo firewall-cmd --complete-reload
users with no privileges Link to heading
- it’s recommended to use a specific user different from ssh connected user
create a user with no sudo,wheel privileges but give him/her a /home
sudo useradd -m podstack
sudo passwd podstack
sudo loginctl enable-linger podstack
then login
su --login podstack
use a specific directory to handle stack and containers dir/files
mkdir -p vault/{logs,rustic/{config,certs,data},vwdata,wireguard}
cd vault
vaultwarden Link to heading
enabling signup is ok for creating fisrt user (i’ll disable it later)
i’m not configuring smtp for sending emails to users but it’s trivial to do so
- as stack is only reachable behind vpn conf is not hardened
caddy Link to heading
- reverse proxy and tls handling 3 sites
[https://vault.cloud.domain.tld] [https://wg.cloud.domain.tld] [https://rustic.cloud.domain.tld]
- logging is not mandatory but usefull for debugging/reporting/ids
~/vault/Caddyfile
vault.cloud.domain.tld {
reverse_proxy vaultwarden:8080
header {
X-Real-IP {remote_host}
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
log {
format json
output file data/vault_access.log {
roll_local_time
roll_keep 12
roll_keep_for 365d
}
}
}
wg.cloud.domain.tld {
reverse_proxy wg-easy:51821
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
log {
format json
output file data/wg_access.log {
roll_local_time
roll_keep 12
roll_keep_for 365d
}
}
}
rustic.cloud.domain.tld {
reverse_proxy rustic-server:8000
header {
X-Real-IP {remote_host}
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
log {
format json
output file data/restic_access.log {
roll_local_time
roll_keep 12
roll_keep_for 365d
}
}
}
wg-easy Link to heading
wg-easy creates its own config file at start in ~/vault/wireguard/wg0.conf
you should not modify directly this file
use command as variables directly in compose
- WG_POST_UP=echo ${POST_UP} > /etc/wireguard/post-up.txt
- to automate deployment with ansible read this
dns for wireguard clients is unbound (optionnal)
dns unbound Link to heading
a very minimalistic unbound distroless
conf uses quad9 and opennic servers
~/vault/unbound/unbound.conf
server:
verbosity: 4
log-file: "/opt/unbound/logs/unbound.log"
log-queries: yes # Log all queries
log-replies: yes # Log all replies
interface: 0.0.0.0
access-control: 10.8.0.0/24 allow
access-control: 10.0.0.0/24 allow
do-ip4: yes
do-udp: yes
do-tcp: yes
tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt"
root-hints: "/opt/unbound/etc/unbound/root.key"
hide-identity: yes
hide-version: yes
use-caps-for-id: yes
prefetch: yes
qname-minimisation: yes
harden-glue: yes
harden-dnssec-stripped: yes
cache-min-ttl: 3600
cache-max-ttl: 86400
forward-zone:
name: "."
forward-tls-upstream: yes
forward-addr: 9.9.9.9@853
forward-addr: 9.9.9.11@853
forward-addr: 147.135.40.4@853
rustic-server Link to heading
building rustic-server
~/vault/rustic-server.build
FROM alpine AS builder
ARG RUSTIC_SERVER_VERSION
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "amd64" ]; then \
ASSET="rustic_server-x86_64-unknown-linux-musl.tar.xz";\
elif [ "$TARGETARCH" = "arm64" ]; then \
ASSET="rustic_server-aarch64-unknown-linux-musl.tar.xz"; \
fi; \
wget https://github.com/rustic-rs/rustic_server/releases/download/${RUSTIC_SERVER_VERSION}/${ASSET} && \
tar -xf ${ASSET} --strip-components=1 && \
mkdir /etc_files && \
touch /etc_files/passwd && \
touch /etc_files/group
FROM scratch
COPY --from=builder /rustic-server /rustic-server
COPY --from=builder /etc_files/ /etc/
EXPOSE 8000
ENTRYPOINT ["/rustic-server", "serve"]
on host vps you’ll need httpd-tools
dnf install httpd-tools
create htpasswd file
htpasswd -B -c ~/vault/rustic/data/.htpasswd username
create acl.toml
file to define permissions of users and groups on repositories
~/vault/restic/config/acl.toml
[default]
luigi = "Read"
brian = "Modify"
[luigi]
alice = "Modify"
bob = "Append"
compose file Link to heading
- to be docker compatible take off
z
Z
~/vault/podman-compose.yaml
version: '3.8'
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.domain.tld"
TZ: Europe/Paris
LOG_LEVEL: error
EXTENDED_LOGGING: true
LOG_FILE: "/data/vault_access.log"
ROCKET_PORT: 8080
WEBSOCKET_ENABLED: false
# generate with podman run --rm -it vaultwarden/server /vaultwarden hash
# or echo -n 'YourSecureAdminPassword' | argon2 "$(openssl rand -base64 16)" -e -id -k 65536 -t 3 -p 4 | sed 's/\$/\$\$/g'
ADMIN_TOKEN: REDACTED
SIGNUPS_ALLOWED: true
volumes:
- ./vdata:/data:rw,Z
networks:
securestack:
ipv4_address: 10.0.0.3
expose:
- "8080"
caddy:
image: caddy_custom:latest
container_name: caddy
build:
context: .
dockerfile: caddy.build
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro,Z
- caddy_data:/data
- caddy_config:/config
- ./logs:/srv/data:z
networks:
securestack:
ipv4_address: 10.0.0.4
wg-easy:
image: wg-easy/wg-easy
container_name: wg-easy
privileged: true
environment:
# generate hash with podman run --rm -it ghcr.io/wg-easy/wg-easy wgpw 'REDACTED'
# take off single quotes from output `'` and double `$`
- PASSWORD_HASH=REDACTED
- WG_HOST=REDATEDIP
- WG_DEFAULT_DNS=10.0.0.6
- WG_POST_UP=iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE && iptables -A FORWARD -i wg0 -j ACCEPT && iptables -A FORWARD -o wg0 -j ACCEPT
- WG_POST_DOWN=iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE && iptables -D FORWARD -i wg0 -j ACCEPT && iptables -D FORWARD -o wg0 -j ACCEPT
- LANG=en
- UI_TRAFFIC_STATS=true
- UI_CHART_TYPE=1
ports:
- "51820:51820/udp"
- "127.0.0.1:51821:51821/tcp"
volumes:
- ./wireguard:/etc/wireguard:rw,Z
cap_add:
- NET_ADMIN
- NET_RAW
- SYS_MODULE
sysctls:
- net.ipv4.ip_forward=1
- net.ipv4.conf.all.src_valid_mark=1
- net.ipv6.conf.all.disable_ipv6=0
- net.ipv6.conf.all.forwarding=1
- net.ipv6.conf.default.forwarding=1
restart: unless-stopped
networks:
securestack:
ipv4_address: 10.0.0.5
unbound:
image: klutchell/unbound
container_name: unbound
volumes:
- ./unbound/unbound.conf:/opt/unbound/etc/unbound/unbound.conf:ro,Z
- ./unbound/root.key:/opt/unbound/etc/unbound/root.key:rw,Z
- ./unbound/logs:/opt/unbound/logs:rw,Z
expose:
- "53"
- "853"
restart: unless-stopped
networks:
securestack:
ipv4_address: 10.0.0.6
networks:
securestack:
driver: bridge
ipam:
config:
- subnet: 10.0.0.0/24
volumes:
caddy_data:
caddy_config:
starting stack
podman-compose up -d
ssh failover Link to heading
in case something goes wrong with wireguard access via ssh (port forwarding) is easy
enable AllowTcpForwarding
in sshd_config
sudo sed -i '/^#*AllowTcpForwarding/ s/^#*AllowTcpForwarding.*/AllowTcpForwarding yes/; t; $ a AllowTcpForwarding yes' /etc/ssh/sshd_config
restart sshd
sudo systemctl restart sshd
- modify
/etc/hosts
with127.0.0.1 vault.cloud.domain.tld
sudo sed -i 's/^127\.0\.0\.1 .*/127.0.0.1 vault.cloud.domain.tld/' /etc/hosts
to reach vaultwarden
ssh -L 8080:127.0.0.1:443 user@vpsip
https://vault.cloud.domain.tld
bitwarden client Link to heading
gnu/linux clients tested
- cli
2025.5.0
- bitwarden official (flatpak)
2025.5.1
- chrome extension
2025.5.1
install bw cli
wget -c https://github.com/bitwarden/clients/releases/download/cli-v2025.5.0/bw-linux-2025.5.0.zip -P /tmp
cd /tmp && unzip bw-linux-2025.5.0.zip
sudo cp bw /usr/local/bin/
set it up
bw config server https://vault.cloud.domain.tld
bw login
export your temporary session token to be able to do operations without password
import firefox passwords
in firefox go to about:logins
then export your logins as .csv
bw import firefoxcsv Downloads/passwords.csv
restic client Link to heading
cargo install rustic
mkdir -p ~/config/rustic
~/.config/rustic/rustic.toml
[repository]
repository = "rest:https://rustic.cloud.domain.tld/"
password = "test"
troubleshooting Link to heading
few commands to troubleshoot podman
podman ps
podman network ls
podman network inspect securestack
podman logs -f container1
invoke a shell in container
podman exec -it vaultwarden /bin/sh
since pasta version and podman version i got this error
[caddy] | Error: unable to start container 5c1ff1aa3e1c761b838b235709a454d43a7352c277e5759839d0dd1cf5056ec3: setting up Pasta: pasta failed with exit code 1:
[caddy] | Couldn't open PID file /tmp/poduser-runtime/containers/networks/rootless-netns/rootless-netns-conn.pid: Permission denied
[caddy] |
because of selinux
sudo ausearch -m avc -ts recent
time->Thu Jun 19 20:32:29 2025
type=AVC msg=audit(1750357949.492:579): avc: denied { add_name } for pid=3278 comm="pasta.avx2" name="rootless-netns-conn.pid" scontext=unconfined_u:unconfined_r:pasta_t:s0-s0:c0.c1023 tcontext=unconfined_u:object_r:container_var_run_t:s0 tclass=dir permissive=0
----
time->Thu Jun 19 20:33:22 2025
type=AVC msg=audit(1750358002.688:597): avc: denied { create } for pid=3405 comm="pasta.avx2" name="rootless-netns-conn.pid" scontext=unconfined_u:unconfined_r:pasta_t:s0-s0:c0.c1023 tcontext=unconfined_u:object_r:container_var_run_t:s0 tclass=file permissive=0
----
time->Thu Jun 19 20:33:28 2025
type=AVC msg=audit(1750358008.406:602): avc: denied { create } for pid=3494 comm="pasta.avx2" name="rootless-netns-conn.pid" scontext=unconfined_u:unconfined_r:pasta_t:s0-s0:c0.c1023 tcontext=unconfined_u:object_r:container_var_run_t:s0 tclass=file permissive=0
----
time->Thu Jun 19 20:34:35 2025
type=AVC msg=audit(1750358075.265:621): avc: denied { write open } for pid=3606 comm="pasta.avx2" path="/tmp/poduser-runtime/containers/networks/rootless-netns/rootless-netns-conn.pid" dev="tmpfs" ino=234 scontext=unconfined_u:unconfined_r:pasta_t:s0-s0:c0.c1023 tcontext=unconfined_u:object_r:container_var_run_t:s0 tclass=file permissive=0
----
time->Thu Jun 19 20:34:42 2025
type=AVC msg=audit(1750358082.616:665): avc: denied { write } for pid=3715 comm="pasta.avx2" name="rootless-netns-conn.pid" dev="tmpfs" ino=234 scontext=unconfined_u:unconfined_r:pasta_t:s0-s0:c0.c1023 tcontext=unconfined_u:object_r:container_var_run_t:s0 tclass=file permissive=0
i needed to adapt selinux
sudo ausearch -c 'pasta.avx2' --raw | sudo audit2allow -M mypasta
sudo semodule -i mypasta.pp
sudo chcon -R -t container_var_run_t /tmp/poduser-runtime
ressources Link to heading
- vaultwarden
https://github.com/kagiko/vaultwarden-wiki/blob/master/Using-Podman.md
https://mijo.remotenode.io/posts/tailscale-caddy-docker/
https://github.com/dani-garcia/vaultwarden/discussions/4531
https://github.com/dani-garcia/vaultwarden/blob/main/.env.template
https://github.com/dani-garcia/vaultwarden/wiki/Caddy-2.x-with-Cloudflare-DNS
https://github.com/kagiko/vaultwarden-wiki/blob/master/Proxy-examples.md
- wg-easy
https://wg-easy.github.io/wg-easy/Pre-release/examples/tutorials/podman-nft/
https://www.procustodibus.com/blog/2022/10/wireguard-in-podman/#network-mode-with-podman-compose
https://github.com/wg-easy/wg-easy/blob/master/docker-compose.yml
- rustic-server
https://github.com/rustic-rs/rustic_server/tree/main/containers
https://github.com/rustic-rs/rustic_server/blob/main/USAGE.md
https://rustic.cli.rs/docs/commands/init/rest.html
https://rustic.cli.rs/docs/getting_started.html
- ansible deploy
https://github.com/guerzon/ansible-role-vaultwarden/tree/main
https://helgeklein.com/blog/vaultwarden-setup-guide-with-automatic-https/