Self‑hosting email is one of those projects that sounds simple until you actually try it. Between TLS, reverse proxies, Autodiscover, and modern mail client expectations, it’s very easy to end up with redirect loops, broken SMTP, or certificates that load everywhere except where you need them.
This post walks through a working, real‑world setup: running Mailu behind Caddy with Let’s Encrypt certificates (DNS‑01). It’s written from the perspective of what actually works, not just what looks correct on paper.
All domains, paths, and identifiers in this post are examples.
—
Why This Architecture
Mail servers are special:
They need TLS for web, IMAP, and SMTP
They need predictable hostnames
They must support Autodiscover for Outlook, Gmail, and mobile clients
The architecture below solves all of that cleanly:
Internet
│
▼
[Caddy :443] ← public HTTPS
│
▼
[Mailu Front :443] ← internal TLS
│
├─ IMAP (993)
├─ SMTP (465)
└─ Webmail / Admin
Key design choice:
> Caddy proxies to Mailu’s HTTPS port (443), not HTTP.
That single decision avoids most redirect and TLS issues.
—
Directory Layout (Example)
Here’s a clean, anonymized layout that works well:
/opt/mail/
├── mailu/
│ ├── data/
│ ├── mail/
│ ├── dkim/
│ ├── certs/
│ │ ├── cert.pem
│ │ └── key.pem
│ ├── docker-compose.yml
│ └── mailu.env
│
├── caddy/
│ ├── Caddyfile
│ └── docker-compose.yml
│
└── letsencrypt/
You can place this anywhere — /opt, /srv, or a mounted volume.
—
Certificates: Let’s Encrypt with DNS‑01
Mail servers benefit greatly from DNS‑01 validation:
No open ports required
Works with multiple subdomains
Ideal behind proxies
Requesting a Certificate (Example)
certbot certonly \
–dns-provider \
-d mail.example.com \
-d autodiscover.example.com \
-d autoconfig.example.com
After success:
/etc/letsencrypt/live/mail.example.com/
├── fullchain.pem
└── privkey.pem
—
Making Certificates Available to Mailu
Mailu expects certificates inside its own volume:
cp /etc/letsencrypt/live/mail.example.com/fullchain.pem \
/opt/mail/mailu/certs/cert.pem
cp /etc/letsencrypt/live/mail.example.com/privkey.pem \
/opt/mail/mailu/certs/key.pem
chmod 644 /opt/mail/mailu/certs/cert.pem
chmod 600 /opt/mail/mailu/certs/key.pem
If these files are empty or truncated, IMAP and SMTP will fail immediately.
—
Mailu Configuration (Minimal, Correct)
In mailu.env:
DOMAIN=example.com
HOSTNAMES=mail.example.com,autodiscover.example.com,autoconfig.example.com
TLS_FLAVOR=cert
ENABLE_AUTODISCOVER=true
WEB_ADMIN=/admin
WEB_WEBMAIL=/webmail
This ensures Mailu advertises the correct endpoints to clients.
—
Caddy: The Reverse Proxy That Makes This Work
Here’s the critical part.
Caddyfile
mail.example.com,
autodiscover.example.com,
autoconfig.example.com {
@root path /
redir @root /webmail 302
reverse_proxy https://mailu-front:443 {
transport http {
tls_insecure_skip_verify
}
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
}
}
Why HTTPS → HTTPS Matters
Mailu internally enforces HTTPS. If you proxy to port 80, nginx will continuously attempt to “upgrade” requests — even when the client is already using HTTPS.
Result:
Infinite 301 redirects
Broken admin UI
Webmail never loads
Proxying to 443 avoids this entirely.
—
Starting the Stack
cd /opt/mail/mailu
docker compose up -d
cd /opt/mail/caddy
docker compose up -d
Confirm everything is running:
docker ps
—
Verifying the Web Interfaces
curl -Ik https://mail.example.com/admin/
curl -Ik https://mail.example.com/webmail/
Expected:
No redirect loops
Login page loads
—
Verifying Mail Protocols
IMAP (993)
openssl s_client -connect mail.example.com:993 \
-servername mail.example.com -brief </dev/null
SMTP (465)
openssl s_client -connect mail.example.com:465 \
-servername mail.example.com -brief </dev/null
Modern Mailu deployments often do not support STARTTLS on 587. Port 465 is the correct choice.
—
Autodiscover Test
curl -X POST https://autodiscover.example.com/autodiscover/autodiscover.xml \
-H “Content-Type: text/xml” \
–data ‘<Autodiscover xmlns=”http://schemas.microsoft.com/exchange/autodiscover/outlook/requestschema/2006″>
<Request>
<EMailAddress>user@example.com</EMailAddress>
<AcceptableResponseSchema>
http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a
</AcceptableResponseSchema>
</Request>
</Autodiscover>’
Clients should receive IMAP and SMTP settings automatically.
—
Automatic Certificate Renewal
certbot renew –deploy-hook “
docker restart mailu-front \
docker restart mailu-imap \
docker restart mailu-smtp
“
This ensures mail protocols reload certificates correctly.
—
Lessons Learned
Proxying Mailu via HTTP causes redirect loops
Empty certificate files break IMAP/SMTP instantly
Port 465 is now the safest SMTP option
DNS‑01 validation is ideal for mail servers
—
Final Thoughts
Mail servers don’t fail loudly — they fail subtly. A single redirect, missing TLS flag, or incorrect port can break everything downstream.
This setup has proven reliable, debuggable, and future‑proof. If you’re self‑hosting mail today, this architecture will save you hours of frustration.
—
All paths and domains shown are examples. Replace them with values appropriate to your environment.
Blog
-
Running Mailu Behind Caddy with Let’s Encrypt (A Practical Guide)
-
How to Install WordPress on Portainer with Caddy Proxy
Running WordPress behind a modern, automatic HTTPS reverse proxy is easier than ever with Caddy and Portainer. In this guide, you’ll deploy WordPress and MariaDB as Docker containers, fronted by Caddy for TLS and smart HTTP handling, and you’ll manage it all through Portainer’s friendly UI. Follow along step by step to go from a clean host to a secure, production-ready WordPress site reachable at your domain.
Understanding the Setup: Portainer, Caddy, WordPress
At a high level, you’ll host three services: Caddy as the public-facing reverse proxy and TLS terminator, WordPress as the application, and MariaDB as the database. Caddy listens on ports 80 and 443, obtains and renews Let’s Encrypt certificates automatically, and forwards traffic to WordPress. WordPress only needs to be reachable by Caddy, keeping the app and database isolated from the public internet.
Portainer sits alongside these services and gives you a web UI to deploy, monitor, and manage your Docker stack. Instead of running all commands by hand, you’ll paste a Docker Compose file into Portainer and click Deploy. Portainer will pull images, create networks and volumes (or use the ones you prepared), and launch the stack.
Networking-wise, you’ll use a public “web” network for anything the proxy needs to reach and a private “wp” network for WordPress-to-DB traffic. Persistent Docker volumes will store your database and WordPress files so container restarts or image updates don’t erase your data.
Prerequisites: Domain, DNS, and Server Access
You need a domain name you control so that Caddy can provision HTTPS. Plan which hostname you’ll use, for example example.com and optionally www.example.com. You’ll point these DNS records to your server’s public IP address, and once the DNS is live, Caddy will request a valid certificate from Let’s Encrypt on first run.
A Linux server (cloud VM or dedicated) with a public IPv4 (and optionally IPv6) is required. You’ll need shell access with sudo privileges to install Docker and prepare directories. Make sure ports 80 and 443 are open in your firewall and not in use by another service like Apache or Nginx.
Have a text editor available on the server (nano or vim) for creating the Caddyfile, and keep strong, unique passwords ready for your database and WordPress admin. If you’re behind a CDN or DDoS protection service, ensure it’s configured to proxy 80/443 and pass traffic to your server.
Install Docker and Portainer on Your Host
You’ll use Docker to run isolated containers for Caddy, WordPress, and MariaDB. Portainer adds a convenient web UI for deploying stacks and reviewing logs. If you already have Docker installed, you can skip directly to launching Portainer; otherwise, install Docker first and add your user to the docker group so you can run it without sudo.
# Install Docker the quick way curl -fsSL https://get.docker.com -o get-docker.sh sh get-docker.sh sudo usermod -aG docker $USER # Log out and back in (or run: newgrp docker) to apply group change # Run Portainer CE docker volume create portainer_data docker run -d -p 8000:8000 -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latestOnce Portainer is up, access it at https://YOUR_SERVER_IP:9443 to complete the initial admin setup. Add your local Docker environment when prompted. Confirm your firewall allows 9443 (Portainer), and remember you’ll later expose 80/443 for Caddy.
Set Up a Docker Network and Persistent Volumes
To keep things clean and secure, you’ll create a shared “web” network for the proxy and any public apps, plus a private “wp” network used by WordPress and the database. You’ll also prepare Docker volumes so your Caddy certificates, database data, and WordPress files persist across updates.
# Public-facing network shared by Caddy and WordPress docker network create web # Persistent data volumes docker volume create caddy_data docker volume create db_data docker volume create wp_dataThis approach lets Portainer’s stack reference existing networks/volumes and ensures that certificates, database records, and uploads are not lost when you redeploy. If you prefer, you can let the stack create volumes automatically, but pre-creating them makes backups and maintenance simpler.
Create a Caddyfile with Automatic HTTPS Rules
Caddy’s configuration is simple and powerful. You’ll define your domains, enable automatic HTTPS, add basic security headers, and tell Caddy to reverse proxy requests to the WordPress container. Replace example.com with your real domain and set a valid email for Let’s Encrypt notices.
# /opt/caddy/Caddyfile { email you@example.com } example.com, www.example.com { encode zstd gzip header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" } reverse_proxy wordpress:80 }Create the directory and file on your host and secure it appropriately. For example, run: sudo mkdir -p /opt/caddy && sudo nano /opt/caddy/Caddyfile, then paste the content above. This file will be mounted into the Caddy container read-only so you can update it later without rebuilding the image.
Prepare a Docker Compose Stack for WordPress
You’ll define all three services—caddy, wordpress, and db—in a single Compose file so Portainer can deploy them as a stack. The caddy service binds ports 80/443 and mounts your Caddyfile and certificate storage volume; WordPress connects to MariaDB on the private network; and both Caddy and WordPress share the public “web” network so traffic can flow.
# docker-compose.yml version: "3.8" services: caddy: image: caddy:2 container_name: caddy restart: unless-stopped ports: - "80:80" - "443:443" volumes: - caddy_data:/data - /opt/caddy/Caddyfile:/etc/caddy/Caddyfile:ro networks: - web - wp db: image: mariadb:11 container_name: wp-db restart: unless-stopped environment: - MARIADB_DATABASE=wordpress - MARIADB_USER=wp - MARIADB_PASSWORD=CHANGE_ME_STRONG - MARIADB_ROOT_PASSWORD=CHANGE_ME_VERY_STRONG volumes: - db_data:/var/lib/mysql networks: - wp wordpress: image: wordpress:latest container_name: wordpress restart: unless-stopped depends_on: - db environment: - WORDPRESS_DB_HOST=db:3306 - WORDPRESS_DB_USER=wp - WORDPRESS_DB_PASSWORD=CHANGE_ME_STRONG - WORDPRESS_DB_NAME=wordpress volumes: - wp_data:/var/www/html networks: - web - wp volumes: caddy_data: external: true db_data: external: true wp_data: external: true networks: web: external: true wp: driver: bridgeEdit the passwords and domain before deploying. If you changed any volume or network names earlier, update them here to match. In production, treat secrets carefully—consider moving credentials to Docker secrets or environment variables managed by your platform.
Deploy the Stack in Portainer and Verify Logs
Open Portainer at https://YOUR_SERVER_IP:9443, log in, and select your local Docker environment. Go to Stacks, click Add stack, give it a name like wordpress-with-caddy, and paste your docker-compose.yml content into the Web editor. Click Deploy the stack and watch as Portainer brings the services online.
After deployment, open Containers in Portainer and check Logs for caddy. On first run, Caddy should solve HTTP-01 challenges and obtain certificates from Let’s Encrypt for your domain. If DNS isn’t pointed yet or ports 80/443 are blocked, you’ll see ACME errors; fix those and restart the caddy container.
Also check the wordpress and wp-db logs for healthy startup messages. WordPress will wait for the database to be reachable. If you see connection errors, confirm credentials and that both services share the wp network.
Point Your Domain DNS to the Server IP Address
In your domain registrar or DNS host, create A (and AAAA if you have IPv6) records for your hostname(s) pointing to your server’s public IP. For example, create an A record for @ (example.com) and another for www, both to 203.0.113.10. A low TTL (like 300 seconds) can speed up changes during initial setup.
DNS changes can take minutes to propagate. While you wait, you can run dig example.com or nslookup example.com from your local machine to verify the new records are live. Only after the domain resolves to your server will Let’s Encrypt issue a certificate successfully.
Once propagation is complete, restart the caddy container from Portainer or run docker restart caddy on the host to trigger certificate issuance if it didn’t already. Then browse to https://example.com and confirm you see the WordPress installer with a valid padlock.
Run WordPress Setup and Configure Site URLs
Visit your domain at https://example.com to complete the WordPress installation wizard. Choose your language, set a site title, and create an admin username and strong password. When finished, log in to the dashboard to start configuring themes, plugins, and content.
Check Settings → General and confirm both WordPress Address (URL) and Site Address (URL) use https and your correct domain. If you ever need to force HTTPS in the admin area, you can add define(‘FORCE_SSL_ADMIN’, true); to wp-config.php, but with Caddy handling TLS and reverse proxy headers, WordPress should detect HTTPS automatically.
Go to Settings → Permalinks and choose a modern structure like Post name, then save. Create a test post, upload an image, and view it on the front end to confirm everything works over HTTPS. If you see mixed-content warnings, update any hard-coded http links in your theme or settings.
Hardening, Backups, and Common Troubleshooting
Harden your deployment by using least-privilege DB credentials (a dedicated non-root user, as shown), keeping images updated, and limiting write access where possible. Consider disabling xmlrpc if you don’t need it, enforce strong passwords and 2FA, and review security headers in your Caddyfile. Make sure only ports 80/443 are exposed publicly; the database should remain internal on the wp network.
Backups should cover both the database and WordPress files. Dump the database on a schedule and archive the wp_data volume. For example, you can run a simple dump from the host: docker exec wp-db sh -c ‘exec mysqldump -u wp -p”$MARIADB_PASSWORD” wordpress’ > wp-$(date +%F).sql, and then copy wp_data for uploads and installed plugins/themes.
If Caddy can’t obtain certificates, verify DNS points to your server, ports 80/443 are open, and no other service is binding those ports. For 502/504 errors, check container logs and that the caddy, wordpress, and db services share the correct networks. If you use a CDN like Cloudflare, ensure your SSL mode is Full (strict preferred) and that proxying is enabled for your A/AAAA records. Finally, confirm your server’s time is correct—certificate issuance can fail if the clock is far off.
You now have a clean, maintainable WordPress stack running under Portainer with Caddy providing automatic HTTPS and smart reverse proxying. This setup keeps components decoupled, makes updates easy, and sets a strong foundation for performance and security. As you grow, you can add caching, staging environments, and CI/CD while preserving the same simple, containerized workflow.
-
How to Install Inkscape AppImage on Ubuntu and Add a Desktop Shortcut
If you want to install Inkscape on Ubuntu using an AppImage, follow these step-by-step instructions. This guide also shows you how to add a desktop shortcut for easy access.
Prerequisites
- Download the latest Inkscape AppImage from the official site.
- Make sure you have libfuse2 installed on Ubuntu for AppImage compatibility.
Step 1: Install Required Dependencies
Open a terminal and run:
sudo add-apt-repository universe sudo apt update sudo apt install libfuse2
Step 2: Extract the Inkscape AppImage
Give permission and extract the AppImage:
chmod +x Inkscape-xxx.AppImage ./Inkscape-xxx.AppImage --appimage-extractReplace
Inkscape-xxx.AppImagewith your downloaded filename.
Step 3: Move Inkscape Files to System Directory
Move the extracted folder for easier management:
sudo mv squashfs-root /opt/inkscape
Step 4: Create a Desktop Shortcut for Inkscape
Create and edit a new desktop entry:
nano ~/inkscape.desktopPaste the following into the file:
[Desktop Entry] Name=Inkscape Type=Application Categories=Graphics; MimeType=image/svg+xml; Exec=/opt/inkscape/AppRun %F Icon=/opt/inkscape/inkscape.svg Terminal=false StartupNotify=trueSave and close the file.
Step 5: Install the Desktop File
Add the shortcut to your system menu:
sudo desktop-file-install ~/inkscape.desktop
Launch Inkscape from Your Application Menu
You can now find and launch Inkscape from your applications menu, complete with the official icon and full AppImage support.