feat(deploy): aibook.fun deploy layer (terraform + ansible + docker on DO) #4
@@ -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
|
||||
@@ -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
|
||||
@@ -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=<aibook-fun-project-id>
|
||||
./itofu.sh init && ./itofu.sh plan && ./itofu.sh apply
|
||||
./itofu.sh output -raw droplet_ip
|
||||
|
||||
# add Cloudflare A record: aibook.fun → <that-ip>
|
||||
|
||||
cd ..
|
||||
ansible-playbook -i 'root@<that-ip>,' ansible/deploy.yml
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `terraform/` — droplet + IP + firewall + monitoring
|
||||
- `docker/` — Dockerfile + compose + Caddy
|
||||
- `ansible/` — droplet convergence
|
||||
@@ -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
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
# Usage: ansible-playbook -i 'root@<droplet-ip>,' 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
|
||||
@@ -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@<droplet-ip>,'
|
||||
#
|
||||
# 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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 <image> 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"]
|
||||
@@ -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
|
||||
Generated
+41
@@ -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",
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run OpenTofu with TF_VAR_* creds injected from Infisical /tofu/.
|
||||
# Usage: infisical login && INFISICAL_PROJECT_ID=<id> ./itofu.sh <init|plan|apply|...>
|
||||
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 <init|plan|apply|output|destroy|...> [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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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@<this-droplet-ip>,'
|
||||
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.
|
||||
@@ -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 = "<YOUR_DIGITALOCEAN_TOKEN>"
|
||||
|
||||
ssh_key_fingerprints = ["<YOUR_SSH_KEY_FINGERPRINT>"]
|
||||
|
||||
# pin to admin IP/32 or VPN range for prod
|
||||
ssh_source_cidrs = ["0.0.0.0/0", "::/0"]
|
||||
|
||||
spaces_access_key = "<YOUR_SPACES_ACCESS_KEY>"
|
||||
spaces_secret_key = "<YOUR_SPACES_SECRET_KEY>"
|
||||
spaces_encryption_key = "<YOUR_SSE_C_KEY_BASE64>"
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user