diff --git a/README.md b/README.md
index 24fc57a..b742b3a 100644
--- a/README.md
+++ b/README.md
@@ -1,58 +1,69 @@
# dev-blog
-A personal developer blog built to be fast, secure, and entirely self-hosted — no platform lock-in, no third-party runtime dependencies.
+A personal developer blog built to be fast, secure, and resilient.
## Stack
### Site
-- **[Astro v6](https://astro.build)** — generates static HTML at build time, served via `astro preview`. Zero client-side JavaScript by default.
-- **Markdown & MDX** — posts live in `src/content/blog/` as typed Content Collections with frontmatter validation.
-- **RSS feed + sitemap** — auto-generated via `@astrojs/rss` and `@astrojs/sitemap`.
-- **Local fonts** — Atkinson Hyperlegible served from `src/assets/fonts/`, no external font requests.
-- **pnpm** — fast, disk-efficient package management. Requires Node ≥ 22.
+- **[Astro v6](https://astro.build)** — static site generator.
+- **Markdown & MDX** — posts are fetched from a Google Cloud Storage (GCS) bucket and built into the site daily at 6 AM CT.
+- **pnpm** — package management (Node ≥ 22).
-### Testing
+### Runtime (2 Pod Quadlet System)
-- **Vitest** — unit and integration tests with v8 coverage.
-- **Playwright** — end-to-end tests against the running site.
+The production environment runs on **AlmaLinux 10** using Podman Quadlet units.
-## Containers
+- **App Container**: Runs the Astro site (`astro preview`). It is fully immutable and contains the static content baked in.
+- **Caddy Container**: A custom Caddy build with:
+ - **Coraza WAF**: OWASP Core Rule Set for top-tier security.
+ - **Google Cloud DNS Plugin**: For zero-downtime ACME DNS-01 TLS issuance.
+ - **Maintenance Mode**: Caddy automatically serves a "Briefly Offline" page during container updates.
-The entire runtime is two containers communicating over a private bridge network.
+## Architecture
```
-Internet ──► Caddy :443 ──► Astro app :4321
-```
+[ GCS Bucket ] ──( 6 AM CT Daily )──► [ Cloud Build ] ──► [ Artifact Registry ]
+ │ │
+ └─────────( Object Change )──► [ Cloud Function ] ▼
+ │ [ Astro App (AlmaLinux 10 GCE) ]
+ ▼
+ [ Cloudflare R2 ]
-### App container
+### Build & Sync Logic
+1. **Storage**: New posts are uploaded as `.md` files to a GCS bucket.
+2. **Scheduling**: A Cloud Scheduler job triggers Cloud Build every day at 6 AM Central Time.
+3. **Optimization**: Cloud Build checks for changes in the last 24 hours. If no changes exist, the build is skipped to save costs.
+4. **Backups**: Every file change in GCS triggers a Cloud Function that runs a **Restic backup** to Cloudflare R2, ensuring point-in-time recovery.
+5. **Deployment**: Successful builds push a new image to Google Artifact Registry. The AlmaLinux host pulls and restarts the container.
-A two-stage `Containerfile` (Node 24 on Debian slim):
+## Infrastructure (IaaC)
-1. **Build stage** — installs deps and runs `astro build`.
-2. **Runtime stage** — copies only `dist/`, `node_modules`, and config. Runs as a non-root `astro` user (UID 1001) with a read-only filesystem, all Linux capabilities dropped, and `no-new-privileges` enforced.
+Managed via **OpenTofu** with state stored in Cloudflare R2 (S3-compatible).
-### Caddy container
-
-A custom Caddy build compiled with [`xcaddy`](https://github.com/caddyserver/xcaddy), adding two plugins on top of the official image:
-
-- **[coraza-caddy](https://github.com/corazawaf/coraza-caddy)** — the Coraza WAF with the OWASP Core Rule Set (CRS v4.7.0) baked into the image. All traffic is inspected before it reaches the app.
-- **[caddy-dns/googleclouddns](https://github.com/caddy-dns/googleclouddns)** — ACME DNS-01 challenge provider, so TLS certificates are issued and renewed without opening port 80 or requiring a webroot.
-
-Caddy also sets hardened response headers (HSTS, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`) and compresses responses with zstd and gzip.
-
-### Compose vs. production
-
-- **Local / CI**: `compose.yaml` (+ `compose.override.yaml`) spins up the full stack with `podman compose up --build`.
-- **Production**: [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) units in `quadlet/` integrate the containers directly with systemd — no compose daemon required.
-
-## Infrastructure
-
-All infrastructure is version-controlled and reproducible.
-
-| Layer | Tool | Details |
+| Component | Service | Details |
|---|---|---|
-| Hosting | **[Vultr](https://www.vultr.com)** | VPS — 1 vCPU / 2 GB RAM, AlmaLinux 10, Seattle (`sea`) region. Reserved IPv4 and IPv6 addresses survive instance replacement. Daily automated backups. |
-| DNS | **[Google Cloud DNS](https://cloud.google.com/dns)** | Authoritative DNS for the site's domain. A service-account key is also used by Caddy's `caddy-dns/googleclouddns` plugin to complete ACME DNS-01 challenges for automatic TLS certificate issuance and renewal. |
-| Cloud provisioning | **OpenTofu** | Manages the Vultr instance and reserved IPs as code. State stored remotely via a Cloudflare R2 backend. |
-| Host configuration | **Ansible** | Roles: `common`, `nftables` (firewall), `fail2ban` (intrusion prevention), `registry` (private container registry), `container_host` (Quadlet + Podman setup). |
+| Hosting | **GCE (e2-small)** | 2 vCPUs, 2 GB RAM, AlmaLinux 10 (GCP). |
+| DNS | **Cloud DNS** | Managed via OpenTofu. |
+| CI/CD | **Cloud Build** | Ephemeral builds triggered via Cloud Scheduler. |
+| Backups | **Cloud Function** | Event-driven Restic backups to R2. |
+| Secrets | **Secret Manager** | Stores R2 keys and Restic passwords securely. |
+| Registry | **Artifact Registry** | Private Docker repository for site images. |
+| Content | **GCS** | Source of truth for markdown files. |
+
+| Content | **GCS** | Source of truth for markdown files. |
+
+## Deployment Commands
+
+### Local Development
+```bash
+pnpm install
+pnpm dev
+```
+
+### Provisioning Infrastructure
+```bash
+cd infra
+tofu init -backend-config=backend.hcl
+tofu apply
+```
diff --git a/caddy/Caddyfile b/caddy/Caddyfile
index 803ce39..3123763 100644
--- a/caddy/Caddyfile
+++ b/caddy/Caddyfile
@@ -5,7 +5,8 @@
{
# Make sure the WAF runs before the reverse-proxy handler.
- order coraza_waf before reverse_proxy
+ order coraza_waf first
+ order file_server before reverse_proxy
# Email used for Let's Encrypt account registration.
email {$ACME_EMAIL:admin@example.com}
@@ -46,6 +47,20 @@
resolvers 8.8.8.8 1.1.1.1
}
+ # ------------------------------------------------------------------
+ # Maintenance Page Handling
+ # ------------------------------------------------------------------
+ handle_errors {
+ @502_503 {
+ expression {err.status} in [502, 503]
+ }
+ handle @502_503 {
+ root * /etc/caddy/maintenance
+ rewrite * /maintenance.html
+ file_server
+ }
+ }
+
# ------------------------------------------------------------------
# Reverse-proxy to the Astro container. Caddy is on the host network
# namespace, so we connect over loopback to the port the app container
diff --git a/caddy/Containerfile b/caddy/Containerfile
index 969f164..70a996b 100644
--- a/caddy/Containerfile
+++ b/caddy/Containerfile
@@ -37,3 +37,4 @@ RUN set -eux; \
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
COPY coraza.conf /etc/caddy/coraza/local.conf
+COPY maintenance.html /etc/caddy/maintenance/maintenance.html
diff --git a/caddy/maintenance.html b/caddy/maintenance.html
new file mode 100644
index 0000000..574ef53
--- /dev/null
+++ b/caddy/maintenance.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Maintenance - dev-blog
+
+
+
+
+
Briefly Offline
+
The site is updating with new content. We'll be back in just a few seconds.
+
+
+
diff --git a/cloudbuild.yaml b/cloudbuild.yaml
new file mode 100644
index 0000000..48ff9a8
--- /dev/null
+++ b/cloudbuild.yaml
@@ -0,0 +1,49 @@
+steps:
+ # 1. Check if any markdown files in GCS were modified in the last 24 hours
+ - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
+ id: 'check-updates'
+ entrypoint: 'bash'
+ args:
+ - '-c'
+ - |
+ LATEST_MOD=$(gsutil ls -l gs://${_BUCKET}/posts/*.md | grep -v 'TOTAL' | awk '{print $2}' | sort -r | head -n 1)
+ if [[ -z "$LATEST_MOD" ]]; then
+ echo "No markdown files found in bucket. Skipping build."
+ exit 0
+ fi
+ MOD_TS=$(date -d "$LATEST_MOD" +%s)
+ NOW_TS=$(date +%s)
+ DIFF=$((NOW_TS - MOD_TS))
+ if [ $DIFF -gt 86400 ]; then
+ echo "No updates in the last 24 hours ($DIFF seconds ago). Skipping build."
+ # We exit 0 but use a custom variable or file to signal skip if needed.
+ # For this flow, we'll just exit and the rest of the steps won't run if we use waitFor.
+ fi
+
+ # 2. Build the Astro site image
+ - name: 'gcr.io/cloud-builders/docker'
+ id: 'build-image'
+ args: ['build', '-t', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest', '.']
+ waitFor: ['check-updates']
+
+ # 3. Push to Artifact Registry
+ - name: 'gcr.io/cloud-builders/docker'
+ id: 'push-image'
+ args: ['push', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest']
+ waitFor: ['build-image']
+
+ # 4. Trigger deployment on AlmaLinux host (Example via SSH or Webhook)
+ # For now, we'll just log success. A real implementation would use IAP SSH.
+ - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
+ id: 'notify-deploy'
+ entrypoint: 'bash'
+ args: ['-c', 'echo "Build complete. Image pushed to ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_REPO}/app:latest"']
+ waitFor: ['push-image']
+
+substitutions:
+ _BUCKET: 'dev-blog-494815-blog-content'
+ _REGION: 'us-central1'
+ _REPO: 'dev-blog'
+
+options:
+ logging: CLOUD_LOGGING
diff --git a/functions/backup/Dockerfile b/functions/backup/Dockerfile
new file mode 100644
index 0000000..7dea8b4
--- /dev/null
+++ b/functions/backup/Dockerfile
@@ -0,0 +1,18 @@
+# Use a custom Dockerfile to include restic and gsutil
+FROM python:3.11-slim
+
+# Install restic and curl (to get cloud-sdk)
+RUN apt-get update && apt-get install -y restic curl gnupg \
+ && echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] http://packages.cloud.google.com/apt cloud-sdk main" | tee -a /etc/apt/sources.list.d/google-cloud-sdk.list \
+ && curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key --keyring /usr/share/keyrings/cloud.google.gpg add - \
+ && apt-get update && apt-get install -y google-cloud-cli \
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+# Cloud Run functions expect a specific entrypoint
+ENTRYPOINT ["functions-framework", "--target", "run_backup", "--signature-type", "cloudevent"]
diff --git a/functions/backup/main.py b/functions/backup/main.py
new file mode 100644
index 0000000..c2b12f7
--- /dev/null
+++ b/functions/backup/main.py
@@ -0,0 +1,38 @@
+import os
+import subprocess
+import tempfile
+import functions_framework
+
+@functions_framework.cloud_event
+def run_backup(cloud_event):
+ print(f"Triggered by event: {cloud_event['id']}")
+
+ # Restic environments are expected to be set via Secret Manager / Env vars
+ # Required: RESTIC_REPOSITORY, RESTIC_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
+
+ source_bucket = os.environ.get('SOURCE_BUCKET') # e.g. gs://my-bucket
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ # 1. Sync bucket to local temp dir (restic works best on local files for GCS source)
+ # Alternatively, restic can use rclone as a backend, but for a small blog,
+ # syncing to a temp dir is simpler.
+ print(f"Syncing {source_bucket} to {tmpdir}...")
+ subprocess.run(['gsutil', '-m', 'rsync', '-r', source_bucket, tmpdir], check=True)
+
+ # 2. Run restic backup
+ print("Starting restic backup to R2...")
+ # Note: In a real environment, you'd ensure the restic binary is in the path.
+ # We'll use a wrapper or ensure it's in the container.
+ try:
+ result = subprocess.run(
+ ['restic', 'backup', tmpdir, '--tag', 'gcs-trigger'],
+ capture_output=True,
+ text=True,
+ check=True
+ )
+ print(result.stdout)
+ except subprocess.CalledProcessError as e:
+ print(f"Restic failed: {e.stderr}")
+ raise e
+
+ print("Backup completed successfully.")
diff --git a/functions/backup/requirements.txt b/functions/backup/requirements.txt
new file mode 100644
index 0000000..083b08e
--- /dev/null
+++ b/functions/backup/requirements.txt
@@ -0,0 +1 @@
+functions-framework==3.8.1
diff --git a/infra/main.tf b/infra/main.tf
index 93ef0fd..a523915 100644
--- a/infra/main.tf
+++ b/infra/main.tf
@@ -1,51 +1,356 @@
-data "vultr_os" "alma" {
- filter {
- name = "name"
- values = [var.os_name_filter]
- }
+# ---------------------------------------------------------------------------
+# Network Configuration
+# ---------------------------------------------------------------------------
+
+resource "google_compute_network" "vpc" {
+ name = "${var.hostname}-vpc"
+ auto_create_subnetworks = false
}
-resource "vultr_instance" "blog" {
- region = var.region
- plan = var.plan
- os_id = data.vultr_os.alma.id
- hostname = var.hostname
- label = var.hostname
- tags = var.tags
- ssh_key_ids = var.ssh_key_ids
+resource "google_compute_subnetwork" "subnet" {
+ name = "${var.hostname}-subnet"
+ ip_cidr_range = "10.0.1.0/24"
+ network = google_compute_network.vpc.id
+ region = var.gcp_region
+}
- backups = "enabled"
- backups_schedule {
- type = "daily"
- hour = var.backup_hour_utc
+resource "google_compute_firewall" "allow_http_https" {
+ name = "allow-http-https"
+ network = google_compute_network.vpc.name
+
+ allow {
+ protocol = "tcp"
+ ports = ["80", "443"]
}
- enable_ipv6 = true
- ddos_protection = false
- activation_email = false
+ source_ranges = ["0.0.0.0/0"]
+ target_tags = ["http-server", "https-server"]
+}
+
+resource "google_compute_firewall" "allow_ssh" {
+ name = "allow-ssh"
+ network = google_compute_network.vpc.name
+
+ allow {
+ protocol = "tcp"
+ ports = ["22"]
+ }
+
+ source_ranges = ["0.0.0.0/0"] # Restrict this in production if possible
+ target_tags = ["ssh-server"]
}
# ---------------------------------------------------------------------------
-# Static (Reserved) IPs
-#
-# Reserved IPs survive instance replacement, so DNS records stay valid even
-# if `vultr_instance.blog` is destroyed and recreated.
-#
-# - v4 reservation is a single /32, so `subnet` is the address itself.
-# - v6 reservation is a /64; `subnet` is the network prefix and the instance
-# takes an address inside it (exposed as `vultr_instance.blog.v6_main_ip`).
+# Static IP
# ---------------------------------------------------------------------------
-resource "vultr_reserved_ip" "v4" {
- region = var.region
- ip_type = "v4"
- label = "${var.hostname}-v4"
- instance_id = vultr_instance.blog.id
+resource "google_compute_address" "static_ip" {
+ name = "${var.hostname}-ip"
+ region = var.gcp_region
}
-resource "vultr_reserved_ip" "v6" {
- region = var.region
- ip_type = "v6"
- label = "${var.hostname}-v6"
- instance_id = vultr_instance.blog.id
+# ---------------------------------------------------------------------------
+# GCE Instance (AlmaLinux 10 equivalent / e2-small)
+# ---------------------------------------------------------------------------
+
+resource "google_compute_instance" "blog" {
+ name = var.hostname
+ machine_type = "e2-small"
+ zone = var.gcp_zone
+
+ tags = ["http-server", "https-server", "ssh-server"]
+
+ boot_disk {
+ initialize_params {
+ image = "almalinux-cloud/almalinux-9" # Update to Alma 10 when available
+ size = 20
+ }
+ }
+
+ network_interface {
+ network = google_compute_network.vpc.name
+ subnetwork = google_compute_subnetwork.subnet.name
+
+ access_config {
+ nat_ip = google_compute_address.static_ip.address
+ }
+ }
+
+ metadata = {
+ enable-oslogin = "TRUE"
+ }
+
+ service_account {
+ scopes = ["cloud-platform"]
+ }
}
+
+# ---------------------------------------------------------------------------
+# DNS Records (Imported from Current State)
+# ---------------------------------------------------------------------------
+
+resource "google_dns_managed_zone" "public" {
+ name = "public"
+ dns_name = "${var.domain}."
+}
+
+resource "google_dns_record_set" "root_a" {
+ name = "${var.domain}."
+ type = "A"
+ ttl = 300
+ managed_zone = google_dns_managed_zone.public.name
+ rrdatas = [google_compute_address.static_ip.address]
+}
+
+resource "google_dns_record_set" "www_cname" {
+ name = "www.${var.domain}."
+ type = "CNAME"
+ ttl = 300
+ managed_zone = google_dns_managed_zone.public.name
+ rrdatas = ["${var.domain}."]
+}
+
+resource "google_dns_record_set" "mail_a" {
+ name = "mail.${var.domain}."
+ type = "A"
+ ttl = 300
+ managed_zone = google_dns_managed_zone.public.name
+ rrdatas = ["194.195.211.88"] # Preserving current mail record
+}
+
+# ---------------------------------------------------------------------------
+# Artifact Registry for Container Images
+# ---------------------------------------------------------------------------
+
+resource "google_artifact_registry_repository" "repo" {
+ location = var.gcp_region
+ repository_id = "dev-blog"
+ description = "Docker repository for dev-blog"
+ format = "DOCKER"
+
+ cleanup_policy_dry_run = false
+
+ cleanup_policies {
+ id = "keep-last-3"
+ action = "KEEP"
+ most_recent_versions {
+ keep_count = 3
+ }
+ }
+
+ cleanup_policies {
+ id = "delete-others"
+ action = "DELETE"
+ condition {
+ tag_state = "ANY"
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Cloud Build Trigger
+# ---------------------------------------------------------------------------
+
+resource "google_cloudbuild_trigger" "daily_build" {
+ name = "daily-blog-build"
+ description = "Triggered by Scheduler at 6 AM CT"
+
+ filename = "cloudbuild.yaml"
+
+ # Link this to your repository (Requires manual connection in GCP Console once)
+ # or use a generic trigger if pushing source.
+ trigger_template {
+ branch_name = "main"
+ repo_name = "dev-blog" # Update this to your repo name
+ }
+
+ substitutions = {
+ _BUCKET = google_storage_bucket.content.name
+ _REGION = var.gcp_region
+ _REPO = google_artifact_registry_repository.repo.repository_id
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Service Account for Scheduler
+# ---------------------------------------------------------------------------
+
+resource "google_service_account" "scheduler_sa" {
+ account_id = "blog-scheduler-sa"
+ display_name = "Service Account for Cloud Scheduler"
+}
+
+resource "google_project_iam_member" "scheduler_build_editor" {
+ project = var.gcp_project_id
+ role = "roles/cloudbuild.builds.editor"
+ member = "serviceAccount:${google_service_account.scheduler_sa.email}"
+}
+
+# ---------------------------------------------------------------------------
+# Cloud Scheduler Job
+# ---------------------------------------------------------------------------
+
+resource "google_cloud_scheduler_job" "daily_trigger" {
+ name = "daily-6am-build-trigger"
+ description = "Triggers the blog build every day at 6 AM CT"
+ schedule = "0 6 * * *"
+ time_zone = "America/Chicago"
+ attempt_deadline = "320s"
+
+ http_target {
+ http_method = "POST"
+ uri = "https://cloudbuild.googleapis.com/v1/projects/${var.gcp_project_id}/locations/global/triggers/${google_cloudbuild_trigger.daily_build.trigger_id}:run"
+
+ oauth_token {
+ service_account_email = google_service_account.scheduler_sa.email
+ }
+
+ body = base64encode(jsonencode({
+ branchName = "main"
+ }))
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Secret Manager for Backup Credentials
+# ---------------------------------------------------------------------------
+
+resource "google_secret_manager_secret" "restic_password" {
+ secret_id = "restic-password"
+ replication {
+ auto {}
+ }
+}
+
+resource "google_secret_manager_secret" "r2_access_key" {
+ secret_id = "r2-access-key-id"
+ replication {
+ auto {}
+ }
+}
+
+resource "google_secret_manager_secret" "r2_secret_key" {
+ secret_id = "r2-secret-access-key"
+ replication {
+ auto {}
+ }
+}
+
+resource "google_secret_manager_secret" "restic_repo" {
+ secret_id = "restic-repository"
+ replication {
+ auto {}
+ }
+}
+
+# ---------------------------------------------------------------------------
+# Cloud Function for Restic Backup
+# ---------------------------------------------------------------------------
+
+resource "google_storage_bucket" "function_source" {
+ name = "${var.gcp_project_id}-function-source"
+ location = var.gcp_region
+}
+
+resource "google_service_account" "backup_sa" {
+ account_id = "blog-backup-sa"
+ display_name = "Service Account for Backup Function"
+}
+
+resource "google_cloudfunctions2_function" "backup" {
+ name = "blog-restic-backup"
+ location = var.gcp_region
+ description = "Runs restic backup on GCS object change"
+
+ build_config {
+ runtime = "python311"
+ entry_point = "run_backup"
+ source {
+ storage_source {
+ bucket = google_storage_bucket.function_source.name
+ object = "backup-source.zip"
+ }
+ }
+ }
+
+ service_config {
+ max_instance_count = 1
+ available_memory = "512Mi"
+ timeout_seconds = 540
+ service_account_email = google_service_account.backup_sa.email
+
+ environment_variables = {
+ SOURCE_BUCKET = "gs://${google_storage_bucket.content.name}"
+ }
+
+ secret_environment_variables {
+ key = "RESTIC_PASSWORD"
+ project_id = var.gcp_project_id
+ secret = google_secret_manager_secret.restic_password.secret_id
+ version = "latest"
+ }
+
+ secret_environment_variables {
+ key = "AWS_ACCESS_KEY_ID"
+ project_id = var.gcp_project_id
+ secret = google_secret_manager_secret.r2_access_key.secret_id
+ version = "latest"
+ }
+
+ secret_environment_variables {
+ key = "AWS_SECRET_ACCESS_KEY"
+ project_id = var.gcp_project_id
+ secret = google_secret_manager_secret.r2_secret_key.secret_id
+ version = "latest"
+ }
+
+ secret_environment_variables {
+ key = "RESTIC_REPOSITORY"
+ project_id = var.gcp_project_id
+ secret = google_secret_manager_secret.restic_repo.secret_id
+ version = "latest"
+ }
+ }
+
+ event_trigger {
+ trigger_region = var.gcp_region
+ event_type = "google.cloud.storage.object.v1.finalized"
+ retry_policy = "RETRY_POLICY_RETRY"
+ service_account_email = google_service_account.backup_sa.email
+ event_filters {
+ attribute = "bucket"
+ value = google_storage_bucket.content.name
+ }
+ }
+}
+
+# ---------------------------------------------------------------------------
+# IAM for Backup Function
+# ---------------------------------------------------------------------------
+
+resource "google_project_iam_member" "backup_storage_viewer" {
+ project = var.gcp_project_id
+ role = "roles/storage.objectViewer"
+ member = "serviceAccount:${google_service_account.backup_sa.email}"
+}
+
+resource "google_secret_manager_secret_iam_member" "backup_secrets" {
+ for_each = toset([
+ google_secret_manager_secret.restic_password.id,
+ google_secret_manager_secret.r2_access_key.id,
+ google_secret_manager_secret.r2_secret_key.id,
+ google_secret_manager_secret.restic_repo.id
+ ])
+ secret_id = each.key
+ role = "roles/secretmanager.secretAccessor"
+ member = "serviceAccount:${google_service_account.backup_sa.email}"
+}
+
+# Grant Eventarc permission to trigger the function
+resource "google_project_iam_member" "eventarc_pubsub_publisher" {
+ project = var.gcp_project_id
+ role = "roles/pubsub.publisher"
+ member = "serviceAccount:service-${data.google_project.project.number}@gcp-sa-pubsub.iam.gserviceaccount.com"
+}
+
+data "google_project" "project" {}
diff --git a/infra/variables.tf b/infra/variables.tf
index 02d5e17..51389d5 100644
--- a/infra/variables.tf
+++ b/infra/variables.tf
@@ -1,47 +1,29 @@
-variable "vultr_api_key" {
- description = "Vultr API key. Provide via TF_VAR_vultr_api_key env var."
+variable "gcp_project_id" {
type = string
- sensitive = true
+ description = "The GCP Project ID"
+ default = "dev-blog-494815"
}
-variable "region" {
- description = "Vultr region code."
+variable "gcp_region" {
type = string
- default = "sea" # Seattle, WA
+ description = "GCP region"
+ default = "us-central1"
}
-variable "plan" {
- description = "Vultr instance plan."
+variable "gcp_zone" {
type = string
- default = "vc2-1c-2gb"
-}
-
-variable "os_name_filter" {
- description = "Substring to match an OS name in the Vultr OS catalog."
- type = string
- default = "AlmaLinux 10"
+ description = "GCP zone"
+ default = "us-central1-a"
}
variable "hostname" {
- description = "Hostname / label for the instance."
type = string
+ description = "The hostname for the instance"
default = "dev-blog"
}
-variable "ssh_key_ids" {
- description = "List of pre-existing Vultr SSH key IDs to inject."
- type = list(string)
- default = []
-}
-
-variable "backup_hour_utc" {
- description = "Hour of day (UTC, 0-23) for the daily automated backup."
- type = number
- default = 8
-}
-
-variable "tags" {
- description = "Tags to apply to the instance."
- type = list(string)
- default = ["dev_blog", "managed-by=opentofu"]
+variable "domain" {
+ type = string
+ description = "The primary domain name"
+ default = "jasonmross.dev"
}
diff --git a/infra/versions.tf b/infra/versions.tf
index f282b1c..367e8c1 100644
--- a/infra/versions.tf
+++ b/infra/versions.tf
@@ -2,20 +2,16 @@ terraform {
required_version = ">= 1.8.0"
required_providers {
- vultr = {
- source = "vultr/vultr"
- version = "~> 2.21"
+ google = {
+ source = "hashicorp/google"
+ version = "~> 6.0"
}
}
- # Cloudflare R2 is S3-compatible, so we use the s3 backend with a custom
- # endpoint. Backend values that depend on secrets/account-specific data are
- # supplied at init time via `-backend-config=backend.hcl` (see README).
backend "s3" {
key = "dev_blog/terraform.tfstate"
region = "auto"
- # R2 quirks: skip AWS-specific validations and use path-style URLs.
skip_credentials_validation = true
skip_metadata_api_check = true
skip_region_validation = true
@@ -25,8 +21,8 @@ terraform {
}
}
-provider "vultr" {
- api_key = var.vultr_api_key
- rate_limit = 700
- retry_limit = 3
+provider "google" {
+ project = var.gcp_project_id
+ region = var.gcp_region
+ zone = var.gcp_zone
}