commit 52843aa9e74aa8dbf05d12f641f4f1f07ed89784 Author: Josh Date: Fri Jan 9 00:22:05 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..245935e --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# Terraform +*.tfstate +*.tfstate.* +.terraform/ +.terraform.lock.hcl +crash.log + +# Secrets +terraform.tfvars +files/env/*.env +!files/env/*.example + +# IDE +.idea/ +*.swp +.vscode/ diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..8283f6b --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,11 @@ +when: + branch: main + event: push + +steps: + - name: validate + image: hashicorp/terraform:1.10 + commands: + - terraform init -backend=false + - terraform validate + - terraform fmt -check diff --git a/files/cloud-init/ops.yml b/files/cloud-init/ops.yml new file mode 100644 index 0000000..ff354ca --- /dev/null +++ b/files/cloud-init/ops.yml @@ -0,0 +1,109 @@ +#cloud-config + +package_update: true +package_upgrade: true + +packages: + - docker.io + - docker-compose-v2 + - git + - curl + - sqlite3 + +users: + - name: deploy + groups: docker, sudo + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - ${ssh_public_key} + +write_files: + - path: /opt/writekit/.env + permissions: '0600' + content: | + ${indent(6, env_file)} + + - path: /opt/writekit/docker-compose.yml + permissions: '0644' + content: | + ${indent(6, docker_compose)} + + - path: /etc/docker/daemon.json + permissions: '0644' + content: | + { + "insecure-registries": ["10.0.0.3:5000"], + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } + + - path: /opt/writekit/.ssh/deploy_key + permissions: '0600' + content: | + ${indent(6, deploy_ssh_private_key)} + + - path: /opt/writekit/setup-forgejo-oauth.sh + permissions: '0755' + content: | + #!/bin/bash + set -a + . /opt/writekit/.env + set +a + + DB="/var/lib/docker/volumes/writekit_forgejo-data/_data/gitea/gitea.db" + + for i in {1..60}; do + [ -f "$$DB" ] && break + sleep 5 + done + + sleep 10 + + sqlite3 "$$DB" </dev/null || true + docker compose exec -T forgejo gitea admin user create --username "$$WOODPECKER_ADMIN" --email "$${WOODPECKER_ADMIN}@localhost" --password "temppass123" --admin 2>/dev/null || \ + docker compose exec -T forgejo gitea admin user change-password --username "$$WOODPECKER_ADMIN" --must-change-password=false 2>/dev/null + sqlite3 "/var/lib/docker/volumes/writekit_forgejo-data/_data/gitea/gitea.db" "UPDATE user SET is_admin=1 WHERE lower_name='$$(echo $$WOODPECKER_ADMIN | tr '[:upper:]' '[:lower:]')';" + echo "User $$WOODPECKER_ADMIN promoted to admin" + +runcmd: + - systemctl enable docker + - systemctl start docker + - mkdir -p /opt/writekit/.ssh + - chown -R deploy:deploy /opt/writekit + + - | + set -a + . /opt/writekit/.env + set +a + cd /opt/writekit && docker compose up -d + + - /opt/writekit/setup-forgejo-oauth.sh + + - | + for i in {1..30}; do + ssh-keyscan -H 10.0.0.2 >> /opt/writekit/.ssh/known_hosts 2>/dev/null && break + sleep 10 + done + chown deploy:deploy /opt/writekit/.ssh/known_hosts + +final_message: "WriteKit ops server ready after $$UPTIME seconds" diff --git a/files/cloud-init/prod.yml b/files/cloud-init/prod.yml new file mode 100644 index 0000000..bd99130 --- /dev/null +++ b/files/cloud-init/prod.yml @@ -0,0 +1,58 @@ +#cloud-config + +package_update: true +package_upgrade: true + +packages: + - docker.io + - docker-compose-v2 + - git + - curl + +users: + - name: deploy + groups: docker, sudo + shell: /bin/bash + sudo: ALL=(ALL) NOPASSWD:ALL + ssh_authorized_keys: + - ${ssh_public_key} + - ${deploy_ssh_public_key} + +write_files: + - path: /opt/writekit/.env + permissions: '0600' + content: | + ${indent(6, env_file)} + + - path: /opt/writekit/docker-compose.yml + permissions: '0644' + content: | + ${indent(6, docker_compose)} + + - path: /etc/docker/daemon.json + permissions: '0644' + content: | + { + "insecure-registries": ["10.0.0.3:5000"], + "log-driver": "json-file", + "log-opts": { + "max-size": "10m", + "max-file": "3" + } + } + +runcmd: + - systemctl enable docker + - systemctl start docker + - mkdir -p /opt/writekit/data/tenants + - chown -R deploy:deploy /opt/writekit + + - | + set -a + . /opt/writekit/.env + set +a + cd /opt/writekit && docker compose up -d postgres traefik + sleep 10 + docker compose exec -T postgres psql -U writekit -c "CREATE DATABASE writekit_staging;" || true + +final_message: "WriteKit prod server ready after $$UPTIME seconds" diff --git a/files/env/ops.env.example b/files/env/ops.env.example new file mode 100644 index 0000000..5035f60 --- /dev/null +++ b/files/env/ops.env.example @@ -0,0 +1,12 @@ +DOMAIN=writekit.dev +ACME_EMAIL=you@example.com + +CF_DNS_API_TOKEN= + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +WOODPECKER_AGENT_SECRET= +WOODPECKER_FORGEJO_CLIENT= +WOODPECKER_FORGEJO_SECRET= +WOODPECKER_ADMIN=josh diff --git a/files/env/prod.env.example b/files/env/prod.env.example new file mode 100644 index 0000000..eaa0e2b --- /dev/null +++ b/files/env/prod.env.example @@ -0,0 +1,41 @@ +DOMAIN=writekit.dev +REGISTRY_URL=10.0.0.3:5000 +ACME_EMAIL=you@example.com + +PG_PASSWORD= + +CF_DNS_API_TOKEN= + +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET=writekit-assets +R2_PUBLIC_URL=https://assets.writekit.dev + +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +SESSION_SECRET= + +LEMON_API_KEY= +LEMON_STORE_ID= +LEMON_WEBHOOK_SECRET= + +WISE_API_KEY= +WISE_PROFILE_ID= + +OAUTH2_PROXY_COOKIE_SECRET= +OAUTH2_PROXY_ALLOWED_USERS=josh + +GITHUB_CLIENT_ID_STAGING= +GITHUB_CLIENT_SECRET_STAGING= +SESSION_SECRET_STAGING= +LEMON_API_KEY_STAGING= +LEMON_WEBHOOK_SECRET_STAGING= +WISE_API_KEY_STAGING= +WISE_PROFILE_ID_STAGING= diff --git a/files/stacks/docker-compose.ops.yml b/files/stacks/docker-compose.ops.yml new file mode 100644 index 0000000..918350c --- /dev/null +++ b/files/stacks/docker-compose.ops.yml @@ -0,0 +1,118 @@ +services: + traefik: + image: traefik:v3.6 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + command: + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=ops + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.cloudflare.acme.dnschallenge=true + - --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.cloudflare.acme.email=$${ACME_EMAIL} + - --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json + environment: + - CF_DNS_API_TOKEN=$${CF_DNS_API_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certs:/letsencrypt + networks: + - ops + + registry: + image: registry:2 + restart: unless-stopped + ports: + - "5000:5000" + volumes: + - registry-data:/var/lib/registry + environment: + - REGISTRY_STORAGE_DELETE_ENABLED=true + networks: + - ops + + forgejo: + image: codeberg.org/forgejo/forgejo:11 + restart: unless-stopped + environment: + - USER_UID=1000 + - USER_GID=1000 + - FORGEJO__server__DOMAIN=source.$${DOMAIN} + - FORGEJO__server__ROOT_URL=https://source.$${DOMAIN} + - FORGEJO__server__SSH_DOMAIN=source.$${DOMAIN} + - FORGEJO__server__SSH_PORT=22 + - FORGEJO__server__SSH_LISTEN_PORT=2222 + - FORGEJO__database__DB_TYPE=sqlite3 + - FORGEJO__service__DISABLE_REGISTRATION=true + - FORGEJO__service__ALLOW_ONLY_EXTERNAL_REGISTRATION=true + - FORGEJO__webhook__ALLOWED_HOST_LIST=external,loopback,10.0.0.0/24 + - FORGEJO__security__INSTALL_LOCK=true + - FORGEJO__actions__ENABLED=false + volumes: + - forgejo-data:/data + ports: + - "2222:2222" + labels: + - traefik.enable=true + - traefik.http.routers.forgejo.rule=Host(`source.$${DOMAIN}`) + - traefik.http.routers.forgejo.tls=true + - traefik.http.routers.forgejo.tls.certresolver=cloudflare + - traefik.http.services.forgejo.loadbalancer.server.port=3000 + networks: + - ops + + woodpecker: + image: woodpeckerci/woodpecker-server:v3 + restart: unless-stopped + environment: + - WOODPECKER_HOST=https://ci.$${DOMAIN} + - WOODPECKER_FORGEJO=true + - WOODPECKER_FORGEJO_URL=http://forgejo:3000 + - WOODPECKER_FORGEJO_CLIENT=$${WOODPECKER_FORGEJO_CLIENT} + - WOODPECKER_FORGEJO_SECRET=$${WOODPECKER_FORGEJO_SECRET} + - WOODPECKER_AGENT_SECRET=$${WOODPECKER_AGENT_SECRET} + - WOODPECKER_ADMIN=$${WOODPECKER_ADMIN} + volumes: + - woodpecker-data:/var/lib/woodpecker + depends_on: + - forgejo + labels: + - traefik.enable=true + - traefik.http.routers.woodpecker.rule=Host(`ci.$${DOMAIN}`) + - traefik.http.routers.woodpecker.tls=true + - traefik.http.routers.woodpecker.tls.certresolver=cloudflare + - traefik.http.services.woodpecker.loadbalancer.server.port=8000 + networks: + - ops + + woodpecker-agent: + image: woodpeckerci/woodpecker-agent:v3 + restart: unless-stopped + environment: + - WOODPECKER_SERVER=woodpecker:9000 + - WOODPECKER_AGENT_SECRET=$${WOODPECKER_AGENT_SECRET} + - WOODPECKER_BACKEND_DOCKER_NETWORK=writekit_ops + - WOODPECKER_BACKEND_DOCKER_VOLUMES=/opt/writekit/.ssh:/mnt/ssh:ro + - DOCKER_HOST=unix:///var/run/docker.sock + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - woodpecker + networks: + - ops + +networks: + ops: + +volumes: + traefik-certs: + registry-data: + forgejo-data: + woodpecker-data: diff --git a/files/stacks/docker-compose.prod.yml b/files/stacks/docker-compose.prod.yml new file mode 100644 index 0000000..1808540 --- /dev/null +++ b/files/stacks/docker-compose.prod.yml @@ -0,0 +1,187 @@ +services: + traefik: + image: traefik:v3.6 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + command: + - --api.dashboard=false + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --providers.docker.network=prod + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entrypoint.to=websecure + - --entrypoints.web.http.redirections.entrypoint.scheme=https + - --certificatesresolvers.cloudflare.acme.dnschallenge=true + - --certificatesresolvers.cloudflare.acme.dnschallenge.provider=cloudflare + - --certificatesresolvers.cloudflare.acme.email=$${ACME_EMAIL} + - --certificatesresolvers.cloudflare.acme.storage=/letsencrypt/acme.json + environment: + - CF_DNS_API_TOKEN=$${CF_DNS_API_TOKEN} + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - traefik-certs:/letsencrypt + networks: + - prod + + imaginary: + image: h2non/imaginary:latest + restart: unless-stopped + command: -enable-url-source=false -max-allowed-size=15728640 + networks: + - prod + + postgres: + image: postgres:16-alpine + restart: unless-stopped + volumes: + - postgres-data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=writekit + - POSTGRES_USER=writekit + - POSTGRES_PASSWORD=$${PG_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U writekit"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - prod + + oauth2-proxy: + image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1 + restart: unless-stopped + environment: + - OAUTH2_PROXY_PROVIDER=github + - OAUTH2_PROXY_CLIENT_ID=$${GITHUB_CLIENT_ID_STAGING} + - OAUTH2_PROXY_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET_STAGING} + - OAUTH2_PROXY_COOKIE_SECRET=$${OAUTH2_PROXY_COOKIE_SECRET} + - OAUTH2_PROXY_COOKIE_DOMAINS=.$${DOMAIN} + - OAUTH2_PROXY_EMAIL_DOMAINS=* + - OAUTH2_PROXY_GITHUB_USERS=$${OAUTH2_PROXY_ALLOWED_USERS} + - OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180 + - OAUTH2_PROXY_REVERSE_PROXY=true + - OAUTH2_PROXY_SET_XAUTHREQUEST=true + - OAUTH2_PROXY_PASS_ACCESS_TOKEN=true + labels: + - traefik.enable=true + - traefik.http.routers.oauth2-proxy.rule=Host(`auth.staging.$${DOMAIN}`) + - traefik.http.routers.oauth2-proxy.tls=true + - traefik.http.routers.oauth2-proxy.tls.certresolver=cloudflare + - traefik.http.services.oauth2-proxy.loadbalancer.server.port=4180 + - traefik.http.middlewares.staging-auth.forwardauth.address=http://oauth2-proxy:4180/oauth2/auth + - traefik.http.middlewares.staging-auth.forwardauth.trustForwardHeader=true + - traefik.http.middlewares.staging-auth.forwardauth.authResponseHeaders=X-Auth-Request-User,X-Auth-Request-Email + networks: + - prod + + writekit-prod: + image: $${REGISTRY_URL}/writekit:main + restart: unless-stopped + environment: + - ENV=prod + - DOMAIN=$${DOMAIN} + - BASE_URL=https://$${DOMAIN} + - DATABASE_URL=postgres://writekit:$${PG_PASSWORD}@postgres:5432/writekit?sslmode=disable + - DATA_DIR=/data + - R2_ACCOUNT_ID=$${R2_ACCOUNT_ID} + - R2_ACCESS_KEY_ID=$${R2_ACCESS_KEY_ID} + - R2_SECRET_ACCESS_KEY=$${R2_SECRET_ACCESS_KEY} + - R2_BUCKET=$${R2_BUCKET} + - R2_PUBLIC_URL=$${R2_PUBLIC_URL} + - GITHUB_CLIENT_ID=$${GITHUB_CLIENT_ID} + - GITHUB_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET} + - GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=$${GOOGLE_CLIENT_SECRET} + - DISCORD_CLIENT_ID=$${DISCORD_CLIENT_ID} + - DISCORD_CLIENT_SECRET=$${DISCORD_CLIENT_SECRET} + - SESSION_SECRET=$${SESSION_SECRET} + - LEMON_API_KEY=$${LEMON_API_KEY} + - LEMON_STORE_ID=$${LEMON_STORE_ID} + - LEMON_WEBHOOK_SECRET=$${LEMON_WEBHOOK_SECRET} + - WISE_API_KEY=$${WISE_API_KEY} + - WISE_PROFILE_ID=$${WISE_PROFILE_ID} + - IMAGINARY_URL=http://imaginary:9000 + volumes: + - tenants-prod:/data + labels: + - traefik.enable=true + - traefik.http.routers.writekit-prod-platform.rule=Host(`$${DOMAIN}`) + - traefik.http.routers.writekit-prod-platform.tls=true + - traefik.http.routers.writekit-prod-platform.tls.certresolver=cloudflare + - traefik.http.routers.writekit-prod-platform.service=writekit-prod + - traefik.http.routers.writekit-prod-blogs.rule=HostRegexp(`^(?!staging\.).+\.$${DOMAIN}$$`) + - traefik.http.routers.writekit-prod-blogs.priority=10 + - traefik.http.routers.writekit-prod-blogs.tls=true + - traefik.http.routers.writekit-prod-blogs.tls.certresolver=cloudflare + - traefik.http.routers.writekit-prod-blogs.tls.domains[0].main=$${DOMAIN} + - traefik.http.routers.writekit-prod-blogs.tls.domains[0].sans=*.$${DOMAIN} + - traefik.http.routers.writekit-prod-blogs.service=writekit-prod + - traefik.http.services.writekit-prod.loadbalancer.server.port=8080 + depends_on: + postgres: + condition: service_healthy + networks: + - prod + + writekit-staging: + image: $${REGISTRY_URL}/writekit:dev + restart: unless-stopped + environment: + - ENV=staging + - DOMAIN=staging.$${DOMAIN} + - BASE_URL=https://staging.$${DOMAIN} + - DATABASE_URL=postgres://writekit:$${PG_PASSWORD}@postgres:5432/writekit_staging?sslmode=disable + - DATA_DIR=/data + - R2_ACCOUNT_ID=$${R2_ACCOUNT_ID} + - R2_ACCESS_KEY_ID=$${R2_ACCESS_KEY_ID} + - R2_SECRET_ACCESS_KEY=$${R2_SECRET_ACCESS_KEY} + - R2_BUCKET=$${R2_BUCKET} + - R2_PUBLIC_URL=$${R2_PUBLIC_URL} + - GITHUB_CLIENT_ID=$${GITHUB_CLIENT_ID_STAGING} + - GITHUB_CLIENT_SECRET=$${GITHUB_CLIENT_SECRET_STAGING} + - GOOGLE_CLIENT_ID=$${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=$${GOOGLE_CLIENT_SECRET} + - DISCORD_CLIENT_ID=$${DISCORD_CLIENT_ID} + - DISCORD_CLIENT_SECRET=$${DISCORD_CLIENT_SECRET} + - SESSION_SECRET=$${SESSION_SECRET_STAGING} + - LEMON_API_KEY=$${LEMON_API_KEY_STAGING} + - LEMON_STORE_ID=$${LEMON_STORE_ID} + - LEMON_WEBHOOK_SECRET=$${LEMON_WEBHOOK_SECRET_STAGING} + - WISE_API_KEY=$${WISE_API_KEY_STAGING} + - WISE_PROFILE_ID=$${WISE_PROFILE_ID_STAGING} + - IMAGINARY_URL=http://imaginary:9000 + volumes: + - tenants-staging:/data + labels: + - traefik.enable=true + - traefik.http.routers.writekit-staging-platform.rule=Host(`staging.$${DOMAIN}`) + - traefik.http.routers.writekit-staging-platform.tls=true + - traefik.http.routers.writekit-staging-platform.tls.certresolver=cloudflare + - traefik.http.routers.writekit-staging-platform.middlewares=staging-auth + - traefik.http.routers.writekit-staging-platform.service=writekit-staging + - traefik.http.routers.writekit-staging-blogs.rule=HostRegexp(`^.+\.staging\.$${DOMAIN}$$`) + - traefik.http.routers.writekit-staging-blogs.priority=20 + - traefik.http.routers.writekit-staging-blogs.tls=true + - traefik.http.routers.writekit-staging-blogs.tls.certresolver=cloudflare + - traefik.http.routers.writekit-staging-blogs.tls.domains[0].main=staging.$${DOMAIN} + - traefik.http.routers.writekit-staging-blogs.tls.domains[0].sans=*.staging.$${DOMAIN} + - traefik.http.routers.writekit-staging-blogs.middlewares=staging-auth + - traefik.http.routers.writekit-staging-blogs.service=writekit-staging + - traefik.http.services.writekit-staging.loadbalancer.server.port=8080 + depends_on: + postgres: + condition: service_healthy + networks: + - prod + +networks: + prod: + +volumes: + traefik-certs: + postgres-data: + tenants-prod: + tenants-staging: diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..7e51405 --- /dev/null +++ b/main.tf @@ -0,0 +1,264 @@ +locals { + prod_env_file = file("${path.module}/files/env/prod.env") + prod_compose = file("${path.module}/files/stacks/docker-compose.prod.yml") + ops_env_file = file("${path.module}/files/env/ops.env") + ops_compose = file("${path.module}/files/stacks/docker-compose.ops.yml") +} + +resource "tls_private_key" "deploy" { + algorithm = "ED25519" +} + +resource "hcloud_ssh_key" "default" { + name = "writekit" + public_key = var.ssh_public_key +} + +resource "hcloud_network" "writekit" { + name = "writekit-internal" + ip_range = "10.0.0.0/16" +} + +resource "hcloud_network_subnet" "writekit" { + network_id = hcloud_network.writekit.id + type = "cloud" + network_zone = "eu-central" + ip_range = "10.0.0.0/24" +} + +resource "hcloud_server" "prod" { + name = "writekit-prod" + image = "ubuntu-24.04" + server_type = var.prod_server_type + location = var.location + ssh_keys = [hcloud_ssh_key.default.id] + + user_data = templatefile("${path.module}/files/cloud-init/prod.yml", { + ssh_public_key = var.ssh_public_key + deploy_ssh_public_key = tls_private_key.deploy.public_key_openssh + env_file = local.prod_env_file + docker_compose = local.prod_compose + }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + labels = { + environment = "production" + managed_by = "terraform" + } +} + +resource "hcloud_server_network" "prod" { + server_id = hcloud_server.prod.id + network_id = hcloud_network.writekit.id + ip = "10.0.0.2" +} + +resource "hcloud_firewall" "prod" { + name = "writekit-prod" + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.allowed_ssh_ips + } + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = ["10.0.0.0/24"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + protocol = "tcp" + port = "1-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + protocol = "udp" + port = "1-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + protocol = "icmp" + destination_ips = ["0.0.0.0/0", "::/0"] + } +} + +resource "hcloud_firewall_attachment" "prod" { + firewall_id = hcloud_firewall.prod.id + server_ids = [hcloud_server.prod.id] +} + +resource "hcloud_server" "ops" { + name = "writekit-ops" + image = "ubuntu-24.04" + server_type = var.ops_server_type + location = var.location + ssh_keys = [hcloud_ssh_key.default.id] + + user_data = templatefile("${path.module}/files/cloud-init/ops.yml", { + ssh_public_key = var.ssh_public_key + deploy_ssh_private_key = tls_private_key.deploy.private_key_openssh + env_file = local.ops_env_file + docker_compose = local.ops_compose + }) + + public_net { + ipv4_enabled = true + ipv6_enabled = true + } + + labels = { + environment = "ops" + managed_by = "terraform" + } +} + +resource "hcloud_server_network" "ops" { + server_id = hcloud_server.ops.id + network_id = hcloud_network.writekit.id + ip = "10.0.0.3" +} + +resource "hcloud_firewall" "ops" { + name = "writekit-ops" + + rule { + direction = "in" + protocol = "tcp" + port = "22" + source_ips = var.allowed_ssh_ips + } + + rule { + direction = "in" + protocol = "tcp" + port = "80" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "443" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "2222" + source_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "in" + protocol = "tcp" + port = "5000" + source_ips = ["10.0.0.0/24"] + } + + rule { + direction = "out" + protocol = "tcp" + port = "1-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + protocol = "udp" + port = "1-65535" + destination_ips = ["0.0.0.0/0", "::/0"] + } + + rule { + direction = "out" + protocol = "icmp" + destination_ips = ["0.0.0.0/0", "::/0"] + } +} + +resource "hcloud_firewall_attachment" "ops" { + firewall_id = hcloud_firewall.ops.id + server_ids = [hcloud_server.ops.id] +} + +resource "cloudflare_record" "root" { + zone_id = var.cloudflare_zone_id + name = "@" + content = hcloud_server.prod.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_record" "wildcard" { + zone_id = var.cloudflare_zone_id + name = "*" + content = hcloud_server.prod.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_record" "staging" { + zone_id = var.cloudflare_zone_id + name = "staging" + content = hcloud_server.prod.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_record" "staging_wildcard" { + zone_id = var.cloudflare_zone_id + name = "*.staging" + content = hcloud_server.prod.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_record" "source" { + zone_id = var.cloudflare_zone_id + name = "source" + content = hcloud_server.ops.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_record" "ci" { + zone_id = var.cloudflare_zone_id + name = "ci" + content = hcloud_server.ops.ipv4_address + type = "A" + proxied = true +} + +resource "cloudflare_r2_bucket" "assets" { + account_id = var.cloudflare_account_id + name = "writekit-assets" + location = "WEUR" +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..de8f6bd --- /dev/null +++ b/outputs.tf @@ -0,0 +1,20 @@ +output "prod_ip" { + value = hcloud_server.prod.ipv4_address +} + +output "ops_ip" { + value = hcloud_server.ops.ipv4_address +} + +output "urls" { + value = { + app = "https://${var.domain}" + staging = "https://staging.${var.domain}" + source = "https://source.${var.domain}" + ci = "https://ci.${var.domain}" + } +} + +output "r2_bucket" { + value = cloudflare_r2_bucket.assets.name +} diff --git a/terraform.tfvars.example b/terraform.tfvars.example new file mode 100644 index 0000000..e5511f8 --- /dev/null +++ b/terraform.tfvars.example @@ -0,0 +1,13 @@ +hcloud_token = "" +cloudflare_api_token = "" +cloudflare_zone_id = "" +cloudflare_account_id = "" + +ssh_public_key = "ssh-ed25519 AAAA..." + +location = "nbg1" +prod_server_type = "cx22" +ops_server_type = "cx22" + +allowed_ssh_ips = ["YOUR_IP/32"] +domain = "writekit.dev" diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..6a088ae --- /dev/null +++ b/variables.tf @@ -0,0 +1,45 @@ +variable "hcloud_token" { + type = string + sensitive = true +} + +variable "cloudflare_api_token" { + type = string + sensitive = true +} + +variable "cloudflare_zone_id" { + type = string +} + +variable "cloudflare_account_id" { + type = string +} + +variable "ssh_public_key" { + type = string +} + +variable "location" { + type = string + default = "nbg1" +} + +variable "prod_server_type" { + type = string + default = "cpx21" +} + +variable "ops_server_type" { + type = string + default = "cpx21" +} + +variable "allowed_ssh_ips" { + type = list(string) +} + +variable "domain" { + type = string + default = "writekit.dev" +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..6db3d9c --- /dev/null +++ b/versions.tf @@ -0,0 +1,26 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + hcloud = { + source = "hetznercloud/hcloud" + version = "~> 1.58" + } + cloudflare = { + source = "cloudflare/cloudflare" + version = "~> 4.52" + } + tls = { + source = "hashicorp/tls" + version = "~> 4.0" + } + } +} + +provider "hcloud" { + token = var.hcloud_token +} + +provider "cloudflare" { + api_token = var.cloudflare_api_token +}