diff --git a/README.md b/README.md
index 7f33d3e..edf2012 100644
--- a/README.md
+++ b/README.md
@@ -116,6 +116,22 @@ sudo BACKUP_BUCKET=my-mediawiki-backups \
---
+## Step 5b — Set up SSL
+
+Port 80 is immediately redirected to HTTPS. The AMI ships with the `mod_ssl`
+self-signed cert as a placeholder, so browsers will show a security warning
+until certbot replaces it. Once DNS is pointed at the new instance and port 443
+is reachable, obtain a real certificate:
+
+```bash
+sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh
+```
+
+This runs `certbot --apache`, obtains a Let's Encrypt cert for `wiki.asmbly.org`,
+updates the `:443` vhost in `mediawiki.conf`, and installs the renewal timer.
+
+---
+
## Step 6 — Validate & cut over
```bash
@@ -139,7 +155,7 @@ When satisfied:
## Automated backups (new server)
-The new server runs `scripts/backup/backup-with-retention.sh` nightly at 02:00 UTC
+The new server runs `scripts/backup/backup-with-retention.sh` nightly at 08:00 UTC
via `/etc/cron.d/mediawiki-backup`. It uploads to S3 with GFS rotation:
| Tier | When | S3 prefix | Expires |
@@ -154,27 +170,53 @@ via `/etc/cron.d/mediawiki-backup`. It uploads to S3 with GFS rotation:
```
config/
- mediawiki/LocalSettings.php ← Config-as-code; secrets injected by envsubst at build time
- httpd/mediawiki.conf ← Apache vhost template
- php/mediawiki.ini ← PHP tuning
+ cloudwatch/mediawiki-cwa.json ← CloudWatch agent config
+ cron/mediawiki-backup ← Cron job definitions
+ cron/mediawiki-jobs ← MediaWiki job runner cron
+ httpd/mediawiki.conf ← Apache vhost template
+ httpd/security.conf ← Apache security headers
+ logrotate/httpd-mediawiki ← Log rotation for Apache
+ logrotate/mediawiki-backup ← Log rotation for backups
+ mariadb/mariadb.repo ← MariaDB 10.11 yum repo
+ mariadb/mediawiki.cnf ← MariaDB tuning
+ mediawiki/LocalSettings.php ← Config-as-code; secrets injected by envsubst at build time
+ mediawiki/composer.local.json ← Composer local overrides
+ mediawiki/robots.txt ← robots.txt for the wiki
+ mediawiki/assets/ ← Static assets copied to DocumentRoot (logos, favicons)
+ php/mediawiki.ini ← PHP tuning
+ system/limits.conf ← OS limits
+ system/mediawiki-backup.sysconfig ← /etc/sysconfig for backup cron
+ system/sysctl.conf ← Kernel tuning
docs/
- lifecycle.json ← S3 Lifecycle Rules (apply once to bucket)
- s3-backup-setup.md ← Bucket setup walkthrough
+ lifecycle.json ← S3 Lifecycle Rules (apply once to bucket)
+ s3-backup-setup.md ← Bucket setup walkthrough
+ iam/README.md ← IAM setup guide
+ iam/oidc-trust-policy.json ← OIDC trust policy for GitHub Actions
+ iam/packer-policy.json ← Minimal IAM policy for Packer
+ iam/setup-oidc-role.sh ← Script to create OIDC role
+ iam/setup-vpc.sh ← Script to create VPC for Packer
packer/
- mediawiki.pkr.hcl ← Packer HCL2 build template
- variables.pkr.hcl ← Variable declarations
- variables.pkrvars.hcl.example ← Copy → my.auto.pkrvars.hcl and fill in
+ mediawiki.pkr.hcl ← Packer HCL2 build template
+ variables.pkr.hcl ← Variable declarations
+ variables.pkrvars.example ← Copy → my.auto.pkrvars.hcl and fill in
scripts/
- 00-system.sh AL2025 base packages (lua, cronie, gettext, …)
- 01-php.sh PHP 8.3 + extensions
- 02-mariadb.sh MariaDB 10.11 LTS
- 03-httpd.sh Apache httpd + vhost
- 04-mediawiki.sh MW 1.43 core + envsubst → LocalSettings.php
- 05-extensions.sh REL1_43 extensions (Gerrit + GitHub)
- 06-finalize.sh Harden, clean, enable services
- 07-backup-setup.sh Install backup cron + /etc/sysconfig/mediawiki-backup
+ 00-system.sh AL2023 base packages (lua, cronie, gettext, …)
+ 01-php.sh PHP 8.3 + extensions
+ 02-mariadb.sh MariaDB 10.11 LTS
+ 03-httpd.sh Apache httpd + vhost
+ 04-mediawiki.sh MW 1.43 core + envsubst → LocalSettings.php
+ 05-extensions.sh REL1_43 extensions (Gerrit + GitHub)
+ 06-finalize.sh Harden, clean, enable services
+ 07-backup-setup.sh Install backup cron + /etc/sysconfig/mediawiki-backup
+
+packer-test/
+ Dockerfile.test ← Container image for local packer script testing
+ test-local.sh ← Run packer scripts locally in Docker
+ mock-aws ← Stub AWS CLI for offline testing
+ container-systemctl ← systemctl shim for containers
+ test.env.example ← Copy → test.env and fill in
scripts/
inventory/gather-info.sh Server inventory report
@@ -184,12 +226,12 @@ scripts/
backup/config-export.sh Redacted config export → S3
restore/restore.sh Restore DB + images from S3 (reusable)
restore/upgrade-1.35-to-1.43.sh One-time: run update.php + post-upgrade maintenance
+ setup/setup-ssl.sh Post-launch: obtain Let's Encrypt cert via certbot
.github/workflows/
build-ami.yml Packer build on tag push / manual dispatch
+ packer-validate.yml Validate packer template on pull requests
-output/
- info.txt Inventory captured from the existing 1.35 server
```
---
diff --git a/config/httpd/mediawiki.conf b/config/httpd/mediawiki.conf
index cc01dd1..cd0c478 100644
--- a/config/httpd/mediawiki.conf
+++ b/config/httpd/mediawiki.conf
@@ -1,18 +1,36 @@
# mediawiki.conf — Apache vhost for MediaWiki
-# Uploaded to /tmp/ by Packer; installed by 03-httpd.sh.
-# SSL is handled by Let's Encrypt (certbot) post-launch.
+#
+# HTTP redirects to HTTPS immediately. The HTTPS vhost uses the self-signed
+# cert created by mod_ssl on first install; certbot replaces it post-launch:
+# sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh
+# ── HTTP: redirect to HTTPS ───────────────────────────────────────────────────
+ ServerName wiki.asmbly.org
+ RewriteEngine On
+ RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
+
+
+# ── HTTPS: MediaWiki application ──────────────────────────────────────────────
+
ServerName wiki.asmbly.org
DocumentRoot /var/www/mediawiki
- # Short URLs: /wiki/Article_Name → index.php
+ SSLEngine on
+ # Self-signed cert (installed by mod_ssl) — replaced by certbot post-launch
+ SSLCertificateFile /etc/pki/tls/certs/localhost.crt
+ SSLCertificateKeyFile /etc/pki/tls/private/localhost.key
+
+ Header always set Strict-Transport-Security "max-age=63072000"
+
+ # ── Short URLs: /wiki/Article_Name → index.php ────────────────────────────
RewriteEngine On
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-d
RewriteRule ^/wiki/(.*)$ /index.php [L]
RedirectMatch ^/$ /wiki/Main_Page
+ # ── DocumentRoot ──────────────────────────────────────────────────────────
Options -Indexes +FollowSymLinks
AllowOverride All
@@ -20,23 +38,59 @@
DirectoryIndex index.php
- # Protect sensitive files
-
+ # ── Block directories that contain PHP source / tools / installer ─────────
+ # None of these should ever be reachable via HTTP.
+
Require all denied
-
-
+
+
+ # ── Block root-level non-web files ────────────────────────────────────────
+
+ # PHP: autoload.php is a Composer autoloader — not a web entry point.
+ # The 8 real entry points (index, api, load, rest, img_auth, thumb,
+ # thumb_handler, opensearch_desc) are intentionally left open.
+
Require all denied
- # Uploads: no PHP execution
+ # Composer/build config files
+
+ Require all denied
+
+
+ # Documentation and data files with no web purpose
+
+ Require all denied
+
+
+ # ALLCAPS no-extension docs: COPYING, CREDITS, FAQ, HISTORY, INSTALL,
+ # RELEASE-NOTES-*, SECURITY, UPGRADE
+
+ Require all denied
+
+
+ # ── Images: accessible but no PHP execution, no .htaccess override ───────
+ # AllowOverride None prevents user-uploaded .htaccess files from
+ # changing security settings in this directory.
Options -Indexes
+ AllowOverride None
Require all denied
-
+ # ── Extensions / skins: static assets only, no PHP execution ─────────────
+ # PHP files in these trees are loaded via include() by MW core, never
+ # via direct HTTP requests.
+
+ Options -Indexes
+
+ Require all denied
+
+
+
+
Options -Indexes
Require all denied
@@ -46,7 +100,3 @@
ErrorLog /var/log/httpd/mediawiki-error.log
CustomLog /var/log/httpd/mediawiki-access.log combined
-
-# HTTPS vhost — configured by certbot (Let's Encrypt) post-launch:
-# sudo dnf install -y certbot python3-certbot-apache
-# sudo certbot --apache -d wiki.asmbly.org
diff --git a/config/mediawiki/LocalSettings.php b/config/mediawiki/LocalSettings.php
index ae5b60f..633b22e 100644
--- a/config/mediawiki/LocalSettings.php
+++ b/config/mediawiki/LocalSettings.php
@@ -12,9 +12,7 @@
# MW_SECRET_KEY — generate: openssl rand -hex 64
# MW_UPGRADE_KEY — generate: openssl rand -hex 16
# MW_SMTP_PASSWORD — Gmail app password for notification@asmbly.org
-# ⚠ ROTATE THIS — old value was exposed in info.txt
# MW_DISCOURSE_SECRET — Discourse SSO shared secret
-# ⚠ ROTATE THIS — old value was exposed in info.txt
#
# Derived from the MediaWiki 1.35.8 LocalSettings.php captured in output/info.txt
# and translated to 1.43 conventions. Changes from 1.35:
@@ -47,7 +45,7 @@
# =============================================================================
$wgLogo = "$wgScriptPath/ASMBLY_Avatar_135x135.png";
-$wgFavicon = "$wgScriptPath/ASMBLY_avatar-1-150x150.png";
+$wgFavicon = "$wgScriptPath/ASMBLY_Avatar-150x150.png";
$wgLogos = [
'1x' => "$wgScriptPath/ASMBLY_Avatar_135x135.png",
@@ -130,7 +128,6 @@
$wgEmailAuthentication = false;
# Gmail SMTP relay
-# ⚠ MW_SMTP_PASSWORD must be rotated — old value was exposed in repo history
$wgSMTP = [
'host' => 'ssl://smtp.gmail.com',
'IDHost' => 'gmail.com',
@@ -211,7 +208,6 @@
wfLoadExtension( 'ParserFunctions' );
wfLoadExtension( 'PdfHandler' );
wfLoadExtension( 'Poem' );
-wfLoadExtension( 'RenameUser' );
wfLoadExtension( 'ReplaceText' );
wfLoadExtension( 'SecureLinkFixer' );
wfLoadExtension( 'SpamBlacklist' );
@@ -284,40 +280,34 @@
# ── PluggableAuth v7 + DiscourseSsoConsumer ───────────────────────────────────
#
-# PluggableAuth v7 (required for MW 1.39+) changed from flat global variables
-# to a $wgPluggableAuth_Config array. The old 1.35 config used:
-# $wgPluggableAuth_ButtonLabelMessage = "Log In With Discourse";
-# which is removed in v7.
-#
-# The button label is now the array key in $wgPluggableAuth_Config.
-# See: https://www.mediawiki.org/wiki/Extension:PluggableAuth#Configuration
-#
-# ⚠ MW_DISCOURSE_SECRET must be rotated — old value was exposed in repo history
wfLoadExtension( 'PluggableAuth' );
wfLoadExtension( 'DiscourseSsoConsumer' );
$wgPluggableAuth_Config = [
- 'Log In With Discourse' => [
- 'plugin' => 'DiscourseSsoConsumer',
- 'data' => [],
- ],
+ 'Log In With Discourse' => [ 'plugin' => 'DiscourseSsoConsumer' ],
];
# Uncomment to allow local admin login alongside Discourse SSO:
# $wgPluggableAuth_EnableLocalLogin = true;
-$wgDiscourseSsoConsumer_DiscourseUrl = "https://yo.asmbly.org";
-$wgDiscourseSsoConsumer_SsoSharedSecret = "${MW_DISCOURSE_SECRET}";
-$wgDiscourseSsoConsumer_ExposeName = true;
-$wgDiscourseSsoConsumer_ExposeEmail = false;
-$wgDiscourseSsoConsumer_EnableAutoRelogin = true;
-
-# Discourse group → MediaWiki group mapping
-# Discourse groups 'makers' and 'community' → MW 'editor'
-# Discourse groups 'sysops' and 'leadership' → MW 'sysop' and 'bureaucrat'
-$wgDiscourseSsoConsumer_GroupMaps = [
- 'editor' => [ 'makers', 'community' ],
- 'sysop' => [ 'sysops', 'leadership' ],
- 'bureaucrat' => [ 'sysops', 'leadership' ],
-];
+# DiscourseSsoConsumer uses a hook-based config (flat globals removed in 6.x).
+# See: https://codeberg.org/centertap/DiscourseSsoConsumer#configure-discoursessoconsumer-using-a-hook-function
+$wgHooks['DiscourseSsoConsumer_Configure'][] =
+ function ( array &$config ) {
+ $config['DiscourseUrl'] = 'https://yo.asmbly.org';
+ $config['Sso']['Enable'] = true;
+ $config['Sso']['SharedSecret'] = '${MW_DISCOURSE_SECRET}';
+ $config['Sso']['EnableAutoRelogin'] = true;
+ $config['User']['ExposeName'] = true;
+ $config['User']['ExposeEmail'] = false;
+ # Discourse group → MediaWiki group mapping
+ # 'makers' and 'community' → editor
+ # 'sysops' and 'leadership' → sysop + bureaucrat
+ $config['User']['GroupMaps'] = [
+ 'editor' => [ 'makers', 'community' ],
+ 'sysop' => [ 'sysops', 'leadership' ],
+ 'bureaucrat' => [ 'sysops', 'leadership' ],
+ ];
+ return true;
+ };
diff --git a/config/mediawiki/assets/.gitkeep b/config/mediawiki/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/config/mediawiki/assets/ASMBLY_Avatar_100x100.png b/config/mediawiki/assets/ASMBLY_Avatar_100x100.png
new file mode 100644
index 0000000..843076a
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_100x100.png differ
diff --git a/config/mediawiki/assets/ASMBLY_Avatar_135x135.png b/config/mediawiki/assets/ASMBLY_Avatar_135x135.png
new file mode 100644
index 0000000..4f52dfc
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_135x135.png differ
diff --git a/config/mediawiki/assets/ASMBLY_Avatar_150x150.png b/config/mediawiki/assets/ASMBLY_Avatar_150x150.png
new file mode 100644
index 0000000..f6b1c63
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_150x150.png differ
diff --git a/config/mediawiki/assets/ASMBLY_Avatar_202x202.png b/config/mediawiki/assets/ASMBLY_Avatar_202x202.png
new file mode 100644
index 0000000..0cf69fd
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_202x202.png differ
diff --git a/config/mediawiki/assets/ASMBLY_Avatar_270x270.png b/config/mediawiki/assets/ASMBLY_Avatar_270x270.png
new file mode 100644
index 0000000..283202e
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_Avatar_270x270.png differ
diff --git a/config/mediawiki/assets/ASMBLY_WordMark_135x40.png b/config/mediawiki/assets/ASMBLY_WordMark_135x40.png
new file mode 100644
index 0000000..9a1b391
Binary files /dev/null and b/config/mediawiki/assets/ASMBLY_WordMark_135x40.png differ
diff --git a/config/mediawiki/robots.txt b/config/mediawiki/robots.txt
new file mode 100644
index 0000000..e0e3c6d
--- /dev/null
+++ b/config/mediawiki/robots.txt
@@ -0,0 +1,48 @@
+# robots.txt for wiki.asmbly.org
+#
+# All article content is open to crawlers via BOTH URL forms:
+# /wiki/Article_Title (short URL, canonical)
+# /index.php?title=Article (legacy URL — must remain crawlable for SEO)
+#
+# We block only the dynamic/action URL variants that generate high server load
+# without producing indexable content.
+#
+# References:
+# https://www.mediawiki.org/wiki/Manual:Robots.txt
+# https://en.wikipedia.org/robots.txt
+
+User-agent: *
+
+# ── Special pages ─────────────────────────────────────────────────────────────
+# Dynamically generated; no indexable article content.
+Disallow: /wiki/Special:
+Disallow: /wiki/Special%3A
+Disallow: /index.php?title=Special:
+Disallow: /index.php?title=Special%3A
+
+# ── Action pages (edit, history, info, raw, submit, etc.) ─────────────────────
+# Per-request DB queries with no index value.
+Disallow: /*?action=
+Disallow: /index.php?action=
+
+# ── Diff and revision-comparison pages ───────────────────────────────────────
+Disallow: /*?diff=
+Disallow: /index.php?diff=
+Disallow: /index.php?oldid=
+
+# ── Particular dynamic pages ───────────────────────────────────────
+Disallow: /index.php?title=Help
+Disallow: /index.php?title=Image
+Disallow: /index.php?title=MediaWiki
+Disallow: /index.php?title=Special:
+Disallow: /index.php?title=Template
+
+# ── Printable versions (duplicate content) ────────────────────────────────────
+Disallow: /*?printable=yes
+
+# ── Allow the Internet Archiver to index action=raw and thereby store the raw wikitext of pages ────────────────────────────────────
+User-agent: ia_archiver
+Allow: /*&action=raw
+
+# ── Crawl-delay: limit load on a small-instance server ───────────────────────
+Crawl-delay: 1
diff --git a/packer-test/container-systemctl b/packer-test/container-systemctl
index 1fa1bcc..65ea37f 100644
--- a/packer-test/container-systemctl
+++ b/packer-test/container-systemctl
@@ -63,7 +63,12 @@ _mariadb_stop() {
_httpd_start() {
echo "[systemctl-mock] Starting httpd directly (no systemd)..."
- httpd -k start 2>/dev/null || apachectl start 2>/dev/null || true
+ httpd -t || { echo "[systemctl-mock] ERROR: httpd config check failed" >&2; return 1; }
+ httpd -k start 2>/dev/null || apachectl start 2>/dev/null || {
+ echo "[systemctl-mock] ERROR: httpd failed to start — check /var/log/httpd/error_log" >&2
+ cat /var/log/httpd/error_log 2>/dev/null | tail -20 || true
+ return 1
+ }
}
_httpd_stop() {
diff --git a/packer/mediawiki.pkr.hcl b/packer/mediawiki.pkr.hcl
index c3054d8..63f89e6 100644
--- a/packer/mediawiki.pkr.hcl
+++ b/packer/mediawiki.pkr.hcl
@@ -54,7 +54,7 @@ source "amazon-ebs" "mediawiki" {
launch_block_device_mappings {
device_name = "/dev/xvda"
- volume_size = 30
+ volume_size = 16
volume_type = "gp3"
delete_on_termination = true
encrypted = false
diff --git a/packer/scripts/03-httpd.sh b/packer/scripts/03-httpd.sh
index 6d39e5e..745ad87 100644
--- a/packer/scripts/03-httpd.sh
+++ b/packer/scripts/03-httpd.sh
@@ -3,13 +3,40 @@
# Phase 3: Install Apache httpd + mod_php, configure a MediaWiki vhost.
set -euxo pipefail
-# ── Install Apache ────────────────────────────────────────────────────────────
-dnf install -y httpd mod_ssl
+# ── Install Apache + certbot ──────────────────────────────────────────────────
+dnf install -y httpd mod_ssl certbot python3-certbot-apache
# ── Apache global configuration tweaks ────────────────────────────────────────
cp /tmp/config/httpd/security.conf /etc/httpd/conf.d/security.conf
+# Remove default conf files that are unnecessary or conflict with our vhost:
+# autoindex.conf — directory listing (we set -Indexes everywhere)
+# userdir.conf — ~/public_html user directories (~username URLs)
+# welcome.conf — Apache test page; our vhost replaces it
+#
+# ssl.conf is intentionally kept: it provides the required global SSL directives
+# (Listen 443 https, SSLSessionCache, SSLProtocol, etc.) that mod_ssl needs to
+# initialise. Its _default_:443 catch-all vhost coexists harmlessly with our
+# named *:443 vhost because SNI routing takes precedence.
+rm -f /etc/httpd/conf.d/autoindex.conf \
+ /etc/httpd/conf.d/userdir.conf \
+ /etc/httpd/conf.d/welcome.conf
+
+# ── Ensure the self-signed SSL certificate exists ─────────────────────────────
+# mod_ssl's post-install scriptlet should generate this, but on AL2023 it may
+# not run in all environments. Generate it explicitly if absent.
+if [[ ! -f /etc/pki/tls/certs/localhost.crt ]]; then
+ echo "Generating self-signed SSL certificate..."
+ mkdir -p /etc/pki/tls/certs /etc/pki/tls/private
+ openssl req -newkey rsa:2048 -nodes \
+ -keyout /etc/pki/tls/private/localhost.key \
+ -x509 -days 3650 \
+ -out /etc/pki/tls/certs/localhost.crt \
+ -subj "/C=US/ST=Texas/L=Austin/O=Asmbly/CN=wiki.asmbly.org"
+ chmod 600 /etc/pki/tls/private/localhost.key
+fi
+
# ── MediaWiki vhost ───────────────────────────────────────────────────────────
mkdir -p /var/www/mediawiki
mkdir -p /var/log/httpd
@@ -20,6 +47,7 @@ cp /tmp/config/httpd/mediawiki.conf /etc/httpd/conf.d/mediawiki.conf
cp /tmp/config/logrotate/httpd-mediawiki /etc/logrotate.d/httpd-mediawiki
# ── Enable and start ──────────────────────────────────────────────────────────
+httpd -t # fail fast with a readable error if config is broken
systemctl enable httpd
systemctl start httpd
diff --git a/packer/scripts/04-mediawiki.sh b/packer/scripts/04-mediawiki.sh
index 8046501..607e78d 100644
--- a/packer/scripts/04-mediawiki.sh
+++ b/packer/scripts/04-mediawiki.sh
@@ -83,4 +83,22 @@ mkdir -p "${MW_ROOT}/cache"
chown apache:apache "${MW_ROOT}/cache"
chmod 775 "${MW_ROOT}/cache"
+# ── robots.txt ───────────────────────────────────────────────────────────────
+cp /tmp/config/mediawiki/robots.txt "${MW_ROOT}/robots.txt"
+chown root:apache "${MW_ROOT}/robots.txt"
+chmod 644 "${MW_ROOT}/robots.txt"
+
+# ── Static assets (logos, favicons) ──────────────────────────────────────────
+# Files committed to config/mediawiki/assets/ are served from the DocumentRoot
+# because $wgScriptPath = "". Copy any non-hidden files that exist.
+ASSETS_SRC="/tmp/config/mediawiki/assets"
+if compgen -G "${ASSETS_SRC}/*" > /dev/null 2>&1; then
+ cp "${ASSETS_SRC}"/* "${MW_ROOT}/"
+ chown root:apache "${MW_ROOT}"/*.png "${MW_ROOT}"/*.ico 2>/dev/null || true
+ chmod 644 "${MW_ROOT}"/*.png "${MW_ROOT}"/*.ico 2>/dev/null || true
+ echo "Installed static assets from ${ASSETS_SRC}"
+else
+ echo "WARNING: no static assets found in ${ASSETS_SRC} — logos will be missing until added"
+fi
+
echo "04-mediawiki.sh complete — MediaWiki ${MW_VERSION} installed at ${MW_ROOT}"
diff --git a/scripts/setup/setup-ssl.sh b/scripts/setup/setup-ssl.sh
new file mode 100644
index 0000000..cb5b3f7
--- /dev/null
+++ b/scripts/setup/setup-ssl.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# scripts/setup/setup-ssl.sh
+# Post-launch: obtain a Let's Encrypt certificate and reconfigure Apache.
+#
+# Run once after the instance has its final IP/DNS and port 80/443 are
+# reachable from the internet:
+#
+# sudo bash /opt/mediawiki-ami/setup/setup-ssl.sh
+#
+# After this script completes:
+# - Apache serves the wiki over HTTPS with a valid cert
+# - HTTP redirects to HTTPS (certbot preserves the redirect in mediawiki.conf)
+# - Certbot installs a systemd timer for automatic renewal
+#
+# Optional env vars:
+# DOMAIN — defaults to wiki.asmbly.org
+# EMAIL — defaults to admin@asmbly.org
+
+set -euo pipefail
+
+DOMAIN="${DOMAIN:-wiki.asmbly.org}"
+EMAIL="${EMAIL:-admin@asmbly.org}"
+
+log() { echo "[$(date -u +"%Y-%m-%d %H:%M:%S")] $*"; }
+
+log "Obtaining Let's Encrypt certificate for ${DOMAIN}..."
+certbot --apache \
+ --non-interactive \
+ --agree-tos \
+ --email "${EMAIL}" \
+ -d "${DOMAIN}"
+
+systemctl reload httpd
+
+log "SSL configured for ${DOMAIN}"
+log "Auto-renewal: $(systemctl is-enabled certbot-renew.timer 2>/dev/null || echo 'check /etc/cron.d/certbot')"
+