# Wiki.js deployment on AWS EKS Auto Mode cluster

## 0. Fixed inputs and constraints

- Goal: design and deploy the infrastructure required to host Wiki.js on AWS using IaC.
- Must satisfy: reliability, security, scalability, observability, automation.
- Deliverables: IaC implementation, architecture diagram, setup + teardown documentation, security considerations.
- Cloud: AWS.
- Kubernetes: Amazon EKS Auto Mode.
- Database: AWS RDS PostgreSQL (external DB configuration for Wiki.js).
- GitOps: ArgoCD.
- CI/CD runner: GitHub Actions.
- IaC: Terraform, modular, use AWS-published modules where available.
- Monorepo, multiple independent Terraform deployments ("layers"), separate state per layer.
- Cross-layer dynamic values exchange: AWS Systems Manager Parameter Store.
- Secrets: no GitHub repository-level secrets/variables available; store secrets in AWS Secrets Manager only.
- Terraform state locking: S3 native lockfile locking (`use_lockfile=true`).
- Mandatory: S3 bucket for Wiki.js assets (provisioned and wired end-to-end).
- Mandatory: for every layer, provision workflow and destroy workflow.

## 1. Target architecture (logical)

- Route 53 Hosted Zone (domain account) -> DNS record for `wiki.<domain>`
- ALB (created via EKS Auto Mode ingress) with TLS (ACM cert exists in deployment account)
- EKS Auto Mode cluster (private subnets) runs:

  - ArgoCD (platform namespace)
  - Wiki.js (app namespace)
  - Secrets retrieval (CSI driver or external-secrets controller)
- RDS PostgreSQL (Multi-AZ, private subnets, SG restricted to cluster)
- S3 assets bucket (SSE-KMS, versioning, BPA, lifecycle) accessed from Wiki.js via IRSA/pod identity
- SSM Parameter Store holds non-secret inter-layer contract values
- Secrets Manager holds all secrets consumed by workloads and automation

## 2. Monorepo layout (layers as separate Terraform deployments)

```bash
repo/
  terraform/
    00-bootstrap/
    01-dns-main/
    10-network/
    20-eks/
    30-data-rds/
    35-storage-s3-assets/
    40-platform/
    45-argocd/
    50-app-wikijs/
  apps/
    wikijs/
      values/
        dev.yaml
        prod.yaml
      argocd/
        application.yaml
  docs/
    architecture/
    runbooks/
    security/
  .github/workflows/
```

Layer rules:

- Each `terraform/<layer>/` is a standalone root module with its own backend key.
- No `terraform_remote_state` across layers.
- Only contract mechanism between layers is Parameter Store reads/writes.
- No secrets in code, logs, or state.

## 3. Terraform backend and state (S3 + lockfile)

Bootstrap provisions the remote state bucket (SSE-KMS, versioning, BPA).

Backend configuration for all non-bootstrap layers:

- S3 backend
- `use_lockfile = true` (state locking is S3 native lockfile only)
- encryption enabled (SSE-KMS)
- Terraform workspaces: workspace name = `<region>-<env>` (e.g. `eu-west-1-dev`); state key = `env:/<workspace>/<layer>/terraform.tfstate`

Operational implications:

- IAM permissions must allow creating/updating/deleting both the state object and the lockfile object for the same prefix.

## 4. Parameter Store contract (single source for inter-layer values)

### Naming convention

- `wikijs/<region>/<env>/<layer>/<key>` (AWS SSM API uses leading slash: `/wikijs/<region>/<env>/<layer>/<key>`)

### Types

- Non-secrets: `String`
- Secrets: never stored as raw values in Parameter Store; store only ARNs/IDs when needed.

### Bootstrap writes first (so all layers can read)

Minimum bootstrap contract keys:

- `wikijs/<region>/<env>/00-bootstrap/tfstate_bucket`
- `wikijs/<region>/<env>/00-bootstrap/tfstate_kms_key_arn`
- `wikijs/<region>/<env>/00-bootstrap/domain_name` (non-secret)
- `wikijs/<region>/<env>/00-bootstrap/wikijs_fqdn` (non-secret)
- `wikijs/<region>/<env>/00-bootstrap/hosted_zone_id` (domain account zone id, non-secret)
- `wikijs/<region>/<env>/00-bootstrap/acm_cert_arn` (deployment account cert ARN, non-secret metadata)

Per-layer publishing rule:

- Each layer publishes only its own outputs under its own prefix.
- Destroy deletes its own prefix after successful `terraform destroy`.

## 5. Secrets model (Secrets Manager only)

### Hard rule

- No GitHub Actions secrets/vars.
- All secrets required by workloads and automation are stored in AWS Secrets Manager.
- Terraform must not generate secrets in a way that causes plaintext values to land in Terraform state.

### Secret inventory (minimum)

1. RDS master credential secret:

    - Implement using RDS "managed master password" so Secrets Manager stores the generated password without Terraform materializing it.
    - Terraform stores only the secret ARN (non-secret metadata).

2. Wiki.js application secrets:

    - Wiki.js secret material (app secret key, admin bootstrap credentials if used, SMTP creds if used, OAuth client secrets if used) stored as separate Secrets Manager secrets.
    - Terraform references only ARNs; Kubernetes consumes values via secret sync mechanism.

3. ArgoCD repo access (only if repo is private):

    - Store deploy key / token material in Secrets Manager.
    - Sync to ArgoCD repo credentials secret in-cluster.
    - Terraform references only ARNs and the Kubernetes secret name, not the secret value.

## 6. GitHub Actions (OIDC role exists, no repo secrets)

### Baseline

- GitHub OIDC already configured; `github-role` exists and is assumed by workflows.
- No repo secrets: the role-to-assume ARN is supplied as a **required string input** in each dispatching workflow (not from repository variables or secrets); env/region come from `workflow_dispatch` inputs or path filters; dynamic values are read from Parameter Store after assuming the role.
- `workflow_dispatch` inputs use **choice** for env and region where possible; region is shown as `region_code: Display Name` (e.g. `eu-west-1: Ireland`), and a **SetRegion** job extracts the region code (text before the colon) and passes it to the reusable workflow.

### Workflow structure (per layer)

- One reusable workflow: `.github/workflows/_terraform-layer.yaml`
- Per-layer wrappers that call it (extension **.yaml**):
  - `tf-<layer>-provision.yaml` (e.g. `tf-20-eks-provision.yaml`)
  - `tf-<layer>-destroy.yaml` (e.g. `tf-20-eks-destroy.yaml`)

### Reusable workflow: responsibilities and enforcement

**File:** `.github/workflows/_terraform-layer.yaml` (single job).

**Steps (provision):**

1. Checkout repository.
2. Configure AWS credentials via OIDC (`role-to-assume` input).
3. Build backend config: for non-bootstrap layers, read `tfstate_bucket` and `tfstate_kms_key_arn` from Parameter Store at `wikijs/<region>/<env>/00-bootstrap/`; for bootstrap, use local or fixed backend. Do not read any lock table.
4. `terraform init` (with `-backend-config=backend.hcl` for non-bootstrap).
5. Select or create Terraform workspace `<region>-<env>` (e.g. `eu-west-1-dev`).
6. `terraform fmt -check`, `terraform validate`.
7. `terraform plan -out=tfplan`, upload plan artifact.
8. `terraform apply -auto-approve tfplan`. Layer outputs to Parameter Store are managed by Terraform `aws_ssm_parameter` resources.

**Steps (destroy):**

1. Same checkout, OIDC, backend config, and init.
2. Gate: run continues only if `confirm_destroy` equals `DESTROY-<layer>-<env>-<region>`.
3. `terraform destroy -auto-approve`. Layer-owned SSM keys are removed if managed by Terraform.

**Enforcement:**

- **Permissions:** `id-token: write`, `contents: read` (required for `aws-actions/configure-aws-credentials` OIDC).
- **Environment:** Job uses GitHub Environment `tf-<env>-<layer>`; use required reviewers on that environment to gate destroy.
- **Concurrency:** Group `terraform-<layer>-<env>-<region>`, `cancel-in-progress: false` to avoid apply/destroy overlap.
- **Destroy trigger:** Destroy workflows are `workflow_dispatch` only and require the typed confirmation input above.

### Minimal YAML skeletons

#### 1) Reusable workflow: `_terraform-layer.yaml`

```yaml
name: terraform-layer

on:
  workflow_call:
    inputs:
      layer:
        type: string
        required: true
      layer_path:
        type: string
        required: true
      env:
        type: string
        required: true
      region:
        type: string
        required: true
      action:
        type: string
        required: true # provision | destroy
      confirm_destroy:
        type: string
        required: false
      deployment_account_role_arn:
        type: string
        required: true

permissions:
  id-token: write
  contents: read

concurrency:
  group: terraform-${{ inputs.layer }}-${{ inputs.env }}-${{ inputs.region }}
  cancel-in-progress: false

jobs:
  run:
    name: ${{ inputs.action }} - ${{ inputs.layer }} - ${{ inputs.env }} - ${{ inputs.region }}
    runs-on: ubuntu-latest
    environment: tf-${{ inputs.env }}-${{ inputs.layer }}

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Configure AWS credentials (OIDC)
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ inputs.deployment_account_role_arn }}
          aws-region: ${{ inputs.region }}

      - name: Build backend config (non-bootstrap layers)
        if: inputs.layer != '00-bootstrap'
        shell: bash
        run: |
          set -euo pipefail
          BUCKET=$(aws ssm get-parameter --name "/wikijs/${{ inputs.region }}/${{ inputs.env }}/00-bootstrap/tfstate_bucket" --query "Parameter.Value" --output text)
          KMS=$(aws ssm get-parameter --name "/wikijs/${{ inputs.region }}/${{ inputs.env }}/00-bootstrap/tfstate_kms_key_arn" --query "Parameter.Value" --output text)
          cat > ${{ inputs.layer_path }}/backend.hcl <<EOF
          bucket         = "${BUCKET}"
          kms_key_id     = "${KMS}"
          key            = "${{ inputs.layer }}/terraform.tfstate"
          region         = "${{ inputs.region }}"
          EOF

      - name: Terraform init
        working-directory: ${{ inputs.layer_path }}
        shell: bash
        run: |
          set -euo pipefail
          if [ "${{ inputs.layer }}" = "00-bootstrap" ]; then
            terraform init
          else
            terraform init -backend-config=backend.hcl
          fi

      - name: Terraform workspace select
        if: inputs.layer != '00-bootstrap'
        working-directory: ${{ inputs.layer_path }}
        shell: bash
        run: |
          set -euo pipefail
          WORKSPACE="${{ inputs.region }}-${{ inputs.env }}"
          terraform workspace select "${WORKSPACE}" 2>/dev/null || terraform workspace new "${WORKSPACE}"

      - name: Terraform fmt
        working-directory: ${{ inputs.layer_path }}
        run: terraform fmt -check -recursive

      - name: Terraform validate
        working-directory: ${{ inputs.layer_path }}
        run: terraform validate

      - name: Terraform plan
        working-directory: ${{ inputs.layer_path }}
        run: terraform plan -out=tfplan

      - name: Upload plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ inputs.layer }}-${{ inputs.env }}-${{ inputs.region }}
          path: ${{ inputs.layer_path }}/tfplan

      - name: Apply (provision)
        if: inputs.action == 'provision'
        working-directory: ${{ inputs.layer_path }}
        run: terraform apply -auto-approve tfplan

      - name: Gate destroy confirmation
        if: inputs.action == 'destroy'
        shell: bash
        run: |
          set -euo pipefail
          [ "${{ inputs.confirm_destroy }}" = "DESTROY-${{ inputs.layer }}-${{ inputs.env }}-${{ inputs.region }}" ]

      - name: Destroy
        if: inputs.action == 'destroy'
        working-directory: ${{ inputs.layer_path }}
        run: terraform destroy -auto-approve
```

#### 2) Layer provision wrapper example: `tf-20-eks-provision.yaml`

`workflow_dispatch` inputs use **choice** where possible. Region is shown as `region_code: Display Name`; a **SetRegion** job extracts the region code (text before the colon) and passes it to the reusable workflow. On **push** triggers, default region and env are used.

```yaml
name: tf-20-eks-provision

on:
  workflow_dispatch:
    inputs:
      env:
        description: 'Select Environment'
        type: choice
        required: true
        default: dev
        options:
          - dev
          - prod
      region:
        description: 'Select AWS Region'
        type: choice
        required: true
        default: 'us-east-1: N. Virginia'
        options:
          - 'eu-west-1: Ireland'
          - 'us-east-1: N. Virginia'
      deployment_account_role_arn:
        description: 'ARN of the role to assume for the deployment account (e.g. arn:aws:iam::ACCOUNT:role/github-role)'
        type: string
        required: true
  push:
    branches: [main]
    paths:
      - terraform/20-eks/**

jobs:
  SetRegion:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      region_code: ${{ steps.set_region.outputs.region_code }}
    steps:
      - name: Set Region
        id: set_region
        run: |
          SELECTED_REGION="${{ inputs.region || 'us-east-1: N. Virginia' }}"
          echo "region_code=${SELECTED_REGION%%:*}" >> $GITHUB_OUTPUT

  call:
    needs: SetRegion
    uses: ./.github/workflows/_terraform-layer.yaml
    with:
      layer: "20-eks"
      layer_path: "terraform/20-eks"
      env: ${{ inputs.env || 'dev' }}
      region: ${{ needs.SetRegion.outputs.region_code }}
      action: "provision"
      deployment_account_role_arn: ${{ inputs.deployment_account_role_arn }}
    secrets: inherit
```

#### 3) Layer destroy wrapper example: `tf-20-eks-destroy.yaml`

Destroy workflows use the same **choice** inputs for env and region (with region display format and **SetRegion** job). The **confirm** input remains a **string**; the user must type the exact confirmation pattern (e.g. `DESTROY-20-eks-dev-eu-west-1`).

```yaml
name: tf-20-eks-destroy

on:
  workflow_dispatch:
    inputs:
      env:
        description: 'Select Environment'
        type: choice
        required: true
        options:
          - dev
          - prod
      region:
        description: 'Select AWS Region'
        type: choice
        required: true
        default: 'us-east-1: N. Virginia'
        options:
          - 'eu-west-1: Ireland'
          - 'us-east-1: N. Virginia'
      confirm:
        description: 'Type DESTROY-<layer>-<env>-<region> to confirm'
        type: string
        required: true
      deployment_account_role_arn:
        description: 'ARN of the role to assume for the deployment account (e.g. arn:aws:iam::ACCOUNT:role/github-role)'
        type: string
        required: true

jobs:
  SetRegion:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      region_code: ${{ steps.set_region.outputs.region_code }}
    steps:
      - name: Set Region
        id: set_region
        run: |
          SELECTED_REGION="${{ inputs.region }}"
          echo "region_code=${SELECTED_REGION%%:*}" >> $GITHUB_OUTPUT

  call:
    needs: SetRegion
    uses: ./.github/workflows/_terraform-layer.yaml
    with:
      layer: "20-eks"
      layer_path: "terraform/20-eks"
      env: ${{ inputs.env }}
      region: ${{ needs.SetRegion.outputs.region_code }}
      action: "destroy"
      confirm_destroy: ${{ inputs.confirm }}
      deployment_account_role_arn: ${{ inputs.deployment_account_role_arn }}
    secrets: inherit
```

Replicate the wrappers for every layer, changing only `name`, `paths`, `layer`, and `layer_path`.

## 7. Layer-by-layer implementation plan (execution order)

### Layer 00 - bootstrap (deployment account)

Purpose: remote state bucket, KMS, Parameter Store baseline contract.

Steps:

1. Create KMS keys:

   - State bucket KMS key
   - Secrets Manager KMS key (for all secrets)
2. Create S3 state bucket:

   - versioning enabled
   - SSE-KMS default encryption
   - public access block
3. Write bootstrap contract to Parameter Store:

   - bucket name, kms arn
   - domain_name, wikijs_fqdn, hosted_zone_id
   - acm_cert_arn
4. IAM (Terraform execution):

   - minimal S3 access to state bucket prefix
   - KMS decrypt/encrypt for state
   - SSM Get/Put under `wikijs/<region>/<env>/00-bootstrap/*`

Outputs:

- Stored in Parameter Store only.

### Layer 01 - dns-main (domain account)

Purpose: create the IAM DNS role (**dns_assume_role_arn**) in the domain account that other layers (e.g. layer 50) can assume to manage Route 53 records. This layer does **not** create or manage Route 53 records.

Steps:

1. Run in the deployment account as **deployment_account_role_arn** (state and Parameter Store in deployment account); use a provider alias to assume **domain_account_role_arn** in the domain account (variable `domain_account_role_arn`) that has permission to create IAM roles in the domain account.
2. Read from Parameter Store: `hosted_zone_id` under the bootstrap prefix (to scope the DNS role permissions).
3. Create an IAM role in the domain account (**dns_assume_role_arn**) with:
   - **Trust relationship**: trust policy SHALL allow **deployment_account_role_arn** (the role used in all layers) to assume it (`sts:AssumeRole`).
   - **Permissions**: Route 53 domain/hosted zone info (`GetHostedZone`, `ListResourceRecordSets`) and CRUD on the hosted zone (`ChangeResourceRecordSets`, `GetChange`), scoped to `arn:aws:route53:::hostedzone/<hosted_zone_id>` and `arn:aws:route53:::change/*`.
4. Publish the DNS role ARN (`dns_role_arn`) to Parameter Store in the deployment account.

Outputs:

- Publish `wikijs/<region>/<env>/01-dns-main/dns_role_arn` so layer 50 (and any other layer that needs DNS) can assume **dns_assume_role_arn** to create/update/delete Route 53 records.

**Workflows:** The 01-dns-main provision and destroy workflows have **two** role inputs: `domain_account_role_arn` and `deployment_account_role_arn`. The reusable workflow is called with `deployment_account_role_arn`; both role ARNs are passed to Terraform via `extra_tf_vars`.

### Layer 10 - network (deployment account)

Purpose: VPC, subnets, routing, endpoints, SGs.

Steps:

1. Provision VPC across >= 2 AZs:

   - public subnets (ALB)
   - private subnets (EKS)
   - db subnets (RDS)
2. Egress and endpoints:

   - NAT for private subnet outbound where needed
   - VPC endpoints (at least S3, ECR, STS, Secrets Manager, SSM, CloudWatch Logs) based on constraints
3. Security groups:

   - ALB SG: inbound 443
   - EKS/workload SG: inbound from ALB SG to app port, egress to RDS
   - RDS SG: inbound 5432 from EKS SG only
4. Publish to Parameter Store:

   - vpc id
   - subnet ids (json)
   - SG ids

### Layer 20 - eks (deployment account)

Purpose: EKS Auto Mode cluster and identity foundations.

Steps:

1. Read from Parameter Store (10-network): `vpc_id`, `private_subnet_ids` (JSON), `workload_sg_id`.
2. Create EKS cluster using Auto Mode configuration (private subnets from 10-network).
3. Enable required Auto Mode capabilities for compute, load balancing, and storage.
4. Enable OIDC identity for IRSA/pod identity.
5. Publish to Parameter Store under `wikijs/<region>/<env>/20-eks/`:

   - cluster name
   - oidc_issuer_url (issuer URL without https://)
   - oidc_provider_arn
   - cluster_security_group_id
   - node_security_group_id (for platform/ingress)
   - cluster_endpoint (for downstream layers)

### Layer 30 - data-rds (deployment account)

Purpose: RDS PostgreSQL for Wiki.js.

Steps:

1. Create RDS subnet group using db subnets.
2. Create RDS PostgreSQL instance/cluster configuration:

   - Multi-AZ
   - storage encryption
   - automated backups
   - deletion protection enabled by default
3. Use managed master password so Secrets Manager stores the password without Terraform holding plaintext.
4. Publish to Parameter Store:

   - endpoint, port, db name
   - secret ARN for managed master password

**Implementation:** Terraform root `terraform/30-data-rds`; workflows `tf-30-data-rds-provision.yaml` and `tf-30-data-rds-destroy.yaml`. Publishes `endpoint`, `port`, `db_name`, `secret_arn` under `wikijs/<region>/<env>/30-data-rds/`.

### Layer 35 - storage-s3-assets (deployment account, mandatory)

Purpose: S3 assets bucket and workload access.

Steps:

1. Create KMS key for S3 assets bucket encryption.
2. Create S3 bucket:

   - SSE-KMS default encryption
   - versioning enabled
   - public access block
   - lifecycle rules parameterized (noncurrent expiration, abort multipart, transitions if required)
3. Create bucket policy controls:

   - deny non-TLS
   - deny unencrypted puts (non-KMS)
4. Create IRSA/pod identity role for Wiki.js with least privilege:

   - `s3:ListBucket` on bucket
   - object CRUD on `bucket/prefix/*`
   - KMS encrypt/decrypt/data key on the bucket KMS key
5. Publish to Parameter Store:

   - bucket name, bucket arn, kms key arn
   - wikijs IRSA role arn
   - bucket prefix (optional; for layer 50 Helm values)

**Implementation:** Terraform root `terraform/35-storage-s3-assets`; workflows `tf-35-storage-s3-assets-provision.yaml` and `tf-35-storage-s3-assets-destroy.yaml`. Publishes `bucket_name`, `bucket_arn`, `kms_key_arn`, `wikijs_irsa_role_arn`, `bucket_prefix` under `wikijs/<region>/<env>/35-storage-s3-assets/`.

### Layer 40 - platform (deployment account)

Purpose: cluster add-ons and shared controllers.

Steps:

1. Install secrets sync mechanism:

   - AWS Secrets Store CSI driver and provider, or External Secrets with Secrets Manager backend.
2. Install/enable storage driver support (EBS CSI) consistent with Auto Mode expectations.
3. Install baseline observability (logs/metrics/alerts) to meet assessment requirements.
4. Namespace standards:

   - `argocd` namespace
   - `wikijs` namespace
5. Publish to Parameter Store:

   - namespace names
   - any add-on identifiers required by downstream layers

**Implementation:** Terraform root `terraform/40-platform`; workflows `tf-40-platform-provision.yaml` and `tf-40-platform-destroy.yaml`. Reads `cluster_name` from Parameter Store (20-eks). Publishes `argocd_namespace`, `wikijs_namespace`, `secrets_store_csi_addon_version`, `storage_class_name` under `wikijs/<region>/<env>/40-platform/`. Uses EKS addons (aws-ebs-csi-driver, aws-secrets-store-csi-driver), Helm (Secrets Manager provider, aws-for-fluent-bit), and creates namespaces and an EBS StorageClass.

### Layer 45 - argocd (deployment account)

Purpose: ArgoCD installation and GitOps bootstrap.

Steps:

1. Install ArgoCD (Helm) into `argocd` namespace.
2. Configure ArgoCD access:

   - internal-only by default; external exposure is a separate explicit change (set `argocd_server_fqdn` for subdomain exposure with Ingress, ALB, TLS, optional Route 53 A record).
3. Admin credentials: by default Terraform auto-generates username and password, stores them in a new Secrets Manager secret, and syncs into the cluster via SecretProviderClass and a one-off Job; retrieve credentials from that secret (Parameter Store key `argocd_admin_credentials_secret_arn` gives the secret ARN). Optionally use an existing secret by setting `create_auto_argocd_admin_credentials = false` and providing `argocd_admin_credentials_secret_arn`.
4. Configure repo credentials:

   - If private repo, sync repo credential secret from Secrets Manager into `argocd` namespace.
5. Publish to Parameter Store:

   - ArgoCD namespace, ArgoCD server URL (if exposed), and when admin credentials are from Secrets Manager, `argocd_admin_credentials_secret_arn`.

**Implementation:** Terraform root `terraform/45-argocd`; workflows `tf-45-argocd-provision.yaml` and `tf-45-argocd-destroy.yaml`. Reads `cluster_name` from Parameter Store (20-eks) and `argocd_namespace` from Parameter Store (40-platform). Installs Argo CD via the official Helm chart (argo-cd, <https://argoproj.github.io/argo-helm>). Optional external UI: set `argocd_server_fqdn` (e.g. `argocd.wikijs.talorlik.com`) to enable Ingress with ALB, TLS (ACM from bootstrap), and optional Route 53 A record (reads `hosted_zone_id`, `acm_cert_arn` from 00-bootstrap and `dns_role_arn` from 01-dns-main; may require a second apply after ALB exists). By default auto-generates admin credentials in Secrets Manager and publishes `argocd_admin_credentials_secret_arn`. Publishes `argocd_namespace`, `argocd_server_url` (and when applicable `argocd_admin_credentials_secret_arn`) under `wikijs/<region>/<env>/45-argocd/`.

### Layer 50 - app-wikijs (deployment account)

Purpose: deploy Wiki.js via ArgoCD and wire DB + S3 + ingress + DNS (including the Route 53 record for the Wiki.js FQDN).

Steps:

1. Create ArgoCD Application pointing to `apps/wikijs` path and the Helm chart.
2. Configure Wiki.js values:

   - external PostgreSQL connection via existing Kubernetes secret (synced from Secrets Manager managed password secret)
   - mandatory S3 storage backend configuration (bucket/prefix/region) and bind service account to IRSA role
   - persistence configuration (PVC) as required for runtime data
3. Ingress:

   - dedicated hostname `wikijs_fqdn`
   - TLS via existing ACM cert ARN
   - ALB annotations consistent with EKS Auto Mode
4. Route 53 record (domain account):

   - Read from Parameter Store: bootstrap (`hosted_zone_id`, `wikijs_fqdn`) and 01-dns-main (`dns_role_arn`, i.e. **dns_assume_role_arn**; Terraform retrieves it from Parameter Store during plan/apply/destroy; no CI pass-through).
   - Assume **dns_assume_role_arn** (from 01-dns-main) via a provider alias and create the Route 53 A (alias) record for `wikijs_fqdn` pointing to the ALB (ALB DNS name and hosted zone ID are available in this layer).
5. Publish to Parameter Store:

   - ALB DNS name, ALB hosted zone id, application URL

**Implementation:** Terraform root `terraform/50-app-wikijs`; workflows `tf-50-app-wikijs-provision.yaml` and `tf-50-app-wikijs-destroy.yaml`. Reads from Parameter Store: 00-bootstrap (wikijs_fqdn, hosted_zone_id, acm_cert_arn), 01-dns-main (dns_role_arn; retrieved by Terraform during plan/apply/destroy; no CI pass-through), 20-eks (cluster_name), 30-data-rds (endpoint, port, db_name, secret_arn), 35-storage-s3-assets (bucket_name, bucket_prefix, wikijs_irsa_role_arn), 40-platform (argocd_namespace, wikijs_namespace, storage_class_name). Creates ArgoCD Application (kubernetes_manifest) pointing to `apps/wikijs` with env-specific valueFiles (`values/<env>.yaml`); `apps/wikijs` is a Helm wrapper chart depending on Requarks Wiki.js (<https://charts.js.wiki>). SecretProviderClass syncs RDS secret and Wiki.js app secret from Secrets Manager into the wikijs namespace. ALB lookup via external data source (deterministic load-balancer-name in Helm values); Route 53 A record and SSM outputs created when ALB exists (two-apply note: run provision again after ArgoCD has synced Wiki.js). Publishes `alb_dns_name`, `alb_hosted_zone_id`, `application_url` under `<prefix>/<region>/<env>/50-app-wikijs/`. Prefix, env, and region are set in `terraform.tfvars` per layer (no hardcoded prefix in code).

## 8. Teardown order (destroy workflows)

1. 50-app-wikijs
2. 45-argocd
3. 40-platform
4. 35-storage-s3-assets
5. 30-data-rds
6. 20-eks
7. 10-network
8. 01-dns-main
9. 00-bootstrap (last)

Data safeguards:

- RDS deletion protection requires an explicit, separate change (two-step teardown) before destroy is permitted.

## 9. Documentation deliverables (mapped to assessment)

1. `docs/architecture/diagram.(png|svg)` and `architecture.md` - components and flows.
2. `docs/runbooks/setup.md` - exact layer apply sequence, prerequisites, validation checks.
3. `docs/runbooks/teardown.md` - exact destroy order, data retention steps.
4. `docs/security/security-considerations.md` - IAM least privilege, network boundaries, secrets model, access control.

## 10. Verification checklist (per environment)

- Parameter Store bootstrap contract exists under `wikijs/<region>/<env>/00-bootstrap/*`.
- Terraform backend initializes with S3 state + `use_lockfile=true` for every non-bootstrap layer.
- EKS Auto Mode cluster reachable; OIDC/IRSA functional.
- RDS reachable only from cluster SG; managed password secret ARN exists.
- S3 assets bucket meets: SSE-KMS, versioning, BPA, lifecycle; Wiki.js service account can access via IRSA.
- Parameter Store keys under `wikijs/<region>/<env>/40-platform/` exist (argocd_namespace, wikijs_namespace, secrets_store_csi_addon_version, storage_class_name).
- Parameter Store keys under `wikijs/<region>/<env>/45-argocd/` exist (argocd_namespace, argocd_server_url).
- Parameter Store keys under `wikijs/<region>/<env>/50-app-wikijs/` exist when ALB exists (alb_dns_name, alb_hosted_zone_id, application_url); may require second apply after ArgoCD sync.
- ArgoCD syncs the Wiki.js application to a healthy state.
- Wiki.js reachable on dedicated hostname over HTTPS with the existing ACM cert.
- Monitoring/logging/alerting present to satisfy observability requirement.
