My Little Corner of the Net

Phoning Home with CGNAT and a UniFi Gateway

I’ve been running some sort of VPN to access my home network remotely for years. It probably started around the time I bought my first laptop. Back then, the only other computer I had was a Windows desktop. I wanted to access the desktop when I wasn’t home, but opening port 3389 to the world didn’t seem like such a good idea (and it turns out I was right about that), so my solution was to install an OpenSSH server for Windows and use SSH tunneling to remote in. I called this my “poor man’s VPN.”

When I got my first Raspberry Pi, I decided I’d learn how to run a proper VPN and figured out how to install OpenVPN on it (something that became very popular among RasPi enthusiasts a short time after I did it) to get full access to my whole network without having to do individual port forwards. A few years ago, I upgraded to Wireguard.

A little over a year ago the local fiber ISP, Greenlight Networks, came to my neighborhood. Everything about Greenlight has been amazing, from the fact that they spent a day digging up my yard to run their cables but left it looking almost as if they hadn’t been there that evening, to the fact that they had the entire neighborhood wired in about a week and the service activated in less than a month, to the fact that I now have a symmetric 1Gbps Internet connection for less than I was paying the cable company for 300x10Mbps. The only downside to Greenlight is that they use CGNAT, meaning no remote access, at least not over IPv4.

Greenlight does offer a routable IPv4 address for an additional $10/month, but I wanted to try something else first. When Greenlight was first activated, I decided to move my Wireguard server to a VPS server that I already had for website hosting. Then I configured a Wireguard client on my UniFi gateway to connect to the server on the VPS and set it’s AllowedIPs so that it would route traffic back to my home network. This worked great for spilt tunnel situations (letting Wireguard only route traffic for my network while everything else goes through the client’s default Internet connection), but using it as a full tunnel meant that my traffic emerged on the Internet looking like it was coming from a datacenter, not a residence, which can cause issues with some services (like streaming).

Split tunneling works fine most of the time. I have Tasker configured on my phone to connect to my VPN whenever I’m not connected to my home WiFi and it’s been working great. Since I’m not in the habit on connecting to random open WiFi networks, but instead use my phone’s data plan for the most part, split tunnelling was never a problem. As I was planning for a recent business trip, however, I started to rethink my setup. Greenlight does provide IPv6 connectivity, and that is directly routable, but my limited testing of various guest networks in places I’d visited (doing the very thing I just said I don’t do…for research), showed that lots of them don’t support IPv6.

Since most of the hotels I’ve stayed at have unencrypted guest networks (and this recent one was no exception), I want my traffic to be encrypted somehow, whether this means going through my own VPN or someone else’s (like Surfshark). I have a travel router, so this is easy enough to do. I also travel with a FireTV stick, and I’d like this to be able to access my home network so that I can access my locally stored media. This makes the Surfshark route a little more challenging.

So…could I move my Wireguard server back home and still access it, via IPv4, through the VPS? Turns out, with a bit of work, I can…

DNS and Dynamic DNS

One prerequisite for all of this to work is that you have a DNS host name for your VPS. In my case, I have a public domain name that I use for all of my self-hosted services, so I use vpn.my-self-hosted-app-domain.net for the VPN. I have this configured with an A record that points to my VPS’s IPv4 address and an AAAA record that points to my Unifi gateway’s IPv6 address. I also have a second AAAA record, mystreetname.my-self-hosted-app-domain.net pointed at my gateway’s IPv6 address, but I’m pretty sure this isn’t actually necessary. Using the name of my street as a hostname for accessing my home network is just a convention I came up with years ago, you can use whatever you want.

In BIND syntax, my DNS looks like this:

vpn.my-self-hosted-app-domain.net.           14400 IN A    12.34.56.78
vpn.my-self-hosted-app-domain.net.           600   IN AAAA 123:456:789::1
mystreetname.my-self-hosted-app-domain.net.  600   IN AAAA 123:456:789::1

In theory, my IPv6 assignment should never change, but I have no assurance of that, nor do I have reason to believe that my ISP would give me any advance warning of them changing it, so to be safe, I’ve set the TTL on my AAAA records to 10 minutes and I modified my old DIY dynamic DNS tool to support IPv6 addresses. This is completely optional and is just me being a little paranoid. You can use any dynamic DNS service that supports IPv6, though you might get stuck having to use an ugly hostname if you don’t/can’t run your own service.

To keep my UniFi gateway’s IP address updated in DNS, I need a dyndns2 client that can see the public IPv6 address and update it if it changes. I decided that the easiest way to do this was use a shell script on the gateway itself. You can access the gateway (and any other UniFi devices you have adopted) over SSH by using the password found in the Unifi Network app at Devices > Device Updates and Settings > Device SSH Authentication.

The script is pretty simple. I installed it at /persistent/bin/dyndns.

#!/bin/bash

LAST_IP=`cat /persistent/etc/last_ipv6`
CUR_IP=`ip -6 addr | grep "/128 scope global" | cut -d" " -f6 | cut -d"/" -f1`

if [[ $LAST_IP != $CUR_IP ]]; then
    echo $CUR_IP >/persistent/etc/last_ipv6
    URL="https://USER:PASSWORD@DYNDNS_HOST/nic/update"
    RESULT=`curl -s "$URL?hostname=mystreetname.my-self-hosted-app-domain.net&myip=$CUR_IP"`
    RESULT=`curl -s "$URL?hostneme=vpn.my-self-hosted-app-domain.net&myip=$CUR_IP"`
fi

The script looks up the last known IP address in a file and compares it to the public IPv6 address of the gateway. If it changed since the last run, the script will attempt to ping the dynamic DNS update service with the new IP and update the file. Be sure to change USER, PASSWORD, DYNDNS_HOST, and the hostnames with your own values.

Before you run it, you’ll need to make sure the last_ipv6 file exists and set execute permissions on the script:

mkdir /persistent/etc
touch /persistent/etc/last_ipv6
chmod 755 /persistent/bin/dyndns

Then you can run it to test it out:

/persistent/bin/dyndns

If all goes well (you should see an IPv6 address written to the /persistent/etc/last_ipv6 file and the same IP should show up in your dynamic DNS service’s settings), you can set a cron job to run every 5-10 minutes to keep an eye out for changes:

crontab -e

In the editor that opens enter:

*/5 * * * * /persistent/bin/dyndns

Once you save the file, the crontab will be activated and the updates will start.

I’m hopeful that this will stay in place during firmware updates, but I haven’t had one since setting it up, so we’ll see…

Unifi, Wireguard, and IPv6

The UniFi Gateway Max can run a servers on it, and the UniFi Network app has a very nice UI for configuring them and adding peers but, by default, it only supports IPv4. Fortunately, the Wireguard servers it starts do listen on IPv6, you just need to manually open the firewall to make it happen.

First, you need to create the VPN. This is done through Settings > VPN in the Network app. Make sure the “VPN Server” tab is selected and click “Add New.” You can pretty much leave the defaults as-is, but you might want to change a few things:

  1. Unifi automatically creates a new key pair for the new VPN, but If you have an existing Wireguard server and you don’t want to rekey all of your clients, you can paste in your existing public and private keys. However, since these two fields are disabled in the UI, you’ll need to go into the browser’s developer console to remove the “disabled” attribute from each field. The easiest way to do this is to right click on the field and choose “Inspect”). Then find the word “disabled” in the selected text in the window (or panel) that opens, double click on it so it becomes editable, and then press “delete.” Then go back to the main browser window and you should be able to paste in your key. Repeat for both fields.
  2. When adding a new client, Unifi will, by default, use the IPv4 address of the gateway as the connection address. Since you’re following this tutorial, you probably also have CGNAT and the address displayed is not Internet accessible. This is where your DNS hostname comes in: click the checkbox for “Use Alternate Address for Clients” and then enter your chosen hostname in the field that appears.
  3. Under “Advanced,” if you select “Manual,” you’ll be able to set a custom network address for the VPN network. This is optional–the one that UniFi selects will work fine–but if you’re moving an existing network, you’ll probably want to set this to whatever network range you’re already using.

To allow IPv6 connections to the newly created VPN, go to Settings > Policy Table and you should see an entry labeled “Allow Wireguard VPNs” This is for IPv4, but you can use it as the basis for configuring a new rule for IPv6. Make note of these settings, just in case they vary from what I have below.

To create an IPv6 rule, click “Create New Policy” and set the following values:

  1. Make sure the radio button for “Firewall” is selected.
  2. Enter a name that makes sense, like “Allow Wireguard VPNs (IPv6)”
  3. For “Source Zone,” choose “External” and make sure both “Any” radio buttons are selected.
  4. For “Action” select “Allow”
  5. For “Destination Zone” choose “Gateway.” Make sure “Any” is checked and select “Specific” for “Port.”
  6. In the “Source” list, select “Wireguard.” (If you have more than one Wireguard server running, you may need to set this to a custom port instead.)
  7. For “IP Version” select “IPv6.”
  8. For “Protocol” select “UDP”

Everything else should be fine as-is. Click “Add Policy” to save the rule.

Adding Wireguard Peers (Clients)

Go back to Settings > VPN and select your new Wireguard VPN. To add a peer click the “Add Client” link in the “Clients” section.

Unifi will do most of the work in setting up a new client for you, including creating the key and assigning it the next available IP address. You just need to either scan the QR code with your client device or click the “download” link next to it to download a conf file. You will probably also want to change the “Name” to something recognizable, but that’s up to you.

If you’re bringing an existing client over from a previous setup, click on “Manual.” Now you’ll be able to paste in the client’s existing public key and IP address. If your client will also handle routing for any networks (AllowedIPs in Wireguard terminology), you can add them as well by clicking the “Remote Client Networks” checkbox.

When you’re done adjusting your settings, be sure to scan the code or click the download link, because once you save everything, you can’t access them again. Then click “Add.” When you’re done adding clients, don’t forget to click “Apply Changes” or your new client settings will not be saved (you have no idea how many times I’ve made this mistake…it is not very obvious).

Once you have your clients configured, they should be able to connect to your VPN via IPv6.

Forwarding from IPv4

Now comes the fun part. We need a way for the VPS to receive connections and then forward each byte onto the gateway, sort of like an HTTP reverse proxy. There’s probably a bunch of ways I could do this, I chose to use a small command-line utility called socat.

socat is a pretty common tool and should be available in most Linux package distributions. On my Debian VPS, I can install it with:

sudo apt update
sudo apt install socat

The actual command I’m running is this:

socat -T 180 UDP4-LISTEN:51820,reuseaddr,fork UDP6-SENDTO:mystreetname.my-self-hosted-app-domain.net:51820,reuseaddr

The command is essentially split into two parts: what to do on the local machine (the listener) and where and how to forward the packets onto the remote machine. The -T 180 is just a timeout setting–it tells socat to drop connections after 180 seconds of no activity.

The “listener” section tells socat to listen on UDP port 51820 on IPv4 links and to “fork” any connections to another port.

  • UPD4_LISTEN – listen for UDP connections on IPv4
  • 51820 – the port to listen on (this must be the same as the port your gateway is listening on)
  • reuseaddrsocat is allowed reuse closed connections, even if they haven’t fully timed out yet
  • fork – this tells socat to ask the connecting system to reconnect over a different port, keeping the initial port open for new connections. This allows for multiple connections at the same time.

The “forwarder” section is similar: it tells socat where to forward the packets it receives and how to expect to interact with the service to which it connects.

  • UPD6_SENDTO – send packets over UDP via IPv6
  • mystreetname.my-self-hosted-app-domain.net – the host name or IP address of the Wireguard server (i.e. the Unifi gateway). Here I’m using the mystreetname host name to avoid any conflicts, though I’m pretty sure just using the vpn hostname would work fine, too. If you’re not worried about IP address changes, you can use the actual IP address, just enclose it in square braces ([123:456:789::1]).
  • 51820 – the port on the gateway where Wireguard is listening.
  • reuseaddr – again, socat is allowed to reuse connections that have been closed but not fully timed out
  • forksocat should expect the remote service to ask it to switch to a different port and it should comply when it does

To ensure that socat stays running when you log out, it needs to be turned in to a system service. In Debian, this is done by creating a systemd unit file.

[Unit]
Description=Socat Wireguard Forwarding
After=network-online.target

[Service]
ExecStart=/usr/bin/socat -T 180 UDP4-LISTEN:51820,reuseaddr,fork UDP6-SENDTO:mystreetname.my-self-hosted-app-domain.net:51820,reuseaddr
Restart=always

[Install]
WantedBy=multi-user.target

Create a file named socat-wireguard.service in /etc/systemd/system with the above contents. Then enable and start the service:

sudo systemctl enable socat-wireguard.service
sudo systemctl start socat-wireguard.service

Now your VPN should be available from anywhere, even without IPv6 on your client device.

Split Tunnel vs. Full Tunnel

I’ve mentioned using split tunnels earlier, but I haven’t explained how to set them up. A split tunnel VPN only handles traffic for certain IP address ranges, whereas a full tunnel VPN captures all of the client device’s traffic and routes it all through the tunnel.

With Wireguard, you can use the same key pair to do both split- and full tunneling. The config files that the Unifi Network app creates are set up for full tunneling, but with a small tweak you can switch to split.

Here’s a sample Wireguard configuration created by the Network app:

[Interface]
PrivateKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Address = 10.10.10.10/32
DNS = 10.10.10.1

[Peer]
PublicKey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AllowedIPs = 0.0.0.0/0
Endpoint = vpn.my-self-hosted-app-domain.net:51820

Note the AllowedIPs line in the [Peer] section. This is the list of IP address ranges, in comma separated CIDR notation, that the VPN should handle. In this case 0.0.0.0/0 means “all traffic.” Changing it to a specific address range will limit what the VPN handles. For a typical home network, this might look like this:

AllowedIPs = 192.168.1.0/24

This will route only the traffic for devices with IP addresses of 192.168.1.*, while letting everything else go through the client’s normal Internet connection.

On most of my devices I have configurations for both full and split tunnel connections, allowing me to choose whichever connection type makes the most sense for my given situation.

Final Thoughts

This setup has been working great for me and I had no problems with routing traffic from my hotel, back to my house, and on to the Internet on my recent trip. The hotel WiFi seemed to be capped at around 30Mbps, so that, not the VPN or the crazy roundabout routing was the bottleneck of my connection.

The only downside to this setup is that, though I can connect to the VPN through IPv6, I can’t route IPv6 traffic through it. This is just a configuration limitation on Ubiquity’s part and, try as I might, I can’t find a way around it. That said, IPv6 adoption has been slow and I’ve never found a situation where something I wanted to access was only available via IPv6 so, for now, I don’t think that will be a big issue and if things change in the future, hopefully Ubiquity will also make updates to their software.

I really have no idea how robust the socat forwarder is, either. I wouldn’t trust this setup if I was dealing with hundreds or thousands of concurrent VPN connections, but if you’re dealing with that many, you hopefully have big pipe to the Internet that isn’t CGNATed in the first place. For me, where I never have more than three or four sessions open at the same time (and usually it’s more like zero or one), this is working just fine.

Another thing to watch out for when running a VPN on a VPS is data usage. VPNs can eat up a lot of data because the usage is effectively doubled: everything that comes in immediately goes right back out again. Fortunately for me, IPv4 is a last resort…most of the time I’m connecting over the cellular network, which supports IPv6, so I’m connecting directly to my gateway. My VPS provider also only meters outbound traffic, so I only get “charged” once, and my allotments are big enough that I never come close to reaching them. That might not always be the case for everyone, though, so be sure to keep an eye on your usage so you don’t get any big surprises at the end of the month.

Note: some links in this post are affiliate links. I’m may earn a small commission on the products or services if you choose to purchase them.

icanhazmyownicanhazip.com

Last night I was playing around with routing in my homelab. I wanted a quick way to verify that traffic was reaching a given machine over a given route, and the easiest way to do that, I figured, was to create an icanhazip.com-esque site to display my client machine’s IP address and verify that it was using one I’d expected. The machine I was connecting to already had a Caddy web server running on it, so it wouldn’t be difficult.

My first thought was to do this with PHP. This would basically be a one-liner:

<?php echo $_SERVER['REMOTE_ADDR']; ?>

The only issue: this machine didn’t have PHP installed, and I wasn’t in the mood to get it set up. Perl or bash script? Caddy doesn’t support old-school CGI without a plugin, and again, I really didn’t want to mess with making a custom Caddy build and setting update-alternatives.

That’s when I remembered that Caddy has support for Go-style templates. In a sort of server-side includes kind of way, Caddy can parse a file before it serves it to do variable substitutions (and more, but that’s a discussion for another time). Surely I’d be able to get the remote IP from the request and inject it in a template, right? Yes…and don’t call me Shirley.

Turns out you don’t even need a template file. You can also do it right in the respond handler. I added this to my Caddyfile, set up the DNS entry in my router, and I was off:

ip.myinternal.domain {
    header Content-Type text/plain
    templates
    respond {{.RemoteIP}} 200
}

You’ll need to reload Caddy to pick up the new config, and be sure to update myinternal.domain with something routable on your network. Then just point your browser at that address and you’ll see your current IP address. It’s not incredibly useful on a local network, but it was fun to figure out.

A PHP Router In 45 Lines

Like most things PHP, I have a love-hate relationship with URL routers. Having gotten my start in building “dynamic websites” back in the 90’s when CGI scripts were about the only option, I still prefer simplicity. CGI scripts, generally written in Perl, were slow and had to be started up for every request that came in, so we tried to do as little in them as possible so they world run faster. Because of that, it wasn’t uncommon to have multiple CGI scripts on a site.

Routers came out of modern web app design. Most apps written in languages such as Java, Go, NodeJS, Ruby, or Python run as their own server. There’s basically one program with an endless loop that listens for a request, processes it, and then goes back and listens for the next. The whole app gets loaded into memory once, and every request for every user is handled by that one instance. (That’s an oversimplification, but it’s good enough for this argument.) These apps needed a way to understand what a user was requesting and from this a fairly standard way of mapping URL patterns to functions emerged.

PHP is kind of stuck somewhere between old CGI scripting and modern web apps. While we typically use PHP “servers,” such as FPM, to keep the PHP interpreter running (which speeds up how long it takes before a PHP script can start processing a request), the scripts themselves are still written like old CGI scripts to handle a single request. On the other hand, many PHP URL routers (and in fact, the entire PHP-FIG-defined routing spec) are modeled after how routing is done in the “app is a server” models, which overcomplicates the code and ultimately slows down PHP. Things like request and response objects simply aren’t necessary when you already have PHP’s super globals and you can just write to STDOUT.

Still, as PHP apps become more complex and require more and more dependencies, I like the idea of the front controller model where all requests enter through a single endpoint, as it makes managing dependencies easier. My go-to router for most of my apps these days is Bramus Router. It’s straightforward, easy to use, and I really like that I can use regular expressions to define my routes. Using patterns to validate URL parameters saves me a ton of time not writing simple string validation code.

The other day I started a new project. It’s a super simple app, basically just putting an web interface in front of a set of command line tools. The app will probably have about a dozen routes and doesn’t need to be fancy. The output isn’t much more than some forms and tables, so I decided to just output HTML from my controllers rather than use a template library to handle views, and there’s no need for a database since the app will just be calling the command line tools. I didn’t want to go through the whole process of setting up Composer just to install Bramus Router because I have zero other external dependencies.

So I wrote my own router. It took me about 15 minutes and ended up, without comments, to be exactly 30 lines of code. it consists of one main function, route() which handles parsing the request URI and handling the request on a match, two helper functions: get() and post() for defining GET and POST routes, respectively, and a notFound() function for returning a 404 error when no routes match the request. Like Bramus Router, my routes are defined by regular expressions (though I did not add support for named placeholders the way Bramus Router does). I also did not add handlers for PUT, PATCH, or DELETE since I don’t need them for this app.

<?php
function route($methods, $path, $callable=null, $include=null) {
    $methods = is_array($methods) ? $methods : array_map('trim', explode(',', $methods));
    $matches = [];
    if(in_array($_SERVER["REQUEST_METHOD"], $methods) && preg_match("|$path|", $_SERVER['REQUEST_URI'], $matches)) {
        if($include) {
           include($include);
       }

       if($callable) {
           array_shift($matches);
           call_user_func_array($callable, $matches);
       }

       exit();
   }
}

function get($path, $callable=null, $include=null) {
    route(['GET'], $path, $callable, $include);
}

function post($path, $callable=null, $include=null) {
    route(['POST'], $path, $callable, $include);
}

function notFound($callable=null, $include=null) {
    http_response_code(404);
    route($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], $callable, $include);
}

Each route can have a callback function, an include file, or both. For simple, static routes, no callback needs to be defined and the include can be used to display content. If both a callable and an include are defined, the include will be included first, making it possible to load parts of the app (including the callable function itself) on the fly, thereby preventing the need to compile the entire app on every request (though this will get messy fast and should probably be considered an antipattern).

Here’s some sample routes for a contrived “hello world” app:

<?php
require 'router.php';

// prompt the user for their name so that we can say hello to them
get('^/name/?$', function () {
    echo <<< EOF
  <form mehtod="post">
    <label>Your name: <input type="text" name="name"></label>
    <button>Submit</button>
  </form>
EOF;
});

// load the render library and say hello to the user by name
// note: the render() function is defined in render.php
post('^/name/?$', function () {
    render('Hello, ' . $_POST['name']);
}, 'render.php');

// say hello to the name passed as part of the URL
get('^/(\w+)/?$', function ($name) {
    echo('<h1>Hello, ' . $name . '</h1>');
});

// display the output of hello.php as the app's homepage
get('^/$', null, 'hello.php');

// if no route is matched, send a 404 error
notFound(function () {
    echo('<h1>404 Not Found</h1>');
});

As I was working on the app, I decided that having middleware would be helpful for handling CSRF protection , so I added a before() function, bringing the whole system up to 45 lines of code. The before() function works exactly the same as route(), it just doesn’t exit when it completes.

function before($methods, $path, $callback=null, $include=null) {
    $methods = is_array($methods) ? $methods : array_map('trim', explode(',', $methods));
    $matches = [];
    if(in_array($_SERVER["REQUEST_METHOD"], $methods) && preg_match("|$path|", $_SERVER['REQUEST_URI'], $matches)) {
        if($include) {
            include($include);
        }

        if($callback) {
            array_shift($matches);
            call_user_func_array($callback, $matches);
        }
    }
}

For example:

// called every time a POST request is received
before('POST', '.*', function() {
    echo 'This is a POST request. <br>';
});

Since the handlers are functions, they are executed in the order they are defined. Therefore, any before() middleware should be defined first, then get() and post() routes, and finally the notFound() route last. Otherwise, some parts of the app may never run.

This approach does have some limitations. For example, this approach can’t return a 405 error (method not allowed) when a route’s path matches but the corresponding method does not. That said, many PHP routers I’ve looked at, including Bramus Router, don’t handle this properly. Other than that, it seems to work pretty well, and I may start using it in some of my other projects, though it’s probably best for apps with very little complexity.

Interested in using it? Feel free. I haven’t published it anywhere besides here, but feel free to copy the code and use it in your own projects. Like with most of my code, just be sure to stick to the terms of the MIT license.

Setting Up a Mail Server with Chasquid and Dovecot – Part 3: Final Loose Ends

If you’ve been following along with the first two parts of my email server journey (Part 1, Part 2), you’ll now have a mail server set up and serving mail for your domains. In this third and final installment, I’m going to look at aliases, certificate renewal, “send-only” email accounts, and the “sendmail” program. I’ll also touch on setting up auxiliary services like SpamAssassin and ClamAV, even though I’m not currently using them in my setup.

Aliases

Aliases are an important aspect of any email system. They’re what allow for the maintainer of a website to receive mail sent to webmaster in her primary mailbox and for email groups, where multiple recipients get messages sent to a single address. Chasquid has rather advanced support for alias handling and supports these, and several other use cases.

The aliases File

You may recall, in Part 2, that we created a file named aliases for each domain we added. This is where our aliases will be stored. The format of this file is similar to aliases files for most other SMTP servers. The biggest exception to Chasquid’s setup, compared with other systems I’ve used, is that Chasquid has a file-per-domain setup by default, so it isn’t necessary to specify the domain part of the address for every alias.

The aliases file is basically a flat-file database (one record per line) with the following format:

alias: recipient

So, if Molly is managing my website and I want her to receive messages sent to webmaster, I’d add:

webmaster: molly

If Molly prefers to get her mail at her own domain, we can do that as well:

webmaster: me@molysmail.com

If I want everyone on sales team to get all messages sent to ‘sales@mydomain.com,’ I just separate the recipients with commas:

sales: steve, joe, sally

Aliases can resolve to other aliases. Chasquid restarts the resolution process for each recipient address it finds on an alias match and continues this process until it either delivers the message to a mailbox or forwards the message to a remote server. However, in order to prevent endless loops, Chasquid will fail after ten hops.

sales: steve, joe, sally

# When a message is sent to 'superdupersale', it will get forwarded to 'sales'
# Chasquid will see that 'sales' is also an alias and then send the message
# to each of the 'sales' recipients: Steve, Joe, and Sally
superdupersale: sales

Aliases files are reloaded by Chasquid automatically, so changes are picked up without needing to restart the service.

Chasquid processes the entire aliases file from top to bottom each time a message is received. If the local part (i.e. username) of the address is found more than once, only the last one will be used.

# this line will be ignored because another 'webmaster' entry appears after it
webmaster: me@mollysmail.com

# all mail for webmaster will go to molly (at the current domain) because the
# last entry is the one that is used
webmaster: molly

Chasquid will also stop processing the address after an alias is fully resolved, so if both an alias and a mailbox exist with the same local part, the message will not get delivered to the mailbox.

Drop Characters and Suffix Separators

Chasquid has two handy features in how it processes aliases: drop characters and suffix separators. Both can be specified in the chasquid.conf file in the servers configuration directory (usually /etc/chasquid):

suffix_separators: "+"
drop_characters: "."

Drop characters are simply dropped from the local part of the address before the alias resolution begins. By default, the only drop character is the period (“.”), so periods are ignored when attempting to match aliases.

If a message comes in addressed to john.smith@mydomain.com (or even j.o.h.n.s.m.i.t.h@mydomain.com), the periods will be ignored and the address will match to an alias for johnsmith. However, if an explicit alias for john.smith exists, that one will be matched before the period is dropped.

Suffix separators remove the first instance of the separator character and everything following it from the address for the purpose of address resolution. The default suffix separator is the plus sign (“+”).

If a message comes in for john+smith@mydomain.com, the +smith will be removed, resulting in a match on just john.

Suffix separators were popularized by email services like GMail and are often used as a way to track spam. For example, when signing up for an account on scummysite.com, you might use me+scuummysite@mydomain.com as your email address. When scummysite.com sends you a message, it still goes to your regular inbox, but you can then use filtering tools to move the message to a different folder or to delete it outright, based on the recipient address, which doesn’t get overwritten. And you’ll know that it was scummysite.com that sold your email address when you start seeing emails from scummiersite.com coming to the me+scummysite address. Suffix separators are also sometimes used by applications, such as mailing list managers, to indicate list management commands.

As I mentioned, Chasquid defaults to using periods for drop characters and plus signs for suffix separators by default. If you’d like to change the defaults and/or add additional characters, you’ll need to uncomment the two lines from the chasquid.conf file referenced above. Each character in the string will be considered it’s own drop character (or suffix separator), so this example would drop any periods or underscores:

drop_characters: "._"

Pipe Aliases

Sometimes you don’t want messages to be delivered to a mailbox, but instead to be processed by a program. Examples of this include ticketing systems, where messages to an address like support@mybusiness.com get logged as tickets, or maiing lists, where messages get forwarded to larger, more regulated lists of recipients than is practical with simple group aliases.

Chasquid supports this with “pipe” aliases. When an alias recipient starts with the pipe (“|”) character, Chasquid will use what follows it as a path to a program that it will try to run, and then pass the contents of the message to the program through standard input.

support: | /usr/local/bin/process-ticket

The message is passed in exactly as Chasquid receives it, including all headers. It is up to the receiving program to parse the message and interpret it however it needs.

While it’s not explicitly documented, I discovered that Chasquid will bounce the message (send an error back to the sender) if the piped program returns a non-zero exit status.

Catch-alls

Like many SMTP servers, Chasquid uses the asterisk (“*”) as a catch-all address. When an alias is set using that as the local part, all mail that does not get directed somewhere else will be forwarded to whatever recipient is specified.

*: badmail

Catch-alls should generally be avoided, since they don’t signal back to the sender that an address is not valid and thus catch-all mailboxes tend to collect a lot of spam. They can, however, be helpful for short-term use in testing and debugging, and they can be useful in certain applications, when connected to a pipe.

Chasquid also has an experimental feature (and I haven’t tested it, so I have no idea if it is currently supported by the version of Chasquid that’s currently available in the Debian distros) where an asterisk on the destination side of the alias will be replaced with the local part of the original address. This can be useful when redirecting all mail from one domain to another, such as when a business changes its name or when you have alternate domain names and you want all mail to end up at the primary.

# consider this definition in the mydomain.net aliases file
# any mail sent to any address on mydomain.net will be automatically forwarded
# to the same recipient on mydomain.com
*: *@mydomain.com

The alias-resolve Hook

So far, everything we’ve looked at has been configured through the domain’s aliases file, but chasquid has one more trick up it’s sleeve: the alias-resolve hook. Chasquid will look for a program named alias-resolve inside the hooks directory in the Chasquid configuration directory (so /etc/chasquid/hooks/alias-resolve on most systems). If the file is there and is executable, Chasquid will pass the recipient address as a command line argument. The program can then return a string that follows the format of the right-hand side of of the aliases file and Chasquid will use that to further resolve the alias. If no match is found, the script should exit without any output. This can be a handy way to look up aliases from other sources, such as searching a database or checking another program’s configuration files

There is only one alias-resolve program per server (though you could use it to call a domain-specific subprocess if needed).

There’s a few other less common things you can do with aliases that I haven’t discussed, such a “via” aliases, that let you direct messages on to a recipient on a specific remote server, so be sure to check the documentation. I’m only covering the more common uses.

Certificate Renewal

You’ll recall, from part two, that I opted to use Lego to handle obtaining TLS certificates for my email domains. Although Chasquid is designed to work with Certbot, my use of the Caddy web server caused conflicts. Lego can interface easily with my PowerDNS-based DNS servers to handle ACME challenges, avoiding these conflicts.

To renew a domain’s certificate, you call Lego with the a command like this:

lego --dns pdns --accept-tos --email $TLS_EMAIL --path /etc/chasquid/.lego -d mail.mydomain.com renew

Lego will then check the certificate and, if it is within the threshold for renewal (by default, that’s within 30 days of expiration), it will request a renewal from Let’s Encrypt. If not it will return an error message explaining why it was not renewed.

Unlike Certbot, which has a handy option that will check all of a system’s certificates and renew any of them that are within 30 days of expiration automatically, Lego has no such option. Instead, we must manually check each certificate. To automate this, I’ve written a script, which you can find in my shell scripts GitHub repo that I introduced in part two. The script, which I run once a week, will renew any certificate that’s due to expire in less than 30 days and then only restart the mail servers if any renewals happen.

Download the script and copy it to /root/bin or any other suitable location and give it executable permissions.

sudo wget -O /root/bin/renew-email-certs https://raw.githubusercontent.com/jpitoniak/shell-scripts/refs/heads/main/email-server/renew-email-certs
sudo chmod 700 /root/bin/renew-email-certs

You’ll also need to create a /root/.emailconfig file to store the PowerDNS API credentials, but if you’re already using my scripts from part two, you’ll likely already have this.

cat << EOF | sudo tee /root/.emailconfig
export PDNS_API_URL=https://dnsapi.yourdoamin.com
export PDNS_API_KEY=YOUR_API_KEY
TLS_EMAIL=you@yorudomain.com
EOF

Remember to add your actual API URL and key, of course. Then set a cron job to run the script at a regular interval.

sudo crontab -e

I run mine every Thursday at about 4:15 am, but chose a random time (not midnight) so as not to overwhelm the Let’s Encrypt systems.

15 4 * * 4 /root/renew-email-certs

If you have a MAILTO line at the top of your crontab (and you’ve already set up the local mail handling that’s explained in the “Sendmail” section below), you’ll get an email with the status of all of your certificates every time the script runs. Once it’s running, there should be no certificate maintenance needed, as the script will ensure that all of your email certificates stay up to date.

“Send-only” accounts

We addressed creating email accounts in part two, but sometimes you don’t need a full email account with a mailbox. Sometimes you just want to be able to send messages and don’t need to receive them.

Remember that Chasquid doesn’t allow for any kind of open relays, so in order to send messages through Chasquid servers, you must have an account. This includes applications that need to send mail of behalf of users.

The full-scale mailboxes we created in part two were actually Dovecot accounts and Chasquid was configured to use Dovecot to authenticate them, but Chasquid has it’s own authentication system that can be handy for this use case. To create a Chasquid only account, which will be able to send, but not receive mail, run the following command:

sudo chasquid-util user-add user@domain.com

You’ll be prompted for your desired password and then the account will be created. You can use it to authenticate with any SMTP client or library in order to send mail. The email address will only be used in the SMTP envelope when Chasquid interacts with other mail servers. Chasquid will not overwrite the From: header in the email message, so recipients will see their mail as coming from the senders they expect and will be able to reply without any issues.

I typically set up these accounts using addresses like appname@myprimarydomain.com. It’s unlikely that anyone will ever see this address, let alone have a reason to send a message to it, but if you do want to receive mail sent it, you can, you’ll just need to set an alias to forward it to a different account.

Sendmail

One of the first SMTP servers for Unix was called Sendmail. Although it still exists, most Linux systems have switched to other MTA (mail transfer agent) tools such as Exim or Postfix (or in our case, Chasquid). Sendmail’s roots run deep, however, as the sendmail program it included, which could be used for sending messages from the command line, is still used by lots of programs that need to send messages. These include cron and even the PHP mail() function, among others. Most MTAs include a symbolic link from Sendmail’s standard path, /usr/sbin/sendmail on most Linux distros, so as not to break this functionality.

Chasquid, likely as part of it’s no open relays policy, does not include a Sendmail-compatible interface, but another program, msmtp, can be used to implement it. msmtp is a Sendmail-compatible command-line SMTP client. All it does is accept messages from the command line and then relays them to another SMTP server, local or remote.

To install msmtp, run the following:

sudo apt update
sudo apt install msmtp-mta -y

This will also install the base msmtp package, extending that package with the symlink for sendmal.

msmtp looks for a configuration file at /etc/msmtp in which the receiving SMTP server credentials are stored. Prior to Debian Bookworm, msmtp had it’s SetGID bit set, meaning that it always ran under the msmtp user group. This let the permissions on the config file be set such that the msmtp program could read it but the user calling it could not, ensuring that users couldn’t find the SMTP server password. This changed in starting in Bookworm, so now we need to jump through a few more hoops. (It’s also possible to set user-specific configurations instead of a system-wide one, avoiding all of this, but this will require setting up an account for each user. Six to one; half-dozen to the other.)

msmtp-mta now includes a small server program msmtpd that is supposed to function as a local proxy server to get around the issues that prevent the SetGID approach from working now. Unfortunately, its use is not documented anywhere, and I was unable to get it to work. Instead, I found a similar Python script, mailproxy, and I’m using that to create a localhost-only mail server that passes mail to Chasquid.

To install mailproxy, run the following:

sudo wget -O /usr/local/bin/mailproxy https://raw.githubusercontent.com/kz26/mailproxy/refs/heads/master/mailproxy.py
chmod 755 /usr/local/bin/mailproxy

Mailproxy will run from it’s own user account:

adduser --system --no-create-home --group mailproxy

You will also need to create a SystemD unit file to manage running mailproxy as a service:

echo << EOF | sudo tee /etc/systemd/system/mailproxy.service
[Unit]
Description=MailProxy SMTP Proxy Service

[Service]
ExecStart=/usr/local/bin/mailproxy /etc/mailproxy.ini

# Disable Python's buffering of STDOUT and STDERR, so that output from the
# service shows up immediately in systemd's logs
Environment=PYTHONUNBUFFERED=1

Restart=on-failure
User=mailproxy

[Install]
WantedBy=multi-user.target
EOF

And a mailproxy.ini file with your Chasquid credentials. This is a good use for a “send-only” account:

echo << EOF | sudo tee /etc/mailproxy.ini
[local]
host = 127.0.0.1
port = 2525

[remote]
host = 127.0.0.1
port = 465
use_ssl = yes
starttls = no
smtp_auth = yes
smtp_auth_user = user@yourdomain.com
smtp_auth_password = YOUR_EMAIL_PASSWORD
EOF

sudo chown mailproxy:mailproxy /etc/mailproxy.ini
sudo chmod 640 /etc/mailproxy.ini

This sets up a tiny mail “server” listening on port 2525 of localhost. (The [local] port is arbitrary. You could use port 25, but depending on your Chasquid configuration, port 25 could be in use or otherwise blocked, so it’s probably best to use something else.) This server will pass mail it receives on to the Chasquid server for delivery using the credentials set in the [remote]. Be sure to set the correct user and password, and then start up the service by running:

sudo systemctl enable mailproxy.service
sudo systemctl start mailproxy.service

Now configure msmtp to send the mail it receives through the mailproxy service:

echo << EOF | sudo tee /etc/msmtprc
account default
host 127.0.0.1
port 2525
EOF

Now you should be able to send a message using something like this:

/usr/sbin/sendmail --read-envelope-from -i -t << EOF
From: me@myserver.com
To: me@someplaceelse.com
Subject: Hello?

If all goes well, this should be delivered.
EOF

SpamAssassin and ClamAV

As I mentioned in Part 1, I’m using a service called MXGuardDog to prefilter my incoming mail for spam, so I have not installed any spam or virus handling tools on the server as nothing bad should be getting through. Since not everyone will be doing this, though, I wanted to touch on both of them briefly.

SpamAssassin is an open source spam filtering tool. It uses text analysis, blacklists, Bayesian filtering, and other tools to analyze a message and assign it a spam score. if the score exceeds a certain threshold, the message is considered spam and, in most cases, it will be delivered to a spam folder instead of the inbox.

Similarly, ClamAV is a virus scanner. While it can be used similar to a desktop virus scanner like McAfee to scan an entire Linux machine, here we’d be using it to scan incoming messages for problematic attachments.

To install SpamAssassin and ClamAV, run the following:

sudo apt update
sudo apt install --install-recommends install spamassassin -y
sudo apt install clamav clamav-daemon -y

If you’re using the post-data script that was distributed with the chasquid package, there’s nothing more to do. If it finds either or both of them installed, Chasquid will use them when it runs it’s post-data hook on a message. You may, however, want to check both packages’ documentation and adjust their configurations to best meet your needs.

Wrapping Up

So there we have it. If you followed all three parts of this series, you now have a working SMTP server receiving mail for all of your domains and a working POP3/IMAP4 server for delivering that mail to your users. Both are securely locked down and use encryption for all client-to-server connections. You’ve set up aliases where you need them and you’ve set up your web and command line apps so that they can send mail, too, and you’ve explored the options for spam handling.

I’ll likely write a few more related posts as I continue to work through my email setup. These will likely include a deep-dive into the spam-prevention techniques provided by DMARK, DKIM, and SPF, setting up a mailing list manager that works with Chasquid, and setting up related services, like webmail, so be sure to keep watching for new posts.

Note: I may receive service credits from MXGuarddog for mentioning them here. Regardless, they’ve been a great provider who I’ve been paying a modest sum to manage my spam for years, and my recommendation is genuine.

Setting Up a Mail Server with Chasquid and Dovecot – Part 2: Domains and Accounts

In my last post, I explained how I went about setting up a new email server using Chasquid and Dovecot.  This post will build on that, so be sure to read that one first, if you haven’t already.
Today we’ll look at what’s required to actually get mail delivered to the new server.  We’ll first set up a new domain, and then add email accounts to it.

TLDR: This post outlines the process for setting things up.  If you don’t care about the details, I have a set of shell scripts that I’ve written to automate most of this.

Adding an Email Domain

To add an email domain to the server, we need to set up a bunch of files and directories.  Replace example.com with the actual email domain, obviously.

sudo mkdir /etc/chasquid/{domains,certs}/example.com
sudo touch /etc/chasquid/domains/example.com/{aliases,passwd,users}
sudo chown -R chasquid:chasquid /etc/chasquid/domains
sudo chown -R dovecot.dovecot
sudo chown dovecot:dovecot /etc/chasquid/domains.example.com/passwd
sudo chmod 0660 /etc/chasquid/domains/example.com/*

This creates directories under /etc/chasquid for the configuration (domains) and certificates (certs) for the example.com domain.

We then add three files: aliases, which will hold any email aliases that are set up on the domain; passwd, which will contain the usernames and passwords for email accounts added to the domain; and users, which will contain the account information for any Chasquid-only user accounts.  Chasquid accounts are handy when you want to be able to send mail, such as from an application, but don’t need an accompanying mailbox to go with it.  We’ll cover these in the next post.

Once everything is created, we set the ownership of the files to the user running chasquid (or, in the case of the passwd file, the user running Dovecot) to ensure that those users can write to the files.

DKIM Signing

DKIM (DomainKeys Identified Mail) is an anti-spam measure where the sending server signs outgoing messages using a private key.  The associated public key is published to the domain’s DNS records, where a receiving server can access it and verify that the message came from an authorized sender.  Many large email providers, including GMail and Microsoft, are now requiring domains that send large volumes of mail to use DKIM or risk having their mail treated as spam.  Even if your domain doesn’t reach the threshold to require it, it’s still a smart idea to use DKIM.

The latest versions of Chasquid (>=1.14) have built-in DKIM signing, but unfortunately the version currently installed by Debian Bookworm is a bit older (1.12 as of this writing), so we’ll need to install a third-party tool to do the work.

I’m currently using the driusan/dkim DKIM utilities.  These are written in Go and require compiling, so you’ll need to install the Go compiler (note: Go 1.25.1 was current at the time of writing, be sure to check the Go downloads page for the most up-to-date version).

wget https://go.dev/dl/go1.25.1.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.1.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin

Then run the following commands to build and install the dkimsign, dkimverify, and dkimkeygen utilities:

go install github.com/driusan/dkim/cmd/dkimsign@latest
go install github.com/driusan/dkim/cmd/dkimverify@latest
go install github.com/driusan/dkim/cmd/dkimkeygen@latest
sudo cp ~/go/bin/{dkimsign,dkimverify,dkimkeygen} /usr/local/bin

Next, we need to create a DKIM signing key.  This needs to be done for each domain.
First, switch to the domain’s directory under /etc/chasquid/certs:

cd /etc/chasquid/certs/example.com

Then run the dkimkeygen command:

sudo dkimkeygen

This will create two files in the directory, private.pem which contains the private key, and dns.txt which contains the public key in the format needed for the DNS record.  Chasquid needs the key file to be named dkim_privkey.pem.

sudo mv private.pem dkim_privkey.pem

Next, you’ll need to create a dkim_selector file in the domain’s directory under domains.  The selector can be whatever you want (“mail” is common).  I like to use the machine’s hostname and today’s date.  This gives me the ability to easily rotate keys or change email servers while also keeping the old public keys available so that receiving servers can still verify older mail.

sudo echo $(hostname -s)$(date +'%Y%m%d') | sudo tee /etc/chasquid/domains/example.com/dkim_selector

Finally, you’ll need to create a new record on your domain’s DNS server with the contents of the dns.txt file.  The record should be a TXT record, the record name should be exampleYYYYMMDD._domainkey.example.com (substituting the selector name you created above and the proper domain name, of course) and the content should be the entire contents of the file.

Each time Chasquid receives an email message, it runs the post-data script (found in /etc/chasquid/hooks) to see what further processing it should be before delivering it to either Dovecot or to the intended recipient’s SMTP server.  The post data script that is included in the Debian distro is set up to automatically handle DKIM signing or validation (depending on whether it’s an outgoing or incoming message) if it can find the DKIM utilities we just compiled and a valid signing certificate (for outgoing mail), so no additional configuration is necessary.

TLS Certificates

Chasquid requires that TLS be enabled for clients submitting mail to be delivered, so we’ll first need to get a certificate. (Mail coming in from other servers does not strictly require TLS, but Chasquid has a nice feature that blocks mail coming in on a non-secured connection when it has previously seen mail from the same domain that is secured.  This isn’t a huge deal in my setup, since all of my mail first passes through my spam filtering service, MXGuarddog, so only their servers interact with mine, but it can help prevent some spam from getting in when outside servers are interfacing with Chasquid directly.)

While not strictly required, I’m using a separate certificate for each domain that I’m hosting.  This lets my users access their mailboxes using “mail.specificdomain.com” in their mail clients without needing to accept any invalid certificates and it makes for a seamless transition if I ever decide to split any domains off to different servers in the future.

I’m getting my certificates from Let’s Encrypt and I’m using the Lego ACME client to do it.  Chasquid is actually set up to interface nicely with Certbot, but because I’m using the Caddy webserver on this machine, and Caddy has it’s own ACME client, it would have been difficult (if not impossible) to get Certbot working correctly.  Lego can interface directly with my DNS servers (running PowerDNS), which is what prompted me to go that route.

Assuming you’re also using PDNS, before attempting to obtain a cert, you’ll need to ensure that the PowerDNS API is running on your DNS server (it is not on by default) and you’ll need your server’s API key.  Use caution in doing this, as an improperly set up API could let an attacker into your system. You can find instructions for enabling the API on the PowerDNS site.

Now that we’re ready to go, we’ll first install Lego and create a directory where the certificates will be stored.

sudo apt update
sudo apt install lego
sudo mkdir -p /etc/chasquid/.lego/certificates
sudo chmod 755 /etc/chasquid/.lego /etc/chasquid/.lego/certificates

Lego looks for PDNS_API_URL and PDNS_API_KEY environment variables to interface with the DNS server, so set these as appropriate.

PDNS_API_URL=https://dns.example.com/
PDNS_API_KEY=ENTER_API_KEY

With those set, we can request the certificate.  I use the singular ‘mail.example.com’ hostname to access all services, but if you’d prefer additional names, such as ‘smtp’, ‘pop3’, or ‘imap’, you can add them with additional -d parameters and they’ll also be added to the certificate.

sudo lego --dns pdns --accept-tos --email your.address@example.com --path /etc/chasquid/.lego -d mail.example.com run

Since Chasquid is expecting Certbot’s naming conventions, but Lego uses a different one, we’ll symlink the certificates to where Chasquid expects them to be:

sudo ln -s /etc/chasquid/.lego/certificates/mail.example.com.crt /home/chasquid/certs/mail.example.com/fullchain.pem
sudo ln -s /etc/chasquid/.lego/certificates/mail.example.com.key /home/chasquid/certs/mail.example.com/privkey.pem

To add the certs to Dovecot, we’ll create a config file in /etc/dovecot/domains that points to the certificate files.

cat << EOF | sudo tee /etc/dovecot/domains/mail.example.conf
local_name mail.$1 {
&nbsp; &nbsp; ssl_cert = </etc/chasquid/certs/example/fullchain.pem
&nbsp; &nbsp; ssl_key = </etc/chasquid/certs/example/privkey.pem
}
EOF

Finally, we’ll restart/reload the servers so that they’ll pick up the new configuration (reloading is generally preferred over a full restart, but Chasquid only has a restart option).

systemctl reload dovecot
systemctl restart chasquid

Creating Email Accounts

Now that the domain is set up, the only thing left to do is add users.  To do this, you’ll need two things: the desired email address and a password.

Passwords are stored as hashes in the /etc/chasquid/domains/example.com/passwd file.  The doveadm pw command can be used to hash the password with the Dovecot server’s preferred hashing method:

PASSWD=$(doveadm pw)

This will prompt for a password and return the hash, which will be stored in a shell variable named PASSWD.

To create the user account, append a line to the passwd file like so:

echo "user@example.com:$PASSWD:dovenull:dovenull:::::" >>/etc/chasquid/domains/example.com/passwd

The first field contains the username, which is the full email address; next is the password hash, as returned above; and ‘dovenull’ is the user and group that will “own” the user’s mailbox on the server.  The remaining fields are not needed for this specific setup, so we leave them empty, hence the ‘:::::’ at the end of the file.  Some additional features that could be added include the directory where mail should be placed (which would override the global value we set up in Part 1), mailbox quotas (which I’m not using), and network restrictions (to only allow access to the mailbox from certain subnets, for example), among other things.

Once the user is written to the file, they’ll be able to log in using their favorite email client and receive mail sent by other users.

MXGuarddog

In part one, I mentioned that I’m using a third-party spam filtering service, MXGuarddog, to filter my mail. All mail for my domains first gets delivered to them, they scan it, and then forward the legitimate mail on to my Chasquid server. In order to do this, they need to know the email addresses to accept.

At this point, if you’re using MXGuarddog or a similar service, don’t forget to set up the new account with the service provider.

MX Record

The final set in setting up a mail server is setting an MX, or mail exchanger, record in your domain’s DNS.  The MX record tells mail senders where to find the server that handles mail for your domain.

The MX record should have the fully qualified domain name that will be used for addressing mail as it’s name (example.com), and the name of the server that will receive it’s mail as the record’s contents (mail.example.com).  It should also have a priority which is used to determine the order in which servers are tried if there is more than one MX record.  If you only have one server, set the priority to 10.

If you’re using a third-party service like MXGuarddog to process your mail, be sure to use the MX record settings they provide to ensure you mail gets routed correctly.

Wrapping Up

We now have a fully functional mail server that can send, receive, and deliver messages for our users.  In the next post, I’ll cover the remaining loose ends of the setup, such as aliases and pipes, TLS certificate renewal, send-only email accounts (and why you might want them), and how to set up auxiliary services, like SpamAssassin or ClamAV.

Note: I may receive service credits from MXGuarddog for mentioning them here. Regardless, they’ve been a great provider who I’ve been paying a modest sum to manage my spam for years, and my recommendation is genuine.

<