Files
Chris CoutinhoandClaude Opus 4.7 e4c552cd19 fix(infra): address PR review feedback on tf modules
Deployer role:
- Add servicediscovery actions; module always creates Cloud Map namespace
  and service so the policy must grant CreatePrivateDnsNamespace etc.
- Make Route53 + ACM permissions unconditional. The server module always
  issues an ACM cert and writes Route53 records (no CloudFront default-cert
  path exists), so gating these on route53_zone_ids was broken. Split
  Route53 into hosted-zone management (always) plus record-set mutation
  (scoped to caller-supplied zones, falls back to *).
- Remove unused cloudfront:* statement; no CloudFront resources in module.
- Replace acm:* wildcard with explicit cert-management action set.

Server module:
- qdrant_image_tag is now nullable with default null and validated against
  use_external_qdrant, so external-qdrant callers can omit it instead of
  passing a sentinel "unused" value.
- task_role_arn and efs_id outputs marked sensitive; qdrant_dns_name returns
  null when use_external_qdrant = true.
- ALB SG now has matching IPv6 egress rule (was v4-only).
- nextcloud_url validates the https:// scheme.
- random_pet.subdomain keeper includes zone_name so a zone migration that
  preserves zone_id still triggers regeneration.
- Pin required_version >= 1.9 on both modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:24:09 +02:00

8.5 KiB

Terraform modules for nextcloud-mcp-server on AWS

POC Terraform modules to spin up a nextcloud-mcp-server on AWS ECS Fargate, behind an ALB, with EFS-backed task storage. Two Qdrant modes are supported: an in-VPC Qdrant ECS task (default) or an external/managed Qdrant service (URL + API key supplied via Secrets Manager).

This directory ships two modules:

Module Purpose
nextcloud-mcp-deployer-role A least-privilege IAM role + policy scoped to deploying the MCP server module. Apply this first, in the target AWS account, with a privileged bootstrap principal.
nextcloud-mcp-server The actual MCP server: ECS cluster, ALB, EFS, IAM task/execution roles, Route53 record, ACM certificate, optional Qdrant ECS service. Apply this second, by assuming the deployer role.

Two-phase deploy

┌──────────────┐     ┌────────────────────────────┐     ┌─────────────────────┐
│  Bootstrap   │────▶│  Apply deployer-role       │────▶│  Apply mcp-server   │
│  principal   │     │  (creates an IAM role +    │     │  (assume the role,  │
│  (admin)     │     │  scoped policy)            │     │  provision the     │
│              │     │                            │     │  MCP server)        │
└──────────────┘     └────────────────────────────┘     └─────────────────────┘

The split lets a customer create a stable, minimum-permission role in their account once, and then re-apply the MCP server module repeatedly under that role without ever exposing admin credentials to the deploy pipeline.

Phase 1 — bootstrap the deployer role

Prerequisite: bootstrap IAM policy

The principal that applies nextcloud-mcp-deployer-role only needs IAM permissions to manage the role and its attached policy. The policy below is scoped to the module defaults (role_path = /clients/, role_name = nextcloud-mcp-deployer); widen the resource ARNs if you override either input.

Copy this into the AWS console (IAM → Policies → Create policy → JSON) and attach it to the user / role that runs terraform apply for the deployer-role module:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BootstrapManageDeployerRole",
      "Effect": "Allow",
      "Action": [
        "iam:CreateRole",
        "iam:GetRole",
        "iam:DeleteRole",
        "iam:UpdateRole",
        "iam:UpdateAssumeRolePolicy",
        "iam:TagRole",
        "iam:UntagRole",
        "iam:ListRoleTags",
        "iam:AttachRolePolicy",
        "iam:DetachRolePolicy",
        "iam:ListAttachedRolePolicies",
        "iam:ListRolePolicies",
        "iam:PutRolePolicy",
        "iam:GetRolePolicy",
        "iam:DeleteRolePolicy"
      ],
      "Resource": "arn:aws:iam::*:role/clients/nextcloud-mcp-deployer*"
    },
    {
      "Sid": "BootstrapManageDeployerPolicy",
      "Effect": "Allow",
      "Action": [
        "iam:CreatePolicy",
        "iam:GetPolicy",
        "iam:GetPolicyVersion",
        "iam:ListPolicyVersions",
        "iam:CreatePolicyVersion",
        "iam:DeletePolicyVersion",
        "iam:DeletePolicy",
        "iam:TagPolicy",
        "iam:UntagPolicy",
        "iam:ListEntitiesForPolicy"
      ],
      "Resource": "arn:aws:iam::*:policy/clients/nextcloud-mcp-deployer-policy"
    },
    {
      "Sid": "BootstrapStsCallerIdentity",
      "Effect": "Allow",
      "Action": ["sts:GetCallerIdentity"],
      "Resource": "*"
    }
  ]
}

Apply the deployer-role module

terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 6.0" }
  }
}

provider "aws" {
  region = "eu-west-1"
}

module "nextcloud_mcp_deployer_role" {
  source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-deployer-role?ref=master"

  trusted_principal_arns = [
    "arn:aws:iam::123456789012:root", # principal that will assume the role
  ]

  # Optional: set true if the Secrets Manager secret is managed in the same TF run as the MCP server.
  # allow_secret_create = true

  # Optional: required only if you use the module's custom-domain (Route53 + ACM) mode.
  # route53_zone_ids = ["Z0123456789ABCDEFGHIJ"]
}

output "deployer_role_arn" {
  value = module.nextcloud_mcp_deployer_role.role_arn
}

terraform apply produces a role ARN like arn:aws:iam::<account-id>:role/clients/nextcloud-mcp-deployer. Hand that ARN to whichever pipeline / human applies Phase 2.

Phase 2 — deploy the MCP server

Provider configured to assume the deployer role

provider "aws" {
  region = "eu-west-1"
  assume_role {
    role_arn = "arn:aws:iam::123456789012:role/clients/nextcloud-mcp-deployer"
    # external_id = "..." # if you set one in the deployer-role trust policy
  }
}

Pre-existing requirements

  • A VPC with public subnets (for the ALB and ECS task ENIs — the module runs tasks with assign_public_ip = true, no NAT gateway needed) and private subnets in matching AZs (for EFS mount targets).

  • A Route53 public hosted zone the module can write to. The module generates a random <two-words>.<your-zone> subdomain and provisions the ACM cert + DNS records itself.

  • A Secrets Manager secret with a JSON value containing the keys below. Create it manually, or via Terraform (set allow_secret_create = true on the deployer role and manage it in the same plan).

    Required keys:

    {
      "host": "https://your-nextcloud.example.com",
      "client_id": "<oidc-client-id>",
      "client_secret": "<oidc-client-secret>",
      "token_encryption_key": "<random 32-byte url-safe base64>",
      "webhook_secret": "<random shared secret>"
    }
    

    When use_external_qdrant = true, also include:

    {
      "qdrant_url": "https://<your-qdrant-cluster>.cloud.qdrant.io:6333",
      "qdrant_api_key": "<qdrant api key>"
    }
    

Mode A — in-VPC Qdrant (default)

Runs Qdrant as a second Fargate task on the same ECS cluster, exposed to the MCP server via Cloud Map private DNS. Storage on EFS.

module "nextcloud_mcp_server" {
  source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=master"

  vpc_id             = "vpc-0123456789abcdef0"
  public_subnet_ids  = ["subnet-aaa", "subnet-bbb"]
  private_subnet_ids = ["subnet-ccc", "subnet-ddd"]

  zone_id   = "Z0123456789ABCDEFGHIJ"
  zone_name = "example.com"

  nextcloud_url = "https://nextcloud.example.com"

  image_tag        = "0.75.2"   # pin to a release; never :latest
  qdrant_image_tag = "v1.17.1"

  secret_arn = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nextcloud-mcp-aws-env-XXXXXX"
}

output "mcp_url" {
  value = module.nextcloud_mcp_server.url
}

Mode B — external / managed Qdrant

Skip the Qdrant ECS task entirely; point the MCP server at a managed Qdrant cluster (e.g. Qdrant Cloud, or a Qdrant you run elsewhere). qdrant_url and qdrant_api_key come from the same Secrets Manager secret.

module "nextcloud_mcp_server" {
  source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=master"

  vpc_id             = "vpc-0123456789abcdef0"
  public_subnet_ids  = ["subnet-aaa", "subnet-bbb"]
  private_subnet_ids = ["subnet-ccc", "subnet-ddd"]

  zone_id   = "Z0123456789ABCDEFGHIJ"
  zone_name = "example.com"

  nextcloud_url = "https://nextcloud.example.com"

  image_tag = "0.75.2"

  secret_arn = "arn:aws:secretsmanager:eu-west-1:123456789012:secret:nextcloud-mcp-aws-env-XXXXXX"

  use_external_qdrant = true
  qdrant_collection   = "nextcloud-mcp"
  # qdrant_image_tag intentionally omitted — only required when use_external_qdrant = false.
}

Pinning the module version

?ref=master is fine for a POC but pins to a moving target. Once a release tag exists in this repo (e.g. infra-tf-v0.1.0), pin to it:

source = "git::https://github.com/cbcoutinho/nextcloud-mcp-server.git//infra/terraform/nextcloud-mcp-server?ref=infra-tf-v0.1.0"

Module-specific docs

Each module's full input/output reference lives in its own README.md: