From 8ef06cb3e77b03917a6dd6257dada9c3087bc882 Mon Sep 17 00:00:00 2001 From: dilonne Date: Wed, 2 Sep 2026 10:57:09 +0200 Subject: [PATCH] feat(deploy): aibook.fun deploy layer (terraform + ansible + docker on DO) --- .gitea/workflows/deploy.yml | 105 +++++++++++++++ deploy/.gitignore | 73 ++++++++++ deploy/README.md | 25 ++++ deploy/ansible.cfg | 33 +++++ deploy/ansible/deploy.yml | 150 +++++++++++++++++++++ deploy/ansible/harden.yml | 76 +++++++++++ deploy/ansible/requirements.yml | 8 ++ deploy/docker/Caddyfile | 23 ++++ deploy/docker/Dockerfile | 41 ++++++ deploy/docker/compose.prod.yaml | 59 ++++++++ deploy/terraform/.terraform.lock.hcl | 41 ++++++ deploy/terraform/backend.tf | 25 ++++ deploy/terraform/itofu.sh | 48 +++++++ deploy/terraform/main.tf | 113 ++++++++++++++++ deploy/terraform/monitoring.tf | 44 ++++++ deploy/terraform/outputs.tf | 28 ++++ deploy/terraform/templates/cloud-init.yaml | 85 ++++++++++++ deploy/terraform/terraform.tfvars.example | 27 ++++ deploy/terraform/variables.tf | 82 +++++++++++ deploy/terraform/versions.tf | 17 +++ marketing-site/next.config.mjs | 3 + 21 files changed, 1106 insertions(+) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 deploy/.gitignore create mode 100644 deploy/README.md create mode 100644 deploy/ansible.cfg create mode 100644 deploy/ansible/deploy.yml create mode 100644 deploy/ansible/harden.yml create mode 100644 deploy/ansible/requirements.yml create mode 100644 deploy/docker/Caddyfile create mode 100644 deploy/docker/Dockerfile create mode 100644 deploy/docker/compose.prod.yaml create mode 100644 deploy/terraform/.terraform.lock.hcl create mode 100644 deploy/terraform/backend.tf create mode 100755 deploy/terraform/itofu.sh create mode 100644 deploy/terraform/main.tf create mode 100644 deploy/terraform/monitoring.tf create mode 100644 deploy/terraform/outputs.tf create mode 100644 deploy/terraform/templates/cloud-init.yaml create mode 100644 deploy/terraform/terraform.tfvars.example create mode 100644 deploy/terraform/variables.tf create mode 100644 deploy/terraform/versions.tf diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a52dfd8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,105 @@ +# aibook-fun — CI/CD: build the Next.js marketing site, push to reg.mini.dev, +# pin the image ref in Infisical, then trigger a pull + bounce on the droplet. +# +# Runs on Gitea Actions (GitHub-compatible syntax). This repo has Actions +# enabled; the runner must be labeled `ubuntu-latest`. +# +# Repository secrets required (Settings → Actions → Secrets): +# MINIMUS_TOKEN reg.mini.dev registry token (u: minimus) +# INFISICAL_CLIENT_ID Infisical Machine Identity Client ID +# INFISICAL_CLIENT_SECRET Infisical Machine Identity Client Secret +# INFISICAL_PROJECT_ID the /AIBook-Fun project ID +# DROPLET_IP droplet reserved IP (or resolve via DNS) +# DROPLET_SSH_KEY private SSH key for root@ on the droplet +# +# NOTE: first provision is NOT done here — it's terraform + ansible converge +# (deploy/docker-compose/terraform + ansible/deploy.yml). This workflow handles +# everything after the site exists: every main-branch push to app/deploy code +# rebuilds and rolls forward idempotently. + +name: deploy + +on: + push: + branches: [main] + paths: + - 'marketing-site/**' + - 'deploy/**' + - '.gitea/workflows/**' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: aibook-fun-deploy + cancel-in-progress: false + +env: + REGISTRY: reg.mini.dev + IMAGE_REPO: weownai/aibook-fun-marketing + INFISICAL_ENV: prod + INFISICAL_PATH: /AIBook-Fun + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build image + run: | + docker build \ + --pull \ + -f deploy/docker-compose/docker/Dockerfile \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ github.sha }}" \ + -t "${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:latest" \ + marketing-site/ + + - name: Login to reg.mini.dev + run: echo "${{ secrets.MINIMUS_TOKEN }}" | docker login "${{ env.REGISTRY }}" -u minimus --password-stdin + + - name: Push image + run: | + docker push "${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ github.sha }}" + docker push "${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:latest" + + - name: Pin AIBOOK_IMAGE in Infisical + env: + INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }} + INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }} + run: | + sudo curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | sudo -E bash + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq infisical >/dev/null + export INFISICAL_TOKEN="$(infisical login --method=universal-auth --plain --silent)" + infisical secrets set \ + AIBOOK_IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}:${{ github.sha }}" \ + --projectId="${{ secrets.INFISICAL_PROJECT_ID }}" \ + --env="${{ env.INFISICAL_ENV }}" \ + --path="${{ env.INFISICAL_PATH }}" + + - name: Deploy to droplet (pull + up) + uses: appleboy/ssh-action@v1 + with: + host: ${{ secrets.DROPLET_IP }} + username: root + key: ${{ secrets.DROPLET_SSH_KEY }} + command_timeout: "15m" + script: | + set -euo pipefail + cd /opt/aibook_fun + # Droplet-side Infisical auth (auth file written by cloud-init). + source .infisical-auth.env + export INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$INFISICAL_CLIENT_ID" + export INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$INFISICAL_CLIENT_SECRET" + export INFISICAL_TOKEN="$(infisical login --method=universal-auth --plain --silent)" + # Re-auth reg.mini.dev if the docker login from ansible has lapsed. + MINIMUS_TOKEN="$(infisical secrets get MINIMUS_TOKEN --projectId="$INFISICAL_PROJECT_ID" --env=prod --path=/AIBook-Fun --plain || true)" + if [ -n "${MINIMUS_TOKEN:-}" ]; then + printf '%s' "$MINIMUS_TOKEN" | docker login reg.mini.dev -u minimus --password-stdin + fi + # AIBOOK_IMAGE is injected by `infisical run`; also export it so the + # ${AIBOOK_IMAGE:?} compose ref resolves even without run-injection. + export AIBOOK_IMAGE="$(infisical secrets get AIBOOK_IMAGE --projectId="$INFISICAL_PROJECT_ID" --env=prod --path=/AIBook-Fun --plain)" + infisical run --projectId="$INFISICAL_PROJECT_ID" --env=prod --path=/AIBook-Fun \ + -- docker compose up -d --remove-orphans diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..675c7ed --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,73 @@ +# aibook-fun deploy — gitignore +# ⚠️ CRITICAL: Never commit credentials or state files + +# ============================================================================= +# Terraform / OpenTofu State (CRITICAL - contains infrastructure details) +# ============================================================================= +*.tfstate +*.tfstate.* +*.tfstate.backup +.terraform/ +# NOTE: .terraform.lock.hcl is NOT ignored — commit it so provider versions +# are reproducible across machines/CI runs. + +# Terraform variable files with real values +terraform.tfvars +terraform.tfvars.json +*.auto.tfvars +*.auto.tfvars.json + +# Terraform crash logs +crash.log +crash.*.log + +# ============================================================================= +# Environment Files (CRITICAL - contains passwords) +# ============================================================================= +.env +.env.prod +.env.local +.env.*.local + +# ============================================================================= +# Backups (may contain sensitive data) +# ============================================================================= +backups/ +*.tar.gz +*.sql +*.sql.gz + +# ============================================================================= +# Logs +# ============================================================================= +logs/ +*.log + +# ============================================================================= +# OS & Editor +# ============================================================================= +.DS_Store +.DS_Store? +._* +Thumbs.db +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.code-workspace + +# ============================================================================= +# Docker +# ============================================================================= +docker-compose.override.yml +docker-compose.override.yaml + +# ============================================================================= +# Ansible +# ============================================================================= +*.retry +.vault-pass + +# OpenTofu saved plans (sensitive) — never commit +*.tfplan diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a9e4c7b --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,25 @@ +# Deploy + +Provisions a DO droplet + reserved IP + firewall + monitoring, builds the +Next.js image locally on the droplet, runs it behind Caddy. + +## Deploy from scratch + +```bash +cd terraform +infisical login +export INFISICAL_PROJECT_ID= +./itofu.sh init && ./itofu.sh plan && ./itofu.sh apply +./itofu.sh output -raw droplet_ip + +# add Cloudflare A record: aibook.fun → + +cd .. +ansible-playbook -i 'root@,' ansible/deploy.yml +``` + +## Structure + +- `terraform/` — droplet + IP + firewall + monitoring +- `docker/` — Dockerfile + compose + Caddy +- `ansible/` — droplet convergence diff --git a/deploy/ansible.cfg b/deploy/ansible.cfg new file mode 100644 index 0000000..a940d9a --- /dev/null +++ b/deploy/ansible.cfg @@ -0,0 +1,33 @@ +# Ansible settings for deploying aibook-fun. +# +# The whole point of this file is SSH connection reuse. On a workstation whose +# SSH key lives in a hardware-backed agent (Secretive, a YubiKey, the Secure +# Enclave), every *new* TCP connection needs a fresh signature — which means a +# Touch ID prompt. Ansible opens many connections per run and does so +# non-interactively, so those prompts cannot be answered and the run dies with: +# +# sign_and_send_pubkey: signing failed ... agent refused operation +# +# Ansible's own defaults make this worse in two ways, both fixed below: +# +# 1. It keeps control sockets in ~/.ansible/cp, separate from the ones the +# operator's own `ssh` has already authenticated. Pointing it at the same +# directory and the same %C naming as OpenSSH means ansible *reuses* a +# master connection the operator authorised earlier — no new signature. +# 2. ControlPersist defaults to 60s, so a master closes between steps of a +# deploy and the next one needs another signature. +# +# Together: authenticate once at the start of a work session, then deploy +# repeatedly without touching the key again. +[defaults] +host_key_checking = True +interpreter_python = auto_silent + +[ssh_connection] +# Match OpenSSH's own ControlPath so ansible shares the operator's masters. +# %%C is escaped for configparser — ssh receives %C (a hash of local host, +# remote host, port and user), exactly what ~/.ssh/config uses. +control_path_dir = ~/.ssh/sockets +control_path = %(directory)s/%%C +ssh_args = -C -o ControlMaster=auto -o ControlPersist=4h +pipelining = True diff --git a/deploy/ansible/deploy.yml b/deploy/ansible/deploy.yml new file mode 100644 index 0000000..63da008 --- /dev/null +++ b/deploy/ansible/deploy.yml @@ -0,0 +1,150 @@ +--- +# Usage: ansible-playbook -i 'root@,' deploy/ansible/deploy.yml +# Pre: droplet cloud-init complete (.bootstrap-complete marker present) + +- name: Deploy aibook-fun app layer + hosts: all + gather_facts: false + become: true + vars: + app_dir: /opt/aibook_fun + repo_dir: /opt/aibook_fun/repo + repo_url: "https://git.weown.tools/WeOwnAI/aibook-fun.git" + repo_branch: main + image_tag: "aibook-fun-marketing:v1" + + pre_tasks: + - name: Verify droplet has completed cloud-init bootstrap + ansible.builtin.stat: + path: "{{ app_dir }}/.bootstrap-complete" + register: bootstrap_marker + + - name: Fail clearly if bootstrap is not complete + ansible.builtin.fail: + msg: >- + {{ app_dir }}/.bootstrap-complete is missing — droplet has not + finished cloud-init. Wait a few minutes after `tofu apply` and retry. + Check /var/log/cloud-init-output.log on the droplet. + when: not bootstrap_marker.stat.exists + + - name: Ensure app dir exists + ansible.builtin.file: + path: "{{ app_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + tasks: + # ─── Clone / update repo on droplet ──────────────────────────────────── + - name: Clone (or update) aibook-fun repo on droplet + ansible.builtin.git: + repo: "{{ repo_url }}" + dest: "{{ repo_dir }}" + version: "{{ repo_branch }}" + force: true + register: git_clone + notify: Rebuild image + + # ─── Upload Dockerfile (deploy/ scaffold not yet pushed to git.weown.tools) ─ + - name: Upload Dockerfile to droplet + ansible.builtin.copy: + src: ../docker/Dockerfile + dest: "{{ app_dir }}/Dockerfile" + owner: root + group: root + mode: "0644" + + # ─── Build docker image locally on droplet (unconditional — docker layer cache handles no-ops) ─ + - name: Build Next.js docker image locally + ansible.builtin.shell: | + set -euo pipefail + docker build -t {{ image_tag }} -f {{ app_dir }}/Dockerfile {{ repo_dir }}/marketing-site/ + args: + executable: /bin/bash + register: image_build + changed_when: "'writing image' in image_build.stderr" + notify: Reconcile docker compose stack + + # ─── Upload compose + Caddyfile ──────────────────────────────────────── + - name: Upload compose.yaml + ansible.builtin.copy: + src: ../docker/compose.prod.yaml + dest: "{{ app_dir }}/compose.yaml" + owner: root + group: root + mode: "0644" + notify: Reconcile docker compose stack + + - name: Upload Caddyfile + ansible.builtin.copy: + src: ../docker/Caddyfile + dest: "{{ app_dir }}/Caddyfile" + owner: root + group: root + mode: "0644" + notify: Reconcile docker compose stack + + - name: Ensure caddy log dir is writable + ansible.builtin.file: + path: /var/log/caddy + state: directory + owner: root + group: root + mode: "0777" + + - name: Ensure Caddy Docker volumes are writable (caddy runs as UID 1000) + ansible.builtin.shell: | + set -euo pipefail + for suffix in caddy_data caddy_config; do + path="/var/lib/docker/volumes/aibook_fun_${suffix}/_data" + if [ -d "$path" ]; then + chown -R 1000:1000 "$path" + fi + done + args: + executable: /bin/bash + changed_when: false + + # ─── Ensure compose stack is running (idempotent) ────────────────────── + - name: Ensure compose stack is running (idempotent — `up -d` no-op if unchanged) + ansible.builtin.shell: | + set -euo pipefail + cd {{ app_dir }} + docker compose up -d --remove-orphans + args: + executable: /bin/bash + register: compose_up + changed_when: "'Started' in compose_up.stderr or 'Created' in compose_up.stderr or 'Recreated' in compose_up.stderr" + + - name: Wait for web app to respond + ansible.builtin.uri: + url: http://127.0.0.1:3000/ + status_code: [200] + register: health_probe + retries: 20 + delay: 5 + until: health_probe.status == 200 + + - name: Report success + ansible.builtin.debug: + msg: "aibook-fun app layer deployed. URL: https://aibook.fun" + + handlers: + - name: Rebuild image + ansible.builtin.shell: | + set -euo pipefail + cd {{ repo_dir }} + docker build -t {{ image_tag }} -f {{ repo_dir }}/deploy/docker/Dockerfile {{ repo_dir }}/marketing-site/ + args: + executable: /bin/bash + changed_when: false + + - name: Reconcile docker compose stack + ansible.builtin.shell: | + set -euo pipefail + cd {{ app_dir }} + docker compose up -d --force-recreate --remove-orphans + args: + executable: /bin/bash + changed_when: false diff --git a/deploy/ansible/harden.yml b/deploy/ansible/harden.yml new file mode 100644 index 0000000..3f4151e --- /dev/null +++ b/deploy/ansible/harden.yml @@ -0,0 +1,76 @@ +# aibook-fun — host hardening (CIS-aligned, Phase A) +# +# Applies the DevSec hardening baseline (≈ CIS Ubuntu L1) to the droplet: +# kernel/sysctl, auth policy, filesystem perms, SSH crypto + policy — then +# measures the result with Lynis. Idempotent; run any time after bootstrap: +# +# ansible-galaxy collection install -r ansible/requirements.yml # once +# ansible-playbook ansible/harden.yml -i 'root@,' +# +# PHASE A vs PHASE B (deliberate scope — do not "fix" this into Phase B): +# Phase A (this play): CIS L1 OS + SSH hardening with root login KEPT +# (key-only). Every fleet tool (deploy.sh, backup.sh, manage-droplets.sh) +# SSHes as root@; removing root here would break the fleet mid-flight. +# Phase B (tracked, pre-first-customer): ops-user model (root SSH off, +# password-sudo 2FA), mesh/CIDR-locked port 22 — per SOP-INFRA-012. Needs +# the fleet tooling migrated off root@ first. +# +# Docker-safe exceptions (the CIS-L2-vs-container-runtime conflicts): +# - net.ipv4.ip_forward stays 1 — Docker bridge networking requires it. A +# defaults-level override silently LOSES to the inner role's defaults; +# it must be a task-var on the include (hit live 2026-07-03: containers +# got DNS but no TCP egress when this landed at 0). +--- +- name: Harden aibook-fun host (CIS L1 via devsec — Phase A) + hosts: all + become: true + vars: + run_lynis: true + + tasks: + - name: "OS hardening (devsec.hardening.os_hardening ≈ CIS Ubuntu L1)" + ansible.builtin.include_role: + name: devsec.hardening.os_hardening + vars: + # MUST be task-vars here (see header) — Docker bridge needs ip_forward. + sysctl_overwrite: + net.ipv4.ip_forward: 1 + + - name: "SSH hardening (devsec.hardening.ssh_hardening — key-only, strong KEX/ciphers)" + ansible.builtin.include_role: + name: devsec.hardening.ssh_hardening + vars: + # Phase A: root stays (fleet tooling), but key-only and CIS crypto. + ssh_permit_root_login: "without-password" + ssh_password_authentication: false + ssh_challenge_response_authentication: false + + - name: "Measure: install + run Lynis (hardening index; non-blocking)" + when: run_lynis | bool + block: + - name: "Install Lynis" + ansible.builtin.apt: + name: lynis + state: present + update_cache: true + + - name: "Run Lynis audit (quick)" + ansible.builtin.command: lynis audit system --quick --no-colors + register: lynis_out + changed_when: false + failed_when: false + + - name: "Lynis summary (record this index in the site CHANGELOG)" + ansible.builtin.debug: + msg: "{{ lynis_out.stdout_lines | select('search', 'Hardening index|Warnings|Suggestions|Tests performed') | list }}" + + - name: "Verify Docker survived hardening (containers must still run)" + # brace-free format string: Go-template braces here would be re-templated + # by ansible's own Jinja pass and error out. + ansible.builtin.command: docker info --format json + register: docker_ok + changed_when: false + + - name: "Docker status" + ansible.builtin.debug: + msg: "Docker OK (server {{ (docker_ok.stdout | from_json).ServerVersion | default('unknown') }}) — hardening did not break the runtime" diff --git a/deploy/ansible/requirements.yml b/deploy/ansible/requirements.yml new file mode 100644 index 0000000..34c64c4 --- /dev/null +++ b/deploy/ansible/requirements.yml @@ -0,0 +1,8 @@ +# Ansible Galaxy requirements for the optional hardening play (harden.yml). +# Install once per operator workstation: +# ansible-galaxy collection install -r ansible/requirements.yml +collections: + # DevSec Hardening Framework — CIS/STIG-aligned os_hardening + ssh_hardening. + # https://github.com/dev-sec/ansible-collection-hardening + - name: devsec.hardening + version: ">=8.8.0" diff --git a/deploy/docker/Caddyfile b/deploy/docker/Caddyfile new file mode 100644 index 0000000..b81aba7 --- /dev/null +++ b/deploy/docker/Caddyfile @@ -0,0 +1,23 @@ + +aibook.fun { + + # AIBook.fun is a static marketing site — pure reverse proxy to the Next.js + # standalone server. No auth/headers added here beyond baseline security. + reverse_proxy web:3000 + + log { + # Host-bind-mounted /var/log/caddy/ so logs survive container recreation. + output file /var/log/caddy/aibook-fun.log + } + + header { + # Force HTTPS for a year (HSTS — a lapsed cert is a service outage, not a + # warning; weown rail per W24 phase4 findings). + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options nosniff + X-Frame-Options SAMEORIGIN + X-XSS-Protection "1; mode=block" + Referrer-Policy strict-origin-when-cross-origin + -Server + } +} diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile new file mode 100644 index 0000000..f5b2cbc --- /dev/null +++ b/deploy/docker/Dockerfile @@ -0,0 +1,41 @@ +# syntax=docker/dockerfile:1 +# aibook-fun — Next.js 16 marketing site (marketing-site/), standalone output. +# +# Build context = marketing-site/ (see .gitea/workflows/deploy.yml): +# docker build -f deploy/docker/Dockerfile -t marketing-site/ +# +# Multi-stage: deps → build (next build → .next/standalone) → slim non-root runner. +# Requires `output: 'standalone'` in next.config.mjs (added on this branch). +# Node 20.9+ / 22+ required by Next 16; pin 22 LTS. + +FROM node:22-alpine AS deps +WORKDIR /app +# Lockfiles first for layer caching. +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +# Next standalone ships as a single node server.js; run as non-root (uid 1001). +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nextjs + +# Static assets not bundled into the standalone output. +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 +CMD ["node", "server.js"] diff --git a/deploy/docker/compose.prod.yaml b/deploy/docker/compose.prod.yaml new file mode 100644 index 0000000..eee664c --- /dev/null +++ b/deploy/docker/compose.prod.yaml @@ -0,0 +1,59 @@ +services: + web: + image: aibook-fun-marketing:v1 + pull_policy: never + restart: unless-stopped + ports: + # Loopback-only: lets caddy proxy and health probes reach the app. + # Not internet-exposed; caddy handles :80/:443 → web:3000. + - "127.0.0.1:3000:3000" + networks: + - aibooknet + healthcheck: + test: ["CMD-SHELL", "curl -f -o /dev/null -w '%{http_code}' http://localhost:3000/ | grep -q '^200$' || exit 1"] + start_period: 30s + interval: 30s + timeout: 10s + retries: 5 + deploy: + resources: + limits: + memory: 512M + reservations: + memory: 128M + + caddy: + image: caddy:2 + restart: unless-stopped + depends_on: + - web + environment: + DOMAIN: aibook.fun + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + - /var/log/caddy:/var/log/caddy + networks: + - aibooknet + deploy: + resources: + limits: + memory: 256M + reservations: + memory: 128M + +volumes: + caddy_data: + name: aibook_fun_caddy_data + caddy_config: + name: aibook_fun_caddy_config + +networks: + aibooknet: + name: aibook_fun_net + driver: bridge diff --git a/deploy/terraform/.terraform.lock.hcl b/deploy/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..b5ba5af --- /dev/null +++ b/deploy/terraform/.terraform.lock.hcl @@ -0,0 +1,41 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/digitalocean/digitalocean" { + version = "2.100.0" + constraints = "~> 2.36" + hashes = [ + "h1:+1p5n5KeGjz/ulw/ZLXoaBkF+2UTgKqWqERw1nA2sJQ=", + "h1:79G5uDt1VHL6gWtSnK8lCOHZpo/jp/kSGA7FXtqQvX4=", + "h1:7r9AZrpc8pB6PRsJfmxSGLzIRmohmfpX6RrnA1FLbh4=", + "h1:9VsRnY7rmGiMFVG4ix3ry2fx8B8d7l9PyEpmGjjiW3I=", + "h1:LchVh6xhpXw1PEnl2mRnsLlR4dzLePw8M2vw4DuPk9U=", + "h1:OmnMLcKMFnZLI1CvTgQBod0awOpuVxsfB8dqBejfwE8=", + "h1:Q9BxzK1Tp8YeuCUXVogVfqTsVCrWUUJJdvpL7UA0W/s=", + "h1:R13HFu3vyxrd4aHyh/+p2YY0vWpPAFjafBRYVuIL5HE=", + "h1:RJH5t57bLx1b2+l+gS6RRHyakhEQjSKL/QYu6npCtbI=", + "h1:WQ2ClvMwBILHDzWWhAm3LBRra4HkSe9taUlUGSLBNWI=", + "h1:WbQfCVfC+GU0qPSoZKuoEllr3cMqwTC1Fx5DfbUjy/o=", + "h1:iwjK8zLBTHWHICCP/O+QFcyxH16bV4gKPFUbmu2foqc=", + "h1:pTFXceDc5intkLpVFVW3PKfwkF1CvmnWi3vYcF6SAQs=", + "h1:pc1iMmHPQA37PCkVWTRzuO/WgaoSGYtSBVP5paO6nK4=", + "h1:uDVCzJGtpV2DqyPWoYWjQpbEV48PxS6/U7/uiGfDDsU=", + "h1:yyBAhHZmejmdvjuvsF2gAwIZebqOmisQIT5dOQqQx9A=", + "zh:021dbdacf7dd636cb66990964737bc4a6402160b5301e06b6fdab03a814f7e96", + "zh:083ff822b22738eedc9eae29aa5d4c369b2de921e49fefa9858a4e7c6274b0c1", + "zh:10695b5f7dbbe8ba1a20ac6bea1ab8613b43d2b1f6f9b53369bed296b2dff4d2", + "zh:110124b9d07cd1f0f2831ad27dfeb5a010a070952ea9a9e675ca3cfe158a0ffa", + "zh:409a8b22ffb9a53caf57d0a16f5f8ac0a2fcd61b6b0bcd512a685ea26cd06047", + "zh:488e4b494c198c0696c85d2c73c66c40567ca2ad5230bbd95d8ffdead86e646d", + "zh:4dd5b6e8c1be5fb619812fe738d0b49e0d24731fb290c0ab5190b7bc60c70e66", + "zh:6717fa7f623ad4c9b4d99acceffd1372d272ff02d7c98259dd17ec7ef11b836c", + "zh:7a25445caebd2e7f6ab2381578ecaf31744159a7e4a16476f19112d2e50d8519", + "zh:86b3d7050ba04ec7e0a14dbee8c0540fca8f4377fb072f87c88a6fcd26388714", + "zh:c23f30a38930c283055b6f04acbcad06cb0dd3c71c172eb23bb239f50dd12823", + "zh:d1f9c5be7f7d5c71830357bdd9c0fb529185e9380092e1b792f969495c943790", + "zh:d222573e7de8f9ad1c5b87a6c44c2dc59947c3968b3e70b7d4c76263d6c8fe5c", + "zh:e1f2d606661435416c4d2b48db80244d0acef88a5ce81b5dcbd65ff2642b1b4d", + "zh:ea13fad19414586864557601c59e85188601e150c815ec1e71f8ed83f1942a50", + "zh:f795be71dc933aaec7145f93068da17f227783964d33a79de591a042aac7c3ca", + ] +} diff --git a/deploy/terraform/backend.tf b/deploy/terraform/backend.tf new file mode 100644 index 0000000..bee5c92 --- /dev/null +++ b/deploy/terraform/backend.tf @@ -0,0 +1,25 @@ +# aibook-fun - Terraform Backend Configuration +# State stored in DigitalOcean Spaces (S3-compatible) for team sharing, SSE-C +# encrypted at rest. +# +# Pass credentials via itofu.sh (preferred) or -backend-config flags: +# access_key / secret_key / sse_customer_key +# Backend config cannot reference Terraform variables — itofu.sh forwards them. + +terraform { + backend "s3" { + endpoint = "https://atl1.digitaloceanspaces.com" + bucket = "weown-tools-state" + key = "aibook-fun/aibook-fun.tfstate" + region = "us-east-1" + encrypt = true + acl = "private" + + # Required for DO Spaces (S3-compatible but not AWS) + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true + skip_region_validation = true + skip_s3_checksum = true + } +} diff --git a/deploy/terraform/itofu.sh b/deploy/terraform/itofu.sh new file mode 100755 index 0000000..e091a54 --- /dev/null +++ b/deploy/terraform/itofu.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Run OpenTofu with TF_VAR_* creds injected from Infisical /tofu/. +# Usage: infisical login && INFISICAL_PROJECT_ID= ./itofu.sh +set -euo pipefail + +INFISICAL_PROJECT_ID="${INFISICAL_PROJECT_ID:-}" +INFISICAL_ENV="${INFISICAL_ENV:-prod}" +TOFU_PATH="/tofu" +PLAN_FILE="${WEOWN_TOFU_PLAN:-plan.tfplan}" + +: "${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID not set. Export it (e.g. from site.conf) before running itofu.sh.}" + +command -v infisical >/dev/null 2>&1 || { echo "ERROR: infisical CLI not found." >&2; exit 1; } +command -v tofu >/dev/null 2>&1 || { echo "ERROR: tofu not found." >&2; exit 1; } +infisical export --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" >/dev/null 2>&1 || { + echo "ERROR: cannot read $INFISICAL_PROJECT_ID $INFISICAL_ENV $TOFU_PATH — run 'infisical login' and check your access." >&2; exit 1; } +[ "$#" -ge 1 ] || { echo "usage: ./itofu.sh [args]" >&2; exit 1; } + +irun() { infisical run --projectId="$INFISICAL_PROJECT_ID" --env="$INFISICAL_ENV" --path="$TOFU_PATH" -- "$@"; } + +# S3 backend reads AWS_* env on every state op — so ALL tofu calls go through trun. +# shellcheck disable=SC2016 +trun() { irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu "$@"' itofu "$@"; } + +case "$1" in + init) + shift + # shellcheck disable=SC2016 + irun bash -c 'export AWS_ACCESS_KEY_ID="$TF_VAR_spaces_access_key" AWS_SECRET_ACCESS_KEY="$TF_VAR_spaces_secret_key"; exec tofu init -reconfigure \ + -backend-config="sse_customer_key=$TF_VAR_spaces_encryption_key" "$@"' itofu "$@" + ;; + plan) + shift + trun plan -out="$PLAN_FILE" "$@" + ;; + apply) + shift + if [ "$#" -eq 0 ] && [ -f "$PLAN_FILE" ]; then + set +e; trun apply "$PLAN_FILE"; rc=$?; set -e + rm -f "$PLAN_FILE" + exit "$rc" + fi + trun apply "$@" + ;; + *) + trun "$@" + ;; +esac diff --git a/deploy/terraform/main.tf b/deploy/terraform/main.tf new file mode 100644 index 0000000..414daa3 --- /dev/null +++ b/deploy/terraform/main.tf @@ -0,0 +1,113 @@ +# aibook-fun - Main Infrastructure +# Managed by OpenTofu + +resource "digitalocean_droplet" "web" { + name = "aibook-fun" + image = var.droplet_image + size = var.droplet_size + region = var.region + monitoring = true + backups = true + + # CPU/RAM-only resize (provider default is true = also grow the disk). + resize_disk = false + + ssh_keys = var.ssh_key_fingerprints + + user_data = templatefile("${path.module}/templates/cloud-init.yaml", { + project_name = "aibook_fun" + }) + + tags = ["aibook-fun", "web", "weown-ai"] + + lifecycle { + # A droplet destroy means losing every Docker volume + cert state on it. + # make any such plan a hard error instead of an accident. + prevent_destroy = true + # user_data/tags/ssh_keys are create-time-only on DO; ignore drift so + # unrelated changes don't arm `# forces replacement` on next apply. + ignore_changes = [user_data, tags, ssh_keys] + } +} + +# Optional encrypted-at-rest data volume (default 0 = stateless: droplet local +# disk only). Enable by setting data_volume_size_gb > 0 if the site ever needs +# durable state; DO block storage is encrypted at rest, the droplet disk is not. +resource "digitalocean_volume" "data" { + count = var.data_volume_size_gb > 0 ? 1 : 0 + region = var.region + name = "aibook-fun-data" + size = var.data_volume_size_gb + initial_filesystem_type = "ext4" + description = "aibook-fun encrypted-at-rest data (docker data-root)" + + lifecycle { + prevent_destroy = true + } +} + +resource "digitalocean_volume_attachment" "data" { + count = var.data_volume_size_gb > 0 ? 1 : 0 + droplet_id = digitalocean_droplet.web.id + volume_id = digitalocean_volume.data[0].id +} + +resource "digitalocean_reserved_ip" "web" { + region = var.region +} + +resource "digitalocean_reserved_ip_assignment" "web" { + ip_address = digitalocean_reserved_ip.web.ip_address + droplet_id = digitalocean_droplet.web.id +} + +#trivy:ignore:AVD-DIG-0001 # Public web server: HTTP/HTTPS inbound from internet is required by design +#trivy:ignore:AVD-DIG-0003 # Public web server: unrestricted outbound required for package installs, ACME, image pulls +resource "digitalocean_firewall" "web" { + name = "aibook-fun-fw" + droplet_ids = [digitalocean_droplet.web.id] + + # SSH — restrict via var.ssh_source_cidrs. ⚠️ PRODUCTION: pin to admin IP/32 or VPN + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = var.ssh_source_cidrs + } + + # HTTP (ACME challenge + redirect) + inbound_rule { + protocol = "tcp" + port_range = "80" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # HTTPS + inbound_rule { + protocol = "tcp" + port_range = "443" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # HTTPS/QUIC (HTTP/3) + inbound_rule { + protocol = "udp" + port_range = "443" + source_addresses = ["0.0.0.0/0", "::/0"] + } + + # All outbound TCP (apt, ACME, container image pulls) + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + # All outbound UDP (DNS, NTP, QUIC) + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + tags = ["aibook-fun"] +} diff --git a/deploy/terraform/monitoring.tf b/deploy/terraform/monitoring.tf new file mode 100644 index 0000000..9614b16 --- /dev/null +++ b/deploy/terraform/monitoring.tf @@ -0,0 +1,44 @@ +# aibook-fun - Monitoring Configuration +# Managed by OpenTofu + +resource "digitalocean_monitor_alert" "cpu" { + count = var.enable_monitoring ? 1 : 0 + alerts { + email = [var.alert_email] + } + window = "5m" + type = "v1/insights/droplet/cpu" + compare = "GreaterThan" + value = var.cpu_alert_threshold + enabled = true + entities = [digitalocean_droplet.web.id] + description = "aibook-fun: CPU usage above ${var.cpu_alert_threshold}% for 5 min" +} + +resource "digitalocean_monitor_alert" "memory" { + count = var.enable_monitoring ? 1 : 0 + alerts { + email = [var.alert_email] + } + window = "5m" + type = "v1/insights/droplet/memory_utilization_percent" + compare = "GreaterThan" + value = var.memory_alert_threshold + enabled = true + entities = [digitalocean_droplet.web.id] + description = "aibook-fun: Memory usage above ${var.memory_alert_threshold}% for 5 min" +} + +resource "digitalocean_monitor_alert" "disk" { + count = var.enable_monitoring ? 1 : 0 + alerts { + email = [var.alert_email] + } + window = "5m" + type = "v1/insights/droplet/disk_utilization_percent" + compare = "GreaterThan" + value = var.disk_alert_threshold + enabled = true + entities = [digitalocean_droplet.web.id] + description = "aibook-fun: Disk usage above ${var.disk_alert_threshold}% for 5 min" +} diff --git a/deploy/terraform/outputs.tf b/deploy/terraform/outputs.tf new file mode 100644 index 0000000..9876cfc --- /dev/null +++ b/deploy/terraform/outputs.tf @@ -0,0 +1,28 @@ +# aibook-fun - Terraform Outputs +# Managed by OpenTofu + +output "droplet_ip" { + description = "Droplet IPv4 address (reserved IP)" + value = digitalocean_reserved_ip.web.ip_address +} + +output "reserved_ip" { + description = "Reserved IP address (alias for droplet_ip; stable across rebuilds)" + value = digitalocean_reserved_ip.web.ip_address +} + +output "droplet_id" { + description = "Droplet ID" + value = digitalocean_droplet.web.id +} + +output "domain" { + description = "Primary domain" + value = var.domain +} + +output "site_url" { + description = "Site URL" + value = "https://${var.domain}" +} + diff --git a/deploy/terraform/templates/cloud-init.yaml b/deploy/terraform/templates/cloud-init.yaml new file mode 100644 index 0000000..cd3162d --- /dev/null +++ b/deploy/terraform/templates/cloud-init.yaml @@ -0,0 +1,85 @@ +#cloud-config +# First-boot bootstrap: base packages + Docker + git. App layer via ansible. + +package_update: true +packages: + - apt-transport-https + - ca-certificates + - curl + - gnupg + - lsb-release + - jq + - unzip + - git + +write_files: + # Docker installation + - path: /tmp/install-docker.sh + permissions: "0755" + content: | + #!/bin/bash + set -euo pipefail + curl -fsSL https://get.docker.com -o /tmp/get-docker.sh + sh /tmp/get-docker.sh + systemctl enable docker + systemctl start docker + usermod -aG docker ubuntu + + # Docker daemon config (log rotation) + - path: /etc/docker/daemon.json + content: | + { + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + }, + "storage-driver": "overlay2" + } + + # Encrypted-at-rest data volume setup (runs BEFORE Docker is installed). + # Graceful no-op when no volume is attached (data_volume_size_gb = 0 default + # for aibook-fun stateless marketing site). + - path: /tmp/setup-data-volume.sh + permissions: "0755" + content: | + #!/bin/bash + set -euo pipefail + DEV="/dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data" + MNT="/mnt/${project_name}_data" + for i in $(seq 1 24); do + [ -e "$DEV" ] && break + sleep 5 + done + if [ ! -e "$DEV" ]; then + echo "no data volume attached after 120s - using droplet local disk (stateless mode)" + exit 0 + fi + blkid "$DEV" >/dev/null 2>&1 || mkfs.ext4 -q "$DEV" + mkdir -p "$MNT" + grep -q "scsi-0DO_Volume_aibook-fun-data" /etc/fstab || \ + echo "$DEV $MNT ext4 defaults,noatime,nofail,discard 0 2" >> /etc/fstab + mountpoint -q "$MNT" || mount "$MNT" + mkdir -p "$MNT/docker" + jq --arg dr "$MNT/docker" '. + {"data-root": $dr}' /etc/docker/daemon.json > /tmp/daemon.json.new + mv /tmp/daemon.json.new /etc/docker/daemon.json + mkdir -p /opt/${project_name} + echo "data volume mounted at $MNT (docker data-root relocated)" + +runcmd: + # Data volume setup only when a block volume is attached + - 'bash -c "[ -e /dev/disk/by-id/scsi-0DO_Volume_aibook-fun-data ] && bash /tmp/setup-data-volume.sh || echo no-data-volume"' + - [bash, /tmp/install-docker.sh] + - [systemctl, restart, docker] + # Ensure the app dir exists for ansible to land the compose + Caddyfile + - [mkdir, -p, /opt/${project_name}] + # Marker so ansible/deploy.yml can confirm the host is ready + - [touch, /opt/${project_name}/.bootstrap-complete] + # Enable automatic security updates + - dpkg-reconfigure -plow unattended-upgrades + +final_message: | + aibook-fun bootstrap complete. Next step from the operator workstation: + ansible-playbook aibook-fun/ansible/deploy.yml -i 'root@,' + Ansible will clone the repo, build the Docker image locally on this droplet, + and bring up the compose stack. Site served at https://aibook.fun. diff --git a/deploy/terraform/terraform.tfvars.example b/deploy/terraform/terraform.tfvars.example new file mode 100644 index 0000000..c4227aa --- /dev/null +++ b/deploy/terraform/terraform.tfvars.example @@ -0,0 +1,27 @@ +# ⚠️ NEVER commit terraform.tfvars with real values. Use ./itofu.sh instead — +# it reads TF_VAR_* straight from Infisical /tofu/. + +do_token = "" + +ssh_key_fingerprints = [""] + +# pin to admin IP/32 or VPN range for prod +ssh_source_cidrs = ["0.0.0.0/0", "::/0"] + +spaces_access_key = "" +spaces_secret_key = "" +spaces_encryption_key = "" + +domain = "aibook.fun" +region = "atl1" +droplet_size = "s-2vcpu-4gb-amd" +droplet_image = "ubuntu-24-04-x64" + +data_volume_size_gb = 0 + +enable_monitoring = true +# MUST be verified on the DO account +alert_email = "PLT@weown.net" +cpu_alert_threshold = 80 +memory_alert_threshold = 90 +disk_alert_threshold = 85 diff --git a/deploy/terraform/variables.tf b/deploy/terraform/variables.tf new file mode 100644 index 0000000..8eb5512 --- /dev/null +++ b/deploy/terraform/variables.tf @@ -0,0 +1,82 @@ +variable "domain" { + type = string + default = "aibook.fun" +} + +variable "region" { + type = string + default = "atl1" +} + +variable "droplet_size" { + type = string + default = "s-2vcpu-4gb-amd" +} + +variable "droplet_image" { + type = string + default = "ubuntu-24-04-x64" +} + +variable "ssh_key_fingerprints" { + type = list(string) +} + +variable "ssh_source_cidrs" { + description = "CIDR list allowed to reach port 22 — pin to admin IP/32 or VPN range for prod" + type = list(string) + default = ["0.0.0.0/0", "::/0"] +} + +variable "do_token" { + type = string + sensitive = true +} + +variable "spaces_access_key" { + type = string + sensitive = true +} + +variable "spaces_secret_key" { + type = string + sensitive = true +} + +variable "spaces_encryption_key" { + description = "DO Spaces SSE-C encryption key (32-byte AES-256, base64)" + type = string + sensitive = true +} + +variable "data_volume_size_gb" { + description = "0 = stateless (droplet local disk); >0 attaches encrypted DO block volume for Docker data-root" + type = number + default = 0 +} + +variable "enable_monitoring" { + type = bool + default = true +} + +variable "alert_email" { + description = "Email for monitoring alerts — MUST be verified on the DO account" + type = string + default = "PLT@weown.net" +} + +variable "cpu_alert_threshold" { + type = number + default = 80 +} + +variable "memory_alert_threshold" { + type = number + default = 90 +} + +variable "disk_alert_threshold" { + type = number + default = 85 +} diff --git a/deploy/terraform/versions.tf b/deploy/terraform/versions.tf new file mode 100644 index 0000000..17b4d8e --- /dev/null +++ b/deploy/terraform/versions.tf @@ -0,0 +1,17 @@ +# aibook-fun - OpenTofu Version Constraints +# Managed by OpenTofu + +terraform { + required_version = ">= 1.7.0" + + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.36" + } + } +} + +provider "digitalocean" { + token = var.do_token +} diff --git a/marketing-site/next.config.mjs b/marketing-site/next.config.mjs index 847a3f0..d3e18fc 100644 --- a/marketing-site/next.config.mjs +++ b/marketing-site/next.config.mjs @@ -1,6 +1,9 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + // Deploy layer (deploy/docker/Dockerfile) builds the standalone + // output. No runtime behavior change for preview/dev. + output: 'standalone', // A stray package-lock.json sits at the repo root; pin the workspace root here // so Turbopack doesn't infer the wrong one. turbopack: {