Infrastructure as Code for ChatGPT Apps (Terraform, Pulumi)

Managing infrastructure manually for ChatGPT applications becomes unsustainable at scale. When you're deploying MCP servers, API gateways, databases, and CDN configurations across multiple environments, infrastructure as code (IaC) transforms chaos into reproducible, version-controlled automation.

This guide provides production-ready Terraform and Pulumi implementations specifically designed for ChatGPT applications. You'll learn how to provision cloud resources, manage state securely, integrate with CI/CD pipelines, and deploy infrastructure changes with confidence.

Why Infrastructure as Code for ChatGPT Apps?

Traditional manual infrastructure management fails when you need to:

  • Deploy identical environments across development, staging, and production
  • Scale horizontally by provisioning multiple regional deployments
  • Maintain compliance with auditable infrastructure changes
  • Recover from disasters by rebuilding infrastructure from code
  • Collaborate effectively with team members using version control

ChatGPT applications have unique infrastructure requirements: MCP server deployments, OAuth provider configurations, widget CDN distribution, and database schemas for user state. IaC ensures these components are consistently provisioned and properly connected.

Terraform vs Pulumi: Making the Choice

Terraform uses HashiCorp Configuration Language (HCL), offering:

  • Mature ecosystem with 3,000+ providers
  • Declarative syntax optimized for infrastructure
  • Strong state management and locking
  • Extensive community resources

Pulumi uses general-purpose languages (TypeScript, Python, Go), providing:

  • Familiar programming constructs (loops, functions, classes)
  • Type safety and IDE autocomplete
  • Easier testing with standard unit testing frameworks
  • Native component abstractions

Both tools excel at managing ChatGPT app infrastructure. Choose Terraform for teams preferring declarative configuration or Pulumi for developers wanting programming language flexibility.

IaC Fundamentals for ChatGPT Applications

Declarative vs Imperative Approaches

Declarative (Terraform): You define the desired end state. The tool determines how to achieve it.

# Terraform - declare desired state
resource "aws_lambda_function" "mcp_server" {
  function_name = "chatgpt-mcp-server"
  runtime       = "nodejs20.x"
  memory_size   = 512
}

Imperative (Pulumi with programmatic control): You write code that executes to create resources.

// Pulumi - programmatic resource creation
const mcpServer = new aws.lambda.Function("chatgpt-mcp-server", {
  runtime: "nodejs20.x",
  memorySize: 512,
  handler: "index.handler"
});

Both approaches produce identical infrastructure, but differ in how changes are expressed.

State Management Architecture

IaC tools maintain a state file mapping your code to actual cloud resources. For ChatGPT apps, state includes:

  • MCP server Lambda/Cloud Function ARNs
  • API Gateway endpoints and route configurations
  • Database connection strings and schemas
  • OAuth client IDs and redirect URIs
  • CDN distribution IDs for widget hosting

Critical: Never store state files in version control. Always use remote backends (S3, GCS, Terraform Cloud) with encryption and state locking to prevent concurrent modifications.

Modules and Reusability

Create reusable modules for common ChatGPT app patterns:

  • MCP Server Module: Lambda/Cloud Function + API Gateway + IAM roles
  • OAuth Module: Provider configuration + redirect handling + token storage
  • Database Module: Firestore/DynamoDB setup + indexes + security rules
  • CDN Module: CloudFront/Cloud CDN + widget distribution + SSL certificates

Modules eliminate duplication and enforce organizational standards across multiple ChatGPT applications.

Terraform Implementation for ChatGPT Apps

AWS Infrastructure with Terraform

This complete Terraform configuration provisions a production-ready ChatGPT app on AWS:

# terraform/aws/main.tf
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "chatgpt-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Project     = "ChatGPT-App"
      Environment = var.environment
      ManagedBy   = "Terraform"
    }
  }
}

# MCP Server Lambda Function
resource "aws_lambda_function" "mcp_server" {
  function_name = "${var.app_name}-mcp-server-${var.environment}"
  runtime       = "nodejs20.x"
  handler       = "index.handler"
  memory_size   = 512
  timeout       = 30

  filename         = var.lambda_zip_path
  source_code_hash = filebase64sha256(var.lambda_zip_path)

  role = aws_iam_role.lambda_execution.arn

  environment {
    variables = {
      NODE_ENV           = var.environment
      DYNAMODB_TABLE     = aws_dynamodb_table.app_state.name
      OAUTH_CLIENT_ID    = var.oauth_client_id
      OAUTH_CLIENT_SECRET = var.oauth_client_secret
      API_BASE_URL       = "https://${aws_api_gateway_domain_name.api.domain_name}"
    }
  }

  tracing_config {
    mode = "Active"
  }

  vpc_config {
    subnet_ids         = var.private_subnet_ids
    security_group_ids = [aws_security_group.lambda.id]
  }
}

# Lambda IAM Role
resource "aws_iam_role" "lambda_execution" {
  name = "${var.app_name}-lambda-execution-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_vpc" {
  role       = aws_iam_role.lambda_execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole"
}

resource "aws_iam_role_policy" "lambda_dynamodb" {
  name = "dynamodb-access"
  role = aws_iam_role.lambda_execution.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect = "Allow"
      Action = [
        "dynamodb:GetItem",
        "dynamodb:PutItem",
        "dynamodb:UpdateItem",
        "dynamodb:Query",
        "dynamodb:Scan"
      ]
      Resource = [
        aws_dynamodb_table.app_state.arn,
        "${aws_dynamodb_table.app_state.arn}/index/*"
      ]
    }]
  })
}

# API Gateway REST API
resource "aws_api_gateway_rest_api" "chatgpt_api" {
  name        = "${var.app_name}-api-${var.environment}"
  description = "ChatGPT App MCP Server API"

  endpoint_configuration {
    types = ["REGIONAL"]
  }
}

resource "aws_api_gateway_resource" "mcp" {
  rest_api_id = aws_api_gateway_rest_api.chatgpt_api.id
  parent_id   = aws_api_gateway_rest_api.chatgpt_api.root_resource_id
  path_part   = "mcp"
}

resource "aws_api_gateway_method" "mcp_post" {
  rest_api_id   = aws_api_gateway_rest_api.chatgpt_api.id
  resource_id   = aws_api_gateway_resource.mcp.id
  http_method   = "POST"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "mcp_lambda" {
  rest_api_id             = aws_api_gateway_rest_api.chatgpt_api.id
  resource_id             = aws_api_gateway_resource.mcp.id
  http_method             = aws_api_gateway_method.mcp_post.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.mcp_server.invoke_arn
}

resource "aws_lambda_permission" "api_gateway" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.mcp_server.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_api_gateway_rest_api.chatgpt_api.execution_arn}/*/*"
}

# DynamoDB Table for App State
resource "aws_dynamodb_table" "app_state" {
  name           = "${var.app_name}-state-${var.environment}"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "userId"
  range_key      = "sessionId"

  attribute {
    name = "userId"
    type = "S"
  }

  attribute {
    name = "sessionId"
    type = "S"
  }

  attribute {
    name = "createdAt"
    type = "N"
  }

  global_secondary_index {
    name            = "createdAt-index"
    hash_key        = "userId"
    range_key       = "createdAt"
    projection_type = "ALL"
  }

  point_in_time_recovery {
    enabled = true
  }

  server_side_encryption {
    enabled = true
  }

  tags = {
    Name = "${var.app_name}-state"
  }
}

# CloudFront Distribution for Widgets
resource "aws_cloudfront_distribution" "widgets" {
  enabled             = true
  is_ipv6_enabled     = true
  default_root_object = "index.html"
  price_class         = "PriceClass_100"

  origin {
    domain_name = aws_s3_bucket.widgets.bucket_regional_domain_name
    origin_id   = "S3-${aws_s3_bucket.widgets.id}"

    s3_origin_config {
      origin_access_identity = aws_cloudfront_origin_access_identity.widgets.cloudfront_access_identity_path
    }
  }

  default_cache_behavior {
    allowed_methods        = ["GET", "HEAD", "OPTIONS"]
    cached_methods         = ["GET", "HEAD"]
    target_origin_id       = "S3-${aws_s3_bucket.widgets.id}"
    viewer_protocol_policy = "redirect-to-https"
    compress               = true

    forwarded_values {
      query_string = false
      cookies {
        forward = "none"
      }
    }

    min_ttl     = 0
    default_ttl = 86400
    max_ttl     = 31536000
  }

  restrictions {
    geo_restriction {
      restriction_type = "none"
    }
  }

  viewer_certificate {
    cloudfront_default_certificate = true
  }
}

# Outputs
output "mcp_server_url" {
  value       = "https://${aws_api_gateway_domain_name.api.domain_name}/mcp"
  description = "MCP Server API endpoint"
}

output "widget_cdn_url" {
  value       = "https://${aws_cloudfront_distribution.widgets.domain_name}"
  description = "Widget CDN distribution URL"
}

output "dynamodb_table_name" {
  value       = aws_dynamodb_table.app_state.name
  description = "DynamoDB table for app state"
}

GCP Infrastructure with Terraform

Deploy the same ChatGPT app architecture on Google Cloud Platform:

# terraform/gcp/main.tf
terraform {
  required_version = ">= 1.6.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }

  backend "gcs" {
    bucket = "chatgpt-terraform-state"
    prefix = "production/terraform.tfstate"
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

# Cloud Function for MCP Server
resource "google_cloudfunctions2_function" "mcp_server" {
  name        = "${var.app_name}-mcp-server-${var.environment}"
  location    = var.region
  description = "ChatGPT MCP Server"

  build_config {
    runtime     = "nodejs20"
    entry_point = "handler"

    source {
      storage_source {
        bucket = google_storage_bucket.function_source.name
        object = google_storage_bucket_object.function_code.name
      }
    }
  }

  service_config {
    max_instance_count    = 100
    available_memory      = "512Mi"
    timeout_seconds       = 60
    service_account_email = google_service_account.mcp_server.email

    environment_variables = {
      NODE_ENV         = var.environment
      FIRESTORE_PROJECT = var.project_id
      OAUTH_CLIENT_ID  = var.oauth_client_id
    }

    secret_environment_variables {
      key        = "OAUTH_CLIENT_SECRET"
      project_id = var.project_id
      secret     = google_secret_manager_secret.oauth_secret.secret_id
      version    = "latest"
    }
  }
}

# Service Account for Cloud Function
resource "google_service_account" "mcp_server" {
  account_id   = "${var.app_name}-mcp-server"
  display_name = "MCP Server Service Account"
}

resource "google_project_iam_member" "mcp_firestore" {
  project = var.project_id
  role    = "roles/datastore.user"
  member  = "serviceAccount:${google_service_account.mcp_server.email}"
}

# API Gateway
resource "google_api_gateway_api" "chatgpt_api" {
  provider = google-beta
  api_id   = "${var.app_name}-api"
}

resource "google_api_gateway_api_config" "chatgpt_api" {
  provider      = google-beta
  api           = google_api_gateway_api.chatgpt_api.api_id
  api_config_id = "${var.app_name}-config-${var.environment}"

  openapi_documents {
    document {
      path = "openapi.yaml"
      contents = base64encode(templatefile("${path.module}/openapi.yaml", {
        function_url = google_cloudfunctions2_function.mcp_server.service_config[0].uri
      }))
    }
  }
}

resource "google_api_gateway_gateway" "chatgpt_gateway" {
  provider   = google-beta
  gateway_id = "${var.app_name}-gateway"
  api_config = google_api_gateway_api_config.chatgpt_api.id
  region     = var.region
}

# Firestore Database (assuming already created)
resource "google_firestore_index" "user_sessions" {
  collection = "sessions"

  fields {
    field_path = "userId"
    order      = "ASCENDING"
  }

  fields {
    field_path = "createdAt"
    order      = "DESCENDING"
  }
}

# Cloud CDN for Widgets
resource "google_compute_backend_bucket" "widgets" {
  name        = "${var.app_name}-widgets-backend"
  bucket_name = google_storage_bucket.widgets.name
  enable_cdn  = true

  cdn_policy {
    cache_mode        = "CACHE_ALL_STATIC"
    client_ttl        = 3600
    default_ttl       = 86400
    max_ttl           = 31536000
    negative_caching  = true
    serve_while_stale = 86400
  }
}

resource "google_storage_bucket" "widgets" {
  name          = "${var.app_name}-widgets-${var.environment}"
  location      = var.region
  force_destroy = false

  uniform_bucket_level_access = true

  cors {
    origin          = ["https://chatgpt.com"]
    method          = ["GET", "HEAD"]
    response_header = ["Content-Type"]
    max_age_seconds = 3600
  }
}

# Outputs
output "mcp_server_url" {
  value       = google_api_gateway_gateway.chatgpt_gateway.default_hostname
  description = "MCP Server API Gateway URL"
}

output "widget_cdn_url" {
  value       = "https://${google_compute_backend_bucket.widgets.name}"
  description = "Widget CDN URL"
}

output "firestore_project" {
  value       = var.project_id
  description = "Firestore project ID"
}

Reusable Terraform Modules

Create a standardized MCP server module for consistent deployments:

# modules/mcp-server/main.tf
variable "app_name" {
  type        = string
  description = "Application name"
}

variable "environment" {
  type        = string
  description = "Environment (dev, staging, production)"
}

variable "runtime" {
  type        = string
  default     = "nodejs20.x"
  description = "Lambda runtime"
}

variable "memory_size" {
  type        = number
  default     = 512
  description = "Lambda memory in MB"
}

variable "source_code_path" {
  type        = string
  description = "Path to Lambda deployment package"
}

variable "environment_variables" {
  type        = map(string)
  default     = {}
  description = "Environment variables for Lambda"
}

# Lambda Function
resource "aws_lambda_function" "mcp_server" {
  function_name = "${var.app_name}-mcp-${var.environment}"
  runtime       = var.runtime
  handler       = "index.handler"
  memory_size   = var.memory_size
  timeout       = 30

  filename         = var.source_code_path
  source_code_hash = filebase64sha256(var.source_code_path)

  role = aws_iam_role.execution.arn

  environment {
    variables = merge(
      var.environment_variables,
      {
        APP_NAME    = var.app_name
        ENVIRONMENT = var.environment
      }
    )
  }

  tracing_config {
    mode = "Active"
  }
}

# IAM Role with minimal permissions
resource "aws_iam_role" "execution" {
  name = "${var.app_name}-mcp-execution-${var.environment}"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = {
        Service = "lambda.amazonaws.com"
      }
    }]
  })
}

resource "aws_iam_role_policy_attachment" "basic_execution" {
  role       = aws_iam_role.execution.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}

# CloudWatch Log Group
resource "aws_cloudwatch_log_group" "mcp_server" {
  name              = "/aws/lambda/${aws_lambda_function.mcp_server.function_name}"
  retention_in_days = 30
}

# Outputs
output "function_arn" {
  value       = aws_lambda_function.mcp_server.arn
  description = "Lambda function ARN"
}

output "function_name" {
  value       = aws_lambda_function.mcp_server.function_name
  description = "Lambda function name"
}

output "invoke_arn" {
  value       = aws_lambda_function.mcp_server.invoke_arn
  description = "Lambda invoke ARN for API Gateway"
}

# Usage in root module:
# module "production_mcp" {
#   source = "./modules/mcp-server"
#
#   app_name           = "chatgpt-fitness-assistant"
#   environment        = "production"
#   source_code_path   = "../dist/mcp-server.zip"
#
#   environment_variables = {
#     DYNAMODB_TABLE = aws_dynamodb_table.state.name
#     OAUTH_CLIENT_ID = var.oauth_client_id
#   }
# }

Pulumi Implementation for ChatGPT Apps

AWS with Pulumi TypeScript

Pulumi brings type safety and programming language features to infrastructure:

// pulumi/aws/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as path from "path";

const config = new pulumi.Config();
const environment = pulumi.getStack();
const appName = config.require("appName");

// DynamoDB Table for App State
const appStateTable = new aws.dynamodb.Table("app-state", {
  name: `${appName}-state-${environment}`,
  billingMode: "PAY_PER_REQUEST",
  hashKey: "userId",
  rangeKey: "sessionId",

  attributes: [
    { name: "userId", type: "S" },
    { name: "sessionId", type: "S" },
    { name: "createdAt", type: "N" },
  ],

  globalSecondaryIndexes: [{
    name: "createdAt-index",
    hashKey: "userId",
    rangeKey: "createdAt",
    projectionType: "ALL",
  }],

  pointInTimeRecovery: { enabled: true },
  serverSideEncryption: { enabled: true },

  tags: {
    Environment: environment,
    ManagedBy: "Pulumi",
  },
});

// IAM Role for Lambda
const lambdaRole = new aws.iam.Role("mcp-lambda-role", {
  assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
    Service: "lambda.amazonaws.com",
  }),
});

new aws.iam.RolePolicyAttachment("lambda-basic-execution", {
  role: lambdaRole.name,
  policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});

// DynamoDB access policy
const dynamodbPolicy = new aws.iam.RolePolicy("lambda-dynamodb", {
  role: lambdaRole.id,
  policy: pulumi.all([appStateTable.arn]).apply(([tableArn]) =>
    JSON.stringify({
      Version: "2012-10-17",
      Statement: [{
        Effect: "Allow",
        Action: [
          "dynamodb:GetItem",
          "dynamodb:PutItem",
          "dynamodb:UpdateItem",
          "dynamodb:Query",
          "dynamodb:Scan",
        ],
        Resource: [tableArn, `${tableArn}/index/*`],
      }],
    })
  ),
});

// Lambda Function for MCP Server
const mcpServerLambda = new aws.lambda.Function("mcp-server", {
  name: `${appName}-mcp-server-${environment}`,
  runtime: "nodejs20.x",
  handler: "index.handler",
  memorySize: 512,
  timeout: 30,

  code: new pulumi.asset.FileArchive("../../dist/mcp-server.zip"),

  role: lambdaRole.arn,

  environment: {
    variables: {
      NODE_ENV: environment,
      DYNAMODB_TABLE: appStateTable.name,
      OAUTH_CLIENT_ID: config.requireSecret("oauthClientId"),
      OAUTH_CLIENT_SECRET: config.requireSecret("oauthClientSecret"),
    },
  },

  tracingConfig: {
    mode: "Active",
  },
});

// CloudWatch Log Group
const logGroup = new aws.cloudwatch.LogGroup("mcp-server-logs", {
  name: pulumi.interpolate`/aws/lambda/${mcpServerLambda.name}`,
  retentionInDays: 30,
});

// API Gateway REST API
const api = new aws.apigateway.RestApi("chatgpt-api", {
  name: `${appName}-api-${environment}`,
  description: "ChatGPT App MCP Server API",
});

const mcpResource = new aws.apigateway.Resource("mcp-resource", {
  restApi: api.id,
  parentId: api.rootResourceId,
  pathPart: "mcp",
});

const mcpMethod = new aws.apigateway.Method("mcp-post-method", {
  restApi: api.id,
  resourceId: mcpResource.id,
  httpMethod: "POST",
  authorization: "NONE",
});

const mcpIntegration = new aws.apigateway.Integration("mcp-integration", {
  restApi: api.id,
  resourceId: mcpResource.id,
  httpMethod: mcpMethod.httpMethod,
  integrationHttpMethod: "POST",
  type: "AWS_PROXY",
  uri: mcpServerLambda.invokeArn,
});

const lambdaPermission = new aws.lambda.Permission("api-gateway-invoke", {
  action: "lambda:InvokeFunction",
  function: mcpServerLambda.name,
  principal: "apigateway.amazonaws.com",
  sourceArn: pulumi.interpolate`${api.executionArn}/*/*`,
});

const deployment = new aws.apigateway.Deployment("api-deployment", {
  restApi: api.id,
  stageName: environment,
}, { dependsOn: [mcpIntegration] });

// S3 Bucket for Widgets
const widgetsBucket = new aws.s3.Bucket("widgets-bucket", {
  bucket: `${appName}-widgets-${environment}`,

  corsRules: [{
    allowedHeaders: ["*"],
    allowedMethods: ["GET", "HEAD"],
    allowedOrigins: ["https://chatgpt.com"],
    maxAgeSeconds: 3600,
  }],
});

// CloudFront Distribution
const originAccessIdentity = new aws.cloudfront.OriginAccessIdentity("widgets-oai", {
  comment: "OAI for ChatGPT widgets",
});

const bucketPolicy = new aws.s3.BucketPolicy("widgets-bucket-policy", {
  bucket: widgetsBucket.id,
  policy: pulumi.all([widgetsBucket.arn, originAccessIdentity.iamArn]).apply(
    ([bucketArn, oaiArn]) =>
      JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
          Effect: "Allow",
          Principal: { AWS: oaiArn },
          Action: "s3:GetObject",
          Resource: `${bucketArn}/*`,
        }],
      })
  ),
});

const cdn = new aws.cloudfront.Distribution("widgets-cdn", {
  enabled: true,
  isIpv6Enabled: true,
  defaultRootObject: "index.html",
  priceClass: "PriceClass_100",

  origins: [{
    domainName: widgetsBucket.bucketRegionalDomainName,
    originId: "S3-widgets",
    s3OriginConfig: {
      originAccessIdentity: originAccessIdentity.cloudfrontAccessIdentityPath,
    },
  }],

  defaultCacheBehavior: {
    allowedMethods: ["GET", "HEAD", "OPTIONS"],
    cachedMethods: ["GET", "HEAD"],
    targetOriginId: "S3-widgets",
    viewerProtocolPolicy: "redirect-to-https",
    compress: true,

    forwardedValues: {
      queryString: false,
      cookies: { forward: "none" },
    },

    minTtl: 0,
    defaultTtl: 86400,
    maxTtl: 31536000,
  },

  restrictions: {
    geoRestriction: {
      restrictionType: "none",
    },
  },

  viewerCertificate: {
    cloudfrontDefaultCertificate: true,
  },
});

// Exports
export const mcpServerUrl = pulumi.interpolate`${deployment.invokeUrl}/mcp`;
export const widgetCdnUrl = pulumi.interpolate`https://${cdn.domainName}`;
export const dynamodbTableName = appStateTable.name;
export const lambdaFunctionArn = mcpServerLambda.arn;

GCP with Pulumi TypeScript

Deploy to Google Cloud Platform using Pulumi:

// pulumi/gcp/index.ts
import * as pulumi from "@pulumi/pulumi";
import * as gcp from "@pulumi/gcp";

const config = new pulumi.Config();
const environment = pulumi.getStack();
const appName = config.require("appName");
const projectId = config.require("gcpProjectId");
const region = config.get("region") || "us-central1";

// Service Account for Cloud Function
const mcpServiceAccount = new gcp.serviceaccount.Account("mcp-service-account", {
  accountId: `${appName}-mcp-server`,
  displayName: "MCP Server Service Account",
});

// Grant Firestore access
const firestoreBinding = new gcp.projects.IAMMember("mcp-firestore-access", {
  project: projectId,
  role: "roles/datastore.user",
  member: pulumi.interpolate`serviceAccount:${mcpServiceAccount.email}`,
});

// Secret Manager for OAuth credentials
const oauthSecret = new gcp.secretmanager.Secret("oauth-client-secret", {
  secretId: `${appName}-oauth-secret`,
  replication: {
    auto: {},
  },
});

const oauthSecretVersion = new gcp.secretmanager.SecretVersion("oauth-secret-version", {
  secret: oauthSecret.id,
  secretData: config.requireSecret("oauthClientSecret"),
});

const secretAccessBinding = new gcp.secretmanager.SecretIamMember("secret-access", {
  secretId: oauthSecret.id,
  role: "roles/secretmanager.secretAccessor",
  member: pulumi.interpolate`serviceAccount:${mcpServiceAccount.email}`,
});

// Storage Bucket for Function Source Code
const sourceBucket = new gcp.storage.Bucket("function-source", {
  name: `${appName}-function-source-${environment}`,
  location: region,
  uniformBucketLevelAccess: true,
});

const functionSource = new gcp.storage.BucketObject("mcp-server-source", {
  bucket: sourceBucket.name,
  source: new pulumi.asset.FileArchive("../../dist/mcp-server.zip"),
});

// Cloud Function Gen 2
const mcpFunction = new gcp.cloudfunctionsv2.Function("mcp-server", {
  name: `${appName}-mcp-server-${environment}`,
  location: region,
  description: "ChatGPT MCP Server",

  buildConfig: {
    runtime: "nodejs20",
    entryPoint: "handler",
    source: {
      storageSource: {
        bucket: sourceBucket.name,
        object: functionSource.name,
      },
    },
  },

  serviceConfig: {
    maxInstanceCount: 100,
    availableMemory: "512Mi",
    timeoutSeconds: 60,
    serviceAccountEmail: mcpServiceAccount.email,

    environmentVariables: {
      NODE_ENV: environment,
      FIRESTORE_PROJECT: projectId,
      OAUTH_CLIENT_ID: config.require("oauthClientId"),
    },

    secretEnvironmentVariables: [{
      key: "OAUTH_CLIENT_SECRET",
      projectId: projectId,
      secret: oauthSecret.secretId,
      version: "latest",
    }],
  },
});

// Allow unauthenticated invocations (API Gateway will handle auth)
const invoker = new gcp.cloudfunctionsv2.FunctionIamMember("mcp-invoker", {
  location: mcpFunction.location,
  cloudFunction: mcpFunction.name,
  role: "roles/cloudfunctions.invoker",
  member: "allUsers",
});

// Firestore Composite Index
const sessionIndex = new gcp.firestore.Index("session-index", {
  collection: "sessions",

  fields: [
    { fieldPath: "userId", order: "ASCENDING" },
    { fieldPath: "createdAt", order: "DESCENDING" },
  ],
});

// Storage Bucket for Widgets
const widgetsBucket = new gcp.storage.Bucket("widgets", {
  name: `${appName}-widgets-${environment}`,
  location: region,
  uniformBucketLevelAccess: true,

  cors: [{
    origins: ["https://chatgpt.com"],
    methods: ["GET", "HEAD"],
    responseHeaders: ["Content-Type"],
    maxAgeSeconds: 3600,
  }],
});

// Make widgets publicly readable
const widgetsBucketIam = new gcp.storage.BucketIAMBinding("widgets-public-read", {
  bucket: widgetsBucket.name,
  role: "roles/storage.objectViewer",
  members: ["allUsers"],
});

// Cloud CDN Backend Bucket
const widgetsBackend = new gcp.compute.BackendBucket("widgets-backend", {
  name: `${appName}-widgets-backend`,
  bucketName: widgetsBucket.name,
  enableCdn: true,

  cdnPolicy: {
    cacheMode: "CACHE_ALL_STATIC",
    clientTtl: 3600,
    defaultTtl: 86400,
    maxTtl: 31536000,
    negativeCaching: true,
    serveWhileStale: 86400,
  },
});

// Exports
export const mcpServerUrl = mcpFunction.serviceConfig.uri;
export const widgetsBucketName = widgetsBucket.name;
export const widgetCdnUrl = pulumi.interpolate`https://storage.googleapis.com/${widgetsBucket.name}`;
export const functionServiceAccount = mcpServiceAccount.email;

Pulumi Component Resources

Create reusable component abstractions:

// pulumi/components/McpServerComponent.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

export interface McpServerArgs {
  appName: string;
  environment: string;
  runtime?: string;
  memorySize?: number;
  sourceCodePath: string;
  environmentVariables?: Record<string, pulumi.Input<string>>;
  dynamodbTableArn?: pulumi.Input<string>;
}

export class McpServerComponent extends pulumi.ComponentResource {
  public readonly lambdaFunction: aws.lambda.Function;
  public readonly logGroup: aws.cloudwatch.LogGroup;
  public readonly apiGateway: aws.apigatewayv2.Api;
  public readonly apiUrl: pulumi.Output<string>;

  constructor(name: string, args: McpServerArgs, opts?: pulumi.ComponentResourceOptions) {
    super("custom:chatgpt:McpServer", name, {}, opts);

    const defaultOpts = { parent: this };

    // IAM Role
    const role = new aws.iam.Role(`${name}-role`, {
      assumeRolePolicy: aws.iam.assumeRolePolicyForPrincipal({
        Service: "lambda.amazonaws.com",
      }),
    }, defaultOpts);

    new aws.iam.RolePolicyAttachment(`${name}-basic-execution`, {
      role: role.name,
      policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
    }, defaultOpts);

    // DynamoDB access if table provided
    if (args.dynamodbTableArn) {
      new aws.iam.RolePolicy(`${name}-dynamodb`, {
        role: role.id,
        policy: pulumi.output(args.dynamodbTableArn).apply(tableArn =>
          JSON.stringify({
            Version: "2012-10-17",
            Statement: [{
              Effect: "Allow",
              Action: [
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:UpdateItem",
                "dynamodb:Query",
              ],
              Resource: [tableArn, `${tableArn}/index/*`],
            }],
          })
        ),
      }, defaultOpts);
    }

    // Lambda Function
    this.lambdaFunction = new aws.lambda.Function(`${name}-function`, {
      name: `${args.appName}-mcp-${args.environment}`,
      runtime: args.runtime || "nodejs20.x",
      handler: "index.handler",
      memorySize: args.memorySize || 512,
      timeout: 30,

      code: new pulumi.asset.FileArchive(args.sourceCodePath),
      role: role.arn,

      environment: {
        variables: {
          ...args.environmentVariables,
          APP_NAME: args.appName,
          ENVIRONMENT: args.environment,
        },
      },

      tracingConfig: { mode: "Active" },
    }, defaultOpts);

    // CloudWatch Logs
    this.logGroup = new aws.cloudwatch.LogGroup(`${name}-logs`, {
      name: pulumi.interpolate`/aws/lambda/${this.lambdaFunction.name}`,
      retentionInDays: 30,
    }, defaultOpts);

    // API Gateway HTTP API (simpler than REST API)
    this.apiGateway = new aws.apigatewayv2.Api(`${name}-api`, {
      protocolType: "HTTP",
      name: `${args.appName}-api-${args.environment}`,
    }, defaultOpts);

    const integration = new aws.apigatewayv2.Integration(`${name}-integration`, {
      apiId: this.apiGateway.id,
      integrationType: "AWS_PROXY",
      integrationUri: this.lambdaFunction.arn,
      payloadFormatVersion: "2.0",
    }, defaultOpts);

    const route = new aws.apigatewayv2.Route(`${name}-route`, {
      apiId: this.apiGateway.id,
      routeKey: "POST /mcp",
      target: pulumi.interpolate`integrations/${integration.id}`,
    }, defaultOpts);

    const stage = new aws.apigatewayv2.Stage(`${name}-stage`, {
      apiId: this.apiGateway.id,
      name: "$default",
      autoDeploy: true,
    }, defaultOpts);

    new aws.lambda.Permission(`${name}-api-permission`, {
      action: "lambda:InvokeFunction",
      function: this.lambdaFunction.name,
      principal: "apigateway.amazonaws.com",
      sourceArn: pulumi.interpolate`${this.apiGateway.executionArn}/*/*`,
    }, defaultOpts);

    this.apiUrl = pulumi.interpolate`${this.apiGateway.apiEndpoint}/mcp`;

    this.registerOutputs({
      lambdaFunction: this.lambdaFunction,
      logGroup: this.logGroup,
      apiGateway: this.apiGateway,
      apiUrl: this.apiUrl,
    });
  }
}

// Usage:
// const mcpServer = new McpServerComponent("production-mcp", {
//   appName: "chatgpt-fitness-assistant",
//   environment: "production",
//   sourceCodePath: "../dist/mcp-server.zip",
//   environmentVariables: {
//     OAUTH_CLIENT_ID: config.requireSecret("oauthClientId"),
//   },
//   dynamodbTableArn: appStateTable.arn,
// });
//
// export const mcpUrl = mcpServer.apiUrl;

CI/CD Integration

Terraform GitHub Actions Workflow

Automate Terraform deployments with comprehensive validation:

# .github/workflows/terraform-deploy.yml
name: Terraform Deploy

on:
  push:
    branches: [main]
    paths:
      - 'terraform/**'
      - '.github/workflows/terraform-deploy.yml'
  pull_request:
    branches: [main]
    paths:
      - 'terraform/**'

env:
  TF_VERSION: 1.6.0
  AWS_REGION: us-east-1

jobs:
  validate:
    name: Validate Terraform
    runs-on: ubuntu-latest

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

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Format Check
        run: terraform fmt -check -recursive terraform/

      - name: Terraform Init
        working-directory: terraform/aws
        run: terraform init -backend=false

      - name: Terraform Validate
        working-directory: terraform/aws
        run: terraform validate

      - name: Run tflint
        uses: terraform-linters/setup-tflint@v4
        with:
          tflint_version: latest

      - name: Init tflint
        working-directory: terraform/aws
        run: tflint --init

      - name: Run tflint
        working-directory: terraform/aws
        run: tflint

  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    needs: validate
    if: github.event_name == 'pull_request'

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

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        working-directory: terraform/aws
        run: terraform init

      - name: Terraform Plan
        working-directory: terraform/aws
        run: |
          terraform plan \
            -var="oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }}" \
            -var="oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }}" \
            -out=tfplan

      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: terraform-plan
          path: terraform/aws/tfplan
          retention-days: 5

      - name: Comment Plan on PR
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const plan = fs.readFileSync('terraform/aws/tfplan.txt', 'utf8');

            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan\n\`\`\`hcl\n${plan}\n\`\`\``
            });

  apply:
    name: Terraform Apply
    runs-on: ubuntu-latest
    needs: validate
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production

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

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ env.AWS_REGION }}

      - name: Terraform Init
        working-directory: terraform/aws
        run: terraform init

      - name: Terraform Apply
        working-directory: terraform/aws
        run: |
          terraform apply -auto-approve \
            -var="oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }}" \
            -var="oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }}"

      - name: Output Results
        working-directory: terraform/aws
        run: terraform output -json > outputs.json

      - name: Upload Outputs
        uses: actions/upload-artifact@v4
        with:
          name: terraform-outputs
          path: terraform/aws/outputs.json

Pulumi GitHub Actions Workflow

Deploy with Pulumi's preview and update workflow:

# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy

on:
  push:
    branches: [main]
    paths:
      - 'pulumi/**'
      - '.github/workflows/pulumi-deploy.yml'
  pull_request:
    branches: [main]
    paths:
      - 'pulumi/**'

env:
  PULUMI_VERSION: 3.100.0
  NODE_VERSION: 20

jobs:
  preview:
    name: Pulumi Preview
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request'

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Install dependencies
        working-directory: pulumi/aws
        run: npm ci

      - name: Setup Pulumi
        uses: pulumi/actions@v5
        with:
          pulumi-version: ${{ env.PULUMI_VERSION }}

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Pulumi Preview
        uses: pulumi/actions@v5
        with:
          command: preview
          stack-name: production
          work-dir: pulumi/aws
          comment-on-pr: true
          github-token: ${{ secrets.GITHUB_TOKEN }}
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

  deploy:
    name: Pulumi Update
    runs-on: ubuntu-latest
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    environment: production

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

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}

      - name: Install dependencies
        working-directory: pulumi/aws
        run: npm ci

      - name: Setup Pulumi
        uses: pulumi/actions@v5
        with:
          pulumi-version: ${{ env.PULUMI_VERSION }}

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Pulumi Update
        uses: pulumi/actions@v5
        with:
          command: up
          stack-name: production
          work-dir: pulumi/aws
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

      - name: Export Stack Outputs
        working-directory: pulumi/aws
        run: pulumi stack output --json > outputs.json
        env:
          PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}

      - name: Upload Outputs
        uses: actions/upload-artifact@v4
        with:
          name: pulumi-outputs
          path: pulumi/aws/outputs.json

Infrastructure Validation Script

Validate infrastructure changes before deployment:

#!/bin/bash
# scripts/validate-infrastructure.sh

set -e

echo "🔍 Validating infrastructure configuration..."

# Color codes
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color

# Check if Terraform is installed
if command -v terraform &> /dev/null; then
  echo -e "${GREEN}✓${NC} Terraform installed: $(terraform version -json | jq -r '.terraform_version')"
else
  echo -e "${RED}✗${NC} Terraform not installed"
  exit 1
fi

# Validate Terraform configuration
echo ""
echo "Validating Terraform configuration..."
cd terraform/aws

terraform fmt -check -recursive . || {
  echo -e "${RED}✗${NC} Terraform formatting issues detected"
  echo "Run: terraform fmt -recursive ."
  exit 1
}
echo -e "${GREEN}✓${NC} Terraform formatting validated"

terraform init -backend=false
terraform validate || {
  echo -e "${RED}✗${NC} Terraform validation failed"
  exit 1
}
echo -e "${GREEN}✓${NC} Terraform configuration valid"

# Check for security issues with tfsec
if command -v tfsec &> /dev/null; then
  echo ""
  echo "Running tfsec security scan..."
  tfsec . --minimum-severity MEDIUM || {
    echo -e "${YELLOW}⚠${NC} Security issues detected"
  }
else
  echo -e "${YELLOW}⚠${NC} tfsec not installed, skipping security scan"
fi

# Estimate cost with Infracost (if available)
if command -v infracost &> /dev/null; then
  echo ""
  echo "Estimating infrastructure cost..."
  infracost breakdown --path . --format table
else
  echo -e "${YELLOW}⚠${NC} Infracost not installed, skipping cost estimation"
fi

cd ../..

echo ""
echo -e "${GREEN}✅ Infrastructure validation complete${NC}"

State Management Best Practices

Remote State Configuration

Never store state locally in production. Configure remote backends:

# terraform/backend.tf
terraform {
  backend "s3" {
    bucket         = "chatgpt-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"

    # Enable versioning for state history
    versioning = true
  }
}

# Create S3 bucket and DynamoDB table for state management
resource "aws_s3_bucket" "terraform_state" {
  bucket = "chatgpt-terraform-state"

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_dynamodb_table" "terraform_lock" {
  name           = "terraform-state-lock"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }

  lifecycle {
    prevent_destroy = true
  }
}

State Locking and Versioning

State locking prevents concurrent modifications that could corrupt your infrastructure:

  • Terraform: DynamoDB table provides automatic locking
  • Pulumi: Built-in locking through Pulumi Service or self-hosted backend

State versioning enables rollback to previous infrastructure states:

# Terraform: Restore previous state version
terraform state pull > backup.tfstate
aws s3 cp s3://chatgpt-terraform-state/production/terraform.tfstate.backup current.tfstate
terraform state push current.tfstate

# Pulumi: Rollback to previous update
pulumi stack history
pulumi stack select production
pulumi up --target-dependents --refresh=false

Production Deployment Checklist

Before deploying infrastructure changes to production:

  • Test in staging environment with identical configuration
  • Review Terraform plan or Pulumi preview for unexpected changes
  • Validate security rules (Firestore, IAM, API Gateway authorization)
  • Check resource quotas (Lambda concurrent executions, API Gateway rate limits)
  • Verify OAuth redirect URIs match ChatGPT connector platform requirements
  • Confirm CDN cache invalidation for widget updates
  • Enable monitoring (CloudWatch alarms, GCP monitoring alerts)
  • Document rollback procedures for each resource type
  • Schedule deployment during low-traffic windows
  • Notify team members of deployment timing

Conclusion

Infrastructure as code transforms ChatGPT app deployment from manual error-prone processes to automated, reproducible workflows. Whether you choose Terraform's declarative HCL or Pulumi's programming language flexibility, both tools provide production-ready solutions for managing MCP servers, API gateways, databases, and CDN distributions.

The code examples in this guide provide complete, working implementations you can adapt to your ChatGPT application architecture. Start with a single environment, validate your configuration, then expand to multi-region deployments and complex workflows.

Ready to automate your ChatGPT app infrastructure? MakeAIHQ provides infrastructure templates, CI/CD integration guides, and deployment automation for ChatGPT applications. Sign up for a free trial and deploy your first ChatGPT app with infrastructure as code in under 48 hours.


Related Resources