]> Repos - server-iac/commitdiff
Initial Terraform + Ansible capture of the mail/git/web server
authorAndrew Gundersen <adgundersen@gmail.com>
Fri, 17 Jul 2026 18:56:59 +0000 (18:56 +0000)
committerAndrew Gundersen <adgundersen@gmail.com>
Fri, 17 Jul 2026 18:56:59 +0000 (18:56 +0000)
29 files changed:
.gitignore [new file with mode: 0644]
README.md [new file with mode: 0644]
ansible/ansible.cfg [new file with mode: 0644]
ansible/inventory.example.ini [new file with mode: 0644]
ansible/playbook.yml [new file with mode: 0644]
ansible/roles/base/tasks/main.yml [new file with mode: 0644]
ansible/roles/certbot/tasks/main.yml [new file with mode: 0644]
ansible/roles/certbot/templates/chasquid-deploy-hook.sh.j2 [new file with mode: 0644]
ansible/roles/certbot/templates/dovecot-deploy-hook.sh.j2 [new file with mode: 0644]
ansible/roles/chasquid/handlers/main.yml [new file with mode: 0644]
ansible/roles/chasquid/tasks/main.yml [new file with mode: 0644]
ansible/roles/chasquid/templates/chasquid.conf.j2 [new file with mode: 0644]
ansible/roles/dovecot/handlers/main.yml [new file with mode: 0644]
ansible/roles/dovecot/tasks/main.yml [new file with mode: 0644]
ansible/roles/dovecot/templates/10-mail.conf.j2 [new file with mode: 0644]
ansible/roles/dovecot/templates/10-ssl.conf.j2 [new file with mode: 0644]
ansible/roles/dovecot/templates/11-chasquid.conf.j2 [new file with mode: 0644]
ansible/roles/firewall/tasks/main.yml [new file with mode: 0644]
ansible/roles/gitweb/tasks/main.yml [new file with mode: 0644]
ansible/roles/gitweb/templates/gitweb.conf.j2 [new file with mode: 0644]
ansible/roles/nginx/handlers/main.yml [new file with mode: 0644]
ansible/roles/nginx/tasks/main.yml [new file with mode: 0644]
ansible/roles/nginx/templates/primary-site-with-git.conf.j2 [new file with mode: 0644]
ansible/roles/nginx/templates/redirect-site.conf.j2 [new file with mode: 0644]
ansible/roles/nginx/templates/site.conf.j2 [new file with mode: 0644]
terraform/main.tf [new file with mode: 0644]
terraform/outputs.tf [new file with mode: 0644]
terraform/terraform.tfvars.example [new file with mode: 0644]
terraform/variables.tf [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..e632f1a
--- /dev/null
@@ -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 (file)
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/<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.
diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg
new file mode 100644 (file)
index 0000000..2f00d39
--- /dev/null
@@ -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 (file)
index 0000000..7a765c2
--- /dev/null
@@ -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 (file)
index 0000000..444577c
--- /dev/null
@@ -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 (file)
index 0000000..d2131a6
--- /dev/null
@@ -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 (file)
index 0000000..23a9f4a
--- /dev/null
@@ -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 (file)
index 0000000..b39b269
--- /dev/null
@@ -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 (file)
index 0000000..f705648
--- /dev/null
@@ -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 (file)
index 0000000..5757738
--- /dev/null
@@ -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 (file)
index 0000000..52e76e4
--- /dev/null
@@ -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 (file)
index 0000000..df0e112
--- /dev/null
@@ -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 (file)
index 0000000..d729558
--- /dev/null
@@ -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 (file)
index 0000000..6709a58
--- /dev/null
@@ -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 (file)
index 0000000..f4cc874
--- /dev/null
@@ -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 (file)
index 0000000..02661c1
--- /dev/null
@@ -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 = </etc/letsencrypt/live/mail.{{ primary_domain }}/fullchain.pem
+ssl_key = </etc/letsencrypt/live/mail.{{ primary_domain }}/privkey.pem
diff --git a/ansible/roles/dovecot/templates/11-chasquid.conf.j2 b/ansible/roles/dovecot/templates/11-chasquid.conf.j2
new file mode 100644 (file)
index 0000000..cd5429f
--- /dev/null
@@ -0,0 +1,11 @@
+# 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
+  }
+}
diff --git a/ansible/roles/firewall/tasks/main.yml b/ansible/roles/firewall/tasks/main.yml
new file mode 100644 (file)
index 0000000..c91a041
--- /dev/null
@@ -0,0 +1,45 @@
+---
+- 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.
diff --git a/ansible/roles/gitweb/tasks/main.yml b/ansible/roles/gitweb/tasks/main.yml
new file mode 100644 (file)
index 0000000..2e77327
--- /dev/null
@@ -0,0 +1,11 @@
+---
+- 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
diff --git a/ansible/roles/gitweb/templates/gitweb.conf.j2 b/ansible/roles/gitweb/templates/gitweb.conf.j2
new file mode 100644 (file)
index 0000000..846fe70
--- /dev/null
@@ -0,0 +1,34 @@
+#!/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";
diff --git a/ansible/roles/nginx/handlers/main.yml b/ansible/roles/nginx/handlers/main.yml
new file mode 100644 (file)
index 0000000..4e0a6ca
--- /dev/null
@@ -0,0 +1,5 @@
+---
+- name: reload nginx
+  systemd:
+    name: nginx
+    state: reloaded
diff --git a/ansible/roles/nginx/tasks/main.yml b/ansible/roles/nginx/tasks/main.yml
new file mode 100644 (file)
index 0000000..9db5caa
--- /dev/null
@@ -0,0 +1,38 @@
+---
+- 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`.
diff --git a/ansible/roles/nginx/templates/primary-site-with-git.conf.j2 b/ansible/roles/nginx/templates/primary-site-with-git.conf.j2
new file mode 100644 (file)
index 0000000..1347704
--- /dev/null
@@ -0,0 +1,38 @@
+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;
+    }
+}
diff --git a/ansible/roles/nginx/templates/redirect-site.conf.j2 b/ansible/roles/nginx/templates/redirect-site.conf.j2
new file mode 100644 (file)
index 0000000..7c9774a
--- /dev/null
@@ -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 (file)
index 0000000..2730eb8
--- /dev/null
@@ -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 (file)
index 0000000..e8f6aac
--- /dev/null
@@ -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 (file)
index 0000000..8c4b264
--- /dev/null
@@ -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 (file)
index 0000000..faf47dc
--- /dev/null
@@ -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 (file)
index 0000000..b20f8d6
--- /dev/null
@@ -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     = []
+}