Skip to content

Secrets

Overview

Secrets are stored in AWS Systems Manager Parameter Store as SecureString parameters, encrypted with KMS. ECS tasks read secrets at startup via the secrets block in their container definitions.

Terraform creates the parameters with placeholder values. Real values are set manually after the initial apply.

Naming Convention

SSM parameters follow the pattern:

/<service>/<environment>/<component>/<secret_name>

Examples:

/atrax/stage/atrax-api/database_url
/vault/prod/vault-api/clickhouse_password
/core/stage/core-api/auth_secret

Current Parameters

Core API

Path Description Status
/core/stage/core-api/auth_secret JWT/HMAC signing secret Set
/core/stage/core-api/database_url MariaDB connection URL Not yet created

Atrax

The same paths exist in both stage and prod (substitute {env} for stage or prod).

Path Description
/atrax/{env}/atrax-api/database_url PostgreSQL connection URL — must include ?uselibpqcompat=true&sslmode=require for Prisma's pg adapter, and the password must be URL-encoded (see Atrax service docs)
/atrax/{env}/atrax-api/controller_auth Bearer tokens accepted by atrax-api (comma-separated)
/atrax/{env}/atrax-api/s3_access_key_id S3 access key — populated from the terraform-managed {env}-euc1-atrax-api-s3 IAM user
/atrax/{env}/atrax-api/s3_secret_access_key S3 secret key — same IAM user
/atrax/{env}/atrax-node/controller_url Atrax API URL (plain String): https://atrax-api.{env}.cookiehub.net for stage, https://atrax-api.cookiehub.net for prod
/atrax/{env}/atrax-node/controller_auth Bearer token used by atrax-node — must match one of atrax-api's tokens
/atrax/{env}/atrax-node/bugsnag_api_key Error tracking key

The two S3 secrets are populated from terraform outputs (the IAM user and access key are TF-managed):

# from environments/{env}/eu-central-1
aws ssm put-parameter --type SecureString --overwrite \
  --name /atrax/{env}/atrax-api/s3_access_key_id \
  --value "$(terraform output -raw atrax_api_s3_access_key_id)" \
  --region eu-central-1
aws ssm put-parameter --type SecureString --overwrite \
  --name /atrax/{env}/atrax-api/s3_secret_access_key \
  --value "$(terraform output -raw atrax_api_s3_secret_access_key)" \
  --region eu-central-1

Vault

Path Description
/vault/{env}/postgres/password PostgreSQL master password
/vault/{env}/postgres/db_url PostgreSQL connection URL
/vault/{env}/mariadb/password MariaDB master password (stage only)
/vault/{env}/mariadb/db_url MariaDB connection URL (stage only)
/{group}/{env}/vault-api/clickhouse_url ClickHouse HTTP URL
/{group}/{env}/vault-api/clickhouse_username ClickHouse username
/{group}/{env}/vault-api/clickhouse_password ClickHouse password
/{group}/{env}/vault-api/vault_api_key Static API key for auth
/{group}/{env}/etl/vault_ingest_config JSON config for ETL pipeline
/{group}/{env}/clickhouse/admin_password ClickHouse admin password

How Secrets Work in Terraform

Secrets are defined with a placeholder value and ignore_changes on the value:

resource "aws_ssm_parameter" "database_url" {
  name        = "/atrax/${var.environment}/atrax-api/database_url"
  description = "PostgreSQL connection URL for atrax-api"
  type        = "SecureString"
  value       = "placeholder"

  lifecycle {
    ignore_changes = [value]
  }

  tags = merge(var.base_tags, { Service = "atrax-api" })
}

This means:

  • terraform apply creates the parameter once with "placeholder"
  • After apply, you set the real value manually via CLI
  • Subsequent terraform apply runs leave the value untouched

How ECS Tasks Consume Secrets

In the task definition, plain config goes in environment and secrets go in secrets:

container_definitions = jsonencode([{
  name  = "atrax-api"
  image = "${ecr_repo_url}:latest"

  environment = [
    { name = "PORT", value = "3000" },
  ]

  secrets = [
    { name = "DATABASE_URL", valueFrom = aws_ssm_parameter.database_url.arn },
    { name = "AUTH_SECRET",  valueFrom = aws_ssm_parameter.auth_secret.arn },
  ]
}])

The ECS agent fetches secrets from SSM at task startup and injects them as environment variables. This requires the execution role to have ssm:GetParameter and kms:Decrypt permissions:

resource "aws_iam_policy" "read_ssm" {
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["ssm:GetParameter", "ssm:GetParameters"]
        Resource = [aws_ssm_parameter.database_url.arn]
      },
      {
        Effect   = "Allow"
        Action   = ["kms:Decrypt", "kms:DescribeKey"]
        Resource = "*"
      }
    ]
  })
}

KMS Encryption

  • SSM parameters use the AWS-managed aws/ssm KMS key automatically
  • RDS instances use dedicated customer-managed KMS keys with:
  • Automatic annual key rotation (enable_key_rotation = true)
  • 30-day deletion window
  • prevent_destroy lifecycle protection

Setting and Rotating Secrets

Set a new secret

aws ssm put-parameter \
  --name "/core/stage/core-api/database_url" \
  --value "mysql://user:pass@host:3306/dbname" \
  --type SecureString \
  --region eu-central-1

Update an existing secret

aws ssm put-parameter \
  --name "/core/stage/core-api/database_url" \
  --value "mysql://user:newpass@host:3306/dbname" \
  --type SecureString \
  --overwrite \
  --region eu-central-1

Restart service to pick up new value

ECS tasks only read secrets at startup. After changing a parameter, force a new deployment:

aws ecs update-service \
  --cluster stage-euc1-core-ecs-cluster \
  --service core-api \
  --force-new-deployment \
  --region eu-central-1

Read a secret (for debugging)

aws ssm get-parameter \
  --name "/core/stage/core-api/auth_secret" \
  --with-decryption \
  --region eu-central-1 \
  --query "Parameter.Value" \
  --output text

Database passwords

RDS passwords have ignore_changes = [password] in Terraform. If you rotate a database password, you must update both the RDS instance password and the corresponding SSM parameter, then restart dependent services.