Operations 29 min read

Build a Self‑Hosted Email Server on CentOS 7: Step‑by‑Step Guide

Learn how to set up a complete email system on CentOS 7 using Postfix, Dovecot, MariaDB, Nginx, and Roundcube, covering software installation, configuration, database setup, SSL/TLS, DKIM, SPF, DMARC, and testing, with detailed commands and troubleshooting tips.

Raymond Ops
Raymond Ops
Raymond Ops
Build a Self‑Hosted Email Server on CentOS 7: Step‑by‑Step Guide

Introduction

This guide shows how to build a fully functional self‑hosted email system on a CentOS 7 server. It covers everything from installing required software to configuring Postfix, Dovecot, MariaDB, OpenDKIM, Nginx, and Roundcube, as well as setting up DNS records and testing the setup.

Prerequisites

CentOS 7.9 minimal installation (SELinux and firewall disabled)

Domain name (e.g., example.com) and public IP (e.g., 1.1.1.1)

Root access to the server

Install Required Packages

yum -y update && \
 yum -y install epel-release && \
 yum -y update && \
 yum -y install dovecot dovecot-mysql mariadb-server nginx opendkim php-fpm php-mbstring php-mysql php-xml postfix pypolicyd-spf tar wget

If a key warning appears, press y and hit Enter.

Configure MariaDB (MySQL)

Initialize and Secure MariaDB

systemctl start mariadb
mysql_secure_installation

Follow the prompts to set a root password and answer y to all questions.

Create Database and Users

CREATE DATABASE mail_sys;
CREATE USER 'mail_sys'@'localhost' IDENTIFIED BY 'mail_sys';
GRANT SELECT ON mail_sys.* TO 'mail_sys'@'localhost' IDENTIFIED BY 'mail_sys';
FLUSH PRIVILEGES;

Create Tables

CREATE TABLE `domains` ( `id` int(20) NOT NULL auto_increment, `name` varchar(100) NOT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `users` ( `id` int(20) NOT NULL auto_increment, `domain_id` int(20) NOT NULL, `password` varchar(200) NOT NULL, `email` varchar(200) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `email` (`email`), FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE `aliases` ( `id` int(20) NOT NULL auto_increment, `domain_id` int(20) NOT NULL, `source` varchar(200) NOT NULL, `destination` varchar(200) NOT NULL, PRIMARY KEY (`id`), FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Configure Postfix

Backup Original Configuration

cp -r /etc/postfix /etc/postfix.bak

main.cf (core parameters)

mydomain = example.com
myhostname = mail.example.com
mydestination = localhost
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
myorigin = /etc/mailname
mynetworks = 127.0.0.0/8
mailbox_size_limit = 0
recipient_delimiter = +
inet_protocols = all
inet_interfaces = all
smtp_address_preference = ipv4
smtpd_banner = ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
virtual_transport = lmtp:unix:private/dovecot-lmtp
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
virtual_mailbox_domains = mysql:/etc/postfix/mysql_mailbox_domains.cf
virtual_mailbox_maps = mysql:/etc/postfix/mysql_mailbox_maps.cf
virtual_alias_maps = mysql:/etc/postfix/mysql_alias_maps.cf
smtpd_sender_login_maps = mysql:/etc/postfix/mysql_mailbox_maps.cf, mysql:/etc/postfix/mysql_alias_maps.cf
disable_vrfy_command = yes
strict_rfc821_envelopes = yes
smtpd_sender_restrictions = reject_non_fqdn_sender, reject_unknown_sender_domain, reject_sender_login_mismatch
smtpd_recipient_restrictions = reject_non_fqdn_recipient, reject_unknown_recipient_domain, permit_sasl_authenticated, reject_unauth_destination, check_policy_service unix:private/policyd-spf
virtual_uid_maps = static:2000
virtual_gid_maps = static:2000
message_size_limit = 102400000
smtpd_tls_security_level = may
smtp_tls_security_level = may
smtpd_tls_cert_file=/etc/pki/tls/certs/cert.pem
smtpd_tls_key_file=/etc/pki/tls/private/key.pem
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtpd_tls_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_ciphers = high
smtpd_tls_ciphers = high
smtpd_tls_mandatory_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_mandatory_protocols = TLSv1.2, TLSv1.1, !TLSv1, !SSLv2, !SSLv3
smtp_tls_mandatory_ciphers = high
smtpd_tls_mandatory_ciphers = high
smtpd_tls_mandatory_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
smtpd_tls_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
smtp_tls_exclude_ciphers = MD5, DES, ADH, RC4, PSD, SRP, 3DES, eNULL, aNULL
tls_preempt_cipherlist = yes
smtpd_tls_received_header = yes
policyd-spf_time_limit = 3600

master.cf (service definitions)

smtp      inet  n       -       n       -       -       smtpd
submission inet n       -       n       -       -       smtpd
  -o smtpd_tls_security_level=encrypt
smtps     inet  n       -       n       -       -       smtpd
  -o smtpd_tls_wrappermode=yes
... (other service definitions omitted for brevity) ...

Configure Dovecot

Backup Original Configuration

cp -r /etc/dovecot /etc/dovecot.bak

dovecot.conf

protocols = imap lmtp
!include conf.d/*.conf
!include_try local.conf

10-mail.conf

namespace inbox {
  inbox = yes
}
first_valid_uid = 1000
mbox_write_locks = fcntl
mail_location = maildir:/var/spool/mail/%d/%n
mail_privileged_group = mail

15-mailboxes.conf (special folders)

namespace inbox {
  mailbox Drafts {
    auto = create
    special_use = \Drafts
  }
  mailbox Trash {
    auto = create
    special_use = \Trash
  }
  mailbox Sent {
    auto = create
    special_use = \Sent
  }
}

10-auth.conf

auth_mechanisms = plain login
!include auth-sql.conf.ext

auth-sql.conf.ext

passdb {
  driver = sql
  args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
  driver = static
  args = uid=mail_sys gid=mail_sys home=/var/spool/mail/%d/%n
}

dovecot-sql.conf.ext

driver = mysql
connect = host=localhost dbname=mail_sys user=mail_sys password=mail_sys
default_pass_scheme = SHA512-CRYPT
password_query = SELECT email as user, password FROM users WHERE email='%u';

10-ssl.conf

ssl = required
ssl_cert =

Replace /etc/pki/tls/certs/cert.pem and /etc/pki/tls/private/key.pem with your actual certificate files.

10-master.conf (IMAP/LMTP listeners)

service imap-login {
  inet_listener imap {
    port = 143
  }
  inet_listener imaps {
    port = 993
    ssl = yes
  }
}
service lmtp {
  unix_listener /var/spool/postfix/private/dovecot-lmtp {
    mode = 0600
    user = postfix
    group = postfix
  }
}
service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0666
    user = postfix
    group = postfix
  }
  unix_listener auth-userdb {
    mode = 0600
    user = mail_sys
  }
  user = dovecot
}

Configure OpenDKIM

opendkim.conf

Syslog yes
UMask 002
OversignHeaders From
Socket inet:[email protected]
Domain example.com
KeyFile /etc/opendkim/keys/mail.private
Selector mail
RequireSafeKeys no

Generate Keys

opendkim-genkey -D /etc/opendkim/keys/ -d example.com -s mail && \
chown -R opendkim:opendkim /etc/opendkim/keys/

Add DKIM Milter to Postfix

milter_protocol = 2
milter_default_action = accept
smtpd_milters = inet:127.0.0.1:8891
non_smtpd_milters = inet:127.0.0.1:8891

Configure Nginx and Roundcube (Webmail)

Nginx Virtual Host

server {
  listen 80;
  server_name mail.example.com;
  return 301 https://$server_name$request_uri;
}
server {
  listen 443 ssl http2;
  server_name mail.example.com;
  ssl_certificate "/etc/pki/tls/certs/cert.pem";
  ssl_certificate_key "/etc/pki/tls/private/key.pem";
  add_header Strict-Transport-Security "max-age=15552000; includeSubDomains";
  location / {
    root /usr/share/roundcube;
    index index.php;
  }
  location ~ \.php$ {
    root /usr/share/roundcube;
    fastcgi_pass   127.0.0.1:9000;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include        fastcgi_params;
  }
}

PHP Settings

echo "date.timezone = Asia/Shanghai" >> /etc/php.ini
mkdir -p /var/lib/php/session
chown apache:apache /var/lib/php/session

Roundcube Database

CREATE USER 'roundcube'@'localhost' IDENTIFIED BY 'roundcube';
CREATE DATABASE roundcube;
GRANT ALL ON roundcube.* TO 'roundcube'@'localhost' IDENTIFIED BY 'roundcube';
FLUSH PRIVILEGES;

Complete the web installer at https://mail.example.com/installer/?_step=1 and then lock the installer directory:

chmod -R 000 /usr/share/roundcube/installer/

DNS Records

A record : @ → 1.1.1.1 MX record : @ → mail.example.com SPF record : @ TXT "v=spf1 mx -all" DMARC record : _dmarc TXT "v=DMARC1; p=reject" DKIM record : publish the p=… value from /etc/opendkim/keys/mail.txt as mail._domainkey TXT "v=DKIM1; k=rsa; p=..." PTR record (reverse DNS) should point the server IP to mail.example.com if possible.

Testing

Receive Test

Send an email from an external account (e.g., Gmail or QQ) to [email protected]. Verify it appears in Roundcube.

Send Test

Use mail‑tester.com to send a test email and check that SPF, DKIM, and DMARC pass.

Optional Firewall Configuration

iptables -A INPUT -p tcp -m multiport --dports 25,465,587,993 -j ACCEPT

Save the firewall rules according to your distribution's method.

Conclusion

This tutorial provides a complete, reproducible method to deploy a secure, self‑hosted email service on CentOS 7. By following the steps, you obtain a functional SMTP/IMAP server with webmail access, proper DNS authentication, and basic firewall hardening.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

CentOSpostfixdovecotemail serverroundcubeopendkim
Raymond Ops
Written by

Raymond Ops

Linux ops automation, cloud-native, Kubernetes, SRE, DevOps, Python, Golang and related tech discussions.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.