ulaknode FAQs
Frequently Asked Questions
ulaknode
- 🔗 What is ulaknode and how does it differ from hosted email services?
ulaknode is a free, self-hosted email server for Linux distributed as a single Docker container. Rather than one large all-in-one filter, it bundles a set of focused, industry-standard components into one cohesive package:
- Postfix — SMTP mail transfer
- Dovecot — IMAP mailbox access
- OpenDKIM & OpenDMARC — DKIM signing/verification and DMARC enforcement
- policyd-spf — SPF checking
- Postgrey — greylisting
- SpamAssassin — content-based spam scoring
- ClamAV — antivirus scanning of attachments
- Postscreen & Unbound — DNSBL blocklist checking (Spamhaus, SpamCop) via a dedicated recursive resolver
- Fail2ban — bans brute-force SMTP/IMAP clients at the application layer
Unlike hosted services such as Google Workspace or Microsoft 365, ulaknode gives you complete control over your data. There are no per-user or per-mailbox fees, and you are not subject to a third-party provider's terms of service, storage limits, or data-residency policies. The trade-off is that you are responsible for operating the Linux host it runs on.
- 🔗 What are the system requirements for running ulaknode?
ulaknode requires a Linux server (physical, VM, or VPS) with Docker installed. Minimum recommended specifications for a small organisation:
- CPU: 2 vCPUs
- RAM: 4 GB minimum —
clamdalone can use up to ~2 GB RSS once its virus signature database is loaded - Disk: 40 GB for the OS and container image, plus mailbox storage as needed
- Network: A static public IP address and a fully-qualified domain name (FQDN) with a matching reverse DNS (PTR) record
Port 25 (SMTP) must be open outbound. Many cloud providers block port 25 by default — check with your hosting provider before deploying.
Outbound UDP/TCP port 53 must also be open to the internet at large (not just to one resolver). The container runs its own recursive DNS resolver (Unbound) for DNSBL blocklist checks, which queries root/TLD/authoritative servers directly rather than forwarding to your host's resolver — some providers restrict outbound DNS to their own resolver only, which would need an exception.
If you cap the container's memory (
docker run -m,mem_limit, or a Composedeploy.resources.limits.memory), keep it at 3 GB or more. A tighter limit getsclamdsilently killed by the cgroup OOM killer, disabling virus scanning while the rest of the stack keeps running with no obvious error pointing at the cause. - 🔗 How do I start ulaknode?
ulaknode is a single-container image, so no compose file is needed — replace
mail.yourdomain.comwith your actual mail hostname and run:docker run -d \ --name ulaknode \ --hostname mail.yourdomain.com \ --restart unless-stopped \ -e MAIL_HOSTNAME=mail.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:latestNamed volumes are created automatically on first run. On first boot the container seeds all configuration volumes from the image defaults and applies your hostname automatically. Services start in dependency order: Unbound → ClamAV → Postgrey → OpenDKIM → OpenDMARC → SpamAssassin → Postfix → Dovecot → Fail2ban.
ClamAV loads its full signature database (~1 GB) during startup. Wait about a minute before testing mail flow after a fresh start or restart.
To check the version of all bundled components:
docker exec ulaknode ulaknode-version - 🔗 What DNS records do I need to set up before going live?
Correct DNS configuration is essential for mail deliverability. You need at minimum:
- MX record — points your domain to your mail server hostname, e.g.
mail.yourdomain.com - A record — resolves
mail.yourdomain.comto your server's public IP - PTR record (reverse DNS) — resolves your public IP back to
mail.yourdomain.com; configured with your hosting or ISP provider - SPF TXT record — use
ulaknode-spf generateto produce the correct value (see the SPF FAQ below) - DKIM TXT record — use
ulaknode-dkim showto retrieve the value to publish (see the DKIM FAQ below) - DMARC TXT record — use
ulaknode-dmarc generateto produce the correct value (see the DMARC FAQ below)
Missing or incorrect PTR and SPF records are the most common reason legitimate mail is rejected by major providers. Verify all records with a tool such as MXToolbox after deployment.
- MX record — points your domain to your mail server hostname, e.g.
- 🔗 How do I configure DKIM signing?
ulaknode includes the
ulaknode-dkimtool that manages DKIM keys and updates OpenDKIM's signing configuration automatically.1. Generate a 2048-bit RSA key pair for your domain:
docker exec ulaknode ulaknode-dkim generate yourdomain.comThe default selector is
mail. You can pass a custom selector as a second argument.2. Retrieve the DNS TXT record to publish:
docker exec ulaknode ulaknode-dkim show yourdomain.comThe output shows the exact TXT record to add to your DNS zone, in the form:
mail._domainkey.yourdomain.com IN TXT "v=DKIM1; k=rsa; p=<public-key>"3. Reload OpenDKIM to apply the new key:
docker exec ulaknode ulaknode-service reload opendkimOther useful commands:
# List all DKIM keys docker exec ulaknode ulaknode-dkim list # Remove a key docker exec ulaknode ulaknode-dkim remove yourdomain.com mailAfter DNS propagation, verify signing with mail-tester.com — look for
DKIM=passin the received headers. - 🔗 How do I generate an SPF record?
The
ulaknode-spftool generates a ready-to-publish SPF TXT record and can also look up what is currently published in DNS.Basic SPF using the domain's own MX hosts (recommended starting point):
docker exec ulaknode ulaknode-spf generate yourdomain.com --mxAdd a specific IP address and use a strict fail policy:
docker exec ulaknode ulaknode-spf generate yourdomain.com --mx --ip4 203.0.113.10 --all failAuthorize an external sending service:
docker exec ulaknode ulaknode-spf generate yourdomain.com --mx --include sendgrid.net --all softfailAvailable
--allpolicies:softfail(~all) — unauthorized senders are flagged but not rejected; recommended when starting outfail(-all) — unauthorized senders are rejected outrightneutral(?all) — no assertion about unauthorized senders
Look up the currently published SPF record:
docker exec ulaknode ulaknode-spf show yourdomain.comCopy the output into a DNS TXT record for
yourdomain.com. - 🔗 How do I set up a DMARC policy?
The
ulaknode-dmarctool generates a ready-to-publish DMARC TXT record.Start in monitoring mode (no action on failures, collect aggregate reports):
docker exec ulaknode ulaknode-dmarc generate yourdomain.com \ --policy none \ --rua mailto:dmarc@yourdomain.comMove to quarantine once you are confident in DKIM and SPF:
docker exec ulaknode ulaknode-dmarc generate yourdomain.com \ --policy quarantine \ --rua mailto:dmarc@yourdomain.comStrict reject mode:
docker exec ulaknode ulaknode-dmarc generate yourdomain.com \ --policy reject \ --rua mailto:dmarc@yourdomain.com \ --pct 100The output is the exact TXT record value to publish under
_dmarc.yourdomain.com.Look up the currently published DMARC record:
docker exec ulaknode ulaknode-dmarc show yourdomain.comThe recommended rollout sequence is:
none→quarantine→reject, advancing only after reviewing aggregate reports to ensure no legitimate mail is failing. - 🔗 How do I add or remove email domains?
Use the
ulaknode-domaintool to manage virtual domains hosted on your server.# Add a domain docker exec ulaknode ulaknode-domain add yourdomain.com # List all hosted domains docker exec ulaknode ulaknode-domain list # Show all mailboxes and aliases for a domain docker exec ulaknode ulaknode-domain show yourdomain.com # Remove a domain and all its associated mailboxes and aliases docker exec ulaknode ulaknode-domain remove yourdomain.comAfter adding a domain, set up the DNS records described in the DNS FAQ above, then create mailboxes and DKIM keys for the domain.
- 🔗 How do I add, remove, or manage email accounts?
Use the
ulaknode-mailboxtool for all mailbox operations.Create a new mailbox:
docker exec ulaknode ulaknode-mailbox add alice@yourdomain.com secretpasswordList all mailboxes for a domain:
docker exec ulaknode ulaknode-mailbox list yourdomain.comChange a mailbox password:
docker exec ulaknode ulaknode-mailbox passwd alice@yourdomain.com newpasswordSet a storage quota:
docker exec ulaknode ulaknode-mailbox quota-set alice@yourdomain.com 5GAccepted suffixes:
K,M,G. Use0for unlimited (the default).Check current quota usage:
docker exec ulaknode ulaknode-mailbox quota alice@yourdomain.comShow mailbox details:
docker exec ulaknode ulaknode-mailbox show alice@yourdomain.comRemove a mailbox:
docker exec ulaknode ulaknode-mailbox remove alice@yourdomain.com - 🔗 How do I manage email aliases?
Use the
ulaknode-aliastool to create and manage address aliases. An alias forwards mail from a source address to one or more destination addresses.# Add an alias docker exec ulaknode ulaknode-alias add info@yourdomain.com alice@yourdomain.com # List all aliases for a domain docker exec ulaknode ulaknode-alias list yourdomain.com # Show the destination(s) for a specific alias docker exec ulaknode ulaknode-alias show info@yourdomain.com # Remove an alias docker exec ulaknode ulaknode-alias remove info@yourdomain.comBoth the source and destination must be full email addresses. You can point the same source alias at multiple destinations by running
ulaknode-alias addmultiple times with different destinations. - 🔗 Can a separate web UI let users change their own password or quota?
ulaknode-agentis a small TCP listener, started automatically alongside the other services, that lets a separate sidecar web UI drive password and quota changes without needing a shared filesystem ordocker execaccess into the ulaknode container. It speaks a one-line request/response protocol and runsulaknode-mailbox passwd/quota-setlocally, wheredoveadm/postmapalready live.You don't have to build that sidecar yourself — itefixnet/ulaknode-web is the maintained reference implementation: a self-service
/password/resetpage (single-use, 30-minute emailed link) and a password-protected/adminpanel for looking up a mailbox's current maildir/quota, resetting its password, or changing its quota. It talks toulaknode-agentexactly as described below and holds no local copy of mailbox state.docker run -d \ --name ulaknode-web \ --network host \ -e SECRET_KEY=change-me \ -e ADMIN_PASSWORD=change-me \ -e MAIL_FROM=no-reply@yourdomain.example \ itefixnet/ulaknode-web:latest--network hostis the simplest way to reachulaknode-agentand Postfix, and works as long as the mainulaknodecontainer also runs withnetwork_mode: host. On a bridge-network deployment, pointULAKNODE_AGENT_HOST/SMTP_HOSTat an address that reaches theulaknodecontainer instead, and widenULAKNODE_AGENT_ALLOWED_IPS/Postfix'smynetworkson that side to match.It's opt-in in effect: nothing calls it unless you point a sidecar at it. By default it binds to loopback only (
127.0.0.1:8787) and accepts connections only from127.0.0.1/32. To widen it for a sidecar on a Docker bridge network, set these environment variables on the container:ULAKNODE_AGENT_BIND— interface to bind to (default127.0.0.1)ULAKNODE_AGENT_ALLOWED_IPS— CIDR allowed to connect (default127.0.0.1/32)ULAKNODE_AGENT_PORT— listening port (default8787)
Protocol: a client sends one line, gets one line back. Email/password/quota values are base64-encoded so they can't collide with the field separator:
PASSWD <b64 email> <b64 password> QUOTA-SET <b64 email> <b64 limit> SHOW <b64 email>SHOWreturns the mailbox's current settings (maildir, quota limit) so a sidecar UI can read them instead of guessing or caching them locally. The response isOK: <message>orERROR: <message>.If you don't run a sidecar UI, there's nothing to configure here — the listener stays bound to loopback and unused.
- 🔗 How do I install or renew a TLS certificate?
Use the
ulaknode-certtool to install a certificate (e.g. from Let's Encrypt) and verify it is correctly configured.Install a certificate:
docker exec ulaknode ulaknode-cert install /tmp/fullchain.pem /tmp/privkey.pemShow the currently installed certificate:
docker exec ulaknode ulaknode-cert showVerify the certificate and key match and check expiry:
docker exec ulaknode ulaknode-cert checkThis command warns if the certificate expires within 14 days.
Restart services after installing a new certificate:
docker exec ulaknode ulaknode-service restart postfix docker exec ulaknode ulaknode-service restart dovecot - 🔗 How does spam filtering work, and how can I tune it?
ulaknode filters spam in layers rather than through one unified scoring engine, using a set of focused, well-known tools:
- Postscreen checks the connecting IP against DNSBL blocklists (Spamhaus Zen, SpamCop) before a real SMTP session even starts, using a dedicated recursive resolver (Unbound) so queries aren't misclassified as coming from a shared/open resolver
- policyd-spf checks the sender's SPF record
- OpenDKIM and OpenDMARC verify DKIM signatures and enforce the sender domain's DMARC policy
- Postgrey greylists unfamiliar senders, temporarily deferring first-contact mail — real mail servers retry automatically per the SMTP spec, but most spambots don't
- SpamAssassin scores the remaining mail's content (headers, body text, Bayesian statistics); messages above the configured threshold are tagged or rejected
Train the Bayes classifier with known spam and ham:
docker exec ulaknode sa-learn --spam /path/to/spam.eml docker exec ulaknode sa-learn --ham /path/to/ham.emlCommon tuning via SpamAssassin configuration (edit
/etc/spamassassin/local.cf):- Whitelist a sender: add
whitelist_from address@example.com - Blacklist a sender: add
blacklist_from address@example.com - Adjust the reject/tag threshold: edit
required_score(default:5.0)
After editing configuration, reload SpamAssassin:
docker exec ulaknode ulaknode-service reload spamassassinTo bypass greylisting for a known-good sender, add their address or network to
/etc/postgrey/whitelist_clients.local(this.localfile is read alongside the Debian package's own maintained whitelist, rather than overwriting it) and restart Postgrey:docker exec ulaknode ulaknode-service restart postgreyDNSBL checking is on by default and needs no setup. To tune it, edit
postscreen_dnsbl_sites,postscreen_dnsbl_threshold, orpostscreen_dnsbl_actionin/etc/postfix/main.cf, then reload Postfix. Unbound's own settings (cache size, logging) live in/etc/unbound/unbound.conf.d/ulaknode.conf— restart Unbound after editing:docker exec ulaknode ulaknode-service reload postfix docker exec ulaknode ulaknode-service restart unboundIf mail starts getting rejected with "Service unavailable" or DNSBL false positives, check
docker exec ulaknode ulaknode-errlog unbound— this usually means outbound port 53 is firewalled, so Unbound can't resolve recursively. - 🔗 How does brute-force protection (Fail2ban) work?
ulaknode runs Fail2ban inside the container as a supervised service — no separate install or host bind-mounts needed. Rather than writing
iptablesrules (which don't work from inside a container's network namespace and would requireNET_ADMIN/--privileged), it bans at the application layer by editing the same access-control tables Postfix and Dovecot already read: a Postfixcidraccess table for SMTP, and a filter-scoped Dovecot deny passdb for IMAP.Two jails run by default:
ulaknode-smtp— watches/var/log/mail.logfor SASL auth failures and unauthorized RCPT attempts; a ban blocks SMTP (25/587) onlyulaknode-imap— watches/var/log/dovecot.logfor IMAP login failures; a ban blocks IMAP (993) only
Both default to
maxretry = 5,findtime = 10m,bantime = 1h.Check ban status:
docker exec ulaknode fail2ban-client status docker exec ulaknode fail2ban-client status ulaknode-smtp docker exec ulaknode fail2ban-client status ulaknode-imapUnban an IP:
docker exec ulaknode fail2ban-client set ulaknode-smtp unbanip <ip> docker exec ulaknode fail2ban-client set ulaknode-imap unbanip <ip>Tune the jails by editing
/etc/fail2ban/jail.d/ulaknode.conf(persisted in theulak_fail2banvolume), then reloading:docker exec ulaknode vi /etc/fail2ban/jail.d/ulaknode.conf docker exec ulaknode ulaknode-service reload fail2banFor progressive escalation on repeat offenders, add to each jail:
bantime.increment = true bantime.multiplier = 24 bantime.maxtime = 30dNote this stops credential-stuffing and brute-force login attempts, not volumetric floods — a banned IP still completes the TCP handshake and gets an instant reject. Actual flood/DoS protection is a host- or network-edge concern (rate-limiting at your firewall or provider).
- 🔗 How do I troubleshoot a message that was not delivered?
Start with the error log tool, then inspect the mail queue.
View recent Postfix errors:
docker exec ulaknode ulaknode-errlog postfix docker exec ulaknode ulaknode-errlog postfix 100The optional second argument sets how many lines to show (default: 50). The same command works for
dovecot,opendkim,opendmarc,spamassassin,spamass-milter,clamav,clamav-milter,postgrey,fail2ban, andunbound.View the mail queue:
docker exec ulaknode mailqInspect a specific queued message by its queue ID:
docker exec ulaknode postcat -q QUEUEIDCommon causes of non-delivery:
- Deferred (4xx): temporary issue on the remote side — Postfix will retry automatically
- Bounced (5xx): permanent rejection — read the DSN for the reason (often a missing PTR record, SPF failure, or blocklist listing)
- Spam-filtered locally: check
ulaknode-errlog spamassassinorulaknode-errlog postgreyto see if the message was tagged, greylisted, or rejected before reaching the queue
If you need further assistance, open a ticket at the support portal with the relevant log excerpts.
- 🔗 How do I back up and restore ulaknode data?
ulaknode stores all persistent data in named Docker volumes: mailboxes, logs, TLS certificates, and configuration for Postfix, Dovecot, OpenDKIM, OpenDMARC, SpamAssassin, policyd-spf, Postgrey, ClamAV, Fail2ban, and Unbound. A reliable backup strategy must cover all of these volumes.
We recommend docker-volume-backup-borg, a purpose-built tool that automates encrypted, compressed backups of Docker volumes using BorgBackup. It discovers volumes automatically from running containers and supports both local and remote (SSH) repositories.
Basic backup run targeting the ulaknode container:
docker run --rm \ --env-file .env \ -e BACKUP_CONTAINERS=ulaknode \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /var/lib/docker/volumes:/var/lib/docker/volumes:ro \ -v ~/.ssh:/root/.ssh:ro \ itefixnet/docker-volume-backup-borgConfigure at minimum these variables in your
.envfile:BORG_REPO— destination repository (local path oruser@host:pathfor remote)BORG_PASSPHRASE— encryption passphrase; store this securely, without it restores are impossibleKEEP_DAILY/KEEP_WEEKLY/KEEP_MONTHLY— retention policy (defaults: 7 / 4 / 6)
Stop the container during backup for a fully consistent snapshot:
docker run --rm \ --env-file .env \ -e BACKUP_CONTAINERS=ulaknode \ -e STOP_CONTAINERS=ulaknode \ -v /var/run/docker.sock:/var/run/docker.sock \ -v /var/lib/docker/volumes:/var/lib/docker/volumes:ro \ itefixnet/docker-volume-backup-borgList available backups:
docker run --rm --env-file .env \ itefixnet/docker-volume-backup-borg listRestore a specific archive:
docker run --rm --env-file .env \ -v $(pwd):/restore \ itefixnet/docker-volume-backup-borg extract ARCHIVE_NAMEKeep the
.envfile and your passphrase in a safe, separate location from the backup repository itself. - 🔗 How do I monitor ulaknode's health and mail flow?
itefixnet/ulaknode-exporter is a maintained Prometheus exporter that runs as a sidecar next to the main
ulaknodecontainer. It tails the same log files and reads the same data volumes (mounted read-only, never written to) and exposes counters/gauges for Postfix/Postscreen (DNSBL hits, SASL auth failures, delivery status), Dovecot (logins, failures, active sessions), ClamAV/freshclam, SpamAssassin, Postgrey, and Fail2ban, plus basic host resource usage (load, memory, disk, network). No third-party dependencies — pure Python standard library.docker run -d \ --name ulaknode-exporter \ -p 9101:9101 \ -v ulak_logs:/var/log:ro \ -v ulak_mail:/var/mail:ro \ -v ulak_clamav:/var/lib/clamav:ro \ itefixnet/ulaknode-exporter:latestMetrics are served on
/metrics(default port9101):curl http://localhost:9101/metricsThis assumes the
ulak_logs,ulak_mail, andulak_clamavvolumes already exist — created automatically by the mainulaknodecontainer on first run. A ready-made Grafana dashboard covering every component (an always-visible overview row plus per-component rows collapsed by default) ships alongside the exporter's source.By default the exporter runs in its own network namespace, so its network byte-count metrics describe its own virtual interface, not the host's real NIC traffic. Run it with
network_mode: hostto report real host network throughput — the same tradeoff the standard Prometheusnode_exportermakes — at the cost of network isolation. - 🔗 Is ulaknode really free? Are there hidden per-user fees?
Yes, ulaknode is genuinely free. It is released under a permissive BSD-style license with no per-user, per-mailbox, or per-domain charges. You can run it for any number of users on your own hardware at no cost.
Optional commercial support plans are available starting at $549/year for organisations that want professional assistance with deployment, DNS configuration, migration, maintenance, and spam-filtering optimisation. Multi-year and volume discounts are available.
To learn more about support options or to request a quote, reach us via our contact form.