From 30e18aba6932d596cac8d652a0d6ba044ef246fe Mon Sep 17 00:00:00 2001
From: Zack Pollard <zackpollard@ymail.com>
Date: Wed, 29 May 2024 18:11:07 +0100
Subject: [PATCH] feat(ci): website deployment IaC and github actions (#9857)

* feat(ci): Docs build workflow

* chore(ci): Remove docs from test workflow

* feat(ci): Docs deployment workflow

* fix: )

* fix(ci): Docs build artifact upload path

* fix(ci): Small fixes, logging

* fix: Parse parameters

* feat(ci): Download docs artifact

* feat(ci): Comment docs preview url on PR

* fix(ci): Download artifacts through github-script

* chore(ci): Add TODO

* nit: Tweak log message

* feat: website deployment iac and github actions

---------

Co-authored-by: bo0tzz <git@bo0tzz.me>
---
 .github/workflows/docs-build.yml              |  43 +++++
 .github/workflows/docs-deploy.yml             | 158 ++++++++++++++++++
 .github/workflows/test.yml                    |  22 ---
 deployment/.gitignore                         |  38 +++++
 .../docs-release/.terraform.lock.hcl          |  25 +++
 .../modules/cloudflare/docs-release/config.tf |  11 ++
 .../modules/cloudflare/docs-release/domain.tf |   8 +
 .../cloudflare/docs-release/providers.tf      |   3 +
 .../cloudflare/docs-release/remote-state.tf   |  27 +++
 .../cloudflare/docs-release/terragrunt.hcl    |  20 +++
 .../cloudflare/docs-release/variables.tf      |   4 +
 .../cloudflare/docs/.terraform.lock.hcl       |  25 +++
 deployment/modules/cloudflare/docs/config.tf  |  11 ++
 deployment/modules/cloudflare/docs/domain.tf  |  18 ++
 deployment/modules/cloudflare/docs/locals.tf  |   7 +
 .../modules/cloudflare/docs/providers.tf      |   3 +
 .../modules/cloudflare/docs/remote-state.tf   |  17 ++
 .../modules/cloudflare/docs/terragrunt.hcl    |  24 +++
 .../modules/cloudflare/docs/variables.tf      |   5 +
 deployment/state.hcl                          |  20 +++
 20 files changed, 467 insertions(+), 22 deletions(-)
 create mode 100644 .github/workflows/docs-build.yml
 create mode 100644 .github/workflows/docs-deploy.yml
 create mode 100644 deployment/.gitignore
 create mode 100644 deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
 create mode 100644 deployment/modules/cloudflare/docs-release/config.tf
 create mode 100644 deployment/modules/cloudflare/docs-release/domain.tf
 create mode 100644 deployment/modules/cloudflare/docs-release/providers.tf
 create mode 100644 deployment/modules/cloudflare/docs-release/remote-state.tf
 create mode 100644 deployment/modules/cloudflare/docs-release/terragrunt.hcl
 create mode 100644 deployment/modules/cloudflare/docs-release/variables.tf
 create mode 100644 deployment/modules/cloudflare/docs/.terraform.lock.hcl
 create mode 100644 deployment/modules/cloudflare/docs/config.tf
 create mode 100644 deployment/modules/cloudflare/docs/domain.tf
 create mode 100644 deployment/modules/cloudflare/docs/locals.tf
 create mode 100644 deployment/modules/cloudflare/docs/providers.tf
 create mode 100644 deployment/modules/cloudflare/docs/remote-state.tf
 create mode 100644 deployment/modules/cloudflare/docs/terragrunt.hcl
 create mode 100644 deployment/modules/cloudflare/docs/variables.tf
 create mode 100644 deployment/state.hcl

diff --git a/.github/workflows/docs-build.yml b/.github/workflows/docs-build.yml
new file mode 100644
index 0000000000..bd20286e62
--- /dev/null
+++ b/.github/workflows/docs-build.yml
@@ -0,0 +1,43 @@
+name: Docs build
+on:
+  push:
+    branches: [main]
+    paths:
+      - "docs/**"
+  pull_request:
+    branches: [main]
+    paths:
+      - "docs/**"
+  release:
+    types: [published]
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    defaults:
+      run:
+        working-directory: ./docs
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Run npm install
+        run: npm ci
+
+      - name: Check formatting
+        run: npm run format
+
+      - name: Run build
+        run: npm run build
+
+      - name: Upload build output
+        uses: actions/upload-artifact@v4
+        with:
+          name: docs-build-output
+          path: docs/build/
+          retention-days: 1
diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml
new file mode 100644
index 0000000000..f21b4388c2
--- /dev/null
+++ b/.github/workflows/docs-deploy.yml
@@ -0,0 +1,158 @@
+name: Docs deploy
+on:
+  workflow_run:
+    workflows: ["Docs build"]
+    types:
+      - completed
+
+jobs:
+  checks:
+    runs-on: ubuntu-latest
+    outputs:
+      parameters: ${{ steps.parameters.outputs.result }}
+    steps:
+      - if: ${{ github.event.workflow_run.conclusion == 'failure' }}
+        run: echo 'The triggering workflow failed' && exit 1
+
+      - name: Determine deploy parameters
+        id: parameters
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const eventType = context.payload.workflow_run.event;
+            const isFork = context.payload.workflow_run.repository.fork;
+
+            let parameters;
+
+            console.log({eventType, isFork});
+
+            if (eventType == "push") {
+              const branch = context.payload.workflow_run.head_branch;
+              console.log({branch});
+              const shouldDeploy = !isFork && branch == "main";
+              parameters = {
+                event: "branch",
+                name: "main",
+                shouldDeploy
+              };
+            } else if (eventType == "pull_request") {
+              const pull_number = context.payload.workflow_run.pull_requests[0].number;
+              const {data: pr} = await github.rest.pulls.get({
+                owner: context.repo.owner,
+                repo: context.repo.repo,
+                pull_number
+              });
+              const isApproved = pr.labels.some((l) => l.name == "preview-docs");
+
+              console.log({pull_number, isApproved});
+
+              parameters = {
+                event: "pr",
+                name: `pr-${pull_number}`,
+                shouldDeploy: !isFork || isApproved
+              };
+            } else if (eventType == "release") {
+              parameters = {
+                event: "release",
+                name: context.payload.workflow_run.head_branch,
+                shouldDeploy: !isFork
+              };
+            }
+
+            console.log(parameters);
+            return parameters;
+
+  deploy:
+    runs-on: ubuntu-latest
+    needs: checks
+    if: ${{ fromJson(needs.checks.outputs.parameters).shouldDeploy }}
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Load parameters
+        id: parameters
+        uses: actions/github-script@v7
+        with:
+          script: |
+            const json = `${{ needs.checks.outputs.parameters }}`;
+            const parameters = JSON.parse(json);
+            core.setOutput("event", parameters.event);
+            core.setOutput("name", parameters.name);
+            core.setOutput("shouldDeploy", parameters.shouldDeploy);
+
+      - run: |
+          echo "Starting docs deployment for ${{ steps.parameters.outputs.event }} ${{ steps.parameters.outputs.name }}"
+
+      - name: Download artifact
+        uses: actions/github-script@v7
+        with:
+          script: |
+            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               run_id: context.payload.workflow_run.id,
+            });
+            let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
+              return artifact.name == "docs-build-output"
+            })[0];
+            let download = await github.rest.actions.downloadArtifact({
+               owner: context.repo.owner,
+               repo: context.repo.repo,
+               artifact_id: matchArtifact.id,
+               archive_format: 'zip',
+            });
+            let fs = require('fs');
+            fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/docs-build-output.zip`, Buffer.from(download.data));
+
+      - name: Unzip artifact
+        run: unzip "${{ github.workspace }}/docs-build-output.zip" -d "${{ github.workspace }}/docs/build"
+
+      - name: Deploy Docs Subdomain
+        env:
+          TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
+          TF_VAR_prefix_event_type: ${{ steps.parameters.outputs.event }}
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+          TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
+        uses: gruntwork-io/terragrunt-action@v2
+        with:
+          tg_version: "0.58.12"
+          tofu_version: "1.7.1"
+          tg_dir: "deployment/modules/cloudflare/docs"
+          tg_command: "apply"
+
+      - name: Publish to Cloudflare Pages
+        uses: cloudflare/pages-action@v1
+        with:
+          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN_PAGES_UPLOAD }}
+          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+          projectName: "immich-app"
+          workingDirectory: "docs"
+          directory: "build"
+          branch: ${{ steps.parameters.outputs.name }}
+          wranglerVersion: '3'
+
+      - name: Deploy Docs Release Domain
+        if: ${{ steps.parameters.outputs.event == 'release' }}
+        env:
+          TF_VAR_prefix_name: ${{ steps.parameters.outputs.name}}
+          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+          TF_STATE_POSTGRES_CONN_STR: ${{ secrets.TF_STATE_POSTGRES_CONN_STR }}
+        uses: gruntwork-io/terragrunt-action@v2
+        with:
+          tg_version: '0.58.12'
+          tofu_version: '1.7.1'
+          tg_dir: 'deployment/modules/cloudflare/docs-release'
+          tg_command: 'apply'
+
+      - name: Comment
+        uses: actions-cool/maintain-one-comment@v3
+        if: ${{ steps.parameters.outputs.event == 'pr' }}
+        with:
+          number: ${{ github.event.workflow_run.pull_requests[0].number }}
+          body: |
+            📖 Documentation deployed to [${{ steps.parameters.outputs.name }}.preview.immich.app](https://${{ steps.parameters.outputs.name }}.preview.immich.app)
+          emojis: 'rocket'
+          body-include: '<!-- Docs PR URL -->'
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3d556e5e1d..8ff2b65af4 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -10,28 +10,6 @@ concurrency:
   cancel-in-progress: true
 
 jobs:
-  doc-tests:
-    name: Docs
-    runs-on: ubuntu-latest
-    defaults:
-      run:
-        working-directory: ./docs
-
-    steps:
-      - name: Checkout code
-        uses: actions/checkout@v4
-
-      - name: Run npm install
-        run: npm ci
-
-      - name: Run formatter
-        run: npm run format
-        if: ${{ !cancelled() }}
-
-      - name: Run build
-        run: npm run build
-        if: ${{ !cancelled() }}
-
   server-unit-tests:
     name: Server
     runs-on: ubuntu-latest
diff --git a/deployment/.gitignore b/deployment/.gitignore
new file mode 100644
index 0000000000..653d60a3f1
--- /dev/null
+++ b/deployment/.gitignore
@@ -0,0 +1,38 @@
+# OpenTofu
+
+# Local .terraform directories
+**/.terraform/*
+
+# .tfstate files
+*.tfstate
+*.tfstate.*
+
+# Crash log files
+crash.log
+crash.*.log
+
+# Ignore override files as they are usually used to override resources locally and so
+# are not checked in
+override.tf
+override.tf.json
+*_override.tf
+*_override.tf.json
+
+# Include override files you do wish to add to version control using negated pattern
+# !example_override.tf
+
+# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
+# example: *tfplan*
+
+# Ignore CLI configuration files
+.terraformrc
+terraform.rc
+
+# Terragrunt
+
+# terragrunt cache directories
+**/.terragrunt-cache/*
+
+# Terragrunt debug output file (when using `--terragrunt-debug` option)
+# See: https://terragrunt.gruntwork.io/docs/reference/cli-options/#terragrunt-debug
+terragrunt-debug.tfvars.json
diff --git a/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
new file mode 100644
index 0000000000..91dcc1a19d
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/.terraform.lock.hcl
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/cloudflare/cloudflare" {
+  version     = "4.33.0"
+  constraints = "4.33.0"
+  hashes = [
+    "h1:jfvnxO1kjxUquizrBlswsytWQeHXjvNz6IZwPTuixJ4=",
+    "zh:1839d03c8c30d3eac4f18c78c5c095d44348eb06cc9d758136d16517094b64e3",
+    "zh:1fd94992cbb8ce2943cf4fe3deff01b9b1a0e913d2cdd484ceb96a859d46dc5d",
+    "zh:2b4f37c16a7f6d3712b03980b52b149d4ef6a544917bfe1d1c2ca2d40468daa5",
+    "zh:32ecb8017be0a34f72b9b0fcd43d944b99cdc903c79892a68b48719824fb194c",
+    "zh:6468dee137fa07b0b43f742cfdaabb6620883b00773af370e10755ba579eb7f7",
+    "zh:766504de95a418fd763d9474f39fb147053201d6c4efa3efa456fb39a559b28b",
+    "zh:800342078c0c04a36cb2558d0c5c6bf050a8b4c231abecac59e56c9868b9fa7e",
+    "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
+    "zh:94420269672dc5bef37e1a8efab656ada5c2d6f162b52cc59ea74cf0cf35d633",
+    "zh:972481b86636f00771c2b5f3e408ffd9b66d42942645c8b8e11c4f4bf52285f1",
+    "zh:ac52d6698fa8b2a8ab820ffb59381da27684a205f5a78529b56358efab67fe06",
+    "zh:c924c7ac3a8fc08eff7588627be68fc94958c1aaa65928a9fd73cf1d610a0dbf",
+    "zh:cfdfaeab3fcb522a806fc5b71cbd6096df0fafa06cea2131c0db6074b3b76eed",
+    "zh:d554393736b99bd1f0b60e210e276531bcd8df79f435924879eeecc1a2100a0d",
+    "zh:df827b0e00c9e2d666cfe6409f61446908e5983a07ae32c822ef193f6b56c37c",
+  ]
+}
diff --git a/deployment/modules/cloudflare/docs-release/config.tf b/deployment/modules/cloudflare/docs-release/config.tf
new file mode 100644
index 0000000000..7c3eda4f87
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/config.tf
@@ -0,0 +1,11 @@
+terraform {
+  backend "pg" {}
+  required_version = "~> 1.7"
+
+  required_providers {
+    cloudflare = {
+      source = "cloudflare/cloudflare"
+      version = "4.33.0"
+    }
+  }
+}
diff --git a/deployment/modules/cloudflare/docs-release/domain.tf b/deployment/modules/cloudflare/docs-release/domain.tf
new file mode 100644
index 0000000000..1221ea595a
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/domain.tf
@@ -0,0 +1,8 @@
+resource "cloudflare_record" "immich_app_release_domain" {
+  name = "immich.app"
+  proxied = true
+  ttl = 1
+  type = "CNAME"
+  value = data.terraform_remote_state.cloudflare_immich_app_docs.outputs.immich_app_branch_subdomain
+  zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
+}
diff --git a/deployment/modules/cloudflare/docs-release/providers.tf b/deployment/modules/cloudflare/docs-release/providers.tf
new file mode 100644
index 0000000000..65d0883a9d
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/providers.tf
@@ -0,0 +1,3 @@
+provider "cloudflare" {
+  api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
+}
diff --git a/deployment/modules/cloudflare/docs-release/remote-state.tf b/deployment/modules/cloudflare/docs-release/remote-state.tf
new file mode 100644
index 0000000000..3db42dbfff
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/remote-state.tf
@@ -0,0 +1,27 @@
+data "terraform_remote_state" "api_keys_state" {
+  backend = "pg"
+
+  config = {
+    conn_str = var.tf_state_postgres_conn_str
+    schema_name = "prod_cloudflare_api_keys"
+  }
+}
+
+data "terraform_remote_state" "cloudflare_account" {
+  backend = "pg"
+
+  config = {
+    conn_str = var.tf_state_postgres_conn_str
+    schema_name = "prod_cloudflare_account"
+  }
+}
+
+data "terraform_remote_state" "cloudflare_immich_app_docs" {
+  backend = "pg"
+
+  config = {
+    conn_str = var.tf_state_postgres_conn_str
+    schema_name = "prod_cloudflare_immich_app_docs_${var.prefix_name}"
+  }
+}
+
diff --git a/deployment/modules/cloudflare/docs-release/terragrunt.hcl b/deployment/modules/cloudflare/docs-release/terragrunt.hcl
new file mode 100644
index 0000000000..c3a6f6acae
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/terragrunt.hcl
@@ -0,0 +1,20 @@
+terraform {
+  source = "."
+
+  extra_arguments custom_vars {
+    commands = get_terraform_commands_that_need_vars()
+  }
+}
+
+include {
+  path = find_in_parent_folders("state.hcl")
+}
+
+remote_state {
+  backend = "pg"
+
+  config = {
+    conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
+    schema_name = "prod_cloudflare_immich_app_docs_release"
+  }
+}
diff --git a/deployment/modules/cloudflare/docs-release/variables.tf b/deployment/modules/cloudflare/docs-release/variables.tf
new file mode 100644
index 0000000000..6a219bf4ec
--- /dev/null
+++ b/deployment/modules/cloudflare/docs-release/variables.tf
@@ -0,0 +1,4 @@
+variable "cloudflare_account_id" {}
+variable "tf_state_postgres_conn_str" {}
+
+variable "prefix_name" {}
diff --git a/deployment/modules/cloudflare/docs/.terraform.lock.hcl b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
new file mode 100644
index 0000000000..91dcc1a19d
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/.terraform.lock.hcl
@@ -0,0 +1,25 @@
+# This file is maintained automatically by "tofu init".
+# Manual edits may be lost in future updates.
+
+provider "registry.opentofu.org/cloudflare/cloudflare" {
+  version     = "4.33.0"
+  constraints = "4.33.0"
+  hashes = [
+    "h1:jfvnxO1kjxUquizrBlswsytWQeHXjvNz6IZwPTuixJ4=",
+    "zh:1839d03c8c30d3eac4f18c78c5c095d44348eb06cc9d758136d16517094b64e3",
+    "zh:1fd94992cbb8ce2943cf4fe3deff01b9b1a0e913d2cdd484ceb96a859d46dc5d",
+    "zh:2b4f37c16a7f6d3712b03980b52b149d4ef6a544917bfe1d1c2ca2d40468daa5",
+    "zh:32ecb8017be0a34f72b9b0fcd43d944b99cdc903c79892a68b48719824fb194c",
+    "zh:6468dee137fa07b0b43f742cfdaabb6620883b00773af370e10755ba579eb7f7",
+    "zh:766504de95a418fd763d9474f39fb147053201d6c4efa3efa456fb39a559b28b",
+    "zh:800342078c0c04a36cb2558d0c5c6bf050a8b4c231abecac59e56c9868b9fa7e",
+    "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
+    "zh:94420269672dc5bef37e1a8efab656ada5c2d6f162b52cc59ea74cf0cf35d633",
+    "zh:972481b86636f00771c2b5f3e408ffd9b66d42942645c8b8e11c4f4bf52285f1",
+    "zh:ac52d6698fa8b2a8ab820ffb59381da27684a205f5a78529b56358efab67fe06",
+    "zh:c924c7ac3a8fc08eff7588627be68fc94958c1aaa65928a9fd73cf1d610a0dbf",
+    "zh:cfdfaeab3fcb522a806fc5b71cbd6096df0fafa06cea2131c0db6074b3b76eed",
+    "zh:d554393736b99bd1f0b60e210e276531bcd8df79f435924879eeecc1a2100a0d",
+    "zh:df827b0e00c9e2d666cfe6409f61446908e5983a07ae32c822ef193f6b56c37c",
+  ]
+}
diff --git a/deployment/modules/cloudflare/docs/config.tf b/deployment/modules/cloudflare/docs/config.tf
new file mode 100644
index 0000000000..7c3eda4f87
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/config.tf
@@ -0,0 +1,11 @@
+terraform {
+  backend "pg" {}
+  required_version = "~> 1.7"
+
+  required_providers {
+    cloudflare = {
+      source = "cloudflare/cloudflare"
+      version = "4.33.0"
+    }
+  }
+}
diff --git a/deployment/modules/cloudflare/docs/domain.tf b/deployment/modules/cloudflare/docs/domain.tf
new file mode 100644
index 0000000000..5a6d2122b2
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/domain.tf
@@ -0,0 +1,18 @@
+resource "cloudflare_pages_domain" "immich_app_branch_domain" {
+  account_id   = var.cloudflare_account_id
+  project_name = data.terraform_remote_state.cloudflare_account.outputs.immich_app_pages_project_name
+  domain       = "${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
+}
+
+resource "cloudflare_record" "immich_app_branch_subdomain" {
+  name    = "${var.prefix_name}.${local.deploy_domain_prefix}.immich.app"
+  proxied = true
+  ttl     = 1
+  type    = "CNAME"
+  value   = "${replace(var.prefix_name, "/\\/|\\./", "-")}.${data.terraform_remote_state.cloudflare_account.outputs.immich_app_pages_project_subdomain}"
+  zone_id = data.terraform_remote_state.cloudflare_account.outputs.immich_app_zone_id
+}
+
+output "immich_app_branch_subdomain" {
+  value = cloudflare_record.immich_app_branch_subdomain.hostname
+}
diff --git a/deployment/modules/cloudflare/docs/locals.tf b/deployment/modules/cloudflare/docs/locals.tf
new file mode 100644
index 0000000000..d830687791
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/locals.tf
@@ -0,0 +1,7 @@
+locals {
+  domain_name = "immich.app"
+  preview_prefix = contains(["branch", "pr"], var.prefix_event_type) ? "preview" : ""
+  archive_prefix = contains(["release"], var.prefix_event_type) ? "archive" : ""
+  deploy_domain_prefix = coalesce(local.preview_prefix, local.archive_prefix)
+  is_release = contains(["release"], var.prefix_event_type)
+}
diff --git a/deployment/modules/cloudflare/docs/providers.tf b/deployment/modules/cloudflare/docs/providers.tf
new file mode 100644
index 0000000000..65d0883a9d
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/providers.tf
@@ -0,0 +1,3 @@
+provider "cloudflare" {
+  api_token = data.terraform_remote_state.api_keys_state.outputs.terraform_key_cloudflare_docs
+}
diff --git a/deployment/modules/cloudflare/docs/remote-state.tf b/deployment/modules/cloudflare/docs/remote-state.tf
new file mode 100644
index 0000000000..cf9d99f8f5
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/remote-state.tf
@@ -0,0 +1,17 @@
+data "terraform_remote_state" "api_keys_state" {
+  backend = "pg"
+
+  config = {
+    conn_str = var.tf_state_postgres_conn_str
+    schema_name = "prod_cloudflare_api_keys"
+  }
+}
+
+data "terraform_remote_state" "cloudflare_account" {
+  backend = "pg"
+
+  config = {
+    conn_str = var.tf_state_postgres_conn_str
+    schema_name = "prod_cloudflare_account"
+  }
+}
diff --git a/deployment/modules/cloudflare/docs/terragrunt.hcl b/deployment/modules/cloudflare/docs/terragrunt.hcl
new file mode 100644
index 0000000000..95d7b6879b
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/terragrunt.hcl
@@ -0,0 +1,24 @@
+terraform {
+  source = "."
+
+  extra_arguments custom_vars {
+    commands = get_terraform_commands_that_need_vars()
+  }
+}
+
+include {
+  path = find_in_parent_folders("state.hcl")
+}
+
+locals {
+  prefix_name = get_env("TF_VAR_prefix_name")
+}
+
+remote_state {
+  backend = "pg"
+
+  config = {
+    conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
+    schema_name = "prod_cloudflare_immich_app_docs_${local.prefix_name}"
+  }
+}
diff --git a/deployment/modules/cloudflare/docs/variables.tf b/deployment/modules/cloudflare/docs/variables.tf
new file mode 100644
index 0000000000..9cce2ba770
--- /dev/null
+++ b/deployment/modules/cloudflare/docs/variables.tf
@@ -0,0 +1,5 @@
+variable "cloudflare_account_id" {}
+variable "tf_state_postgres_conn_str" {}
+
+variable "prefix_name" {}
+variable "prefix_event_type" {}
diff --git a/deployment/state.hcl b/deployment/state.hcl
new file mode 100644
index 0000000000..5c3fc7cfa9
--- /dev/null
+++ b/deployment/state.hcl
@@ -0,0 +1,20 @@
+locals {
+  cloudflare_account_id = get_env("CLOUDFLARE_ACCOUNT_ID")
+  cloudflare_api_token  = get_env("CLOUDFLARE_API_TOKEN")
+
+  tf_state_postgres_conn_str = get_env("TF_STATE_POSTGRES_CONN_STR")
+}
+
+remote_state {
+  backend = "pg"
+
+  config = {
+    conn_str = local.tf_state_postgres_conn_str
+  }
+}
+
+inputs = {
+  cloudflare_account_id      = local.cloudflare_account_id
+  cloudflare_api_token       = local.cloudflare_api_token
+  tf_state_postgres_conn_str = local.tf_state_postgres_conn_str
+}