← Alle Insights

Terraform für Trading-Infrastruktur.

Infrastruktur per Klick im AWS- oder GCP-Konsole zusammenbauen geht schnell — und ist zwei Jahre später nicht mehr nachvollziehbar. Wer Trading-Workloads in der Cloud ernsthaft betreibt, kommt um Infrastructure-as-Code nicht herum. Terraform ist dafür das pragmatischste Werkzeug. In diesem Artikel zeige ich, wie ich Trading-Infrastruktur mit Terraform strukturiere, welche Module sich bewähren und wo die typischen Fallen liegen.

Warum Terraform und nicht CloudFormation oder Pulumi?

Drei kurze Antworten. CloudFormation ist AWS-only — wer auch nur einen GCP-Service oder ein DNS-Eintrag bei Cloudflare braucht, hat schon ein Tool-Hopping. Pulumi ist mächtig, aber „Infrastruktur in Python" verleitet zu loop-getriebenem Code, der schwer review-bar ist. Terraform mit HCL ist deklarativ, lesbar und unterstützt jede relevante Cloud sowie Drittanbieter wie Hetzner, Cloudflare, Snowflake oder GitHub. Für Trading-Stacks, die selten in einer einzigen Cloud bleiben, ist das die richtige Wahl.

Was Sie nicht haben sollten: eine Mischung. Ein Terraform-Stack, ein paar Console- Ressourcen daneben, dazu ein Bash-Skript, das „mal eben" eine Lambda deployed. Das ist der Pfad zur unwartbaren Infrastruktur. Entscheiden Sie sich für Terraform — und ziehen Sie es konsequent durch, auch wenn das initial länger dauert.

Repo-Struktur.

Eine bewährte Struktur für einen Trading-Stack hat drei Ebenen: Module für wiederverwendbare Bausteine, Environments für separate Live- Konfigurationen (dev, staging, prod), und einen Backend-Bootstrap für den Remote-State.

# Verzeichnisstruktur
infra/
├── bootstrap/                  # S3-Bucket + DynamoDB fuer Terraform-State
│   ├── main.tf
│   └── outputs.tf
├── modules/
│   ├── trading-cluster/        # ECS-Fargate-Cluster + ALB
│   ├── strategy/               # Eine Strategie: Task-Def, Logs, Alerts
│   ├── data-lake/              # S3 + Glue + Athena
│   └── observability/          # CloudWatch-Dashboards + SNS-Topics
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   └── prod/
└── README.md

Module sind generisch: keine Hardcoded-Namen, alles per Variable. Environments sind spezifisch: hier liegen Namen, Account-IDs, Region, Sizing. Eine Strategie hinzufügen heißt: einen Modul-Block im prod/main.tf ergänzen und Variablen setzen. Kein Code in mehreren Dateien ändern, kein Copy-Paste.

Remote-State und Locking.

Den State lokal in terraform.tfstate abzulegen funktioniert für ein Tutorial. Für produktive Infrastruktur ist es ein Desaster: zwei Personen, die parallel apply ausführen, überschreiben sich gegenseitig den State. Die einzige richtige Lösung ist Remote-State mit Locking.

# bootstrap/main.tf — einmalig pro Account
terraform {
  required_version = ">= 1.7.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}

resource "aws_s3_bucket" "tf_state" {
  bucket = "mg-quant-tfstate-eu-central-1"
}

resource "aws_s3_bucket_versioning" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "tf_state" {
  bucket                  = aws_s3_bucket.tf_state.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_dynamodb_table" "tf_lock" {
  name         = "mg-quant-tfstate-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

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

Versionierter, verschlüsselter S3-Bucket plus DynamoDB-Tabelle für Locks. Dieser Bootstrap läuft einmal manuell, danach nutzen alle anderen Environments dieses Backend.

# environments/prod/main.tf
terraform {
  backend "s3" {
    bucket         = "mg-quant-tfstate-eu-central-1"
    key            = "prod/trading-stack.tfstate"
    region         = "eu-central-1"
    dynamodb_table = "mg-quant-tfstate-lock"
    encrypt        = true
  }
}

Ein typisches Strategie-Modul.

Das Herzstück: ein Modul, das eine einzelne Strategie als deployable Einheit beschreibt. Inputs sind Strategie-Name, Image-Tag, Sizing, Schedule. Outputs sind Task-Definition-ARN und Log-Group-Name.

# modules/strategy/main.tf
variable "strategy_name" {
  description = "Eindeutiger Name der Strategie, lowercase, ohne Sonderzeichen"
  type        = string
  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{2,30}$", var.strategy_name))
    error_message = "Strategie-Name muss lowercase und 3-31 Zeichen lang sein."
  }
}

variable "image_uri" {
  description = "Vollstaendiger ECR-Image-URI inkl. Tag"
  type        = string
}

variable "cpu" {
  type    = number
  default = 1024
}

variable "memory" {
  type    = number
  default = 2048
}

variable "schedule_expression" {
  description = "EventBridge-Schedule, leer fuer Long-Running"
  type        = string
  default     = ""
}

variable "secret_arns" {
  description = "Liste der Secrets-Manager-ARNs, die der Task lesen darf"
  type        = list(string)
  default     = []
}

locals {
  family = "strategy-${var.strategy_name}"
  tags = {
    Strategy    = var.strategy_name
    ManagedBy   = "terraform"
    Environment = var.environment
  }
}

resource "aws_cloudwatch_log_group" "strategy" {
  name              = "/ecs/${local.family}"
  retention_in_days = 30
  tags              = local.tags
}

resource "aws_iam_role" "task" {
  name               = "${local.family}-task"
  assume_role_policy = data.aws_iam_policy_document.task_assume.json
  tags               = local.tags
}

data "aws_iam_policy_document" "task_assume" {
  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
    actions = ["sts:AssumeRole"]
  }
}

resource "aws_iam_role_policy" "secrets" {
  count = length(var.secret_arns) > 0 ? 1 : 0
  name  = "secrets-read"
  role  = aws_iam_role.task.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["secretsmanager:GetSecretValue"]
      Resource = var.secret_arns
    }]
  })
}

resource "aws_ecs_task_definition" "strategy" {
  family                   = local.family
  cpu                      = var.cpu
  memory                   = var.memory
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  execution_role_arn       = var.execution_role_arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name      = "strategy"
    image     = var.image_uri
    essential = true
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        awslogs-group         = aws_cloudwatch_log_group.strategy.name
        awslogs-region        = var.region
        awslogs-stream-prefix = "strategy"
      }
    }
    environment = [
      { name = "STRATEGY_ID", value = var.strategy_name },
      { name = "ENVIRONMENT", value = var.environment }
    ]
  }])

  tags = local.tags
}

output "task_definition_arn" {
  value = aws_ecs_task_definition.strategy.arn
}

output "log_group_name" {
  value = aws_cloudwatch_log_group.strategy.name
}

Jede neue Strategie ist jetzt 15 Zeilen im Environment-File. Validierte Eingaben, saubere IAM-Rollen pro Strategie, dedizierte Log-Gruppe, automatische Tags. Keine Möglichkeit, „mal eben schnell" eine Rolle mit zu vielen Rechten zu vergeben.

Strategie hinzufügen.

# environments/prod/main.tf (Ausschnitt)
module "strategy_momentum_v3" {
  source = "../../modules/strategy"

  strategy_name = "momentum-v3"
  environment   = "prod"
  region        = "eu-central-1"
  image_uri     = "123456789012.dkr.ecr.eu-central-1.amazonaws.com/momentum-v3:1.4.2"
  cpu           = 2048
  memory        = 4096

  execution_role_arn = module.trading_cluster.execution_role_arn

  secret_arns = [
    aws_secretsmanager_secret.ibkr_prod.arn,
    aws_secretsmanager_secret.polygon_prod.arn
  ]
}

Das ist die ganze Arbeit. Ein terraform plan zeigt genau drei neue Ressourcen, ein terraform apply deployed sie. Rollback ist ein Git-Revert plus erneutes Apply.

State niemals manuell editieren.

Eine harte Regel, die ich bei Mandanten oft erklären muss: der Terraform-State ist heilig. Niemand editiert ihn manuell, niemand löscht Ressourcen außerhalb von Terraform, niemand legt Ressourcen außerhalb von Terraform an. Wenn das einmal passiert, ist die Konsistenz weg, und das nächste apply macht überraschende Dinge.

Wenn es wirklich nötig ist, eine Ressource zu importieren oder den State zu reparieren, geschieht das mit dokumentierten Schritten: terraform import für Eingriffe, terraform state mv für Umbenennungen, beides in Pull-Requests mit Vier-Augen-Prinzip. Niemand fasst den State auf eigene Faust an.

CI/CD für Terraform.

Lokales terraform apply ist für die ersten Tage okay. Sobald mehrere Personen an der Infrastruktur arbeiten oder die Infrastruktur produktiv ist, gehört jeder Change durch eine Pipeline. Mein Standard:

Geheimnisse außerhalb des States.

Der State enthält alle Outputs aller Ressourcen. Wenn Sie ein Datenbank-Passwort über eine Terraform-Variable übergeben, landet es im State — verschlüsselt zwar, aber lesbar für jeden mit Bucket-Zugriff. Bessere Praxis: Secrets werden außerhalb von Terraform angelegt (manuell, einmalig), Terraform referenziert nur die ARN. Oder Sie nutzen random_password und schreiben das Ergebnis direkt in den Secrets Manager — und niemand außer der Task-Role darf es lesen.

Sie wollen Ihre Trading-Infrastruktur in Terraform überführen oder ein bestehendes Setup professionalisieren? Erstgespräch buchen — wir gehen Repo-Struktur, Module und Pipeline gemeinsam an.