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>
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 = trueon 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: