--- /dev/null
+*.tfstate
+*.tfstate.*
+.terraform/
+.terraform.lock.hcl
+terraform.tfvars
+ansible/inventory.ini
+*.retry
--- /dev/null
+# server-iac
+
+Terraform + Ansible for reproducing the setup of the mail/git/web server currently
+running as `ubuntu-us-ord` (Linode, `us-ord`), serving `andrewgundersen.net` and
+`crimata.com`.
+
+This captures the parts of the box that are pure configuration. It does **not**
+capture secrets — see below.
+
+## Layout
+
+```
+terraform/ Provisions the Linode itself (size, region, image, networking)
+ansible/ Installs and configures software on a provisioned box
+```
+
+## What's captured
+
+- Base packages, unattended-upgrades, ufw firewall rules
+- nginx site configs (as templates, parameterized by domain)
+- dovecot config (system-user auth via PAM, mbox mail location, chasquid
+ integration socket)
+- chasquid config (dovecot auth, LMTP delivery, hostname)
+- gitweb config (feature flags, highlight extension map, branding)
+- certbot renewal hooks that sync certs into chasquid's cert store and
+ restart dovecot/chasquid on renewal
+
+## What's deliberately NOT captured (secrets)
+
+These can't be "replayed" from config — they're per-identity secrets that
+either need fresh generation per node or a separate secure transfer:
+
+- **TLS certificates** (`/etc/letsencrypt/`) — regenerate fresh via
+ `certbot --nginx` for whatever hostname the new node actually has. Don't
+ try to copy these; a cert for one IP/hostname looks suspicious presented
+ from another.
+- **DKIM private keys** (`/etc/chasquid/certs/<domain>/dkim_privkey.pem`) —
+ these DO need to match what's published in DNS, so unlike TLS certs they
+ can't just be regenerated per node without also updating DNS. If this ever
+ becomes a real multi-node setup, these need deliberate secure distribution
+ (not this repo).
+- **Per-repo GitHub deploy keys** (`/var/www/.ssh/id_ed25519_*`) — each
+ mirrored git repo has its own SSH deploy key scoped to just that repo (see
+ the [[dedicated SSH keys]] rationale — least privilege, so a compromised
+ key only exposes one repo). These are generated per-setup, not shared.
+- **Mail data** (`/var/mail/*`) — this is state, not config. Not in scope
+ for IaC at all; needs its own backup story.
+- **System user passwords** (`andrew`, `me`) — set manually per node.
+
+## Multi-region context
+
+This repo exists because of a discussion about whether it'd ever make sense
+to run this setup in more than one region for durability. The honest
+breakdown of what that would actually require:
+
+- **git**: already solved. Git is distributed by design; this is just more
+ remotes and more `post-receive` hooks, same pattern already in use to
+ mirror to GitHub.
+- **mail (dovecot)**: needs dovecot's built-in `dsync` replication feature.
+ Not set up here — this repo only stands up a single node.
+- **mail (chasquid)**: not actually a sync problem — chasquid is stateless
+ per-message once it hands off to dovecot via LMTP. The real requirement is
+ routing (secondary MX / anycast), not new chasquid config.
+
+None of that is implemented yet. This repo is step one: capture the single
+node's setup so it's reproducible, before deciding whether multi-node is
+actually worth the added complexity.
--- /dev/null
+[defaults]
+inventory = inventory.ini
+host_key_checking = True
--- /dev/null
+[mail_servers]
+mail-server-chicago ansible_host=REPLACE_WITH_IP ansible_user=root
--- /dev/null
+---
+- name: Provision mail/git/web server
+ hosts: mail_servers
+ become: true
+ vars:
+ primary_domain: andrewgundersen.net
+ secondary_domain: crimata.com
+ admin_email: adgundersen@gmail.com
+
+ roles:
+ - base
+ - firewall
+ - nginx
+ - dovecot
+ - chasquid
+ - gitweb
+ - certbot
--- /dev/null
+---
+- name: Update apt cache
+ apt:
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Install core packages
+ apt:
+ name:
+ - nginx
+ - dovecot-core
+ - dovecot-imapd
+ - dovecot-pop3d
+ - dovecot-lmtpd
+ - chasquid
+ - certbot
+ - python3-certbot-nginx
+ - fcgiwrap
+ - gitweb
+ - git
+ - spamassassin
+ - spamc
+ - highlight
+ - python3-markdown
+ - python3-dkim
+ - unattended-upgrades
+ - ufw
+ - acl
+ state: present
+
+# NOTE: the live box currently does NOT have unattended-upgrades enabled
+# despite the package being present in this list historically - confirm
+# /etc/apt/apt.conf.d/20auto-upgrades is actually enabled after a fresh
+# provision, not just installed.
+- name: Enable unattended-upgrades
+ copy:
+ dest: /etc/apt/apt.conf.d/20auto-upgrades
+ content: |
+ APT::Periodic::Update-Package-Lists "1";
+ APT::Periodic::Unattended-Upgrade "1";
+
+- name: Enable spamassassin service
+ systemd:
+ name: spamassassin
+ enabled: true
+ state: started
--- /dev/null
+---
+- name: Deploy chasquid cert-sync deploy hook
+ template:
+ src: chasquid-deploy-hook.sh.j2
+ dest: /etc/letsencrypt/renewal-hooks/deploy/chasquid.sh
+ mode: "0755"
+
+- name: Deploy dovecot restart-on-renew deploy hook
+ template:
+ src: dovecot-deploy-hook.sh.j2
+ dest: /etc/letsencrypt/renewal-hooks/deploy/dovecot.sh
+ mode: "0755"
+
+# Certs themselves are NOT issued here - certbot needs DNS already pointed
+# at this box's IP first (chicken-and-egg with Terraform's output). Issue
+# manually after DNS propagates:
+#
+# certbot --nginx -d {{ primary_domain }} -d www.{{ primary_domain }} \
+# --non-interactive --agree-tos --email {{ admin_email }}
+# certbot certonly --nginx -d mail.{{ primary_domain }} \
+# --non-interactive --agree-tos --email {{ admin_email }}
+# certbot --nginx -d {{ secondary_domain }} -d www.{{ secondary_domain }} \
+# --non-interactive --agree-tos --email {{ admin_email }}
+#
+# Each of these also needs the corresponding DKIM DNS TXT record published
+# and the deploy hooks above already in place, or chasquid/dovecot won't
+# pick up the new certs automatically.
--- /dev/null
+#!/bin/sh
+# Certbot deploy-hook: copy renewed certs somewhere chasquid (running as an
+# unprivileged user) can actually read, since /etc/letsencrypt/{live,archive}
+# are root:root 0700 and chasquid can't traverse them directly.
+set -eu
+
+DOMAIN=$(basename "$RENEWED_LINEAGE")
+DEST="/etc/chasquid/certs-orig/$DOMAIN"
+
+mkdir -p "$DEST"
+cp "$RENEWED_LINEAGE/fullchain.pem" "$DEST/fullchain.pem"
+cp "$RENEWED_LINEAGE/privkey.pem" "$DEST/privkey.pem"
+chown -R chasquid:chasquid "$DEST"
+chmod 750 "$DEST"
+chmod 640 "$DEST/fullchain.pem" "$DEST/privkey.pem"
+
+systemctl try-restart chasquid.service || true
--- /dev/null
+#!/bin/sh
+# Dovecot reads its TLS cert/key as root at startup, before dropping
+# privileges, so it can read /etc/letsencrypt directly - it just needs a
+# restart after each renewal to pick up the new files.
+set -eu
+
+DOMAIN=$(basename "$RENEWED_LINEAGE")
+if [ "$DOMAIN" = "mail.{{ primary_domain }}" ]; then
+ systemctl try-restart dovecot.service || true
+fi
--- /dev/null
+---
+- name: restart chasquid
+ systemd:
+ name: chasquid
+ state: restarted
--- /dev/null
+---
+- name: Deploy chasquid.conf
+ template:
+ src: chasquid.conf.j2
+ dest: /etc/chasquid/chasquid.conf
+ notify: restart chasquid
+
+- name: Create domain directories (chasquid accepts mail for any domain with a dir here)
+ file:
+ path: "/etc/chasquid/domains/{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ loop:
+ - "{{ primary_domain }}"
+ - "{{ secondary_domain }}"
+
+# chasquid will refuse to start with zero domains configured ("No entries
+# found in domains/") - this bit us once on the live box, worth remembering
+# if this playbook is ever run against an empty /etc/chasquid/domains/.
+
+# NOTE: /etc/chasquid/certs is a symlink to /etc/chasquid/certs-orig on the
+# live box, populated by the certbot role's deploy hook (chasquid runs
+# unprivileged and can't read /etc/letsencrypt/{live,archive} directly,
+# which are root:root 0700). This role does not populate certs itself -
+# chasquid will fail to start until the certbot role has run at least once
+# per domain that needs a cert (mail.{{ primary_domain }} at minimum).
+- name: Symlink chasquid cert dir to certs-orig
+ file:
+ src: /etc/chasquid/certs-orig
+ dest: /etc/chasquid/certs
+ state: link
+ force: true
+
+- name: Ensure certs-orig exists
+ file:
+ path: /etc/chasquid/certs-orig
+ state: directory
+ owner: root
+ group: root
--- /dev/null
+# Default hostname to use when saying hello (HELO/EHLO). Should match the
+# mail.* subdomain that has its own cert and, ideally, the PTR/rDNS record
+# for this box's IP - forward/reverse DNS mismatches hurt deliverability.
+hostname: "mail.{{ primary_domain }}"
+
+# Deliver email via lmtp to dovecot.
+mail_delivery_agent_bin: "/usr/bin/mda-lmtp"
+mail_delivery_agent_args: "--addr"
+mail_delivery_agent_args: "/run/dovecot/lmtp"
+mail_delivery_agent_args: "-f"
+mail_delivery_agent_args: "%from%"
+mail_delivery_agent_args: "-d"
+mail_delivery_agent_args: "%to_user%"
+
+# Use dovecot authentication - system users double as mail users, no
+# separate chasquid-managed user database.
+dovecot_auth: true
--- /dev/null
+---
+- name: restart dovecot
+ systemd:
+ name: dovecot
+ state: restarted
--- /dev/null
+---
+# Auth: this relies on auth-system.conf.ext's defaults being enabled
+# (passdb driver=pam, userdb driver=passwd) - i.e. plain Linux system
+# users, no separate virtual-user database. Mailbox users are created with
+# `adduser` (or `adduser --shell /usr/sbin/nologin --disabled-password` for
+# mail-only accounts), not through this playbook.
+- name: Set auth_username_format to local-part only
+ lineinfile:
+ path: /etc/dovecot/conf.d/10-auth.conf
+ regexp: '^#?auth_username_format'
+ line: 'auth_username_format = %Ln'
+ notify: restart dovecot
+
+- name: Deploy mail location config
+ template:
+ src: 10-mail.conf.j2
+ dest: /etc/dovecot/conf.d/10-mail.conf
+ notify: restart dovecot
+
+- name: Deploy SSL cert paths
+ template:
+ src: 10-ssl.conf.j2
+ dest: /etc/dovecot/conf.d/10-ssl.conf
+ notify: restart dovecot
+
+- name: Deploy chasquid auth socket config
+ template:
+ src: 11-chasquid.conf.j2
+ dest: /etc/dovecot/conf.d/11-chasquid.conf
+ notify: restart dovecot
+
+- name: Ensure /var/mail exists with correct group/perms
+ file:
+ path: /var/mail
+ state: directory
+ owner: root
+ group: mail
+ mode: "2775"
--- /dev/null
+# mbox format, INBOX in the classic system mail spool, other folders under
+# ~/mail. Pairs with system-user auth (see 10-auth.conf) - no separate
+# virtual-user database.
+mail_location = mbox:~/mail:INBOX=/var/mail/%u
--- /dev/null
+# NOTE: these paths assume certbot has already issued a cert for
+# mail.{{ primary_domain }} - run the certbot role/ticket step first, or
+# this will fail to start. Dovecot reads these directly as root at
+# startup (unlike chasquid, which runs unprivileged and needs the
+# certs-orig copy - see the certbot role's deploy hooks).
+ssl_cert = </etc/letsencrypt/live/mail.{{ primary_domain }}/fullchain.pem
+ssl_key = </etc/letsencrypt/live/mail.{{ primary_domain }}/privkey.pem
--- /dev/null
+# Allow chasquid to authenticate users via dovecot.
+service auth {
+ unix_listener auth-chasquid-userdb {
+ mode = 0660
+ user = chasquid
+ }
+ unix_listener auth-chasquid-client {
+ mode = 0660
+ user = chasquid
+ }
+}
--- /dev/null
+---
+- name: Set ufw default policies
+ ufw:
+ direction: "{{ item.direction }}"
+ policy: "{{ item.policy }}"
+ loop:
+ - { direction: incoming, policy: deny }
+ - { direction: outgoing, policy: allow }
+
+- name: Allow SSH
+ ufw:
+ rule: allow
+ port: "22"
+ proto: tcp
+
+- name: Allow HTTP/HTTPS
+ ufw:
+ rule: allow
+ port: "80,443"
+ proto: tcp
+
+- name: Allow mail ports
+ ufw:
+ rule: allow
+ port: "{{ item }}"
+ proto: tcp
+ comment: mail
+ loop:
+ - "25" # SMTP
+ - "465" # submission+TLS
+ - "587" # submission
+ - "143" # IMAP
+ - "993" # IMAPS
+ - "110" # POP3
+ - "995" # POP3S
+
+- name: Enable ufw
+ ufw:
+ state: enabled
+ logging: "low"
+
+# IMPORTANT: outbound port 25 is blocked at the Linode network level by
+# default on new accounts, separate from ufw entirely. ufw's outgoing=allow
+# does NOT fix this - it must be requested via a Linode support ticket
+# after provisioning. See README for the ticket template used last time.
--- /dev/null
+---
+- name: Deploy gitweb.conf
+ template:
+ src: gitweb.conf.j2
+ dest: /etc/gitweb.conf
+
+- name: Enable and start fcgiwrap
+ systemd:
+ name: fcgiwrap
+ enabled: true
+ state: started
--- /dev/null
+#!/usr/bin/perl
+# gitweb configuration, referenced via GITWEB_CONFIG in {{ primary_domain }}.conf
+
+our $projectroot = "/srv/git";
+our $projects_list = $projectroot;
+
+$feature{'blame'}{'default'} = [1];
+$feature{'snapshot'}{'default'} = ['tgz', 'zip', 'tbz2'];
+$feature{'pickaxe'}{'default'} = [1];
+$feature{'search'}{'default'} = [1];
+$feature{'grep'}{'default'} = [1];
+$feature{'showsizes'}{'default'} = [1];
+$feature{'patches'}{'default'} = [16];
+$feature{'highlight'}{'default'} = [1];
+$feature{'ctags'}{'default'} = [1];
+
+# gitweb's built-in extension map predates TypeScript/Go; add them so
+# 'highlight' actually colors the file types these repos use.
+$highlight_ext{'ts'} = 'ts';
+$highlight_ext{'tsx'} = 'tsx';
+$highlight_ext{'go'} = 'go';
+
+our $omit_owner = 1;
+
+our $site_name = "Repos";
+our $home_link_str = "projects";
+our @git_base_url_list = ('https://{{ primary_domain }}/repos');
+
+# absolute paths: the default relative "static/..." links break because
+# the canonical UI URL is /repos (no trailing slash), not /repos/
+our @stylesheets = ("/repos/static/gitweb.css");
+our $logo = "/repos/static/git-logo.png";
+our $favicon = "/favicon.png"; # content-layer asset, not provisioned by this role
+our $javascript = "/repos/static/gitweb.js";
--- /dev/null
+---
+- name: reload nginx
+ systemd:
+ name: nginx
+ state: reloaded
--- /dev/null
+---
+- name: Create web roots
+ file:
+ path: "/var/www/{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ loop:
+ - "{{ primary_domain }}/personal-website"
+ - "{{ secondary_domain }}/public"
+
+- name: Deploy primary domain config (with git hosting)
+ template:
+ src: primary-site-with-git.conf.j2
+ dest: "/etc/nginx/conf.d/{{ primary_domain }}.conf"
+ vars:
+ domain: "{{ primary_domain }}"
+ notify: reload nginx
+
+- name: Deploy secondary domain config
+ template:
+ src: site.conf.j2
+ dest: "/etc/nginx/conf.d/{{ secondary_domain }}.conf"
+ vars:
+ domain: "{{ secondary_domain }}"
+ notify: reload nginx
+
+- name: Create /srv/git
+ file:
+ path: /srv/git
+ state: directory
+ owner: root
+ group: root
+
+# NOTE: individual repos (deploy keys, post-receive mirror hooks, actual
+# bare repo content) are NOT provisioned here - that's a per-repo manual
+# step (see server-iac README) since each repo needs its own GitHub deploy
+# key generated and registered interactively via `gh`.
--- /dev/null
+server {
+ server_name {{ domain }} www.{{ domain }};
+ root /var/www/{{ domain }}/personal-website;
+ index index.html;
+
+ listen 80 default_server;
+ listen [::]:80 default_server;
+
+ # gitweb GUI at /repos, query-string mode (no PATH_INFO) so it
+ # doesn't collide with git-http-backend's /repos/<name>.git/... paths
+ location ^~ /repos/static/ {
+ alias /usr/share/gitweb/static/;
+ }
+
+ location = /repos {
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/share/gitweb/gitweb.cgi;
+ fastcgi_param GITWEB_CONFIG /etc/gitweb.conf;
+ fastcgi_param SCRIPT_NAME /repos;
+ fastcgi_pass unix:/run/fcgiwrap.socket;
+ }
+
+ location = /repos/ {
+ return 301 /repos;
+ }
+
+ # git smart HTTP via git-http-backend + fcgiwrap (clone/push)
+ location ~ /repos(/.*) {
+ client_max_body_size 0;
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
+ fastcgi_param GIT_HTTP_EXPORT_ALL "";
+ fastcgi_param GIT_PROJECT_ROOT /srv/git;
+ fastcgi_param PATH_INFO $1;
+ fastcgi_param SCRIPT_NAME /repos;
+ fastcgi_pass unix:/run/fcgiwrap.socket;
+ }
+}
--- /dev/null
+server {
+ listen 80;
+ listen [::]:80;
+ server_name {{ domain }} www.{{ domain }};
+ return 301 https://{{ redirect_target }}$request_uri;
+}
--- /dev/null
+server {
+ server_name {{ domain }} www.{{ domain }};
+ root /var/www/{{ domain }}/{{ site_root_subdir | default('public') }};
+ index index.html;
+
+ listen 80;
+ listen [::]:80;
+}
--- /dev/null
+terraform {
+ required_providers {
+ linode = {
+ source = "linode/linode"
+ version = "~> 2.9"
+ }
+ }
+}
+
+provider "linode" {
+ token = var.linode_api_token
+}
+
+resource "linode_instance" "mail_server" {
+ label = var.label
+ region = var.region
+ type = var.instance_type
+ image = var.image
+ root_pass = var.root_pass
+
+ authorized_keys = var.authorized_ssh_keys
+
+ # NOTE: outbound port 25 is blocked by default on new Linodes. This must
+ # be requested via a support ticket after provisioning - it is not
+ # something Terraform/the API can set. See README.
+}
--- /dev/null
+output "ipv4_address" {
+ value = linode_instance.mail_server.ip_address
+}
+
+output "ipv6_address" {
+ value = linode_instance.mail_server.ipv6
+}
--- /dev/null
+# Copy to terraform.tfvars (gitignored) and fill in.
+linode_api_token = "..."
+root_pass = "..."
+label = "mail-server-chicago"
+region = "us-ord"
+authorized_ssh_keys = [
+ "ssh-ed25519 AAAA... you@example.com",
+]
--- /dev/null
+variable "linode_api_token" {
+ description = "Linode API personal access token. Keep out of version control - pass via TF_VAR_linode_api_token or a .tfvars file that's gitignored."
+ type = string
+ sensitive = true
+}
+
+variable "label" {
+ description = "Linode instance label."
+ type = string
+ default = "mail-server"
+}
+
+variable "region" {
+ description = "Linode region, e.g. us-ord (Chicago), us-east (Virginia), us-southeast (Atlanta), us-central (Dallas)."
+ type = string
+ default = "us-ord"
+}
+
+variable "instance_type" {
+ description = "Linode plan. g6-nanode-1 (1GB RAM, 25GB disk) is what's currently in production - already fairly tight for this workload, consider sizing up for a second node."
+ type = string
+ default = "g6-nanode-1"
+}
+
+variable "image" {
+ type = string
+ default = "linode/ubuntu24.04"
+}
+
+variable "root_pass" {
+ description = "Root password. Prefer authorized_ssh_keys and treat this as a fallback."
+ type = string
+ sensitive = true
+}
+
+variable "authorized_ssh_keys" {
+ description = "Public SSH keys to install for root."
+ type = list(string)
+ default = []
+}