Skip to content

Instantly share code, notes, and snippets.

@WebLenn
Last active March 30, 2026 15:15
Show Gist options
  • Select an option

  • Save WebLenn/d7d74348e7be6f4fb1aa3673353390f5 to your computer and use it in GitHub Desktop.

Select an option

Save WebLenn/d7d74348e7be6f4fb1aa3673353390f5 to your computer and use it in GitHub Desktop.

Solving Poste.io SSL/TLS Issues on Dokploy (Traefik) by Syncing Certs from acme.json

I’m sharing this guide because I spent a lot of time troubleshooting SSL errors while setting up Poste.io behind Dokploy. If you are using Dokploy, you likely noticed that the Let's Encrypt certificates managed by Traefik work perfectly for the web dashboard but aren't automatically applied to the mail server ports (25, 587, 993, 995, etc.). This leads to "SSL/TLS Handshake" errors in external clients like Gmail or Outlook.

The Problem

When trying to generate certificates inside Poste.io itself using the built-in Let's Encrypt client, I constantly hit this error: LEScript.ERROR: 400 ... Unable to update challenge :: authorization must be pending

The Solution: "Reverse Sync"

Instead of forcing Poste.io to get its own certificate, this method "feeds" Dokploy's existing certificates into the Poste.io container. This ensures that the Full Chain (Leaf + Intermediate certificates) is present, which is required for mail clients to verify the server's identity.


1. Docker Compose Configuration

Note: If you plan to use Method 1 (Schedules), you must include the /etc/dokploy/traefik/ volume mapping shown below and make sure your DOMAIN=${DOMAIN} environment variable is setup.

version: "3.8"
services:
  mailserver:
    image: analogic/poste.io:latest
    container_name: mailserver
    restart: unless-stopped
    hostname: ${DOMAIN}
    ports:
      - "25:25"       # SMTP
      - "110:110"     # POP3
      - "143:143"     # IMAP
      - "465:465"     # SMTPS
      - "587:587"     # Submission
      - "993:993"     # IMAPS
      - "995:995"     # POP3S
      - "4190:4190"   # Sieve
    environment:
      - TZ=${TZ}
      - DOMAIN=${DOMAIN}  # Required for the sync script to read the domain ( REQUIRED FOR METHOD 1 )
      - HTTPS=OFF     # We let Dokploy/Traefik handle SSL for the Web UI
      - HTTP_PORT=8080
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - poste-data:/data
      # REQUIRED FOR METHOD 1 (Internal Sync):
      - /etc/dokploy/traefik/:/data/traefik:ro 

volumes:
  poste-data: {}

Method 1: The "Dokploy Schedule" Way (Inside the Container)

This approach, suggested by scardonadev, is great because it keeps everything within the Dokploy UI. It runs the sync script inside the running container.

In Dokploy → Poste.io container → Schedules:

  • Type: Bash
  • Schedule: Every Sunday at midnight (0 0 * * 0)
  • Command:
set -e;

: "--- CONFIGURATION ---";
: "Path to Traefik ACME file (mapped via volume in Compose)";
ACME_FILE="/data/traefik/dynamic/acme.json";

: "Internal Poste.io SSL directory";
POSTE_SSL_DIR="/data/ssl";

: "Safety check to ensure the DOMAIN env variable exists";
echo "Started Certificate sync for $DOMAIN";
if [ -z "$DOMAIN" ]; then
  echo "Error: DOMAIN environment variable is not set!";
  exit 1;
fi;

: "Check if jq is installed, if not install it";
echo "Checking if jq is installed...";
if ! command -v jq >/dev/null 2>&1; then 
  echo "jq not found. Installing...";
  apt-get update -qq && apt-get install -y jq;
fi;

: "Create the SSL Dir";
mkdir -p "$POSTE_SSL_DIR";

: "1. Extract the full certificate block from JSON and decode from Base64";
FULL_CERT=$(jq -r ".letsencrypt.Certificates[] | select(.domain.main==\"$DOMAIN\") | .certificate" "$ACME_FILE" | base64 -d);

: "2. Extract the private key";
PRIVATE_KEY=$(jq -r ".letsencrypt.Certificates[] | select(.domain.main==\"$DOMAIN\") | .key" "$ACME_FILE" | base64 -d);

if [ -z "$FULL_CERT" ] || [ -z "$PRIVATE_KEY" ]; then 
  echo "Certificate for $DOMAIN not found in acme.json";
  exit 1;
fi;

: "3. Save the first certificate block as server.crt (The Leaf/Entity cert)";
echo "$FULL_CERT" | awk "/BEGIN CERTIFICATE/{i++}i==1" > "$POSTE_SSL_DIR/server.crt";

: "4. Save the remaining blocks as ca.crt (The Intermediate/CA Chain)";
echo "$FULL_CERT" | awk "/BEGIN CERTIFICATE/{i++}i>1" > "$POSTE_SSL_DIR/ca.crt";

: "5. Save the private key";
echo "$PRIVATE_KEY" > "$POSTE_SSL_DIR/server.key";

: "6. Set strict permissions (Required by Haraka/Dovecot)";
chmod 600 "$POSTE_SSL_DIR/server.crt" "$POSTE_SSL_DIR/server.key" "$POSTE_SSL_DIR/ca.crt";

: "7. Reload Dovecot to apply changes without restarting the whole container";
dovecot reload || true;

echo "$(date): Certificates synced and reloaded for $DOMAIN";

Method 2: The "VPS Script" Way (Host Level)

Use this if you prefer managing automation via a standard .sh file on your VPS and a system cronjob.

Prerequisite: Ensure jq is installed on your VPS (sudo apt install jq).

The Sync Script (sync-mail-certs.sh):

#!/bin/bash

# --- CONFIGURATION ---
# Path to Dokploy's Traefik ACME file
ACME_FILE="/etc/dokploy/traefik/dynamic/acme.json"

# Path to your Poste.io data volume
# Note: "posteio-container_poste-data" = ID combined with volume name
POSTE_SSL_DIR="/var/lib/docker/volumes/posteio-container_poste-data/_data/ssl"

# The domain used for your mail server
DOMAIN="your.domain.com"
# ---------------------

# 1. Extract the full certificate block from JSON and decode from Base64
FULL_CERT=$(jq -r ".letsencrypt.Certificates[] | select(.domain.main==\"$DOMAIN\") | .certificate" $ACME_FILE | base64 -d)

# 2. Extract the private key
PRIVATE_KEY=$(jq -r ".letsencrypt.Certificates[] | select(.domain.main==\"$DOMAIN\") | .key" $ACME_FILE | base64 -d)

# 3. Save the first certificate block as server.crt (The Leaf/Entity cert)
echo "$FULL_CERT" | awk '/BEGIN CERTIFICATE/{i++}i==1' > $POSTE_SSL_DIR/server.crt

# 4. Save the remaining blocks as ca.crt (The Intermediate/CA Chain)
# Mail clients need this to find the path to the Trusted Root
echo "$FULL_CERT" | awk '/BEGIN CERTIFICATE/{i++}i>1' > $POSTE_SSL_DIR/ca.crt

# 5. Save the private key
echo "$PRIVATE_KEY" > $POSTE_SSL_DIR/server.key

# 6. Set strict permissions (Required by Haraka/Dovecot)
chmod 600 $POSTE_SSL_DIR/server.crt $POSTE_SSL_DIR/server.key $POSTE_SSL_DIR/ca.crt

# 7. Restart the container to apply changes
docker restart mailserver

echo "$(date): Certificates synced and mailserver restarted."

Automation via Cron: Run crontab -e and add:

# Sync certificates every Sunday at midnight
0 0 * * 0 /bin/bash /path/to/your/sync-mail-certs.sh >> /var/log/cert-sync.log 2>&1

Why this works

Let's Encrypt certificates are valid for 90 days, and Dokploy/Traefik typically renews them at the 60-day mark. By syncing once a week, you ensure that your mail server (Dovecot/Haraka) updates its local files long before the old certificate expires.

Important: Once you've run the sync for the first time, go to the Poste.io Admin Panel and ensure the internal Let's Encrypt setting is Disabled. This is required so Poste.io relies exclusively on the local files we just synced.

@scardonadev
Copy link
Copy Markdown

Hi @WebLenn,

thank you so much for your solution. It was honestly the only thing that worked after days and days of trying different approaches — including setting up an internal Nginx and forwarding traffic from Traefik just to make the HTTP-01 challenge work.

Your reverse synchronization approach turned out to be the cleanest and most reliable solution by far.

I took the liberty of sharing the implementation I ended up using. My goal was to have everything configured from inside the Poste.io container itself, without requiring manual cron jobs or scripts on the VPS. I documented it in this small guide:

👉 https://gist.github.com/scardonadev/7fcae8d916f9f4e68c94b2d6a73eb770

I really hope you don’t mind that I used your guide as the foundation for mine. All credit for the original idea is yours — I just wanted to help spread your work and maybe make it easier for others running Poste.io behind Traefik and Dokploy.

Thanks again for sharing such a solid solution 🙏

@WebLenn
Copy link
Copy Markdown
Author

WebLenn commented Feb 21, 2026

@scardonadev You're more than welcome! I’ve taken a look at your Gist, honestly, using the internal scheduler is a brilliant move. I was still new to Dokploy when I wrote mine, so I didn't even consider that approach. It’s definitely a clean way to handle it.

Thanks for sharing and for the kind words 😀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment