Gemini_Generated_Image_4kzcl04kzcl04kzc

When MikroTik Cloud DDNS Breaks: Moving WireGuard to Cloudflare DDNS

I rebooted my home MikroTik router, and both of my site-to-site WireGuard tunnels went quiet. Both remote sites (two small family-run locations I look after) dial into my home router, and neither would reconnect. This post covers how I tracked it down, why MikroTik’s built-in Cloud DDNS let me down, and how I replaced it with a Cloudflare DDNS updater running in Docker, plus a small RouterOS script so the tunnels heal themselves next time.

The setup

  • Home router: MikroTik on RouterOS 7.20, PPPoE WAN with a dynamic public IP.
  • Two remote sites, each with a MikroTik running a WireGuard peer that points back to my home router.
  • The remote peers found my home router using MikroTik’s free *.sn.mynetname.net Cloud DDNS hostname.

The home router only listens. The remote sites always initiate the connection, so if they can’t find my current IP, nothing works.

Step 1: Both sites down means look at your own end

When two independent sites drop at the same moment, the common factor is the home router. The most likely cause after a reboot is a WAN IP change: PPPoE reconnects, the ISP hands out a new address, and the remote peers keep sending to the old one.

A quick comparison confirmed it. The DDNS hostname still resolved to my old IP, while a “what’s my IP” site showed a different one:

nslookup yourname.sn.mynetname.net 1.1.1.1
# Returned the OLD address, not the current one

In Winbox under IP → Cloud, the Public Address field was blank and the status was stuck on updating....

Step 2: Fix the clock first

The reboot was caused by a power outage, and the router’s clock was wrong when it came back up. The NTP client was disabled with no servers configured, so nothing was correcting it. A badly wrong clock can break the router’s cloud services, so this had to be fixed before anything else.

  1. Go to System → NTP Client and tick Enabled.
  2. Add servers, e.g. time.cloudflare.com and za.pool.ntp.org (use your local pool).
  3. Apply and wait for the status to change to synchronized.
  4. Check System → Clock has the correct time zone.

NTP syncing also proved that DNS and outbound UDP from the router itself were working, which narrowed the problem down further.

Step 3: Confirm it’s MikroTik’s side, not yours

With the clock fixed, Force Update still did nothing. Toggling DDNS to auto and back to yes didn’t help either. To see whether the update traffic was actually getting out, I used Tools → Torch on the PPPoE interface, filtered on port 15252 (the port MikroTik Cloud uses), and hit Force Update.

Torch showed traffic between my router and MikroTik’s cloud server, so the packets were reaching them and coming back. The router was doing its job; MikroTik’s backend just wasn’t publishing the new address. The MikroTik forums have plenty of threads describing this exact symptom over the years. Sometimes it clears up on its own; sometimes it doesn’t. Either way, it’s not something you can fix from your end.

Tip: the Winbox Ping tool only accepts IP addresses. To test name resolution from the router, use the terminal:

:put [:resolve cloud2.mikrotik.com]
/ping cloud2.mikrotik.com count=3

Step 4: Replace it with Cloudflare DDNS

My domains are already on Cloudflare, so the cleaner fix was to run my own DDNS updater and stop depending on a service I can’t troubleshoot. I used favonia/cloudflare-ddns, a small, well-maintained container.

Create a scoped API token

  1. In the Cloudflare dashboard, go to My Profile → API Tokens → Create Token.
  2. Use the Edit zone DNS template. The only permission needed is Zone → DNS → Edit.
  3. Under Zone Resources, limit it to the one zone you’re using.
  4. Copy the token and store it in your password manager. Cloudflare only shows it once.

Add it to your Docker stack

I already run cloudflared for a Cloudflare Tunnel on my entry host, so I added the DDNS updater to the same Compose stack (managed in Dockge). The only downside is that redeploying the stack restarts both containers; if that bothers you, give it its own stack.

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    network_mode: host
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}

  cloudflare-ddns:
    image: favonia/cloudflare-ddns:latest
    container_name: cloudflare-ddns
    restart: unless-stopped
    network_mode: host
    user: "1000:1000"
    read_only: true
    cap_drop: [all]
    security_opt: [no-new-privileges:true]
    environment:
      - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
      - DOMAINS=yourrandomname.example.com
      - PROXIED=false
      - IP6_PROVIDER=none
      - UPDATE_CRON=@every 5m
      - TZ=Africa/Johannesburg
networks: {}

Put the token in the stack’s .env file rather than in the Compose file:

CLOUDFLARE_API_TOKEN=your_token_here

A few things worth knowing:

  • PROXIED=false is essential. WireGuard is UDP and won’t work through Cloudflare’s orange-cloud proxy. The record must be DNS only (grey cloud).
  • The container detects your public IP via Cloudflare’s own trace endpoint, so the host must reach the internet through your normal WAN, not through a VPN or exit node.
  • Check the container logs after deploying. You should see it detect your IP and create the A record.

Doesn’t this publish my IP?

Yes, but so did the MikroTik DDNS name, and internet-wide scanners will find your IP regardless. What a DNS record adds is a link between your IP and your domain, and a name that can be guessed. Two easy mitigations:

  • Don’t call it home or vpn. Those are the first names subdomain enumeration tools try. Pick something silly and made up. Since you won’t issue a TLS certificate for it, it won’t show up in Certificate Transparency logs either.
  • WireGuard doesn’t reveal itself. It silently drops any packet not signed by a known peer key, so a port scan can’t tell it’s even there.

The real protection is a tight input firewall on your WAN, not hiding your IP. More on that below.

Step 5: Point the remote peers at the new name

On each remote MikroTik, in Winbox:

  1. Go to WireGuard → Peers and open the peer that points back home.
  2. Change Endpoint to your new Cloudflare hostname. Leave the port as it is.
  3. Make sure Persistent Keepalive is set (e.g. 25s). The remote side has to keep the tunnel alive, because the home router can’t initiate to it.
  4. Apply, then disable and re-enable the peer so it resolves the new name.

Last Handshake should show a recent time within seconds. Nothing changes on the home router.

One catch: if the tunnels are already down, you need another way into the remote routers to make this change, such as a remote support tool on a PC at the site. Lesson learned: always have a second way in that doesn’t depend on the VPN you’re trying to fix.

Step 6: Make the tunnels heal themselves

Even with DNS correct, a RouterOS WireGuard peer can hold onto the IP it resolved earlier. This script checks the peer’s last handshake and, if it’s stale, disables and re-enables the peer to force a fresh DNS lookup. It finds the peer by endpoint name, so nothing else needs labelling.

:local endpoint "yourrandomname.example.com"
:local maxAge 00:05:00

:foreach p in=[/interface wireguard peers find where endpoint-address=$endpoint] do={
    :local hs [/interface wireguard peers get $p last-handshake]
    :if (([:typeof $hs] != "time") || ($hs > $maxAge)) do={
        /interface wireguard peers disable $p
        :delay 2s
        /interface wireguard peers enable $p
        :log warning "WireGuard: handshake stale, re-resolved $endpoint"
    }
}

Set it up on each remote router:

  1. Go to System → Scripts → New. Name it wg-reresolve, tick the read, write and test policies, and paste the script.
  2. Go to System → Scheduler → New. Name it wg-reresolve, set Interval to 00:05:00, On Event to wg-reresolve, and use the same three policies.
  3. To test it, temporarily set maxAge to 00:00:01, run the script, and check the warning appears in the Log and the tunnel comes straight back. Then set it back to 00:05:00.

With a 25s keepalive, a healthy tunnel handshakes roughly every two minutes, so a five-minute threshold won’t trigger falsely. After an IP change, the sites reconnect by themselves within about ten minutes.

Step 7: Turn off MikroTik Cloud DDNS

With nothing depending on it anymore, disable it on the home router. In IP → Cloud, set DDNS Enabled to auto (only active when Back To Home needs it) and untick Update Time, since NTP handles the clock now. Or from the terminal:

/ip cloud set ddns-enabled=no update-time=no

Bonus: Check your logs while you’re in there

While troubleshooting, I opened the router’s log and found millions of hidden entries, almost all of them failed FTP login attempts from random internet IPs. If you see the same thing, a service is reachable from the WAN that shouldn’t be.

  • In IP → Services, disable everything you don’t use (ftp, telnet, www, api).
  • Restrict winbox and ssh with Available From to your LAN and VPN subnets.
  • Make sure your input chain on the WAN interface ends in a drop, allowing only established/related traffic, your WireGuard port, and ICMP.

Takeaways

  • If several sites drop at once, start with the hub.
  • Enable NTP on every MikroTik. Routers without a battery-backed clock come back from a power cut with the wrong time.
  • Don’t rely on a DDNS service you can’t see into. If your domain is on Cloudflare, running your own updater takes five minutes.
  • Make remote peers re-resolve on their own, and always keep a second way into remote sites.
Gemini_Generated_Image_rhtenyrhtenyrhte

Self-Hosted Password Management with Vaultwarden: From Compose File to Mobile App

I’ve been meaning to self-host my password manager for a while, and this week I finally moved it onto my homelab properly. This post walks through how I deployed Vaultwarden (the lightweight, Bitwarden-compatible server written in Rust) in Docker, put it behind my reverse proxy, got email and mobile push notifications working, and sorted out backups. I’ve also included the handful of gotchas that tripped me up along the way, so hopefully they don’t trip you up too.

My setup

For context, here’s what my homelab looks like for this project:

  • Proxmox hosting several Docker LXCs, with stacks managed through Dockge and living under /opt/stacks/ with bind mounts
  • proteus – the LXC that runs my auth and monitoring services (and now Vaultwarden)
  • sabre – the LXC running NPMPlus (with CrowdSec) as my reverse proxy
  • Cloudflare for DNS, AdGuard Home for local DNS, and Proxmox Backup Server for backups

Throughout this post I’ll use vault.example.com as the domain, 192.168.1.121 for the Docker host and 192.168.1.120 for the reverse proxy. Swap in your own.

Hardware requirements (spoiler: basically none)

Vaultwarden is a single Rust binary backed by SQLite. For a personal or family instance it sits at around 10–50 MB of RAM and is effectively idle on CPU. The heavy key-derivation work for your master password happens on your devices, not the server. The database for a few users with thousands of entries is only a few MB. It runs happily on a Raspberry Pi, so I didn’t bother bumping my LXC’s resources at all. Compare that with the official Bitwarden server, which wants 2 GB+ of RAM.

Step 1: Generate a hashed admin token

Vaultwarden has an admin panel at /admin. Rather than a plaintext token, give it an Argon2 hash:

docker run --rm -it vaultwarden/server /vaultwarden hash --preset owasp

Enter a strong password (this is what you’ll type at /admin), and it spits out a string beginning with $argon2id$.... Keep the password somewhere safe outside the vault you’re about to build.

Step 2: The Docker Compose stack

In Dockge I created a new stack called vaultwarden with this compose file:

services:
  vaultwarden:
    image: vaultwarden/server:latest
    container_name: vaultwarden
    restart: unless-stopped
    ports:
      - "8222:80"
    volumes:
      - ./data:/data
    environment:
      DOMAIN: "https://vault.example.com"
      SIGNUPS_ALLOWED: "true"          # flip to false after creating your account
      INVITATIONS_ALLOWED: "true"
      SHOW_PASSWORD_HINT: "false"
      ADMIN_TOKEN: "${ADMIN_TOKEN}"
      TZ: "Africa/Johannesburg"
      LOG_LEVEL: "warn"
      # SMTP
      SMTP_HOST: "mail.example.com"
      SMTP_PORT: "465"
      SMTP_SECURITY: "force_tls"
      SMTP_FROM: "[email protected]"
      SMTP_FROM_NAME: "My Vault"
      SMTP_USERNAME: "${SMTP_USERNAME}"
      SMTP_PASSWORD: "${SMTP_PASSWORD}"
      # Mobile push notifications
      PUSH_ENABLED: "true"
      PUSH_INSTALLATION_ID: "${PUSH_ID}"
      PUSH_INSTALLATION_KEY: "${PUSH_KEY}"

All the secrets live in the stack’s .env file (Dockge has an editor for it right under the compose file):

ADMIN_TOKEN='$argon2id$v=19$m=65540,t=3,p=4$...your-hash...'
[email protected]
SMTP_PASSWORD='your-mailbox-password'
PUSH_ID=your-installation-id
PUSH_KEY=your-installation-key

A couple of notes on that:

  • Single quotes are your friend. Inside single quotes in the .env, Compose treats everything literally: $, #, !, spaces, the lot. That matters for both the Argon2 hash and any password with “funny” characters. The only character that breaks it is a single quote inside the value itself.
  • If you paste the hash straight into the compose file instead, you have to double every $ to $$, or Compose will mangle it and your admin login will silently fail. Keeping it in .env avoids that headache entirely.
  • No port 3012 needed. Older guides tell you to expose a separate WebSocket port. Modern Vaultwarden serves live sync on the main port, so ignore that advice.

Deploy the stack and confirm http://192.168.1.121:8222 loads on your LAN before moving on.

Step 3: SMTP (cPanel-style hosting)

Email is needed for user invites, email verification and email-based 2FA. My mail is on a typical cPanel host, which offers SMTP on port 465. The key detail is that 465 uses implicit TLS, so the setting is SMTP_SECURITY: "force_tls", not starttls. If your ISP blocks outbound 465, try port 587 with starttls instead.

Also make sure SMTP_FROM matches the mailbox you’re authenticating as. cPanel mail servers generally reject or flag mail sent “from” a different address.

Step 4: Mobile push notifications

Without push, the mobile apps only pick up changes when they sync. To get instant updates:

  1. Go to bitwarden.com/host, enter an admin email and choose the United States data region.
  2. Copy the Installation ID and Installation Key into your .env as PUSH_ID and PUSH_KEY.
  3. Redeploy, then log out and back in on your phone so it registers for push.

If you pick the EU region instead, you also need to set PUSH_RELAY_URI and PUSH_IDENTITY_URI to the bitwarden.eu endpoints. With the US region the defaults just work.

Step 5: DNS and the reverse proxy

In Cloudflare I added a proxied DNS record for vault.example.com. Then in NPMPlus I created a proxy host:

  • Forward to: http → 192.168.1.121 : 8222
  • Websockets Support: on (needed for live sync)
  • Block Common Exploits: on
  • SSL: a proper certificate, with Force SSL, HTTP/2 and HSTS enabled

Locking the admin panel to the LAN

There’s no reason for /admin to be reachable from the internet. In the proxy host’s Advanced tab I added:

location /admin {
    allow 192.168.1.0/24;
    allow 192.168.0.0/24;
    deny all;
    proxy_pass http://192.168.1.121:8222;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

Two things caught me here:

  • Allow every subnet you’ll admin from. My servers and my PC live on different subnets, so allowing only the server subnet would have locked me out with a 403. If your router NATs between subnets, check the NPMPlus access log to see which source IP actually arrives.
  • Your LAN traffic has to hit the proxy directly. If your PC resolves the domain via public DNS, the request goes out through Cloudflare and comes back from a public IP, which gets denied. I added a DNS rewrite in AdGuard Home pointing vault.example.com at the proxy’s LAN IP. That fixes it and makes local access snappier too.

Step 6: Create your account, then close the doors

  1. Browse to https://vault.example.com and create your account. The master password cannot be recovered, so choose it carefully.
  2. Set SIGNUPS_ALLOWED: "false" and redeploy.
  3. Log in at /admin and use SMTP → Send test email to confirm mail works.
  4. Turn on two-step login for your vault account (Settings → Security). Store that TOTP somewhere other than the vault itself, and keep the recovery code offline.

Gotcha: anything you save in the admin panel is written to data/config.json, and it overrides your compose environment variables. If you change something in compose and it “doesn’t take”, that file is why.

Step 7: Managing users

With signups closed, everything happens in the admin panel’s Users tab:

  • Invite users by email. They set their own master password from the link.
  • Deauthorize sessions to log someone out everywhere (handy for a lost phone).
  • Disable/Enable, Remove 2FA for someone locked out, or Delete.

What you can’t do is see anyone’s passwords or reset a master password, because everything is end-to-end encrypted. To protect against a forgotten password, have users set up Emergency Access. Put shared logins (family, Wi-Fi, etc.) in an Organization with Collections, so they don’t disappear with one person’s account.

If you’d rather let people self-register from your own domain only, SIGNUPS_DOMAINS_WHITELIST: "example.com" does exactly that.

Step 8: Connecting the apps

In the Bitwarden browser extension, desktop app or mobile app, pick Self-hosted on the login screen and enter your server URL. Then log in as normal. To migrate from another password manager, use Tools → Import data in the web vault.

Gotcha (a.k.a. the most embarrassing one): my first mobile login failed with a java.net.UnknownHostException. The cause? I’d typed .co.zs instead of .co.za in the server URL. If you see “Unable to resolve host”, read the hostname in the error very carefully before you start debugging DNS. Also make sure the URL you use everywhere matches the DOMAIN variable exactly, or you’ll get odd login and attachment issues later.

Step 9: Backups

Everything important lives in ./data: db.sqlite3 (and its -wal/-shm files), attachments/, sends/, the rsa_key* files and config.json.

One thing not to do is rsync or rclone the live SQLite files. I’ve been bitten by that before: it copies files one at a time while they’re changing, and you end up with an inconsistent database.

Because my stacks use bind mounts inside an LXC, my existing Proxmox Backup Server jobs already capture the whole thing. A snapshot-mode backup is a crash-consistent, point-in-time copy of the filesystem, and SQLite in WAL mode is designed to survive exactly that. PBS also supports file-level restore, so I can pull just the data folder back without rolling back every other service on the host. Things worth doing if you go this route:

  • Use Snapshot mode, which needs ZFS or LVM-thin storage under the LXC.
  • Test a file-level restore once and check db.sqlite3 is there.
  • Occasionally do an encrypted Export vault from the web vault and keep it somewhere separate, in case your backup server is down on the day you need it.

If you don’t have PBS, the ttionya/vaultwarden-backup container is a nice alternative. It takes a consistent SQLite backup, zips it with a password and ships it anywhere rclone can reach, including S3-compatible storage.

Updating

Updating is just hitting Update on the stack in Dockge to pull the latest image. Take a backup before major version bumps, and glance at the Vaultwarden release notes on GitHub every so often.

Wrapping up

All in, this was one of the smoother self-hosting projects I’ve done. Vaultwarden is tiny, the Bitwarden clients are excellent, and it slots neatly into an existing Docker + reverse proxy setup. The gotchas were all small (quoting in .env, the TLS mode for port 465, subnet rules on the admin lock, and one very silly typo), but each one could eat an afternoon if you didn’t know to look for it. Hopefully this saves you that afternoon.

Gemini_Generated_Image_lqpvj0lqpvj0lqpv

Supercharge Your Photo Library: Hosting Immich on Proxmox LXC with TrueNAS & NVIDIA

A technical walkthrough on self-hosting the Immich photo library inside a lightweight, unprivileged Proxmox LXC container. It covers securely mapping a TrueNAS NFS share to bypass unprivileged LXC permission errors, passing through an NVIDIA GPU (GTX 1080ti) to the container, and using Dockge for deployment. The result is a highly secure, hardware-accelerated photo backup system with blazing-fast video transcoding and AI facial recognition.