commit d6ff4746d013160cc600955e96b36555cd4433bf Author: Ryan Moon Date: Tue Mar 31 08:11:12 2026 -0500 Initial infra setup: Terraform, Ansible, backup roles diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8762f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +terraform/terraform.tfvars +terraform/.terraform/ +*.tfstate +*.tfstate.backup +ansible/inventory.ini +ansible/group_vars/infra/vault.yml diff --git a/ansible/gitea.yml b/ansible/gitea.yml new file mode 100644 index 0000000..aea515f --- /dev/null +++ b/ansible/gitea.yml @@ -0,0 +1,7 @@ +--- +- name: Set up Gitea + hosts: gitea + become: true + + roles: + - gitea diff --git a/ansible/group_vars/gitea/vault.yml.example b/ansible/group_vars/gitea/vault.yml.example new file mode 100644 index 0000000..e45aa24 --- /dev/null +++ b/ansible/group_vars/gitea/vault.yml.example @@ -0,0 +1,12 @@ +# Copy to vault.yml and encrypt with: ansible-vault encrypt vault.yml +# Reference in playbook with: ansible-playbook --ask-vault-pass gitea.yml +--- +cf_origin_cert: | + -----BEGIN CERTIFICATE----- + + -----END CERTIFICATE----- + +cf_origin_key: | + -----BEGIN PRIVATE KEY----- + + -----END PRIVATE KEY----- diff --git a/ansible/group_vars/infra/vars.yml b/ansible/group_vars/infra/vars.yml new file mode 100644 index 0000000..a4e116a --- /dev/null +++ b/ansible/group_vars/infra/vars.yml @@ -0,0 +1,6 @@ +--- +gitea_domain: "git.lunarfront.tech" +gitea_instance_url: "https://git.lunarfront.tech" +vaultwarden_domain: "vault.lunarfront.tech" +vaultwarden_signups_allowed: false +gitea_db_type: postgres diff --git a/ansible/infra.yml b/ansible/infra.yml new file mode 100644 index 0000000..4c08104 --- /dev/null +++ b/ansible/infra.yml @@ -0,0 +1,10 @@ +--- +- name: Set up infra server + hosts: infra + become: true + + roles: + - gitea + - gitea-runner + - vaultwarden + - backup diff --git a/ansible/inventory.ini.example b/ansible/inventory.ini.example new file mode 100644 index 0000000..2d23103 --- /dev/null +++ b/ansible/inventory.ini.example @@ -0,0 +1,2 @@ +[gitea] + ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519 diff --git a/ansible/roles/backup/defaults/main.yml b/ansible/roles/backup/defaults/main.yml new file mode 100644 index 0000000..f3f1856 --- /dev/null +++ b/ansible/roles/backup/defaults/main.yml @@ -0,0 +1,14 @@ +--- +backup_bucket: "lunarfront-infra" +backup_spaces_region: "nyc3" +backup_schedule: "0 3 * * *" # 3am daily + +backup_dirs: + - src: /var/lib/gitea/data + dest: backups/gitea + - src: /var/lib/vaultwarden/data + dest: backups/vaultwarden + +# Set in vault +spaces_access_key: "" +spaces_secret_key: "" diff --git a/ansible/roles/backup/tasks/main.yml b/ansible/roles/backup/tasks/main.yml new file mode 100644 index 0000000..a5960f0 --- /dev/null +++ b/ansible/roles/backup/tasks/main.yml @@ -0,0 +1,38 @@ +--- +- name: Install rclone + apt: + name: rclone + state: present + update_cache: true + +- name: Create rclone config directory + file: + path: /root/.config/rclone + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy rclone config + template: + src: rclone.conf.j2 + dest: /root/.config/rclone/rclone.conf + owner: root + group: root + mode: "0600" + +- name: Deploy backup script + template: + src: backup.sh.j2 + dest: /usr/local/bin/backup.sh + owner: root + group: root + mode: "0700" + +- name: Schedule daily backup cron job + cron: + name: "lunarfront backup" + job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" + minute: "0" + hour: "3" + user: root diff --git a/ansible/roles/backup/templates/backup.sh.j2 b/ansible/roles/backup/templates/backup.sh.j2 new file mode 100644 index 0000000..bac217e --- /dev/null +++ b/ansible/roles/backup/templates/backup.sh.j2 @@ -0,0 +1,34 @@ +#!/bin/bash +set -euo pipefail + +DATE=$(date +%Y-%m-%d) +MONTH=$(date +%Y-%m) +DAY_OF_MONTH=$(date +%d) + +echo "[$(date)] Starting backup" + +{% for item in backup_dirs %} +echo "[$(date)] Backing up {{ item.src }}" + +# Daily backup — kept for 30 days +rclone sync {{ item.src }} spaces://{{ backup_bucket }}/{{ item.dest }}/daily/${DATE} \ + --s3-acl private + +# Monthly backup on the 1st — kept for 12 months +if [ "${DAY_OF_MONTH}" = "01" ]; then + rclone sync {{ item.src }} spaces://{{ backup_bucket }}/{{ item.dest }}/monthly/${MONTH} \ + --s3-acl private + echo "[$(date)] Monthly backup complete for {{ item.src }}" +fi + +# Prune daily backups older than 30 days +rclone delete spaces://{{ backup_bucket }}/{{ item.dest }}/daily \ + --min-age 30d --s3-acl private + +# Prune monthly backups older than 12 months +rclone delete spaces://{{ backup_bucket }}/{{ item.dest }}/monthly \ + --min-age 365d --s3-acl private + +{% endfor %} + +echo "[$(date)] Backup complete" diff --git a/ansible/roles/backup/templates/rclone.conf.j2 b/ansible/roles/backup/templates/rclone.conf.j2 new file mode 100644 index 0000000..0ced8d8 --- /dev/null +++ b/ansible/roles/backup/templates/rclone.conf.j2 @@ -0,0 +1,7 @@ +[spaces] +type = s3 +provider = DigitalOcean +access_key_id = {{ spaces_access_key }} +secret_access_key = {{ spaces_secret_key }} +endpoint = nyc3.digitaloceanspaces.com +acl = private diff --git a/ansible/roles/gitea-runner/defaults/main.yml b/ansible/roles/gitea-runner/defaults/main.yml new file mode 100644 index 0000000..1af855f --- /dev/null +++ b/ansible/roles/gitea-runner/defaults/main.yml @@ -0,0 +1,10 @@ +--- +gitea_runner_version: "0.2.11" +gitea_runner_data_dir: /var/lib/gitea-runner +gitea_instance_url: "https://git.example.com" + +# Generate in Gitea: Site Admin → Actions → Runners → Create new runner +gitea_runner_token: "" + +gitea_runner_name: "{{ inventory_hostname }}" +gitea_runner_labels: "ubuntu-latest:docker://node:20" diff --git a/ansible/roles/gitea-runner/handlers/main.yml b/ansible/roles/gitea-runner/handlers/main.yml new file mode 100644 index 0000000..46ad856 --- /dev/null +++ b/ansible/roles/gitea-runner/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart gitea-runner + community.docker.docker_compose_v2: + project_src: "{{ gitea_runner_data_dir }}" + state: present + recreate: always diff --git a/ansible/roles/gitea-runner/tasks/main.yml b/ansible/roles/gitea-runner/tasks/main.yml new file mode 100644 index 0000000..0c967e7 --- /dev/null +++ b/ansible/roles/gitea-runner/tasks/main.yml @@ -0,0 +1,27 @@ +--- +- name: Create gitea-runner data directory + file: + path: "{{ gitea_runner_data_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy docker-compose file + template: + src: docker-compose.yml.j2 + dest: "{{ gitea_runner_data_dir }}/docker-compose.yml" + mode: "0600" + notify: Restart gitea-runner + +- name: Deploy runner config + template: + src: config.yml.j2 + dest: "{{ gitea_runner_data_dir }}/config.yml" + mode: "0600" + notify: Restart gitea-runner + +- name: Start gitea-runner + community.docker.docker_compose_v2: + project_src: "{{ gitea_runner_data_dir }}" + state: present diff --git a/ansible/roles/gitea-runner/templates/config.yml.j2 b/ansible/roles/gitea-runner/templates/config.yml.j2 new file mode 100644 index 0000000..ae16844 --- /dev/null +++ b/ansible/roles/gitea-runner/templates/config.yml.j2 @@ -0,0 +1,17 @@ +log: + level: info + +runner: + file: /data/.runner + capacity: 2 # max concurrent jobs — lower if droplet is under load + labels: + - "ubuntu-latest:docker://node:20" + - "ubuntu-22.04:docker://node:20" + +cache: + enabled: true + dir: /data/cache + +container: + network: bridge + force_pull: false # reuse cached images to speed up builds diff --git a/ansible/roles/gitea-runner/templates/docker-compose.yml.j2 b/ansible/roles/gitea-runner/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..5f0294b --- /dev/null +++ b/ansible/roles/gitea-runner/templates/docker-compose.yml.j2 @@ -0,0 +1,14 @@ +services: + gitea-runner: + image: gitea/act_runner:{{ gitea_runner_version }} + container_name: gitea-runner + restart: unless-stopped + volumes: + - {{ gitea_runner_data_dir }}/config.yml:/config.yml + - {{ gitea_runner_data_dir }}/data:/data + - /var/run/docker.sock:/var/run/docker.sock # allows runner to spin up job containers + environment: + CONFIG_FILE: /config.yml + GITEA_INSTANCE_URL: "{{ gitea_instance_url }}" + GITEA_RUNNER_REGISTRATION_TOKEN: "{{ gitea_runner_token }}" + GITEA_RUNNER_NAME: "{{ gitea_runner_name }}" diff --git a/ansible/roles/gitea/defaults/main.yml b/ansible/roles/gitea/defaults/main.yml new file mode 100644 index 0000000..c0faa3d --- /dev/null +++ b/ansible/roles/gitea/defaults/main.yml @@ -0,0 +1,10 @@ +--- +gitea_version: "1.22.3" +gitea_domain: "git.example.com" +gitea_http_port: 3000 +gitea_ssh_port: 2222 +gitea_data_dir: /var/lib/gitea + +# Cloudflare Origin Certificate +cf_origin_cert: "" +cf_origin_key: "" diff --git a/ansible/roles/gitea/handlers/main.yml b/ansible/roles/gitea/handlers/main.yml new file mode 100644 index 0000000..d52610c --- /dev/null +++ b/ansible/roles/gitea/handlers/main.yml @@ -0,0 +1,11 @@ +--- +- name: Restart gitea + community.docker.docker_compose_v2: + project_src: "{{ gitea_data_dir }}" + state: present + recreate: always + +- name: Reload nginx + systemd: + name: nginx + state: reloaded diff --git a/ansible/roles/gitea/tasks/main.yml b/ansible/roles/gitea/tasks/main.yml new file mode 100644 index 0000000..dd7c32f --- /dev/null +++ b/ansible/roles/gitea/tasks/main.yml @@ -0,0 +1,88 @@ +--- +- name: Install dependencies + apt: + name: [nginx, docker.io, docker-compose-v2] + state: present + update_cache: true + +- name: Enable and start Docker + systemd: + name: docker + enabled: true + state: started + +- name: Create gitea data directory + file: + path: "{{ gitea_data_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy docker-compose file + template: + src: docker-compose.yml.j2 + dest: "{{ gitea_data_dir }}/docker-compose.yml" + mode: "0600" + notify: Restart gitea + +- name: Start gitea + community.docker.docker_compose_v2: + project_src: "{{ gitea_data_dir }}" + state: present + +# ─── Cloudflare Origin Certificate ─────────────────────────────────────────── + +- name: Create SSL directory + file: + path: /etc/nginx/ssl + state: directory + owner: root + group: root + mode: "0700" + +- name: Install Cloudflare origin certificate + copy: + content: "{{ cf_origin_cert }}" + dest: /etc/nginx/ssl/cf-origin.pem + owner: root + group: root + mode: "0600" + notify: Reload nginx + +- name: Install Cloudflare origin key + copy: + content: "{{ cf_origin_key }}" + dest: /etc/nginx/ssl/cf-origin.key + owner: root + group: root + mode: "0600" + notify: Reload nginx + +# ─── nginx ──────────────────────────────────────────────────────────────────── + +- name: Deploy nginx config + template: + src: nginx.conf.j2 + dest: /etc/nginx/sites-available/gitea + mode: "0644" + notify: Reload nginx + +- name: Enable nginx site + file: + src: /etc/nginx/sites-available/gitea + dest: /etc/nginx/sites-enabled/gitea + state: link + notify: Reload nginx + +- name: Remove nginx default site + file: + path: /etc/nginx/sites-enabled/default + state: absent + notify: Reload nginx + +- name: Ensure nginx is started + systemd: + name: nginx + enabled: true + state: started diff --git a/ansible/roles/gitea/templates/docker-compose.yml.j2 b/ansible/roles/gitea/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..f00c204 --- /dev/null +++ b/ansible/roles/gitea/templates/docker-compose.yml.j2 @@ -0,0 +1,29 @@ +services: + gitea: + image: gitea/gitea:{{ gitea_version }} + container_name: gitea + restart: unless-stopped + volumes: + - {{ gitea_data_dir }}/data:/data + environment: + USER_UID: 1000 + USER_GID: 1000 + GITEA__server__DOMAIN: "{{ gitea_domain }}" + GITEA__server__ROOT_URL: "https://{{ gitea_domain }}/" + GITEA__server__HTTP_PORT: 3000 + GITEA__server__SSH_DOMAIN: "{{ gitea_domain }}" + GITEA__server__SSH_PORT: "{{ gitea_ssh_port }}" + GITEA__server__SSH_LISTEN_PORT: 2222 + GITEA__server__START_SSH_SERVER: "true" + GITEA__service__DISABLE_REGISTRATION: "true" +{% if gitea_db_url is defined and gitea_db_url %} + GITEA__database__DB_TYPE: "postgres" + GITEA__database__HOST: "{{ gitea_db_host }}" + GITEA__database__NAME: "gitea" + GITEA__database__USER: "{{ gitea_db_user }}" + GITEA__database__PASSWD: "{{ gitea_db_password }}" + GITEA__database__SSL_MODE: "require" +{% endif %} + ports: + - "127.0.0.1:{{ gitea_http_port }}:3000" + - "{{ gitea_ssh_port }}:2222" diff --git a/ansible/roles/gitea/templates/nginx.conf.j2 b/ansible/roles/gitea/templates/nginx.conf.j2 new file mode 100644 index 0000000..ce53d20 --- /dev/null +++ b/ansible/roles/gitea/templates/nginx.conf.j2 @@ -0,0 +1,24 @@ +server { + listen 80; + server_name {{ gitea_domain }}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name {{ gitea_domain }}; + + ssl_certificate /etc/nginx/ssl/cf-origin.pem; + ssl_certificate_key /etc/nginx/ssl/cf-origin.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + location / { + proxy_pass http://127.0.0.1:{{ gitea_http_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/ansible/roles/vaultwarden/defaults/main.yml b/ansible/roles/vaultwarden/defaults/main.yml new file mode 100644 index 0000000..5c0bab2 --- /dev/null +++ b/ansible/roles/vaultwarden/defaults/main.yml @@ -0,0 +1,18 @@ +--- +vaultwarden_domain: "vault.example.com" +vaultwarden_port: 8080 +vaultwarden_data_dir: /var/lib/vaultwarden + +# Set to your postgres connection string or leave as default for SQLite +vaultwarden_database_url: "" # e.g. postgresql://user:pass@host/vaultwarden + +# Restrict signups after first admin account is created +vaultwarden_signups_allowed: "true" + +# Admin token — set this to a strong random string +# Generate with: openssl rand -base64 48 +vaultwarden_admin_token: "" + +# Cloudflare Origin Certificate +cf_origin_cert: "" +cf_origin_key: "" diff --git a/ansible/roles/vaultwarden/handlers/main.yml b/ansible/roles/vaultwarden/handlers/main.yml new file mode 100644 index 0000000..2c38f24 --- /dev/null +++ b/ansible/roles/vaultwarden/handlers/main.yml @@ -0,0 +1,11 @@ +--- +- name: Restart vaultwarden + community.docker.docker_compose_v2: + project_src: "{{ vaultwarden_data_dir }}" + state: present + recreate: always + +- name: Reload nginx + systemd: + name: nginx + state: reloaded diff --git a/ansible/roles/vaultwarden/tasks/main.yml b/ansible/roles/vaultwarden/tasks/main.yml new file mode 100644 index 0000000..99e3fda --- /dev/null +++ b/ansible/roles/vaultwarden/tasks/main.yml @@ -0,0 +1,76 @@ +--- +- name: Install dependencies + apt: + name: [docker.io, docker-compose-v2] + state: present + update_cache: true + +- name: Enable and start Docker + systemd: + name: docker + enabled: true + state: started + +- name: Create vaultwarden data directory + file: + path: "{{ vaultwarden_data_dir }}" + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy docker-compose file + template: + src: docker-compose.yml.j2 + dest: "{{ vaultwarden_data_dir }}/docker-compose.yml" + mode: "0600" + notify: Restart vaultwarden + +- name: Start vaultwarden + community.docker.docker_compose_v2: + project_src: "{{ vaultwarden_data_dir }}" + state: present + +# ─── Cloudflare Origin Certificate ─────────────────────────────────────────── + +- name: Create SSL directory + file: + path: /etc/nginx/ssl + state: directory + owner: root + group: root + mode: "0700" + +- name: Install Cloudflare origin certificate + copy: + content: "{{ cf_origin_cert }}" + dest: /etc/nginx/ssl/cf-origin.pem + owner: root + group: root + mode: "0600" + notify: Reload nginx + +- name: Install Cloudflare origin key + copy: + content: "{{ cf_origin_key }}" + dest: /etc/nginx/ssl/cf-origin.key + owner: root + group: root + mode: "0600" + notify: Reload nginx + +# ─── nginx ──────────────────────────────────────────────────────────────────── + +- name: Deploy nginx config + template: + src: nginx.conf.j2 + dest: /etc/nginx/sites-available/vaultwarden + mode: "0644" + notify: Reload nginx + +- name: Enable nginx site + file: + src: /etc/nginx/sites-available/vaultwarden + dest: /etc/nginx/sites-enabled/vaultwarden + state: link + notify: Reload nginx diff --git a/ansible/roles/vaultwarden/templates/docker-compose.yml.j2 b/ansible/roles/vaultwarden/templates/docker-compose.yml.j2 new file mode 100644 index 0000000..04f7a43 --- /dev/null +++ b/ansible/roles/vaultwarden/templates/docker-compose.yml.j2 @@ -0,0 +1,16 @@ +services: + vaultwarden: + image: vaultwarden/server:latest + container_name: vaultwarden + restart: unless-stopped + volumes: + - {{ vaultwarden_data_dir }}/data:/data + environment: + DOMAIN: "https://{{ vaultwarden_domain }}" + SIGNUPS_ALLOWED: "{{ vaultwarden_signups_allowed | lower }}" + ADMIN_TOKEN: "{{ vaultwarden_admin_token }}" +{% if vaultwarden_database_url %} + DATABASE_URL: "{{ vaultwarden_database_url }}" +{% endif %} + ports: + - "127.0.0.1:{{ vaultwarden_port }}:80" diff --git a/ansible/roles/vaultwarden/templates/nginx.conf.j2 b/ansible/roles/vaultwarden/templates/nginx.conf.j2 new file mode 100644 index 0000000..822cb43 --- /dev/null +++ b/ansible/roles/vaultwarden/templates/nginx.conf.j2 @@ -0,0 +1,27 @@ +server { + listen 80; + server_name {{ vaultwarden_domain }}; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + server_name {{ vaultwarden_domain }}; + + ssl_certificate /etc/nginx/ssl/cf-origin.pem; + ssl_certificate_key /etc/nginx/ssl/cf-origin.key; + + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Required for Bitwarden web vault + client_max_body_size 525m; + + location / { + proxy_pass http://127.0.0.1:{{ vaultwarden_port }}; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/terraform/.gitignore b/terraform/.gitignore new file mode 100644 index 0000000..5c07ef6 --- /dev/null +++ b/terraform/.gitignore @@ -0,0 +1,4 @@ +.terraform/ +terraform.tfvars +*.tfstate +*.tfstate.backup diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..da56f25 --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,49 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/cloudflare/cloudflare" { + version = "4.52.7" + constraints = "~> 4.0" + hashes = [ + "h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", + "zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", + "zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", + "zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", + "zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285", + "zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529", + "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", + "zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", + "zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", + "zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", + "zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", + "zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", + "zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", + "zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", + "zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", + "zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", + ] +} + +provider "registry.terraform.io/digitalocean/digitalocean" { + version = "2.81.0" + constraints = "~> 2.0" + hashes = [ + "h1:47cuHN8MQlnexcfUS5Toyvcg/9hk+EvHr2uNbfkmPfU=", + "zh:0a35eca6ee12b78f4a080b02f1f77b51159d919cfddc15aea0855b41d3632013", + "zh:0f871a3f513b789be86403c8b0568f86425fd3c4c3acb971f1f01c8ff165aafc", + "zh:5b15aa1cc7cbfdb12f2b97b7bd55f1e77dac844d7312919b9727ba11f4a92e56", + "zh:9be812992b0720161ec5f0518957a3b406728dbc31b437f0a336781eb6915714", + "zh:9f2bf1509893ebcc4659408c1aff4f7337646a0369863c6d762998ec8f025d0f", + "zh:a297f7c3d1192efb0f16ca5d9d5df4ac074f8d0f474b1c7d259884dd56998b26", + "zh:a81e51fead5aac3e060cbe58f1bb8e4bc32e030668bf6a0511496a4a2a8c60ee", + "zh:cc224fbe556281319cd2e525368b6c90b360e2ad1c58771eeb2dfd7ce2153ab9", + "zh:d40bc07848e8bbce99fe66a6da12d279cb91caedd0ec61c6948b57f2e076f359", + "zh:d470e974fb520fe2b462f15a44069915636cbbb937a80584414357b045c8b910", + "zh:db4c728d0f26bf24c4d4c0f8000de73f79ef35a16b96c3306b6bccb91abf4b16", + "zh:df02c98612152e31aac9d4d894134949608fa6d666da338ef76e4eec40be45c3", + "zh:e8f47d8cc609e53a290e73064bb6efb9d5ff576e8389e53919e140fbafff9f1c", + "zh:eb1942471dfb434ac96ed5c6e7d7360ed1e314b8c0fa9e4bbfb38c1f83b9f33e", + "zh:fc1f47533813c1abf7888fd8f84a15876d9f7acd62b6746de2db1ca6816bf1e9", + "zh:ff57bb5b47460e2c7cde320ad50851ca0ae3931d3ab21bfc2af0a8f1fb5cdd9c", + ] +} diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..62369b2 --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,165 @@ +terraform { + required_providers { + digitalocean = { + source = "digitalocean/digitalocean" + version = "~> 2.0" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.0" + } + } + + backend "s3" { + endpoints = { + s3 = "https://nyc3.digitaloceanspaces.com" + } + bucket = "lunarfront-infra" + key = "terraform/gitea.tfstate" + region = "us-east-1" # required by S3 backend, ignored by Spaces + skip_credentials_validation = true + skip_metadata_api_check = true + skip_region_validation = true + skip_requesting_account_id = true + force_path_style = true + } +} + +provider "digitalocean" { + token = var.do_token +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +} + +# ─── Cloudflare zone lookup ─────────────────────────────────────────────────── + +data "cloudflare_zone" "main" { + name = var.domain +} + +# ─── Droplet ────────────────────────────────────────────────────────────────── + +data "digitalocean_ssh_key" "main" { + name = var.ssh_key_name +} + +resource "digitalocean_droplet" "gitea" { + name = "gitea" + region = var.region + size = var.droplet_size + image = "ubuntu-24-04-x64" + ssh_keys = [data.digitalocean_ssh_key.main.id] + + tags = ["infra", "gitea"] +} + +# ─── Firewall ───────────────────────────────────────────────────────────────── + +# Cloudflare IPv4 ranges — http://www.cloudflare.com/ips-v4 +locals { + cloudflare_ipv4 = [ + "173.245.48.0/20", + "103.21.244.0/22", + "103.22.200.0/22", + "103.31.4.0/22", + "141.101.64.0/18", + "108.162.192.0/18", + "190.93.240.0/20", + "188.114.96.0/20", + "197.234.240.0/22", + "198.41.128.0/17", + "162.158.0.0/15", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + ] + cloudflare_ipv6 = [ + "2400:cb00::/32", + "2606:4700::/32", + "2803:f800::/32", + "2405:b500::/32", + "2405:8100::/32", + "2a06:98c0::/29", + "2c0f:f248::/32", + ] +} + +resource "digitalocean_firewall" "gitea" { + name = "gitea-firewall" + droplet_ids = [digitalocean_droplet.gitea.id] + + # SSH — your IP only + inbound_rule { + protocol = "tcp" + port_range = "22" + source_addresses = ["${var.admin_ip}/32"] + } + + # HTTP/HTTPS — Cloudflare IPs only + inbound_rule { + protocol = "tcp" + port_range = "80" + source_addresses = concat(local.cloudflare_ipv4, local.cloudflare_ipv6) + } + + inbound_rule { + protocol = "tcp" + port_range = "443" + source_addresses = concat(local.cloudflare_ipv4, local.cloudflare_ipv6) + } + + # Gitea SSH for git push/pull — your IP only + inbound_rule { + protocol = "tcp" + port_range = "2222" + source_addresses = ["${var.admin_ip}/32"] + } + + outbound_rule { + protocol = "tcp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } + + outbound_rule { + protocol = "udp" + port_range = "1-65535" + destination_addresses = ["0.0.0.0/0", "::/0"] + } +} + +# ─── DNS records ────────────────────────────────────────────────────────────── + +resource "cloudflare_record" "gitea" { + zone_id = data.cloudflare_zone.main.id + name = "git" + type = "A" + value = digitalocean_droplet.gitea.ipv4_address + proxied = true + ttl = 1 +} + +resource "cloudflare_record" "vaultwarden" { + zone_id = data.cloudflare_zone.main.id + name = "vault" + type = "A" + value = digitalocean_droplet.gitea.ipv4_address + proxied = true + ttl = 1 +} + +# ─── Project assignment ─────────────────────────────────────────────────────── + +data "digitalocean_project" "infra" { + name = var.do_project_name +} + +resource "digitalocean_project_resources" "infra" { + project = data.digitalocean_project.infra.id + resources = [ + digitalocean_droplet.gitea.urn, + ] +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..ee8e655 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "gitea_ip" { + description = "Public IP of the Gitea droplet" + value = digitalocean_droplet.gitea.ipv4_address +} diff --git a/terraform/terraform.tfvars.example b/terraform/terraform.tfvars.example new file mode 100644 index 0000000..4d9763e --- /dev/null +++ b/terraform/terraform.tfvars.example @@ -0,0 +1,6 @@ +do_token = "your-digitalocean-api-token" +ssh_key_name = "your-key-name-in-do" +region = "nyc3" +droplet_size = "s-1vcpu-2gb" +cloudflare_api_token = "your-cloudflare-api-token" +domain = "example.com" diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..69e7b4d --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,44 @@ +variable "do_token" { + description = "DigitalOcean API token" + type = string + sensitive = true +} + +variable "ssh_key_name" { + description = "Name of the SSH key uploaded to DigitalOcean" + type = string +} + +variable "region" { + description = "DigitalOcean region" + type = string + default = "nyc3" +} + +variable "droplet_size" { + description = "Droplet size slug" + type = string + default = "s-1vcpu-2gb" +} + +variable "cloudflare_api_token" { + description = "Cloudflare API token (needs Zone:DNS:Edit permission)" + type = string + sensitive = true +} + +variable "domain" { + description = "Root domain managed in Cloudflare (e.g. example.com)" + type = string +} + +variable "admin_ip" { + description = "Your public IP for SSH and git access (without /32)" + type = string +} + +variable "do_project_name" { + description = "DigitalOcean project name to assign resources to" + type = string + default = "lunarfront-infra" +}