This guide covers deploying Ulaknode on your own Linux server or VM.
Requirements
- Linux server (Ubuntu 22.04+ or Debian 12+ recommended)
- 2+ vCPU, 4+ GB RAM, 40+ GB disk
- A public IP address you control
- A domain name with DNS management access
Note: if you cap the container's memory (docker run -m, mem_limit, or a Compose deploy.resources.limits.memory), keep it at 3 GB or more. clamd alone can use up to ~2 GB RSS once its signature database is loaded; a tighter limit gets clamd killed by the cgroup OOM killer, silently disabling virus scanning while the rest of the stack keeps running.
1. Install Docker
curl -fsSL https://get.docker.com | sh2. Configure DNS
Before starting the container, set up DNS records for your domain:
| Record | Type | Value |
|---|---|---|
mail.yourdomain.com | A | Your server's public IP |
yourdomain.com | MX | mail.yourdomain.com (priority 10) |
PTR (reverse DNS) should also point your IP back to mail.yourdomain.com. This is configured through your hosting provider or ISP and is important for mail deliverability.
SPF, DKIM, and DMARC records should be added after the container is running (see step 6).
3. Open firewall ports
Allow inbound TCP on the following ports:
| Port | Protocol | Purpose |
|---|---|---|
| 25 | SMTP | Inbound mail from other servers |
| 587 | Submission | Outbound mail from mail clients |
| 993 | IMAPS | Mailbox access (TLS) |
Note: Some ISPs and hosting providers block port 25 by default. Contact your provider to have it unblocked before proceeding.
Also make sure outbound UDP/TCP 53 is allowed to the internet at large (not just to one resolver). The container runs its own recursive DNS resolver (Unbound) that queries root/TLD/authoritative servers directly instead of forwarding to your host's resolver — see DNSBL blocklist checking below for why. Some hosting providers/firewalls restrict outbound DNS to their own resolver only, which would need an exception.
4. Run the container
The image is published on Docker Hub — docker run pulls it automatically if it isn't present locally, so no separate pull step is needed. Replace mail.yourdomain.com and yourdomain.com below with your own values, then start the container:
docker run -d \
--name ulaknode \
--hostname mail.yourdomain.com \
--restart unless-stopped \
-e MAIL_HOSTNAME=mail.yourdomain.com \
-e MAIL_DOMAIN=yourdomain.com \
-p 25:25 \
-p 587:587 \
-p 993:993 \
-v ulak_mail:/var/mail \
-v ulak_logs:/var/log \
-v ulak_clamav:/var/lib/clamav \
-v ulak_postfix:/etc/postfix \
-v ulak_dovecot:/etc/dovecot \
-v ulak_opendkim:/etc/opendkim \
-v ulak_opendmarc:/etc/opendmarc \
-v ulak_spamassassin:/etc/spamassassin \
-v ulak_policyd_spf:/etc/postfix-policyd-spf-python \
-v ulak_postgrey:/etc/postgrey \
-v ulak_fail2ban:/etc/fail2ban \
-v ulak_unbound:/etc/unbound \
-v ulak_certs:/etc/ssl/mail \
-v /etc/letsencrypt/live/mail.yourdomain.com/fullchain.pem:/run/certs/fullchain.pem:ro \
-v /etc/letsencrypt/live/mail.yourdomain.com/privkey.pem:/run/certs/privkey.pem:ro \
itefixnet/ulaknode:latestThe named volumes (ulak_mail, ulak_logs, etc.) are created automatically on first run and persist mail data, logs, and service configuration across container restarts and upgrades. The two bind mounts at the bottom map your host's Let's Encrypt certificate files into the container read-only.
5. Configure TLS
Copy your certificate files into the container, then install them with ulaknode-cert:
docker cp fullchain.pem ulaknode:/tmp/fullchain.pem
docker cp privkey.pem ulaknode:/tmp/privkey.pem
docker exec ulaknode ulaknode-cert install /tmp/fullchain.pem /tmp/privkey.pemThen restart the affected services:
docker exec ulaknode ulaknode-service restart postfix
docker exec ulaknode ulaknode-service restart dovecotTo verify the installed certificate at any time:
docker exec ulaknode ulaknode-cert show
docker exec ulaknode ulaknode-cert check6. Add DKIM, SPF, and DMARC records
DKIM — generate a key for your domain and publish the resulting DNS record:
docker exec ulaknode ulaknode-dkim generate yourdomain.com
# prints the DNS TXT record to publish, then:
docker exec ulaknode ulaknode-service reload opendkimTo retrieve the record again later:
docker exec ulaknode ulaknode-dkim show yourdomain.comSPF — generate the record value for your domain:
docker exec ulaknode ulaknode-spf generate yourdomain.com --mx
# prints the DNS TXT record to publishDMARC — generate the record value. Start with --policy none to monitor before enforcing:
docker exec ulaknode ulaknode-dmarc generate yourdomain.com \
--policy none \
--rua mailto:postmaster@yourdomain.com
# prints the DNS TXT record to publishAll three commands only print the DNS records — publish them in your DNS provider's control panel.
7. Create domains, mailboxes, and aliases
Domains — add a domain before creating any mailboxes under it:
docker exec ulaknode ulaknode-domain add yourdomain.com
docker exec ulaknode ulaknode-domain listMailboxes:
# Add
docker exec ulaknode ulaknode-mailbox add user@yourdomain.com <password>
# Set a quota (optional)
docker exec ulaknode ulaknode-mailbox quota-set user@yourdomain.com 2G
# List all mailboxes for a domain
docker exec ulaknode ulaknode-mailbox list yourdomain.com
# Change password
docker exec ulaknode ulaknode-mailbox passwd user@yourdomain.com <newpassword>
# Remove
docker exec ulaknode ulaknode-mailbox remove user@yourdomain.comAliases:
# Add
docker exec ulaknode ulaknode-alias add info@yourdomain.com user@yourdomain.com
# List all aliases for a domain
docker exec ulaknode ulaknode-alias list yourdomain.com
# Remove
docker exec ulaknode ulaknode-alias remove info@yourdomain.comBrute-force protection (Fail2ban)
Fail2ban runs automatically as part of the stack — no setup needed. It bans brute-force SMTP/IMAP clients at the Postfix/Dovecot application layer (edits the same access-control tables those services already read, rather than touching iptables), so it needs no extra container privileges.
docker exec ulaknode fail2ban-client statusDNSBL blocklist checking
Inbound connections are checked against DNSBL blocklists (Spamhaus Zen, SpamCop) by postscreen, before a full SMTP session even starts. Public DNSBL providers throttle or misclassify queries coming from resolvers that are shared across many users/customers, so this stack runs its own recursive resolver — Unbound — dedicated to just this mail server, instead of forwarding to your host's default DNS.
This is on by default; no setup needed. To tune it, edit /etc/postfix/main.cf (persistent volume):
docker exec -it ulaknode vi /etc/postfix/main.cf
# postscreen_dnsbl_sites, postscreen_dnsbl_threshold, postscreen_dnsbl_action
docker exec ulaknode ulaknode-service reload postfixUnbound's own settings (cache size, logging) live in /etc/unbound/unbound.conf.d/ulaknode.conf, also a persistent volume — restart with docker exec ulaknode ulaknode-service restart unbound after editing.
Verify everything is running
docker exec ulaknode ulaknode-service status allTroubleshooting
- Permission errors after editing configs: run
docker exec ulaknode fix-permissions /etc/ulaknode - Port 25 not reachable: check your firewall rules and confirm your ISP/provider has unblocked the port
- Service errors: use
docker exec ulaknode ulaknode-errlog <service>to inspect recent error logs - Mail rejected with "Service unavailable" / DNSBL false positives, or Unbound failing to resolve: check
docker exec ulaknode ulaknode-errlog unbound— if outbound port 53 is firewalled to the internet at large, Unbound can't do recursive resolution and DNS (DNSBL checks, MX lookups, everything) breaks. See the outbound firewall note in step 3.