Obligatory Warning

Building a web and mail server isn’t like getting a prickly pear cactus, bamboo plant, or even like the Ronco® Showtime® Rotisserie & BBQ. This is like getting a dog: you have to give it care and attention or it will end up becoming a menace to society.

If you build this server and neither update it nor check up on it, it will almost guaranteed either become part of a botnet or end up sending those e-mails you keep getting and wish would stop. Depending on your host and jurisdictions, you may also end up being liable for any damage (both real and intangible) that this server causes. Which is why I provide Web Administration services, but I digress.

You have been warned, and I’ll sleep soundly arming you with the following knowledge.


Last time I tried something like this, I was inspired by Lee Hutchinson’s web server guide and mail server guide. Therefore, I set out setting up a web and mail server on a home PC to learn more about Internet services, as he suggested. While his guides use Ubuntu, I prefer CentOS for key Internet services because it is more secure. When connecting to the Internet, it pays to be more secure.

Here’s the thing: even though CentOS’ “Mother distro” (RHEL) is ubiquitous, there is almost no documentation on configuring it. I ended up having to use and modify a guide for CentOS 5 to get it all working, even though the latest version was CentOS 6.

Once again, looking up ways to configure CentOS 7 as a web and mail server on AWS turns up almost nothing. Maybe everyone that uses RHEL professionally is too busy to make a guide. Maybe setting it up is a rite of passage. Maybe CentOS is so rock-solid that guides need few, if any, updates. I honestly have no idea since I’m not setting up clusters of RHEL servers for a living.

Luckily, I don’t have such time constraints.


Most guides recommend disabling SELinux, but it’s a very useful feature that restricts access to files by providing security contexts for all files. If you’re not in one of that file’s security contexts, you’re not accessing it. This can be troublesome for services that cross communicate, which is why it’s often disabled; however, as before, we’re taking the high road this time around. SELinux contexts are better configured these days, so it’s much easier than it used to be.

Most guides also recommend using the EPEL repository, and I used the now defunct RPMforge repository last time I tried something like this; however, we’ll be avoiding EPEL this time around. It’d probably be fine, but I feel using the default repos will be more stable in the long term.

I use DuckDNS as a DDNS because they are the best (although that is my opinion). As a result, I have some novelty subdomains that I would like to provide a simple, static web page and e-mail for; however, a DDNS has to be notified of what IP address to point queries to. For simplicity, the DDNS also treats the subdomain’s MX record the same as the A record that gets updated.

As I found out last time I tried something like this, it’s easiest to have a server report its IP address to the DDNS and then handle any requests that it gets from there. I also found out that ISPs don’t want you to have an Internet-facing home server because it is “unsafe” (and also happens to use precious, precious bandwidth).

This is where AWS comes into play: you can run what you want, so long as you pay for it (and it doesn’t break any local laws, of course).

Documentation on setting up a CentOS 7 web and mail server on AWS is sparse enough as it is, much less configuring a DDNS to go along with it, so this guide will fill the CentOS 7 web and mail server with DDNS gap to some degree.


Pretty sparse: an AWS EC2 instance running CentOS 7 AMI and DuckDNS providing DDNS services.

Sounds easy enough, right?


Setting up this system won’t be as easy. The first step would be getting an account and API token at the DuckDNS website. The next step would be getting set up to use Amazon EC2. Now, the fun begins.

Configure EC2

First, we need a VPC. The default VPC can be used if this is the first EC2 instance, but try not to have everything running in the same subnet if they don’t need to cross communicate.


  • Enabled Auto-assign IPv4 for the subnet since it will only have one network interface.

Next, it would be wise to have a custom IAM role for the AWS service.

Now that our instance can connect to the Internet and can’t run rampant across AWS services, we can get started with an EC2 instance, just skip Step 3 because we don’t want to delete it when we’re done.


  • When selecting an AMI to use, choose the official CentOS 7 AMI.
  • Opted for a t3.micro instance since that’s what’s ‘in’ right now.
  • Used Security Groups to limit SSH access to My IP, allow HTTP(S), SMTP(S), and IMAP(S) from Anywhere. However, restrict HTTP(S), SMTP(S), and IMAP(S) to My IP as you work on the respective service. After each has been configured, it can be set to accept Inbound connections from Anywhere.
  • Used a key pair for SSH access.
  • It is a good idea to enable billing alerts in case it gets out of hand.
  • The root volume is deleted on termination of the instance, so I enabled termination protection.
  • Made the root volume 10 GB because it should be fairly low volume since they are novelty sites.
  • Went with magnetic storage because it costs less than SSD storage and the I/O speed is not needed.

Finally, we have to request a reverse DNS record from AWS by filling out the form at https://aws.amazon.com/forms/ec2-email-limit-rdns-request . Without this, other servers won’t be able to find out our hostname when checking our IP address. This is a key security feature for mail servers so that random computers can’t send e-mail. Note that the request process can take a few days.

Configure CentOS 7

Now that CentOS 7 is installed, go ahead and run a sudo yum update && sudo yum upgrade to get everything updated.


The CentOS 7 AMI already has SSH set up and external PAM authentication disabled, but automatic upgrades would be a handy addition.

Install yum-cron with sudo yum install yum-cron and change the following line in /etc/yum/yum-cron.conf by invoking vi:

sudo vi /etc/yum/yum-cron.conf

Type a for “append,” then change the following line:

apply_updates = yes

To save, press the ESC key to enter command mode, type : to enter a command, then wq for “write and quit,” and press the ENTER key to issue the command. If you don’t want to save changes, replace wq with q!. Bam, you’re a vi wizard, Harry.

Let’s confirm yum-cron is starting at boot and running with:

sudo systemctl is-enabled yum-cron  # Check if starting at boot
sudo systemctl is-active yum-cron   # Check if running

Mine is set to start at boot, but not running, so start it with:

sudo systemctl start yum-cron

With that done, we can move on to services.


  • Normally, I’m a fan of vim, but installing vim would require 60 MiB of disk space (´;ω;`).

Configure NGINX

Between the web and mail server, the web server is definitely the lesser of two evils. As for which one to install and configure, there are a couple of choices.

Apache is #1 when it comes to serving the web, but NGINX is #2. Since our server is pulling double duty as a mail server (and will, honestly, use more resources as a mail server), we’ll be using NGINX because it is considered to be more efficient than Apache.

Unfortunately, NGINX is not included in the default yum repositories, so we have to add it manually to /etc/yum.repos.d/nginx.repo (per NGINX’s installation instructions) by creating it with sudo vi /etc/yum.repos.d/nginx.repo and adding the following:

name=nginx repo


  • The nginx package is also available in EPEL, but again, we’re avoiding EPEL this time around.

Now, we can install NGINX and confirm it was installed:

sudo yum update && sudo yum install nginx
nginx -V

At the time of this writing, NGINX’s repo had version nginx/1.14.2. NGINX has to be started manually the first time and we may as well start at boot while we’re at it:

sudo systemctl start nginx  # Start service
sudo systemctl enable nginx # Start at boot

We can check if NGINX is working by entering the Public DNS (if configured) or Public IP address in a browser window.

Chrome browser window with NGINX default page.

Next, we ought to improve nginx.conf a bit by invoking sudo vi /etc/nginx/nginx.conf and making it look more like:

user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;

    server_tokens off;
    client_max_body_size  4096k;
    client_header_timeout 10;
    client_body_timeout   10;
    keepalive_timeout     10 10;
    send_timeout          10;

    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

    gzip  on;
    gzip_disable 'msie6';
    gzip_min_length 1100;
    gzip_vary on;
    gzip_proxied any;
    gzip_buffers 16 8k;
    gzip_types text/plain text/css application/json application/x-javascript
        text/xml application/xml application/rss+xml text/javascript
        image/svg+xml application/x-font-ttf font/opentype

    index index.html, index.htm;
    include /etc/nginx/conf.d/*.conf;

The key here is enabling gzip compression on as many types as we can so that the web server sends as little data as possible. AWS is fairly generous with network traffic quotas, but there’s no need to push the envelope.

The default page is all well and good, but we need some pages of our own in a place that is less likely to be wiped out after an NGINX upgrade. For starters, do the following to make a copy of the default site configuration:

cd /etc/nginx/conf.d
sudo cp default.conf website_name_here.conf

Open the new configuration file for editing:

sudo vi website_name_here.conf

We can now add our own server blocks by making it look more like this:

server {
listen       80;
server_name  website.name.here;
root /srv/www/website-name-here;

location / {
    try_files $uri $uri/ =404;
location ~ /\. { access_log off; log_not_found off; deny all; }  # ignore files beginning with '.'
location ~ ~$ { access_log off; log_not_found off; deny all; }  # ignore files beginning with '$'

That root location doesn’t exist, so now we have to make it, change the ownership to the user specified in /etc/nginx/nginx.conf so that it can have access without being the root user, and change the security context so that SELinux plays nice:

sudo mkdir --parents /srv/www/website-name-here
sudo chown --recursive nginx:nginx /srv/www
sudo restorecon -Rv /srv/www/

Add any needed files to the root location using FTP, restart NGINX to apply changes, and it’s good to go!

sudo systemctl restart nginx

Bam! Web server’s done! Well, mostly. We still need to set up SSL/TLS, but for that we need DuckDNS to have our IPv4 address.

Configure DuckDNS

DuckDNS has many ways to “install” updaters for their service. DuckDNS even provides instructions for EC2. Unfortunately, the instructions assume an Ubuntu EC2 instance. Thankfully, we can modify their bash scripts to serve our purposes.

After making the duckdns directory as the default user, we can modify duck.sh to look like this:

while true; do
    latest=`curl -s`
    echo "public-ipv4=$latest"
    if [ "$current" == "$latest" ]
        echo "ip not changed"
        echo "ip has changed - updating"
        echo url="https://www.duckdns.org/update?domains=exampledomain&token=a7c4d0ad-114e-40ef-ba1d-d217904a50f2&ip=" | curl -k -o ~/duckdns/duck.log -K -
    sleep 5m

Someone far more obsessive than I explains the reason for replacing ec2-metadata. TL;DR: It’s very slow (19 seconds!) and has dependencies that take up ~90 MiB of space.

After saving duck.sh and making it executable, we also have to modify duck_daemon.sh to look more like this:

su - default_user_here -c "nohup ~/duckdns/duck.sh > ~/duckdns/duck.log 2>&1&"

Again, we’re not using Ubuntu, so we have to change the default user (you know the default user I’m talking about).

After saving duck_daemon.sh and making it executable, follow the rest of the instructions for EC2 to make sure it’s working and starting at boot.

Now that DuckDNS has our IPv4 address, we can work on getting those SSL/TLS certificates.


It won’t be possible to configure a DKIM or SPF record using a DDNS, so outgoing e-mails may get rejected by some providers.

Configure SSL/TLS

For random DDNS websites that I just feel like having (or otherwise), we’ll need a third-party CA. A third-party CA is an organization that verifies a computer’s hostname and issues a signed certificate stating such. Think of them as digital notary publics.

With third-party CAs, there are a lot of options; however, most of those options cost money. One of the few remaining free CAs is the up-and-coming Let’s Encrypt. Luckily, they recommend using Certbot if using NGINX on CentOS 7. Unluckily, Certbot requires enabling EPEL, and Let’s Encrypt certificates expire after just 90 days as a fair compromise between free and convenient.

Therefore, if you want a certificate that lasts a few years, feel free to shell out cash for it, but I’m going to choose one of the other client options for my novelty sites so that I don’t have to manually renew four times a year per site. Specifically, I’ll be doing what acme.sh says to do.


Use acme.sh‘s --staging option to keep from hitting the rate limits of Let’s Encrypt while you make sure everything is set up properly.

Third-party CA-signed certificates are great for HTTPS connections on NGINX, but they’re also handy for sending e-mails to other servers.

Configure Postfix

Now that we can identify ourselves and securely connect, we can set up Postfix to send and receive e-mail, then Dovecot to deliver and access our e-mails. Postfix and Dovecot have been together almost as long as Pat Sajak and Vanna White (not really, but work with me), and I intend to keep them that way. Actually, they’re easier to configure cross communication with, so that’s why they’re more like peas and carrots: together in just about everything.

Luckily, Postfix is already installed, so all we have to do is set Postfix as the default MTA.

sudo alternatives --set mta /usr/sbin/sendmail.postfix

Let’s go straight to the configuration files. I’ll be using this Postfix guide for CentOS 5 with some security settings to reduce Postfix spam and configuring SSL/TLS until it works with CentOS 7. Go ahead and add or edit /etc/postfix/main.cf to look more like this:

# General settings
myhostname = yourdomain.duckdns.org
mydomain = $myhostname
myorigin = $myhostname
recipient_delimiter = +
mailbox_command = /usr/libexec/dovecot/deliver -c
    /etc/dovecot/dovecot.conf -m "${EXTENSION}"
mailbox_size_limit = 0
home_mailbox = Maildir/
inet_interfaces = all
inet_protocols = ipv4
mydestination =
mynetworks =, x.x.x.0/20  # Set to subnet used when configuring EC2
relayhost =
local_recipient_maps =

# SASL settings
smtpd_sasl_auth_enable = yes
smtpd_sasl_authenticated_header = yes
broken_sasl_auth_clients = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname

# Custom settings
smtpd_banner = $myhostname ESMTP
smtpd_delay_reject = yes
smtpd_helo_required = yes
smtpd_helo_restrictions =
smtpd_sender_restrictions =
smtpd_recipient_restrictions =

smtpd_sender_login_maps = $virtual_mailbox_maps

default_destination_concurrency_limit = 5
disable_vrfy_command = yes
relay_destination_concurrency_limit = 1

# Reject with permanent 550 errors to stop retries
unknown_local_recipient_reject_code = 550
unknown_address_reject_code = 550
unknown_hostname_reject_code = 550
unknown_client_reject_code = 550

# TLS settings
smtpd_tls_auth_only = yes
smtpd_tls_received_header = yes
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
tls_random_source = dev:/dev/urandom
tls_random_exchange_name = /var/lib/postfix/prng_exch
smtpd_tls_ask_ccert = yes
smtpd_tls_cert_file = /etc/postfix/certs/yourdomain.duckdns.org/cert  # locations specified in acme.sh cert installation
smtpd_tls_key_file = /etc/postfix/certs/yourdomain.duckdns.org/key
smtpd_tls_CAfile = /etc/ssl/certs/ca-bundle.crt
smtpd_tls_ciphers = high
smtpd_tls_loglevel = 1
smtpd_tls_security_level = may
smtpd_tls_session_cache_timeout = 3600s
smtp_tls_note_starttls_offer = yes
smtp_tls_security_level = may

# Custom Dovecot and virtual user settings
message_size_limit = 104857600
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
virtual_alias_maps = hash:/etc/postfix/virtual
virtual_mailbox_domains = hash:/etc/postfix/virtual-mailbox-domains
virtual_mailbox_maps = hash:/etc/postfix/virtual-mailbox-users
virtual_transport = dovecot
dovecot_destination_recipient_limit = 1
luser_relay = username@yourdomain.duckdns.org

There’s a lot to unpack here, so I recommend checking out the postfix configuration parameters for specifics. Generally, we’re identifying our mail server as a virtual domain with virtual users. We’re also setting up TLS for secure communications between servers and setting up Dovecot as our MDA. Importantly, we’re also setting up rules on how other servers should speak to us before we listen to them and accept their incoming e-mail.

While main.cf lays the groundwork for all these settings, we now actually have to do the legwork to get them configured. First, in /etc/postfix/master.cf add the following:

submission inet n       -       -       -       -       smtpd

dovecot   unix  -       n       n       -       -       pipe
  flags=DRhu user=vmail:vmail argv=/usr/libexec/dovecot/deliver
  -f ${sender} -d ${recipient}

This defines how we’ll process outgoing mail: let smtpd handle it. It also specifies how Dovecot should be used to deliver incoming mail to our users. That should be enough to start up Postfix without errors and enable it on boot, while we’re at it:

sudo systemctl start postfix
sudo systemctl is-active postfix
sudo systemctl enable postfix
sudo systemctl is-enabled postfix

We continue by adding some aliases to /etc/aliases, if not present:

# Common system aliases
postmaster: webmaster
webmaster:  root
www-data:   webmaster
nginx:      webmaster
postfix:    webmaster

# Person who should get root's mail
root:       username

To update the alias database hash, run sudo newaliases. These are aliases of the local service accounts that get mapped to one of our virtual users so they can receive their e-mail. Mine had many more, but this is an example of the ones we’re setting up.

Create the /etc/postfix/virtual-mailbox-domains file and add the domain to it:

yourdomain.duckdns.org          OK

then hash it as before:

postmap /etc/postfix/virtual-mailbox-domains

This lets Postfix know that our domain is effectively virtual since none of the users in /etc/aliases actually exists. Speaking of virtual users, we need to create the virtual mailboxes for them. To do that, create the /etc/postfix/virtual-mailbox-users and add their e-mails:

username@yourdomain.duckdns.org             username@yourdomain.duckdns.org
postmaster@yourdomain.duckdns.org           postmaster@yourdomain.duckdns.org
webmaster@yourdomain.duckdns.org            webmaster@yourdomain.duckdns.org

and hash it like the others:

postmap /etc/postfix/virtual-mailbox-users

This is great for accounts that should be able to send mail, but don’t need to exist as a local user, like “sales” or “info.”

Finally, we can add any temporary or virtual e-mails by updating /etc/postfix/virtual and adding any e-mails we want:

username@yourdomain.duckdns.org             username@yourdomain.duckdns.org
webmaster@yourdomain.duckdns.org            webmaster@yourdomain.duckdns.org
postmaster@yourdomain.duckdns.org           webmaster@yourdomain.duckdns.org
mail@yourdomain.duckdns.org                 postmaster@yourdomain.duckdns.org
root@yourdomain.duckdns.org                 webmaster@yourdomain.duckdns.org
abuse@yourdomain.duckdns.org                webmaster@yourdomain.duckdns.org
hostmaster@yourdomain.duckdns.org           webmaster@yourdomain.duckdns.org

and hash it like a potato:

postmap /etc/postfix/virtual

The first few are a safety map just in case the virtual mailboxes from earlier don’t automatically map to e-mails. The last two, abuse and hostmaster, aren’t really needed with a DDNS, but it’s a good convention to keep. More addresses can be added for temporary use so long as they map to one of the virtual users we defined previously at some point.

Reload postfix to ensure everything’s working okay:

sudo postfix reload

If no warnings appeared, we can get to work on Dovecot.


  • Install mailx with sudo yum install mailx, then send a test message with echo "How copy? Over." | mail -s "Testing receive" username@yourdomain.duckdns.org && sudo tail -f /var/log/maillog, and use CTL-C to stop checking the maillog.
  • Use sudo postsuper -r ALL && postqueue -f to requeue any deferred messages mentioned in the maillog.
  • Use sudo postsuper -d message_id_number to delete unwanted messages in the mail queue.
  • Use sudo postconf -n to list all settings in main.cf and sudo postconf -Mf to list all settings in master.cf.

Configure Dovecot

Dovecot is a pretty powerful MDA: in addition to delivering mail to our virtual users, it’ll let them access their mail via IMAP and even authenticate them when they try to send/read mail.

Dovecot isn’t installed, so we first have to install it, start it, enable it at boot, then make a system user with its home directory as a mailbox for all of the virtual users we made in Postfix.

sudo yum install dovecot dovecot-pigeonhole
sudo systemctl start dovecot
sudo systemctl enable dovecot
sudo groupadd -g 5000 vmail
sudo useradd -g vmail -u 5000 vmail -d /var/mail/vmail -m

All of Dovecot’s settings are peppered in the files located in the conf.d folder. Therefore, we start with /etc/dovecot/dovecot.conf and add or uncomment these two lines:

protocols = imap sieve
listen = *

Well, /etc/dovecot/conf.d/10-master.conf needs to look more like:

service imap-login {
  inet_listener imap {
    port = 143
  inet_listener imaps {
    port = 993
    ssl = yes

service auth {
  unix_listener /var/spool/postfix/private/auth {
    mode = 0660
    # Assuming default Postfix user and group
    user = postfix
    group = postfix

Then, /etc/dovecot/conf.d/10-mail.conf should have these lines:

mail_home = /var/mail/vmail/%d/%n
mail_location = maildir:/var/mail/vmail/%d/%n/mail:LAYOUT=fs

namespace inbox {
  type = private
  inbox = yes

Next, /etc/dovecot/conf.d/20-imap.conf should have the following lines:

imap_client_workarounds = delay-newmail tb-extra-mailbox-sep
protocol imap {
  mail_max_userip_connections = 10

Moreover, /etc/dovecot/conf.d/10-auth.conf should have these lines:

auth_username_chars = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890.-_@
auth_mechanisms = plain login

Furthermore, we have to enable SSL certification in /etc/dovecot/conf.d/10-ssl.conf:

ssl = required
ssl_cert = </etc/dovecot/certs/yourdomain.duckdns.org/fullchain  # locations specified by acme.sh installation
ssl_key =  </etc/dovecot/certs/yourdomain.duckdns.org/key
ssl_client_ca_dir = /etc/ssl/certs
ssl_client_ca_file = /etc/ssl/certs/ca-bundle.crt

Now, /etc/dovecot/conf.d/15-lda.conf should have:

postmaster_address = postmaster@yourdomain.duckdns.org
hostname = yourdomain.duckdns.org
quota_full_tempfail = yes
rejection_reason = Your message to <%t> was automatically rejected:%n%r

protocol lda {
  mail_plugins = sieve

You guessed it, /etc/dovecot/conf.d/90-plugin.conf needs:

plugin {

You wish, now /etc/dovecot/conf.d/auth-system.conf.ext should have:

passdb {
  driver = passwd-file
  args = username_format=%u scheme=ssha512 /etc/dovecot/passwd.db
  deny = no
  master = no
  pass = no
  skip = never
  result_failure = continue
  result_internalfail = continue
  result_success = return-ok

userdb {
  driver = static
  args = uid=5000 gid=5000 home=/var/mail/vmail/%d/%n

Nope, now /etc/dovecot/conf.d/15-mailboxes.conf needs to look like:

namespace inbox {
    mailbox Drafts {
        special_use = \Drafts
        auto = subscribe
    mailbox Junk {
        special_use = \Junk
        auto = subscribe
    mailbox Trash {
        special_use = \Trash
        auto = subscribe
    mailbox Sent {
        special_use = \Sent
        auto = subscribe

Finally, because I pity you, /etc/dovecot/conf.d/10-logging.conf needs the lines:

auth_verbose = yes
deliver_log_format = msgid=%m: %$

Are there easier ways? Yes, but we’re not covering those because you have to do manual computations before you can use a calculator.

Again, there’s a lot to unpack here. Generally, we’re defining our mail server and configuring the protocols used to allow access to e-mails. We’re also setting up SSL-secured connections and how our users will authenticate themselves once they establish those secured connections. We’re also defining the mailboxes for our virtual users and letting Dovecot know that it and Postfix are besties. Finally, we’re laying the groundwork to use Sieve as a message filter (more on that later).

You’d think that’s all there is to configuring Dovecot, but you’d be wrong. We’ll be needing some passwords stored in a database for those three virtual users we set up in /etc/postfix/virtual-mailbox-users. No, we’re not storing them plaintext in a file like a sticky note on a computer monitor.

To make the passwords unreadable, we’ll be using

doveadm pw -s SSHA512

Enter a password when prompted and it will spit out the password in a SHA512 hashed string that has to be copied to put into the database. This has to be done three times (once for each virtual user).

Once we have each hashed password, we can add it to a database in /etc/dovecot/passwd.db:


Finally, we can reload dovecot with

sudo dovecot reload

If no warnings appeared, we’re technically done! While our Postfix settings will block the most atrocious script kiddies and n00b sysadmins, we’ll still be getting e-mail from everyone and their uncle. Though optional, we can reduce spam even more by adding a bit of spam filtering.

Configure SpamAssassin

At the moment, we can accept any and all e-mail which, if used for sign-up sheets, can get very spammy very fast. Again, since this is a small server for novelty DDNS domains, we’ll only be setting up SpamAssassin and skipping a virus scanner like ClamAV to save resources. I’ll mostly be following this guide for SpamAssassin in CentOS.

First, install SpamAssassin:

sudo yum install spamassassin

Move on to the configuration files starting with /etc/mail/spamassassin/local.cf and make it look more like this:

required_hits 5
report_safe 0
required_score 5
rewrite_header Subject [PossiSpam]

This defines how spammy spam has to be before it is marked as spam. No, ‘PossiSpam’ isn’t a spam-eating, opossum-based superhero, but rather a portmanteau of ‘possible’ and ‘spam’. Normally, being obscure isn’t recommended, but I’m the only one using it, so why not save some characters? With that said, feel free to change it to whatever suits the situation. However, feel free to make #PossiSpam a thing.

Moving on, we need to make a system user and group for the SpamAssassin daemon, deny it a login shell by pointing it to /bin/false, and give it a home directory:

sudo useradd -s /bin/false -d /var/lib/spamassassin -rU spamd
sudo chown spamd:spamd /var/lib/spamassassin

Then, we need to go back into Postfix and tell it to use SpamAssassin by editing or adding the following to /etc/postfix/master.cf:

smtp        inet   n           -           n          -             -              smtpd
  -o content_filter=spamassassin
spamassassin unix  -           n           n          -             -              pipe
  flags=R user=spamd argv=/usr/bin/spamc -e /usr/sbin/sendmail
  -oi -f ${sender} ${recipient}

Finally, update SpamAssassin, reload Postfix, then start and enable the SpamAssassin service:

sudo sa-update
sudo postfix reload
sudo systemctl start spamassassin
sudo systemctl enable spamassassin


There should already be a SpamAssassin rule updater at /etc/cron.d/sa-update.

If no warnings or errors showed up, we can add an optional mail sorter/spam filter.

Configure Sieve

Yes, yet another optional service we’ll be setting up. Truth be told, half of this guide is optional, but we don’t configure optional steps because they’re easy, do we?

We’ll be using Dovecot’s Sieve filter to do something with messages marked as spam by SpamAssassin (as recommended in Lee Hutchinson’s sieve guide).

Sieve was mostly set up in the Configure Dovecot setup, so we’ll just be tying up loose ends to finish up Sieve.

First, we have to make filter directories for Sieve and set the system mailbox user as the owner:

sudo mkdir /var/mail/vmail/sieve-before
sudo mkdir /var/mail/vmail/sieve-after
sudo chown -R vmail:vmail /var/mail/vmail/

The sieve-before directory will have global filters that are applied first, so we start there by making a file named /var/mail/vmail/sieve-before/master.sieve and adding the following:

require ["envelope", "fileinto", "imap4flags", "regex"];

# Delete spam higher than level 10
if header :contains "X-Spam-Level" "**********" {

# Delete improperly formed message IDs
if not header :regex "message-id" ".*@.*\\." {

# File low-level spam in junk folder and mark as read
if header :contains "X-Spam-Level" "*****" {
        fileinto "Junk";
        setflag "\\Seen";

Once the filter is decent enough, turn it into a binary, set the owner as the vmail user, and reload Dovecot:

sudo sievec master.sieve
sudo chown vmail:vmail master.svbin
sudo dovecot reload

Not as much to unpack here, we’re filtering spam and malformed messages. Sieve is actually much more powerful - it can sort e-mails from senders into a specific folder or completely ignore a specific sender. It’s worth looking into what else Sieve can do. Not bad for a freebie service, eh?

That’s it, we’re done! We can send and receive e-mail from the Internet while reducing spam, and it only took a weekend…or did it?

Configure Postscreen

Fine, so maybe more than half of this guide is optional. Postscreen is a service that is provided with Postfix, so it’s also kind of a freebie. Postscreen acts as a bouncer on the SMTP port and only allows clients that pass certain tests to talk to Postfix. Technically, Postscreen will use more resources, so it’s still considered optional if using a lower class EC2 instance.

If you’re still willing to pick up the Gold PP7, then we’ll be following this Postscreen configuration guide which says to start by opening up /etc/postfix/main.cf and adding:

# Postscreen settings
postscreen_access_list = permit_mynetworks,
postscreen_greet_action = enforce
postscreen_dnsbl_action = enforce
postscreen_dnsbl_threshold = 2
postscreen_dnsbl_sites = zen.spamhaus.org, b.barracudacentral.org, bl.spamcop.net

Not much going on here, either. We’re specifying who should always have access, whether other servers should say hello and which level of flagged IPs should be blocked. It also specifies which lists of IPs considered spam we should use. Among those with permitted access is a whitelist file at /etc/postfix/postscreen_access.cidr that we have to create and populate with:

x.x.x.0/20  permit

As before, set it to the subnet used in Configure EC2 to ensure any local servers sending e-mail won’t be blocked. We can also specify other IPs or domains that we either never want to block or always want to block. Next up, we can pay a visit to our old friend, /etc/postfix/master.cf, and add the following near the top (above SpamAssassin):

#smtp        inet   n           -           n          -             -              smtpd -o content_filter=spamassassin
smtp        inet   n           -           n          -             -              postscreen -o content_filter=spamassassin
tlsproxy    unix   -           -           n          -             0              tlsproxy
dnsblog     unix   -           -           n          -             0              dnsblog

Finish up by reloading Postfix with another

sudo postfix reload

We can watch Postfix, Postscreen, and Dovecot in action by keeping an eye on the maillog with sudo tail /var/log/maillog:

Terminal window with tail maillog output.

With that, another optional service bites the dust, and we are even more paranoid than we need to be…or are we?

Configure iptables

Surprise, surprise, another optional service to configure! Iptables is a software firewall that we’ll be using in addition to the hardware firewall set up in Configure EC2. AWS provides DDoS attack protection on their services for free, but only if you use those specific services. Instead, we’ll have to makeshift our own in software. We begin by installing the iptables service with

sudo yum install iptables-services

To confirm, just run sudo iptables -nvL and bask in the emptiness. By default, it allows everything. Technically, this is okay because of the aforementioned hardware firewall, but when you’re on the Internet, you can’t be too paranoid, right?

Let’s get to work by opening /etc/sysconfig/iptables-config and changing the following lines:


These settings will save the tables to /etc/sysconfig/iptables, to which we can create and add the ruleset from Lee Hutchinson’s iptables configuration guide, but tweaked to reject instead of drop:

-A INPUT -s x.x.x.0/20 -i eth0 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 993 -m state --state NEW -m recent --set --name imapssl --rsource
-A INPUT -p tcp -m tcp --dport 993 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name imapssl --rsource -j LOG_AND_REJECT
-A INPUT -p tcp -m tcp --dport 993 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 587 -m state --state NEW -m recent --set --name imap --rsource
-A INPUT -p tcp -m tcp --dport 587 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name imap --rsource -j LOG_AND_REJECT
-A INPUT -p tcp -m tcp --dport 587 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 465 -m state --state NEW -m recent --set --name smtps --rsource
-A INPUT -p tcp -m tcp --dport 465 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name smtps --rsource -j LOG_AND_REJECT
-A INPUT -p tcp -m tcp --dport 465 -j ACCEPT
-A INPUT -p tcp -m tcp --dport 25 -m state --state NEW -m recent --set --name smtp --rsource
-A INPUT -p tcp -m tcp --dport 25 -m state --state NEW -m recent --update --seconds 10 --hitcount 20 --name smtp --rsource -j LOG_AND_REJECT
-A INPUT -p tcp -m tcp --dport 25 -j ACCEPT
-A LOG_AND_REJECT -j LOG --log-prefix "iptables deny: " --log-level 7
-A LOG_AND_REJECT -j REJECT --reject-with icmp-host-prohibited

Once again, set the eth0 line to the subnet used in Configure EC2. Not much going on here - we’re setting a rate limit on the IMAP, IMAPS, SMTP, and SMTPS ports while allowing local traffic. If someone exceeds those limits, we log it and tell them to take a long walk off of a short pier because that port don’t work here.

Now that our better-than-nothing table is configured, start iptables, enable it at boot, and confirm it’s working with:

sudo systemctl start iptables
sudo systemctl enable iptables
sudo iptables -nvL

With any luck, we can disconnect and reconnect our SSH session without being locked out (fun trap for new players).

Alright, for sure this time, the CentOS 7 AMI web and mail server with DDNS is complete and locked down tighter than Fort Knox. Just remember to give it some attention once in a while so it doesn’t end up being a delinquent or a dead Tamagotchi.


Terminal window with top output.

The AWS EC2 instance is successfully running CentOS 7 AMI as both a web and mail server with a DDNS subdomain. I daresay it isn’t a menace to society, either; although that is my opinion.


Yes, it’s a web and mail server, and yes, I did learn a lot, as the inspirational article intended; however, I still feel the system can be more efficient - like implementing SpamAssassin as a postfix milter. Unfortunately, a lot of the Postfix milter services require enabling EPEL, disabling SELinux, or manually downloading updates.

The current implementation does neither and, therefore, may be more secure for it.

Mar. 11, 2019 Update: Nothing big, just added a tag and fixed some typos. Feel free to contact me if you find any more.

Mar. 26, 2019 Update: More punctuation/grammar fixes and formatting tweaks. As always, contact me if you find any more. Be sure to include a social media handle for attribution!

Mar. 31, 2019 Update: You guessed it, more punctuation/grammar fixes. Believe me, I’d get an editor if I could. Although, sleep is the best editor I’ve ever had…

Jun. 14, 2019 Update: Nope, not fixing typos. Added a table of contents because I noticed this article is huge after reviewing it again.