rationale Link to heading
- docker/podman portable for easy migrations
- rootless for all except wg-easy needs privileged mode
- 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- ssh port forwarding as failover access to services
wg-easy webui
context Link to heading
i need of a passwordmanager and backup solution all in one but behind vpn to feel way more secure cool
being able to browse internet while vpn connected is mandatory that’s why i choose full tunnel (with dns)
other designs not coverred here are possible : split tunnel / full exposure (novpn) / etc …
prerequisites Link to heading
- vps/cloud instance
A
dns registrar resolving*.cloud.domain.tld
> vps/instance public ip- 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
if you need to use http-01
challenge (default in caddy ) open http port temporarly each time you generate certs
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
otherwise use dns-01
challenge no need to open http
port
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/caddy/{config,data} vault/rustic/{config,certs,data} vault/{logs,vwdata,wireguard}
cd vault
vaultwarden Link to heading
enabling signup is ok for creating fisrt user remeber to disable it later
i’m not configuring smtp for sending emails to users but it’s trivial to do so
- compose block
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.cloud.domain.tld"
TZ: Europe/Paris
LOG_LEVEL: error
EXTENDED_LOGGING: true
LOG_FILE: "/data/vault_access.log"
ROCKET_PORT: 8080
WEBSOCKET_ENABLED: false
ADMIN_TOKEN: REDACTED
SIGNUPS_ALLOWED: true
volumes:
- ./vdata:/data:rw,Z
- ./logs:/data/logs:z
networks:
securestack:
ipv4_address: 10.0.0.3
expose:
- "8080"
caddy Link to heading
building caddy with dns plugins can help achieve dns-01
using api
here is the build file
~/vault/caddy.build
ARG CADDY_VERSION=latest
FROM caddy:builder AS builder
RUN xcaddy build \
--with https://github.com/caddy-dns/njalla \
--with github.com/mholt/caddy-ratelimit
FROM caddy:${CADDY_VERSION}
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
then we’ll call the build in compose but can force rebuild if needed (update or adding plugins)
compose block
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:Z
- ./caddy/config:/config:Z
- ./logs:/srv/data:z
networks:
securestack:
ipv4_address: 10.0.0.4
Caddyfile is quite simple using common headers for all sites and trying to keep logs for one year (optionnal)
- 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 useful for debugging/reporting/ids
~/vault/Caddyfile
(header_common) {
header {
Strict-Transport-Security "max-age=31536000;"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "no-referrer"
}
}
vault.cloud.domain.tld {
reverse_proxy vaultwarden:8080
log {
format json
output file data/vault_access.log {
roll_local_time
roll_size 30mb
roll_keep_for 365d
}
}
}
wg.cloud.domain.tld {
reverse_proxy wg-easy:51821
log {
format json
output file data/wg_access.log {
roll_local_time
roll_size 30mb
roll_keep_for 365d
}
}
}
rustic.cloud.domain.tld {
reverse_proxy rustic-server:8000
log {
format json
output file data/restic_access.log {
roll_local_time
roll_size 30mb
roll_keep_for 365d
}
}
}
wg-easy Link to heading
it needs to be privileged because of network rights problem with podman
we nee to expose the udp :51820
only webui being reachable on :51821
only when connected
wg-easy creates its own config file at start in ~/vault/wireguard/wg0.conf
you should not modify directly this file !
if you need several POST_UP
and POST_DOWN
rules you can use .env
file and call variables as commands
- WG_POST_UP=echo ${POST_UP} > /etc/wireguard/post-up.txt
dns for wireguard clients is unbound managed by (optionnal)
- compose block
wg-easy:
image: wg-easy/wg-easy
container_name: wg-easy
privileged: true
environment:
- PASSWORD_HASH=REDACTED
- WG_HOST=readactedip
- 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
- TZ=Europe/Paris
- 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
dns unbound Link to heading
a very minimalistic distroless unbound
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
need to build 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 part
rustic-server:
image: rustic-server:latest
container_name: rustic-server
build:
context: .
dockerfile: rustic-server.build
args:
RUSTIC_SERVER_VERSION: "v0.4.0"
TARGETARCH: "amd64"
ports:
- "8000:8000"
volumes:
- ./rustic/config:/etc/rustic-server/config:Z
- ./rustic/certs:/etc/rustic-server/certs:Z
- ./rustic/data:/var/lib/rustic-server/data:Z
- ./logs:/var/log/:z
environment:
- RUSTIC_SERVER_LISTEN=10.0.0.7:8000
- RUSTIC_SERVER_DATA_DIR=/var/lib/rustic-server/data
- RUSTIC_SERVER_QUOTA=0 # 0 means no quota
- RUSTIC_SERVER_VERBOSE=true
- RUSTIC_SERVER_DISABLE_AUTH=false
- RUSTIC_SERVER_HTPASSWD_FILE=/var/lib/rustic-server/data/.htpasswd
- RUSTIC_SERVER_PRIVATE_REPOS=true
- RUSTIC_SERVER_APPEND_ONLY=true
- RUSTIC_SERVER_ACL_PATH=/etc/rustic-server/config/acl.toml
- RUSTIC_SERVER_LOG_FILE=/var/log/rustic-server.log
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
networks:
securestack:
ipv4_address: 10.0.0.7
compose file Link to heading
- to be docker compatible take off
z
Z
~/vault/podman-compose.yaml
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
environment:
DOMAIN: "https://vault.cloud.domain.tld"
TZ: Europe/Paris
LOG_LEVEL: error
EXTENDED_LOGGING: true
LOG_FILE: "/data/vault_access.log"
ROCKET_PORT: 8080
WEBSOCKET_ENABLED: false
ADMIN_TOKEN: REDACTED
SIGNUPS_ALLOWED: true
volumes:
- ./vdata:/data:rw,Z
- ./logs:/data/logs: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:Z
- ./caddy/config:/config:Z
- ./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:
- PASSWORD_HASH=REDACTED
- WG_HOST=REDACTED_IP
- 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
- TZ=Europe/Paris
- 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
- ./logs:/opt/unbound/logs:z
expose:
- "53"
- "853"
restart: unless-stopped
networks:
securestack:
ipv4_address: 10.0.0.6
rustic-server:
image: rustic-server:latest
container_name: rustic-server
build:
context: .
dockerfile: rustic-server.build
args:
RUSTIC_SERVER_VERSION: "v0.4.0"
TARGETARCH: "amd64"
ports:
- "8000:8000"
volumes:
- ./rustic/config:/etc/rustic-server/config:Z
- ./rustic/certs:/etc/rustic-server/certs:Z
- ./rustic/data:/var/lib/rustic-server/data:Z
- ./logs:/var/log/:z
environment:
- RUSTIC_SERVER_LISTEN=10.0.0.7:8000
- RUSTIC_SERVER_DATA_DIR=/var/lib/rustic-server/data
- RUSTIC_SERVER_QUOTA=0 # 0 means no quota
- RUSTIC_SERVER_VERBOSE=true
- RUSTIC_SERVER_DISABLE_AUTH=false
- RUSTIC_SERVER_HTPASSWD_FILE=/var/lib/rustic-server/data/.htpasswd
- RUSTIC_SERVER_PRIVATE_REPOS=true
- RUSTIC_SERVER_APPEND_ONLY=true
- RUSTIC_SERVER_ACL_PATH=/etc/rustic-server/config/acl.toml
- RUSTIC_SERVER_LOG_FILE=/var/log/rustic-server.log
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
deploy:
resources:
limits:
memory: 512M
restart: unless-stopped
networks:
securestack:
ipv4_address: 10.0.0.7
networks:
securestack:
name: "securestack"
driver: bridge
ipam:
config:
- subnet: 10.0.0.0/24
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
- on your client 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 passwords.csv
restic client Link to heading
on your laptop or any client
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/