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:
- Pull-Request:
terraform fmt -check,terraform validate,terraform planautomatisch ausgeführt, Plan als Kommentar im PR. - Review: ein zweites Augenpaar geht den Plan durch. Bei produktiven Changes Pflicht.
- Merge:
terraform applywird vom Pipeline-Runner mit einer Role ausgeführt, die ausschließlich aus der CI heraus assume-bar ist. - Drift-Detection: nächtlicher Cron-Job, der
terraform plan -detailed-exitcodeausführt und bei Abweichungen alarmiert.
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.