setup notes

This document describes how you could set up a server like yourself. It’s also a reminder of how I set this server up, so that I can figure out what’s going on when I look back at this in a week ;)

It’s still a work in progress - see my TODO list for the stuff I want to get to but haven’t yet :)

I’m running on a DigitalOcean $5/month machine, running Debian GNU/Linux 11 in the nyc3 region. I have DigitalOcean’s weekly backups turned on in addition to the daily tarsnap backups described below for redundancy. DO NOT use a floating IP - you will not be able to send outbound mail if you do. The name of the droplet should be the hostname that you will be sending mail from, so that the PTR record is set correctly.

Note that there may be some path-dependency issues in this document, particularly around the HTTPS setup - I’m not describing things in exactly the order I did them, since a lot of this doc pulls info and config files from the running system.

dns setup

web server setup

install software

apt update
apt upgrade
apt install lighttpd git socat # important
apt install curl telnet man htop tmux strace lsof expect make dtrx pandoc # just for fun (and rendering this doc!)


Do this as root, in your home directory (/root/).

git clone
(cd ./ && ./ --install)
mkdir -vp /var/www/html/.well-known/acme-challenge/
chown -R www-data:www-data /var/www/html/.well-known/acme-challenge/
chmod -R 0555 /var/www/html/.well-known/acme-challenge/
mkdir -p /etc/lighttpd/ssl/
(cd /etc/lighttpd/ssl/ && openssl dhparam -out dhparam.pem -dsaparam 4096)
# log out and in again to get in your path --issue -w /var/www/html -d -d -k 4096

Set /root/ to contain:


cat "${certfile}" "${keyfile}" > "${sslfile}"
systemctl restart lighttpd

Then run:

chmod +x --installcert -d -d \
        --capath /etc/lighttpd/ssl/ \
        --reloadcmd /root/

lighttpd setup

rm /etc/lighttpd/conf-enabled/99-unconfigured.conf
touch /var/log/lighttpd/access.log
chown www-data:www-data /var/log/lighttpd/access.log

Set /etc/lighttpd/lighttpd.conf to contain the following:

server.modules = (

server.document-root = "/var/www/html/"
server.upload-dirs = ( "/var/cache/lighttpd/uploads" )
server.errorlog = "/var/log/lighttpd/error.log"
server.breakagelog = "/var/log/lighttpd/breakage.log" = "/var/run/"
server.username = "www-data"
server.groupname = "www-data"
server.bind = ""
server.port = 80

accesslog.filename = "/var/log/lighttpd/access.log" 

userdir.path = "public_html"

server.dir-listing = "enable"
dir-listing.encoding = "utf-8"
dir-listing.external-css = "/dir.css"

server.http-parseopts = (
    "header-strict" => "enable",
    "host-strict" => "enable",
    "host-normalize" => "enable",
    "url-normalize-unreserved" => "enable",
    "url-normalize-required" => "enable",
    "url-ctrls-reject" => "enable",
    "url-path-2f-decode" => "enable",
    "url-path-dotseg-remove" => "enable",

index-file.names = (

url.access-deny = ( "~", ".inc" )
static-file.exclude-extensions = ( ".pl", ".py", ".sh" )

compress.cache-dir = "/var/cache/lighttpd/compress/"
compress.filetype = ( "application/javascript", "text/css", "text/html", "text/plain" )

include_shell "/usr/share/lighttpd/"

$SERVER["socket"] == "[::]:80" {
    $HTTP["host"] =~ ".*" {
        url.redirect = (".*" => "https://%0$0")

$SERVER["socket"] == "" {
    $HTTP["host"] =~ ".*" {
        url.redirect = (".*" => "https://%0$0")

$SERVER["socket"] == "" {
    ssl.engine = "enable"
    ssl.disable-client-renegotiation = "enable"

    ssl.pemfile = "/etc/lighttpd/ssl/" = "/etc/lighttpd/ssl/"
    ssl.dh-file = "/etc/lighttpd/ssl/" = "secp384r1"

    setenv.add-environment = (
        "HTTPS" => "on"

    ssl.openssl.ssl-conf-cmd = ("Protocol" => "-TLSv1.1, -TLSv1, -SSLv3")

    ssl.cipher-list = "EECDH+AESGCM:EDH+AESGCM"

    setenv.add-response-header = (
        "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload"

$SERVER["socket"] == "[::]:443" {
    ssl.engine = "enable"
    ssl.disable-client-renegotiation = "enable"

    ssl.pemfile = "/etc/lighttpd/ssl/" = "/etc/lighttpd/ssl/"
    ssl.dh-file = "/etc/lighttpd/ssl/" = "secp384r1"

    setenv.add-environment = (
        "HTTPS" => "on"

    ssl.openssl.ssl-conf-cmd = ("Protocol" => "-TLSv1.1, -TLSv1, -SSLv3")

    ssl.cipher-list = "EECDH+AESGCM:EDH+AESGCM"

    setenv.add-response-header = (
        "Strict-Transport-Security" => "max-age=31536000; includeSubDomains; preload"

alias.url = ( "/" => "/home/wesleyac/public_html/homepage/" )

$HTTP["host"] == "" {
    cgi.assign = (
        ".py"  => "",
        ".sh" => "",
        ".pl" => "",
        ".cgi" => "",

$HTTP["host"] == "" {
    setenv.add-response-header = (
        "Access-Control-Allow-Origin" => "*",
        "Access-Control-Allow-Methods" => "HEAD, GET, OPTIONS",
        "Access-Control-Expose-Headers" => "Content-Range, Date, Etag, Cache-Control, Last-Modified",
        "Access-Control-Allow-Headers" => "Content-Type, Origin, Accept, Range, Cache-Control",
        "Timing-Allow-Origin" => "*",
        "Content-Type" => "text/plain; charset=UTF-8",
        "Cache-Control" => "no-store",
    expire.url = ( "" => "access plus 0 seconds" )
    cgi.assign = (
        ".py"  => "/root/",
        ".sh" => "/root/",
        ".pl" => "/root/",
        ".cgi" => "/root/",

This gets me an A+ on the SSL Labs test :)

view source details

make a /root/ file:

echo ""
cat $1

this is to prevent shebangs with colons in them from being treated as HTTP headers

postfix setup

apt install mailutils postfix # select "Internet Site" in postfix config

edit /etc/postfix/ to contain the following (change the mydestination line):

smtpd_banner = $myhostname ESMTP $mail_name (Debian/GNU)
biff = no

# appending .domain is the MUA's job.
append_dot_mydomain = no

readme_directory = no

compatibility_level = 2

# TLS parameters
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname =
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
myorigin = /etc/mailname
mydestination = $myhostname, localhost.$mydomain, $mydomain
relayhost = 
mynetworks = [::ffff:]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all

# from inside the chroot, the socket will be in /var/run/opendkim 
smtpd_milters = unix:/var/run/opendkim/opendkim.sock
non_smtpd_milters = unix:/var/run/opendkim/opendkim.sock
milter_default_action = accept
milter_protocol = 6

restart postfix:

systemctl restart postfix

try to send mail:

echo "<3 <3 <3" | mail -s " test email"

discover that you’re on the CBL blacklist. Remove host from CBL blacklist. Try to send mail again. Discover that you’re greylisted by Fastmail. Cry. The SparkPost Authentication Checker may be useful for verifying that SPF is set up correctly.

dkim setup

apt install opendkim opendkim-tools
opendkim-genkey -D /etc/dkimkeys/ -d -s mail
chown opendkim:opendkim /etc/dkimkeys/*
chmod go-rwx /etc/dkimkeys/*
mkdir -p /var/spool/postfix/var/run/opendkim
chown opendkim. /var/spool/postfix/var/run/opendkim
chmod go-rwx /var/spool/postfix/var/run/opendkim
chmod g+x /var/spool/postfix/var/run/opendkim

edit /etc/opendkim.conf:

# Log to syslog
Syslog yes
# Required to use local socket with MTAs that access the socket as a non-
# privileged user (e.g. Postfix)
UMask 007
UserID opendkim
PidFile /var/run/opendkim/

Canonicalization simple
Mode sv
SubDomains no

# Postfix runs inside /var/spool/postfix/ chroot
Socket local:/var/spool/postfix/var/run/opendkim/opendkim.sock

KeyTable file:/etc/dkimkeys/keytable
SigningTable refile:/etc/dkimkeys/signingtable 
InternalHosts refile:/etc/dkimkeys/trustedhosts

# Always oversign From (sign using actual From and a null From to prevent
# malicious signatures header fields (From and/or others) between the signer
# and the verifier.  From is oversigned by default in the Debian pacakge
# because it is often the identity key used by reputation systems and thus
# somewhat security sensitive.
OversignHeaders From

# Specifies a file from which trust anchor data should be read when doing
# DNS queries and applying the DNSSEC protocol.  See the Unbound documentation
# at for the expected format of this file.
TrustAnchorFile /usr/share/dns/root.key

create /etc/dkimkeys/keytable:

create /etc/dkimkeys/signingtable:

create /etc/dkimkeys/trustedhosts:

systemctl restart opendkim
adduser postfix opendkim

Make a DNS TXT record based on /etc/dkimkeys/mail.txt.

firewall setup

apt install ufw
ufw allow ssh
ufw allow http
ufw allow https
ufw allow 25
ufw enable

add a user!

adduser wesleyac
usermod -aG sudo wesleyac
mkdir /home/wesleyac/.ssh
chown wesleyac:wesleyac /home/wesleyac/.ssh/
cp .ssh/authorized_keys /home/wesleyac/.ssh/authorized_keys
chown wesleyac:wesleyac /home/wesleyac/.ssh/authorized_keys

there’s a script at /root/ that listens on localhost:420 for requests to add users:

#!/usr/bin/env python3

import crypt, json, os, stat, shutil, subprocess, base64, struct, binascii, pwd
from http.server import BaseHTTPRequestHandler, HTTPServer

def check_ssh_key(key):
    key = key.split()

    if len(key) != 3:
        return (False, "must have key type, key data, and username")

        data = base64.decodestring(bytes(key[1], 'utf-8'))
        str_len = struct.unpack('>I', data[:4])[0]
        assert(int(str_len) == len(key[0]))
        return (False, "could not decode data")

    if data[4:4+str_len] != bytes(key[0], 'utf-8'):
        return (False, "key type doesn't match data")

    return (True, "")

class Server(BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        data = json.loads(
        errs = []
            user = data["username"]
            email = data["email"]
            passwd = data["password"]
            ssh_key = data["ssh"]
            errs.append("couldn't find key")
        valid_key = check_ssh_key(ssh_key)
        if not valid_key[0]:
            errs.append(f"ssh key error: {valid_key[1]}")
        if user in [str(x.pw_name) for x in pwd.getpwall()]:
            errs.append("username already taken")
        if len(user) > 30:
            errs.append("username too long")
        if not (user.isidentifier() and user.islower()):
            errs.append("disallowed characters in username :(")
        if len(errs) != 0:
            response = json.dumps({"result": "err", "errs": errs}).encode()
  ["useradd", "-p", crypt.crypt(passwd), "-c", email, "-m", user])

            os.makedirs(f"/home/{user}/.ssh/", exist_ok=True)
            with open(f"/home/{user}/.ssh/authorized_keys", 'w') as authorized_keys:
            shutil.chown(f"/home/{user}/.ssh/", user, user)
            shutil.chown(f"/home/{user}/.ssh/authorized_keys", user, user)

            os.makedirs(f"/home/{user}/public_html/", exist_ok=True)
            with open(f"/home/{user}/public_html/", 'w') as index:
cat <<EOF
<!DOCTYPE html>
\t\t<title>{user}'s page!</title>
\t\t<h1>this is {user}'s page!</h1>

\t\t<p>this is the default index page - try changing it in the <a href="{user}/">web editor</a>!</p>

\t\t<p>the current time is $(date)</p>
            shutil.chown(f"/home/{user}/public_html/", user, user)
            shutil.chown(f"/home/{user}/public_html/", user, user)
            os.chmod(f"/home/{user}/public_html/", 0o775)
            response = json.dumps({"result": "ok", "errs": []}).encode()

        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', len(response))

if __name__ == "__main__":
    s = HTTPServer(("", 420), Server)

and a systemd file to start it on boot at /lib/systemd/system/mkusers.service:

Description=Make user accounts



which you can enable via:

systemctl start mkusers
systemctl enable mkusers

disable ssh root and password login

(i might undo this soon, this is just while i have really weak passwords set for testing)

edit /etc/ssh/sshd_config:

ChallengeResponseAuthentication no
PasswordAuthentication no
UsePAM no
PermitRootLogin no
sudo systemctl reload ssh

set up backups via tarsnap

apt install gpg
# verify signature
apt-key add tarsnap-deb-packaging-key.asc
echo "deb$(lsb_release -s -c) ./" | sudo tee -a /etc/apt/sources.list.d/tarsnap.list
apt-get update
apt install tarsnap
tarsnap-keygen --keyfile /root/tarsnap-main.key --user --machine glitsh
tarsnap-keymgmt --outkeyfile tarsnap.key -w tarsnap-main.key
# save tarsnap-main.key somewhere safe then delete it from the server

make a /root/ file:

tarsnap -c -f "$(uname -n)-$(date +%Y-%m-%d_%H-%M-%S)" /home/ /root/ /etc/ /lib/systemd/system/ /var/mail/ /var/spool/ /var/log/ /var/www/

and go ahead and run it to check that it works (including checking that backup restore works!)

and to /etc/crontab, add:

11 4 * * * root /root/

to make a daily backup task. check back later to see that it worked.

you’re done!

enjoy <3