feat(infra): distribute terraform modules under infra/terraform

POC: lift the homelab-grown nextcloud-mcp-server and
nextcloud-mcp-deployer-role Terraform modules into this repo so external
operators can consume them via a `git::` source.

Includes a top-level README documenting the two-phase deploy flow
(bootstrap deployer role with a copy-pasteable IAM policy, then assume the
role to deploy the MCP server) and supports both in-VPC Qdrant and
external/managed Qdrant modes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-01 22:21:41 +02:00
co-authored by Claude Opus 4.7
parent 0c2d3e1086
commit ccf4b91bf9
17 changed files with 1892 additions and 0 deletions
@@ -0,0 +1,128 @@
<!-- BEGIN_TF_DOCS -->
## Requirements
| Name | Version |
| ---- | ------- |
| <a name="requirement_aws"></a> [aws](#requirement\_aws) | ~> 6.0 |
| <a name="requirement_random"></a> [random](#requirement\_random) | ~> 3.6 |
## Providers
| Name | Version |
| ---- | ------- |
| <a name="provider_aws"></a> [aws](#provider\_aws) | 6.43.0 |
| <a name="provider_random"></a> [random](#provider\_random) | 3.8.1 |
## Modules
No modules.
## Resources
| Name | Type |
| ---- | ---- |
| [aws_acm_certificate.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate) | resource |
| [aws_acm_certificate_validation.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/acm_certificate_validation) | resource |
| [aws_cloudwatch_log_group.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
| [aws_cloudwatch_log_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource |
| [aws_ecs_cluster.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_cluster) | resource |
| [aws_ecs_service.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource |
| [aws_ecs_service.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_service) | resource |
| [aws_ecs_task_definition.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource |
| [aws_ecs_task_definition.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecs_task_definition) | resource |
| [aws_efs_access_point.data](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource |
| [aws_efs_access_point.oauth](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource |
| [aws_efs_access_point.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_access_point) | resource |
| [aws_efs_file_system.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_file_system) | resource |
| [aws_efs_mount_target.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/efs_mount_target) | resource |
| [aws_iam_role.execution](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
| [aws_iam_role.qdrant_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
| [aws_iam_role.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource |
| [aws_iam_role_policy.execution_secrets](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy.qdrant_task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy.qdrant_task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy.task_bedrock](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy.task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy.task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy) | resource |
| [aws_iam_role_policy_attachment.execution_managed](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource |
| [aws_lb.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb) | resource |
| [aws_lb_listener.http_redirect](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource |
| [aws_lb_listener.https](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_listener) | resource |
| [aws_lb_target_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/lb_target_group) | resource |
| [aws_route53_record.alias](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource |
| [aws_route53_record.cert_validation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_record) | resource |
| [aws_security_group.alb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
| [aws_security_group.efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
| [aws_security_group.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
| [aws_security_group.task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource |
| [aws_service_discovery_private_dns_namespace.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/service_discovery_private_dns_namespace) | resource |
| [aws_service_discovery_service.qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/service_discovery_service) | resource |
| [aws_vpc_security_group_egress_rule.alb_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
| [aws_vpc_security_group_egress_rule.qdrant_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
| [aws_vpc_security_group_egress_rule.qdrant_all_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
| [aws_vpc_security_group_egress_rule.task_all_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
| [aws_vpc_security_group_egress_rule.task_all_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_egress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.alb_http_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.alb_http_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.alb_https_v4](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.alb_https_v6](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.efs_from_qdrant](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.efs_from_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.qdrant_from_task](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [aws_vpc_security_group_ingress_rule.task_from_alb](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource |
| [random_pet.subdomain](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/pet) | resource |
| [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source |
| [aws_iam_policy_document.ecs_tasks_trust](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.execution_secrets](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.qdrant_task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.task_bedrock](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.task_efs](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_iam_policy_document.task_exec](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source |
| [aws_region.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/region) | data source |
## Inputs
| Name | Description | Type | Default | Required |
| ---- | ----------- | ---- | ------- | :------: |
| <a name="input_allowed_mcp_clients"></a> [allowed\_mcp\_clients](#input\_allowed\_mcp\_clients) | MCP OAuth client allowlist published as ALLOWED\_MCP\_CLIENTS. Each entry is `id` or `id|redirect_uri`. Empty list keeps the upstream defaults (claude-desktop, test-mcp-client). | `list(string)` | `[]` | no |
| <a name="input_allowed_mgmt_client"></a> [allowed\_mgmt\_client](#input\_allowed\_mgmt\_client) | Management API client allowlist published as ALLOWED\_MGMT\_CLIENT (comma-separated client IDs). Required from upstream v0.74.0+: when unset/empty the management API is fail-closed and rejects all tokens. Empty string skips publishing the env var. | `string` | `""` | no |
| <a name="input_bedrock_embedding_model"></a> [bedrock\_embedding\_model](#input\_bedrock\_embedding\_model) | Bedrock model ID used for semantic search embeddings | `string` | `"amazon.titan-embed-text-v2:0"` | no |
| <a name="input_container_port"></a> [container\_port](#input\_container\_port) | Port the server listens on inside the container | `number` | `8004` | no |
| <a name="input_cpu"></a> [cpu](#input\_cpu) | Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no |
| <a name="input_image"></a> [image](#input\_image) | Container image (without tag) | `string` | `"ghcr.io/cbcoutinho/nextcloud-mcp-server"` | no |
| <a name="input_image_tag"></a> [image\_tag](#input\_image\_tag) | Container image tag. Pin to a specific release; avoid :latest. | `string` | n/a | yes |
| <a name="input_log_retention_days"></a> [log\_retention\_days](#input\_log\_retention\_days) | CloudWatch log retention in days | `number` | `30` | no |
| <a name="input_memory"></a> [memory](#input\_memory) | Fargate task memory (MiB) | `number` | `1024` | no |
| <a name="input_name"></a> [name](#input\_name) | Logical name prefix for resources | `string` | `"nextcloud-mcp-server"` | no |
| <a name="input_nextcloud_url"></a> [nextcloud\_url](#input\_nextcloud\_url) | Public URL of the Nextcloud instance the MCP server pairs with (e.g., https://cloud.example.com). Used to advertise the OIDC discovery endpoint via /api/v1/status so the astrolabe Nextcloud app can discover Nextcloud's oidc\_provider as the IdP instead of falling back to http://localhost. | `string` | n/a | yes |
| <a name="input_private_subnet_ids"></a> [private\_subnet\_ids](#input\_private\_subnet\_ids) | Private subnet IDs (for EFS mount targets only). | `list(string)` | n/a | yes |
| <a name="input_public_subnet_ids"></a> [public\_subnet\_ids](#input\_public\_subnet\_ids) | Public subnet IDs (for the ALB and the ECS task ENI). Tasks run with assign\_public\_ip=true since this VPC has no NAT gateway; the task SG only allows ingress from the ALB SG. | `list(string)` | n/a | yes |
| <a name="input_qdrant_collection"></a> [qdrant\_collection](#input\_qdrant\_collection) | Qdrant collection name. Set to a stable value (anything other than upstream's default 'nextcloud\_content') so the upstream config doesn't fall through to its hostname-based auto-naming, which churns the collection on every rolling deploy. | `string` | `"nextcloud-mcp"` | no |
| <a name="input_qdrant_cpu"></a> [qdrant\_cpu](#input\_qdrant\_cpu) | Qdrant Fargate task vCPU units (1024 = 1 vCPU) | `number` | `512` | no |
| <a name="input_qdrant_image"></a> [qdrant\_image](#input\_qdrant\_image) | Qdrant container image (without tag) | `string` | `"qdrant/qdrant"` | no |
| <a name="input_qdrant_image_tag"></a> [qdrant\_image\_tag](#input\_qdrant\_image\_tag) | Qdrant container image tag (e.g., v1.15.0). Pin to a specific release; avoid :latest. Unused when use\_external\_qdrant = true. | `string` | n/a | yes |
| <a name="input_qdrant_memory"></a> [qdrant\_memory](#input\_qdrant\_memory) | Qdrant Fargate task memory (MiB) | `number` | `1024` | no |
| <a name="input_secret_arn"></a> [secret\_arn](#input\_secret\_arn) | ARN of the Secrets Manager secret holding JSON {host, client\_id, client\_secret, token\_encryption\_key, webhook\_secret} | `string` | n/a | yes |
| <a name="input_use_external_qdrant"></a> [use\_external\_qdrant](#input\_use\_external\_qdrant) | When true, skip the in-AWS Qdrant ECS task and source QDRANT\_URL/QDRANT\_API\_KEY from the Secrets Manager secret (keys: qdrant\_url, qdrant\_api\_key). When false, run an in-AWS Qdrant Fargate task and point the MCP server at it via Cloud Map DNS. | `bool` | `false` | no |
| <a name="input_vector_sync_processor_workers"></a> [vector\_sync\_processor\_workers](#input\_vector\_sync\_processor\_workers) | Concurrent embedding workers. Keep at 1 unless you've verified Bedrock quota headroom. | `number` | `1` | no |
| <a name="input_vector_sync_scan_interval"></a> [vector\_sync\_scan\_interval](#input\_vector\_sync\_scan\_interval) | Seconds between background vector sync scans | `number` | `60` | no |
| <a name="input_vpc_id"></a> [vpc\_id](#input\_vpc\_id) | VPC ID to deploy into | `string` | n/a | yes |
| <a name="input_zone_id"></a> [zone\_id](#input\_zone\_id) | Route53 hosted zone ID for the random subdomain | `string` | n/a | yes |
| <a name="input_zone_name"></a> [zone\_name](#input\_zone\_name) | Route53 hosted zone name (without trailing dot), e.g. astrolabeonline.com | `string` | n/a | yes |
## Outputs
| Name | Description |
| ---- | ----------- |
| <a name="output_alb_dns_name"></a> [alb\_dns\_name](#output\_alb\_dns\_name) | n/a |
| <a name="output_ecs_cluster_name"></a> [ecs\_cluster\_name](#output\_ecs\_cluster\_name) | n/a |
| <a name="output_ecs_service_name"></a> [ecs\_service\_name](#output\_ecs\_service\_name) | n/a |
| <a name="output_efs_id"></a> [efs\_id](#output\_efs\_id) | n/a |
| <a name="output_fqdn"></a> [fqdn](#output\_fqdn) | Fully-qualified domain name |
| <a name="output_log_group_name"></a> [log\_group\_name](#output\_log\_group\_name) | n/a |
| <a name="output_qdrant_dns_name"></a> [qdrant\_dns\_name](#output\_qdrant\_dns\_name) | Internal DNS name where mcp-server reaches qdrant |
| <a name="output_qdrant_service_name"></a> [qdrant\_service\_name](#output\_qdrant\_service\_name) | Qdrant ECS service name (null when use\_external\_qdrant = true) |
| <a name="output_subdomain"></a> [subdomain](#output\_subdomain) | Generated random subdomain (label only, without the zone) |
| <a name="output_task_role_arn"></a> [task\_role\_arn](#output\_task\_role\_arn) | n/a |
| <a name="output_url"></a> [url](#output\_url) | Public HTTPS URL of the MCP server |
<!-- END_TF_DOCS -->
+105
View File
@@ -0,0 +1,105 @@
resource "aws_security_group" "alb" {
name = "${var.name}-alb"
description = "Public HTTPS ingress for ${var.name}"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "alb_https_v4" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "HTTPS"
}
resource "aws_vpc_security_group_ingress_rule" "alb_https_v6" {
security_group_id = aws_security_group.alb.id
cidr_ipv6 = "::/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
description = "HTTPS (IPv6)"
}
resource "aws_vpc_security_group_ingress_rule" "alb_http_v4" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "HTTP (redirects to HTTPS)"
}
resource "aws_vpc_security_group_ingress_rule" "alb_http_v6" {
security_group_id = aws_security_group.alb.id
cidr_ipv6 = "::/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
description = "HTTP (IPv6, redirects to HTTPS)"
}
resource "aws_vpc_security_group_egress_rule" "alb_all_v4" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_lb" "this" {
name = var.name
load_balancer_type = "application"
internal = false
subnets = var.public_subnet_ids
security_groups = [aws_security_group.alb.id]
drop_invalid_header_fields = true
}
resource "aws_lb_target_group" "this" {
name = var.name
port = var.container_port
protocol = "HTTP"
target_type = "ip"
vpc_id = var.vpc_id
deregistration_delay = 30
health_check {
path = "/health/live"
protocol = "HTTP"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 3
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate_validation.this.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
protocol = "HTTPS"
port = "443"
status_code = "HTTP_301"
}
}
}
@@ -0,0 +1,52 @@
resource "random_pet" "subdomain" {
length = 2
separator = "-"
# Stable across applies; regenerate only if we point at a different zone.
keepers = {
zone_id = var.zone_id
}
}
locals {
fqdn = "${random_pet.subdomain.id}.${var.zone_name}"
}
resource "aws_acm_certificate" "this" {
domain_name = local.fqdn
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = {
Name = "${var.name}-${random_pet.subdomain.id}"
}
}
resource "aws_route53_record" "cert_validation" {
allow_overwrite = true
zone_id = var.zone_id
name = one(aws_acm_certificate.this.domain_validation_options).resource_record_name
type = one(aws_acm_certificate.this.domain_validation_options).resource_record_type
records = [one(aws_acm_certificate.this.domain_validation_options).resource_record_value]
ttl = 60
}
resource "aws_acm_certificate_validation" "this" {
certificate_arn = aws_acm_certificate.this.arn
validation_record_fqdns = [aws_route53_record.cert_validation.fqdn]
}
resource "aws_route53_record" "alias" {
zone_id = var.zone_id
name = local.fqdn
type = "A"
alias {
name = aws_lb.this.dns_name
zone_id = aws_lb.this.zone_id
evaluate_target_health = true
}
}
+205
View File
@@ -0,0 +1,205 @@
resource "aws_ecs_cluster" "this" {
name = var.name
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_cloudwatch_log_group" "this" {
name = "/ecs/${var.name}"
retention_in_days = var.log_retention_days
}
resource "aws_security_group" "task" {
name = "${var.name}-task"
description = "ECS task ENI for ${var.name}"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "task_from_alb" {
security_group_id = aws_security_group.task.id
referenced_security_group_id = aws_security_group.alb.id
from_port = var.container_port
to_port = var.container_port
ip_protocol = "tcp"
description = "Container port from ALB"
}
resource "aws_vpc_security_group_egress_rule" "task_all_v4" {
security_group_id = aws_security_group.task.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_vpc_security_group_egress_rule" "task_all_v6" {
security_group_id = aws_security_group.task.id
cidr_ipv6 = "::/0"
ip_protocol = "-1"
}
locals {
container_name = var.name
# ALLOWED_MCP_CLIENTS is appended only when non-empty; an empty value would
# override the upstream default (claude-desktop, test-mcp-client) and lock
# everyone out.
container_env = concat(
[
{ name = "ENABLE_LOGIN_FLOW", value = "true" },
{ name = "ENABLE_SEMANTIC_SEARCH", value = "true" },
{ name = "ENABLE_BACKGROUND_OPERATIONS", value = "true" },
# Explicit collection name; otherwise upstream auto-derives one from the
# task hostname, which churns the collection on every rolling deploy.
{ name = "QDRANT_COLLECTION", value = var.qdrant_collection },
{ name = "TOKEN_STORAGE_DB", value = "/app/data/tokens.db" },
{ name = "NEXTCLOUD_MCP_SERVER_URL", value = "https://${local.fqdn}" },
{ name = "OIDC_DISCOVERY_URL", value = "${var.nextcloud_url}/.well-known/openid-configuration" },
{ name = "AWS_REGION", value = data.aws_region.current.region },
{ name = "BEDROCK_EMBEDDING_MODEL", value = var.bedrock_embedding_model },
{ name = "VECTOR_SYNC_SCAN_INTERVAL", value = tostring(var.vector_sync_scan_interval) },
{ name = "VECTOR_SYNC_PROCESSOR_WORKERS", value = tostring(var.vector_sync_processor_workers) },
],
var.use_external_qdrant ? [] : [
{ name = "QDRANT_URL", value = "http://qdrant.${aws_service_discovery_private_dns_namespace.this.name}:${local.qdrant_port}" },
],
length(var.allowed_mcp_clients) > 0 ? [
{ name = "ALLOWED_MCP_CLIENTS", value = join(",", var.allowed_mcp_clients) },
] : [],
var.allowed_mgmt_client != "" ? [
{ name = "ALLOWED_MGMT_CLIENT", value = var.allowed_mgmt_client },
] : [],
)
container_secrets = concat(
[
{ name = "NEXTCLOUD_HOST", valueFrom = "${var.secret_arn}:host::" },
{ name = "NEXTCLOUD_OIDC_CLIENT_ID", valueFrom = "${var.secret_arn}:client_id::" },
{ name = "NEXTCLOUD_OIDC_CLIENT_SECRET", valueFrom = "${var.secret_arn}:client_secret::" },
{ name = "TOKEN_ENCRYPTION_KEY", valueFrom = "${var.secret_arn}:token_encryption_key::" },
{ name = "WEBHOOK_SECRET", valueFrom = "${var.secret_arn}:webhook_secret::" },
],
var.use_external_qdrant ? [
{ name = "QDRANT_URL", valueFrom = "${var.secret_arn}:qdrant_url::" },
{ name = "QDRANT_API_KEY", valueFrom = "${var.secret_arn}:qdrant_api_key::" },
] : [],
)
}
resource "aws_ecs_task_definition" "this" {
family = var.name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = tostring(var.cpu)
memory = tostring(var.memory)
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
volume {
name = "data"
efs_volume_configuration {
file_system_id = aws_efs_file_system.this.id
transit_encryption = "ENABLED"
authorization_config {
access_point_id = aws_efs_access_point.data.id
iam = "ENABLED"
}
}
}
volume {
name = "oauth"
efs_volume_configuration {
file_system_id = aws_efs_file_system.this.id
transit_encryption = "ENABLED"
authorization_config {
access_point_id = aws_efs_access_point.oauth.id
iam = "ENABLED"
}
}
}
container_definitions = jsonencode([
{
name = local.container_name
image = "${var.image}:${var.image_tag}"
essential = true
command = [
"--transport", "streamable-http",
"--oauth",
"--port", tostring(var.container_port),
]
portMappings = [
{
containerPort = var.container_port
protocol = "tcp"
},
]
environment = local.container_env
secrets = local.container_secrets
mountPoints = [
{ sourceVolume = "data", containerPath = "/app/data", readOnly = false },
{ sourceVolume = "oauth", containerPath = "/app/.oauth", readOnly = false },
]
healthCheck = {
command = ["CMD-SHELL", "curl -fsS http://localhost:${var.container_port}/health/live || exit 1"]
interval = 30
timeout = 5
retries = 3
startPeriod = 60
}
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.this.name
awslogs-region = data.aws_region.current.region
awslogs-stream-prefix = "ecs"
}
}
},
])
}
resource "aws_ecs_service" "this" {
name = var.name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = 1
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
availability_zone_rebalancing = "ENABLED"
enable_execute_command = true
network_configuration {
subnets = var.public_subnet_ids
security_groups = [aws_security_group.task.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = local.container_name
container_port = var.container_port
}
deployment_circuit_breaker {
enable = true
rollback = true
}
# The listener must exist before the service registers targets.
depends_on = [
aws_lb_listener.https,
aws_efs_mount_target.this,
]
}
+121
View File
@@ -0,0 +1,121 @@
resource "aws_security_group" "efs" {
name = "${var.name}-efs"
description = "NFS ingress from ${var.name} ECS tasks"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "efs_from_task" {
security_group_id = aws_security_group.efs.id
referenced_security_group_id = aws_security_group.task.id
from_port = 2049
to_port = 2049
ip_protocol = "tcp"
description = "NFS from task"
}
# EFS mount targets accept NFS from any SG referenced by the EFS SG. The
# mcp-server task SG covers itself; the qdrant task SG (when present) gets
# its own ingress rule.
resource "aws_vpc_security_group_ingress_rule" "efs_from_qdrant" {
count = var.use_external_qdrant ? 0 : 1
security_group_id = aws_security_group.efs.id
referenced_security_group_id = aws_security_group.qdrant[0].id
from_port = 2049
to_port = 2049
ip_protocol = "tcp"
description = "NFS from qdrant task"
}
resource "aws_efs_file_system" "this" {
encrypted = true
performance_mode = "generalPurpose"
throughput_mode = "bursting"
lifecycle_policy {
transition_to_ia = "AFTER_30_DAYS"
}
tags = {
Name = var.name
}
}
resource "aws_efs_mount_target" "this" {
for_each = toset(var.private_subnet_ids)
file_system_id = aws_efs_file_system.this.id
subnet_id = each.value
security_groups = [aws_security_group.efs.id]
}
# Container runs as root (upstream Dockerfile has no USER directive), so the
# access points stamp uid/gid 0 on created files.
resource "aws_efs_access_point" "data" {
file_system_id = aws_efs_file_system.this.id
posix_user {
uid = 0
gid = 0
}
root_directory {
path = "/data"
creation_info {
owner_uid = 0
owner_gid = 0
permissions = "0755"
}
}
tags = {
Name = "${var.name}-data"
}
}
resource "aws_efs_access_point" "oauth" {
file_system_id = aws_efs_file_system.this.id
posix_user {
uid = 0
gid = 0
}
root_directory {
path = "/oauth"
creation_info {
owner_uid = 0
owner_gid = 0
permissions = "0755"
}
}
tags = {
Name = "${var.name}-oauth"
}
}
# Qdrant container runs as root (debian-slim base, no USER directive), matching
# the other access points. Mounted at /qdrant/storage which is qdrant's default
# storage_path.
resource "aws_efs_access_point" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
file_system_id = aws_efs_file_system.this.id
posix_user {
uid = 0
gid = 0
}
root_directory {
path = "/qdrant"
creation_info {
owner_uid = 0
owner_gid = 0
permissions = "0755"
}
}
tags = {
Name = "${var.name}-qdrant"
}
}
+155
View File
@@ -0,0 +1,155 @@
data "aws_iam_policy_document" "ecs_tasks_trust" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
###
# Execution role — used by the ECS agent to pull the image, read secrets,
# and ship logs. Does not have application-level permissions.
resource "aws_iam_role" "execution" {
name = "${var.name}-execution"
path = "/ecs/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json
}
resource "aws_iam_role_policy_attachment" "execution_managed" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "execution_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [var.secret_arn]
}
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "secrets-read"
role = aws_iam_role.execution.id
policy = data.aws_iam_policy_document.execution_secrets.json
}
###
# Task role — used by the running container. Needs Bedrock for embeddings
# and EFS client access to the two access points.
resource "aws_iam_role" "task" {
name = "${var.name}-task"
path = "/ecs/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json
}
data "aws_iam_policy_document" "task_bedrock" {
statement {
actions = [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream",
]
resources = ["*"]
condition {
test = "StringEquals"
variable = "aws:RequestedRegion"
values = [data.aws_region.current.region]
}
}
}
resource "aws_iam_role_policy" "task_bedrock" {
name = "bedrock-invoke"
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task_bedrock.json
}
data "aws_iam_policy_document" "task_efs" {
statement {
actions = [
"elasticfilesystem:ClientMount",
"elasticfilesystem:ClientWrite",
"elasticfilesystem:ClientRootAccess",
]
resources = [aws_efs_file_system.this.arn]
condition {
test = "StringEquals"
variable = "elasticfilesystem:AccessPointArn"
values = [
aws_efs_access_point.data.arn,
aws_efs_access_point.oauth.arn,
]
}
}
}
resource "aws_iam_role_policy" "task_efs" {
name = "efs-client"
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task_efs.json
}
# ECS Exec support — lets us `aws ecs execute-command` into a running task
# for debugging without SSH.
data "aws_iam_policy_document" "task_exec" {
statement {
actions = [
"ssmmessages:CreateControlChannel",
"ssmmessages:CreateDataChannel",
"ssmmessages:OpenControlChannel",
"ssmmessages:OpenDataChannel",
]
resources = ["*"]
}
}
resource "aws_iam_role_policy" "task_exec" {
name = "ecs-exec"
role = aws_iam_role.task.id
policy = data.aws_iam_policy_document.task_exec.json
}
###
# Qdrant task role — only needs EFS access to its own access point and
# ECS Exec for debugging. Reuses the shared execution role.
resource "aws_iam_role" "qdrant_task" {
count = var.use_external_qdrant ? 0 : 1
name = "${local.qdrant_name}-task"
path = "/ecs/"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_trust.json
}
data "aws_iam_policy_document" "qdrant_task_efs" {
count = var.use_external_qdrant ? 0 : 1
statement {
actions = [
"elasticfilesystem:ClientMount",
"elasticfilesystem:ClientWrite",
"elasticfilesystem:ClientRootAccess",
]
resources = [aws_efs_file_system.this.arn]
condition {
test = "StringEquals"
variable = "elasticfilesystem:AccessPointArn"
values = [aws_efs_access_point.qdrant[0].arn]
}
}
}
resource "aws_iam_role_policy" "qdrant_task_efs" {
count = var.use_external_qdrant ? 0 : 1
name = "efs-client"
role = aws_iam_role.qdrant_task[0].id
policy = data.aws_iam_policy_document.qdrant_task_efs[0].json
}
resource "aws_iam_role_policy" "qdrant_task_exec" {
count = var.use_external_qdrant ? 0 : 1
name = "ecs-exec"
role = aws_iam_role.qdrant_task[0].id
policy = data.aws_iam_policy_document.task_exec.json
}
@@ -0,0 +1,15 @@
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
@@ -0,0 +1,48 @@
output "url" {
description = "Public HTTPS URL of the MCP server"
value = "https://${local.fqdn}"
}
output "subdomain" {
description = "Generated random subdomain (label only, without the zone)"
value = random_pet.subdomain.id
}
output "fqdn" {
description = "Fully-qualified domain name"
value = local.fqdn
}
output "ecs_cluster_name" {
value = aws_ecs_cluster.this.name
}
output "ecs_service_name" {
value = aws_ecs_service.this.name
}
output "efs_id" {
value = aws_efs_file_system.this.id
}
output "log_group_name" {
value = aws_cloudwatch_log_group.this.name
}
output "task_role_arn" {
value = aws_iam_role.task.arn
}
output "alb_dns_name" {
value = aws_lb.this.dns_name
}
output "qdrant_service_name" {
description = "Qdrant ECS service name (null when use_external_qdrant = true)"
value = var.use_external_qdrant ? null : aws_ecs_service.qdrant[0].name
}
output "qdrant_dns_name" {
description = "Internal DNS name where mcp-server reaches qdrant"
value = "qdrant.${aws_service_discovery_private_dns_namespace.this.name}"
}
@@ -0,0 +1,151 @@
locals {
qdrant_name = "${var.name}-qdrant"
qdrant_port = 6333
}
resource "aws_cloudwatch_log_group" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
name = "/ecs/${local.qdrant_name}"
retention_in_days = var.log_retention_days
}
resource "aws_security_group" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
name = local.qdrant_name
description = "Qdrant ECS task ENI for ${var.name}"
vpc_id = var.vpc_id
}
resource "aws_vpc_security_group_ingress_rule" "qdrant_from_task" {
count = var.use_external_qdrant ? 0 : 1
security_group_id = aws_security_group.qdrant[0].id
referenced_security_group_id = aws_security_group.task.id
from_port = local.qdrant_port
to_port = local.qdrant_port
ip_protocol = "tcp"
description = "Qdrant REST from mcp-server"
}
resource "aws_vpc_security_group_egress_rule" "qdrant_all_v4" {
count = var.use_external_qdrant ? 0 : 1
security_group_id = aws_security_group.qdrant[0].id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_vpc_security_group_egress_rule" "qdrant_all_v6" {
count = var.use_external_qdrant ? 0 : 1
security_group_id = aws_security_group.qdrant[0].id
cidr_ipv6 = "::/0"
ip_protocol = "-1"
}
resource "aws_service_discovery_service" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
name = "qdrant"
dns_config {
namespace_id = aws_service_discovery_private_dns_namespace.this.id
routing_policy = "MULTIVALUE"
dns_records {
type = "A"
ttl = 10
}
}
}
resource "aws_ecs_task_definition" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
family = local.qdrant_name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = tostring(var.qdrant_cpu)
memory = tostring(var.qdrant_memory)
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.qdrant_task[0].arn
volume {
name = "storage"
efs_volume_configuration {
file_system_id = aws_efs_file_system.this.id
transit_encryption = "ENABLED"
authorization_config {
access_point_id = aws_efs_access_point.qdrant[0].id
iam = "ENABLED"
}
}
}
container_definitions = jsonencode([
{
name = "qdrant"
image = "${var.qdrant_image}:${var.qdrant_image_tag}"
essential = true
portMappings = [
{
containerPort = local.qdrant_port
protocol = "tcp"
},
]
mountPoints = [
{ sourceVolume = "storage", containerPath = "/qdrant/storage", readOnly = false },
]
# No container-level healthCheck: qdrant's image (debian:13-slim) ships
# only ca-certificates/tzdata/libunwind8 — no bash, no curl/wget — and
# the qdrant binary has no health subcommand. ECS still detects process
# exit; Cloud Map only routes to RUNNING tasks.
logConfiguration = {
logDriver = "awslogs"
options = {
awslogs-group = aws_cloudwatch_log_group.qdrant[0].name
awslogs-region = data.aws_region.current.region
awslogs-stream-prefix = "ecs"
}
}
},
])
}
# Qdrant uses local file storage with a portalocker file lock; concurrent
# instances can't share the EFS path. Stop the old task before starting the
# new one (brief downtime per qdrant deploy — rare). AZ rebalancing requires
# maxPercent>100, which would re-introduce the overlap, so it's disabled.
# The mcp-server service no longer pays this cost.
resource "aws_ecs_service" "qdrant" {
count = var.use_external_qdrant ? 0 : 1
name = local.qdrant_name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.qdrant[0].arn
desired_count = 1
launch_type = "FARGATE"
deployment_minimum_healthy_percent = 0
deployment_maximum_percent = 100
availability_zone_rebalancing = "DISABLED"
enable_execute_command = true
network_configuration {
subnets = var.public_subnet_ids
security_groups = [aws_security_group.qdrant[0].id]
assign_public_ip = true
}
service_registries {
registry_arn = aws_service_discovery_service.qdrant[0].arn
}
deployment_circuit_breaker {
enable = true
rollback = true
}
depends_on = [
aws_efs_mount_target.this,
]
}
@@ -0,0 +1,5 @@
resource "aws_service_discovery_private_dns_namespace" "this" {
name = "${var.name}.local"
description = "Private DNS namespace for ${var.name} internal services"
vpc = var.vpc_id
}
@@ -0,0 +1,140 @@
variable "name" {
description = "Logical name prefix for resources"
type = string
default = "nextcloud-mcp-server"
}
variable "vpc_id" {
description = "VPC ID to deploy into"
type = string
}
variable "public_subnet_ids" {
description = "Public subnet IDs (for the ALB and the ECS task ENI). Tasks run with assign_public_ip=true since this VPC has no NAT gateway; the task SG only allows ingress from the ALB SG."
type = list(string)
}
variable "private_subnet_ids" {
description = "Private subnet IDs (for EFS mount targets only)."
type = list(string)
}
variable "nextcloud_url" {
description = "Public URL of the Nextcloud instance the MCP server pairs with (e.g., https://cloud.example.com). Used to advertise the OIDC discovery endpoint via /api/v1/status so the astrolabe Nextcloud app can discover Nextcloud's oidc_provider as the IdP instead of falling back to http://localhost."
type = string
}
variable "zone_id" {
description = "Route53 hosted zone ID for the random subdomain"
type = string
}
variable "zone_name" {
description = "Route53 hosted zone name (without trailing dot), e.g. astrolabeonline.com"
type = string
}
variable "image" {
description = "Container image (without tag)"
type = string
default = "ghcr.io/cbcoutinho/nextcloud-mcp-server"
}
variable "image_tag" {
description = "Container image tag. Pin to a specific release; avoid :latest."
type = string
}
variable "secret_arn" {
description = "ARN of the Secrets Manager secret holding JSON {host, client_id, client_secret, token_encryption_key, webhook_secret}"
type = string
}
variable "allowed_mcp_clients" {
description = "MCP OAuth client allowlist published as ALLOWED_MCP_CLIENTS. Each entry is `id` or `id|redirect_uri`. Empty list keeps the upstream defaults (claude-desktop, test-mcp-client)."
type = list(string)
default = []
}
variable "allowed_mgmt_client" {
description = "Management API client allowlist published as ALLOWED_MGMT_CLIENT (comma-separated client IDs). Required from upstream v0.74.0+: when unset/empty the management API is fail-closed and rejects all tokens. Empty string skips publishing the env var."
type = string
default = ""
}
variable "bedrock_embedding_model" {
description = "Bedrock model ID used for semantic search embeddings"
type = string
default = "amazon.titan-embed-text-v2:0"
}
variable "container_port" {
description = "Port the server listens on inside the container"
type = number
default = 8004
}
variable "cpu" {
description = "Fargate task vCPU units (1024 = 1 vCPU)"
type = number
default = 512
}
variable "memory" {
description = "Fargate task memory (MiB)"
type = number
default = 1024
}
variable "log_retention_days" {
description = "CloudWatch log retention in days"
type = number
default = 30
}
variable "vector_sync_scan_interval" {
description = "Seconds between background vector sync scans"
type = number
default = 60
}
variable "vector_sync_processor_workers" {
description = "Concurrent embedding workers. Keep at 1 unless you've verified Bedrock quota headroom."
type = number
default = 1
}
variable "qdrant_image" {
description = "Qdrant container image (without tag)"
type = string
default = "qdrant/qdrant"
}
variable "qdrant_collection" {
description = "Qdrant collection name. Set to a stable value (anything other than upstream's default 'nextcloud_content') so the upstream config doesn't fall through to its hostname-based auto-naming, which churns the collection on every rolling deploy."
type = string
default = "nextcloud-mcp"
}
variable "qdrant_image_tag" {
description = "Qdrant container image tag (e.g., v1.15.0). Pin to a specific release; avoid :latest. Unused when use_external_qdrant = true."
type = string
}
variable "use_external_qdrant" {
description = "When true, skip the in-AWS Qdrant ECS task and source QDRANT_URL/QDRANT_API_KEY from the Secrets Manager secret (keys: qdrant_url, qdrant_api_key). When false, run an in-AWS Qdrant Fargate task and point the MCP server at it via Cloud Map DNS."
type = bool
default = false
}
variable "qdrant_cpu" {
description = "Qdrant Fargate task vCPU units (1024 = 1 vCPU)"
type = number
default = 512
}
variable "qdrant_memory" {
description = "Qdrant Fargate task memory (MiB)"
type = number
default = 1024
}