From: Andrew Gundersen Date: Fri, 17 Jul 2026 18:56:59 +0000 (+0000) Subject: Initial Terraform + Ansible capture of the mail/git/web server X-Git-Url: https://andrewgundersen.net/repos?a=commitdiff_plain;h=46cb8e7998eb20d934d9b8d9903a9dc98651e77f;p=server-iac Initial Terraform + Ansible capture of the mail/git/web server --- 46cb8e7998eb20d934d9b8d9903a9dc98651e77f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e632f1a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +terraform.tfvars +ansible/inventory.ini +*.retry diff --git a/README.md b/README.md new file mode 100644 index 0000000..2db65ac --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# 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//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. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..2f00d39 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,3 @@ +[defaults] +inventory = inventory.ini +host_key_checking = True diff --git a/ansible/inventory.example.ini b/ansible/inventory.example.ini new file mode 100644 index 0000000..7a765c2 --- /dev/null +++ b/ansible/inventory.example.ini @@ -0,0 +1,2 @@ +[mail_servers] +mail-server-chicago ansible_host=REPLACE_WITH_IP ansible_user=root diff --git a/ansible/playbook.yml b/ansible/playbook.yml new file mode 100644 index 0000000..444577c --- /dev/null +++ b/ansible/playbook.yml @@ -0,0 +1,17 @@ +--- +- 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 diff --git a/ansible/roles/base/tasks/main.yml b/ansible/roles/base/tasks/main.yml new file mode 100644 index 0000000..d2131a6 --- /dev/null +++ b/ansible/roles/base/tasks/main.yml @@ -0,0 +1,46 @@ +--- +- 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 diff --git a/ansible/roles/certbot/tasks/main.yml b/ansible/roles/certbot/tasks/main.yml new file mode 100644 index 0000000..23a9f4a --- /dev/null +++ b/ansible/roles/certbot/tasks/main.yml @@ -0,0 +1,27 @@ +--- +- 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. diff --git a/ansible/roles/certbot/templates/chasquid-deploy-hook.sh.j2 b/ansible/roles/certbot/templates/chasquid-deploy-hook.sh.j2 new file mode 100644 index 0000000..b39b269 --- /dev/null +++ b/ansible/roles/certbot/templates/chasquid-deploy-hook.sh.j2 @@ -0,0 +1,17 @@ +#!/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 diff --git a/ansible/roles/certbot/templates/dovecot-deploy-hook.sh.j2 b/ansible/roles/certbot/templates/dovecot-deploy-hook.sh.j2 new file mode 100644 index 0000000..f705648 --- /dev/null +++ b/ansible/roles/certbot/templates/dovecot-deploy-hook.sh.j2 @@ -0,0 +1,10 @@ +#!/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 diff --git a/ansible/roles/chasquid/handlers/main.yml b/ansible/roles/chasquid/handlers/main.yml new file mode 100644 index 0000000..5757738 --- /dev/null +++ b/ansible/roles/chasquid/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart chasquid + systemd: + name: chasquid + state: restarted diff --git a/ansible/roles/chasquid/tasks/main.yml b/ansible/roles/chasquid/tasks/main.yml new file mode 100644 index 0000000..52e76e4 --- /dev/null +++ b/ansible/roles/chasquid/tasks/main.yml @@ -0,0 +1,40 @@ +--- +- 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 diff --git a/ansible/roles/chasquid/templates/chasquid.conf.j2 b/ansible/roles/chasquid/templates/chasquid.conf.j2 new file mode 100644 index 0000000..df0e112 --- /dev/null +++ b/ansible/roles/chasquid/templates/chasquid.conf.j2 @@ -0,0 +1,17 @@ +# 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 diff --git a/ansible/roles/dovecot/handlers/main.yml b/ansible/roles/dovecot/handlers/main.yml new file mode 100644 index 0000000..d729558 --- /dev/null +++ b/ansible/roles/dovecot/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: restart dovecot + systemd: + name: dovecot + state: restarted diff --git a/ansible/roles/dovecot/tasks/main.yml b/ansible/roles/dovecot/tasks/main.yml new file mode 100644 index 0000000..6709a58 --- /dev/null +++ b/ansible/roles/dovecot/tasks/main.yml @@ -0,0 +1,38 @@ +--- +# 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" diff --git a/ansible/roles/dovecot/templates/10-mail.conf.j2 b/ansible/roles/dovecot/templates/10-mail.conf.j2 new file mode 100644 index 0000000..f4cc874 --- /dev/null +++ b/ansible/roles/dovecot/templates/10-mail.conf.j2 @@ -0,0 +1,4 @@ +# 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 diff --git a/ansible/roles/dovecot/templates/10-ssl.conf.j2 b/ansible/roles/dovecot/templates/10-ssl.conf.j2 new file mode 100644 index 0000000..02661c1 --- /dev/null +++ b/ansible/roles/dovecot/templates/10-ssl.conf.j2 @@ -0,0 +1,7 @@ +# 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 = .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; + } +} diff --git a/ansible/roles/nginx/templates/redirect-site.conf.j2 b/ansible/roles/nginx/templates/redirect-site.conf.j2 new file mode 100644 index 0000000..7c9774a --- /dev/null +++ b/ansible/roles/nginx/templates/redirect-site.conf.j2 @@ -0,0 +1,6 @@ +server { + listen 80; + listen [::]:80; + server_name {{ domain }} www.{{ domain }}; + return 301 https://{{ redirect_target }}$request_uri; +} diff --git a/ansible/roles/nginx/templates/site.conf.j2 b/ansible/roles/nginx/templates/site.conf.j2 new file mode 100644 index 0000000..2730eb8 --- /dev/null +++ b/ansible/roles/nginx/templates/site.conf.j2 @@ -0,0 +1,8 @@ +server { + server_name {{ domain }} www.{{ domain }}; + root /var/www/{{ domain }}/{{ site_root_subdir | default('public') }}; + index index.html; + + listen 80; + listen [::]:80; +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..e8f6aac --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,26 @@ +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. +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..8c4b264 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,7 @@ +output "ipv4_address" { + value = linode_instance.mail_server.ip_address +} + +output "ipv6_address" { + value = linode_instance.mail_server.ipv6 +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..faf47dc --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,8 @@ +# 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", +] diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..b20f8d6 --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,40 @@ +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 = [] +}