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 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

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 with 127.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/kagiko/vaultwarden-wiki/blob/master/Running-a-private-vaultwarden-instance-with-Let's-Encrypt-certs.md

https://github.com/dani-garcia/vaultwarden/discussions/4531

https://github.com/kagiko/vaultwarden-wiki/blob/master/SMTP-Configuration.md#using-sendmail-without-docker

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/dani-garcia/vaultwarden/wiki/Running-a-private-vaultwarden-instance-with-Let%27s-Encrypt-certs

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/