GDPR & DORA-Compliant Infra with Terraform

Deploy EU-sovereign AWS infrastructure with encryption and audit logging.

Introduction

30 Second Summary

When a European company stores customer data on AWS, that data can be legally accessed by US authorities regardless of where the server physically sits. EU regulations like GDPR and DORA require companies to actively protect against this risk with concrete technical controls.

In this project, you will build a compliance-focused AWS infrastructure using Terraform that enforces EU data residency, enables audit logging, and encrypts data so that even the cloud provider cannot read it.

What You'll Build

A Terraform-deployed AWS infrastructure locked to EU regions with double-layer encryption, audit logging, and region restriction policies that address GDPR, DORA, and CLOUD Act requirements.

By the end of this project, you'll have:

  • An encrypted S3 bucket with GDPR-compliant configuration locked to the Frankfurt region, plus a KMS customer-managed encryption key.
  • A client-side encryption workflow that makes your data unreadable to AWS even if compelled to hand it over under the US CLOUD Act.
  • A CloudTrail audit trail and an EU region restriction policy for DORA operational resilience.
  • Secret Mission: Add a data lifecycle policy for automated deletion and a CloudWatch alarm for unauthorized API calls.

Are there any prerequisites?

None. This project includes full setup instructions for Terraform, the AWS CLI, and VS Code. You do not need prior experience with any of these tools.

Set Up Your Tools

To build compliance infrastructure as code, you need three tools on your machine. Terraform is an Infrastructure as Code tool that translates configuration files into real cloud resources. The AWS CLI connects your computer to your AWS account so Terraform can deploy on your behalf.

In this step, you will install all three tools, configure your AWS credentials, and create the project folder where your Terraform files will live.

In this step, get ready to:
  • Install and verify the Terraform CLI.
  • Install and configure the AWS CLI with EU region defaults.
  • Create and open your project folder in VS Code.
Install and verify Terraform CLI

Terraform reads .tf configuration files and creates matching cloud resources in your AWS account. You will use it throughout this project to deploy every compliance resource.

  • Press the Windows key to open your search bar.
  • Type PowerShell and press Enter to open it.
  • Install Terraform by running this command:
winget install HashiCorp.Terraform

What does this command do?

The winget package manager downloads the latest Terraform binary and adds it to your system PATH. This is the official Microsoft package manager built into Windows 10 and 11.

  • Close and reopen PowerShell so the new PATH takes effect.
  • Verify the installation by running:
terraform -version

You should see output starting with Terraform v followed by a version number (for example, v1.15.3).

✔️ I see version 1.2 or higher

Your Terraform installation is ready. The project requires version 1.2 or higher, so you are good to go.

ⓧ I see a version lower than 1.2

You have an older version of Terraform that will not work with this project. Upgrade it by running:

winget upgrade --id Hashicorp.Terraform
  • Close and reopen PowerShell after the upgrade completes.
  • Run terraform -version again to confirm the version is now 1.2 or higher.

ⓧ Command not found

PowerShell does not recognize terraform yet. Try these fixes in order:

  • Make sure you closed and reopened PowerShell after running the install command. The PATH update only takes effect in new terminal windows.
  • If you still see the error, the winget install may not have completed. Run winget install HashiCorp.Terraform again and check for error messages in the output.
  • If winget itself is not available on your system, download Terraform directly from the official Terraform downloads page at developer.hashicorp.com/terraform/install.
Install and configure the AWS CLI

The AWS CLI lets you interact with AWS services from the command line. Terraform also uses the credentials stored by the AWS CLI to authenticate when creating resources in your account.

  • Download the AWS CLI installer from the official link: https://awscli.amazonaws.com/AWSCLIV2.msi.
  • Run the downloaded .msi file and follow the installer prompts. Accept the default settings.
  • Close and reopen PowerShell after the installation completes.
  • Verify the installation by running:
aws --version

You should see output starting with aws-cli/2 followed by version details.

✔️ I see a version number

The AWS CLI is installed and ready to configure.

ⓧ Command not found

PowerShell does not recognize the aws command. Try these fixes:

  • Make sure you closed and reopened PowerShell after the MSI installer finished.
  • Re-run the MSI installer from the link above. If you see a Repair option, select it.
  • Restart your computer if the PATH still has not updated.

Now you need to connect the CLI to your AWS account. You will need your AWS Access Key ID and Secret Access Key for this step.

Where do I find my AWS credentials?

Log in to the AWS console, click your account name in the top right, and select Security credentials. Under Access keys, click Create access key. Copy both the Access Key ID and the Secret Access Key.

Store these credentials securely. You will not be able to see the Secret Access Key again after closing the creation dialog.

  • Configure your credentials by running:
aws configure

The CLI will prompt you for four values. Enter them one at a time:

  • For AWS Access Key ID: paste your Access Key ID.
  • For AWS Secret Access Key: paste your Secret Access Key.
  • For Default region name: enter eu-central-1.
  • For Default output format: enter json.

Why eu-central-1?

The eu-central-1 region is AWS Frankfurt. It is the most commonly used EU region for compliance-sensitive workloads and has the broadest AWS service availability in Europe.

Setting this as your default means every AWS resource you create will land in Frankfurt unless you explicitly override it. This is your first GDPR data residency control.

  • Confirm your configuration by running:
aws sts get-caller-identity

You should see a JSON response containing your AWS account ID, user ARN, and user ID. This confirms the CLI can authenticate with your account.

Seeing an authentication error?

If you see a message about invalid credentials, re-run aws configure and double-check that you pasted your Access Key ID and Secret Access Key correctly. Make sure there are no extra spaces.

Help me troubleshoot my AWS CLI authentication error.

Create your project folder

You need a dedicated folder for all the Terraform configuration files you will write throughout this project. VS Code will be your editor for writing and managing these files.

  • If VS Code is not already installed, download it from code.visualstudio.com and run the installer.
  • In your PowerShell window, move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir eu-compliance-infra
cd eu-compliance-infra
  • Confirm the folder exists by running:
ls

The output will be empty since the folder is new. That is expected.

  • Open this folder in VS Code by running:
code .

What does code . do?

The code . command launches VS Code and opens the current folder as a workspace. The dot (.) means "this directory." You should see VS Code open with eu-compliance-infra in the Explorer sidebar on the left.

VS Code didn't open?

If PowerShell says code is not recognized, open VS Code manually. Press the Windows key, type VS Code, and press Enter. Then click File in the top menu, select Open Folder, and navigate to your Desktop to select the eu-compliance-infra folder.

Help me open my project folder in VS Code.

Your tools are installed and configured. In the next step, you will write your first Terraform configuration files and lock your infrastructure to the EU.

Configure Terraform and Lock to EU Region

Your tools are set up and your eu-compliance-infra folder is open in VS Code. Now it's time to write the Terraform configuration that locks your entire infrastructure to the EU.

GDPR requires that EU citizen data stays within EU borders. A single misconfigured region setting could route data to a US data center, exposing your organization to regulatory fines. Terraform lets you codify region restrictions as Infrastructure as Code so they're enforced automatically on every deployment.

In this step, get ready to:
  • Create the Terraform provider configuration locked to EU regions.
  • Define project variables with a validation block that rejects non-EU regions.
  • Initialize the Terraform workspace and download the AWS provider.
Create the Terraform provider configuration

Every Terraform project starts with a provider configuration. This file tells Terraform which cloud provider to use, which version of the provider plugin to download, and how to connect.

  • In VS Code, create a new file called terraform.tf inside your eu-compliance-infra folder.
  • Paste the following code into terraform.tf:
# Declare required providers and minimum Terraform version
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  required_version = ">= 1.2"
}

# Configure the AWS provider with EU region and compliance tags
provider "aws" {
  region = var.aws_region

  # These tags are automatically applied to every resource you create
  default_tags {
    tags = {
      Compliance    = "GDPR-DORA"
      DataResidency = "EU"
      Environment   = var.environment
      ManagedBy     = "Terraform"
      Project       = var.project_name
    }
  }
}

What does this code do?

The required_providers block tells Terraform to download the AWS provider plugin (version ~> 6.0). The tilde-arrow means "any version 6.x but not 7.0 or higher."

The provider "aws" block pins the region to var.aws_region (which you'll define next). The default_tags block automatically labels every resource you create with compliance metadata. This makes it easy to audit which resources are GDPR-managed and who created them.

  • Save terraform.tf.

Seeing red squiggly lines in VS Code?

VS Code may underline var.aws_region and var.environment because those variables don't exist yet. This is expected. You'll define them in the next substep.

If you see other errors, make sure your curly braces and quotes match exactly. Help me fix syntax errors in my terraform.tf file

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

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

  required_version = ">= 1.2"
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Compliance    = "GDPR-DORA"
      DataResidency = "EU"
      Environment   = var.environment
      ManagedBy     = "Terraform"
      Project       = var.project_name
    }
  }
}
Define project variables with EU region validation

Hardcoding values directly into your provider configuration makes it inflexible. Variables let you reuse this configuration across different environments while enforcing compliance rules at the input level.

  • Create a new file called variables.tf inside your eu-compliance-infra folder.
  • Paste the following code into variables.tf:
# Project name used in resource naming
variable "project_name" {
  type        = string
  description = "Name of the compliance project"
  default     = "eu-compliance"
}

# Environment label for tagging (dev, staging, prod)
variable "environment" {
  type        = string
  description = "Environment name"
  default     = "dev"
}

# AWS region with validation to enforce EU-only deployment
variable "aws_region" {
  type        = string
  description = "AWS region - must be an EU region for GDPR compliance"
  default     = "eu-central-1"

  # Reject any region that is not in the EU
  validation {
    condition = contains([
      "eu-west-1",
      "eu-west-2",
      "eu-west-3",
      "eu-central-1",
      "eu-central-2",
      "eu-north-1",
      "eu-south-1",
      "eu-south-2",
    ], var.aws_region)
    error_message = "Region must be an EU region for GDPR compliance."
  }
}

What does this code do?

  • project_name and environment are basic metadata variables used in resource naming and tags. Their defaults mean you can run this project without passing any arguments.
  • aws_region defaults to eu-central-1 (Frankfurt). The validation block is the critical part. It checks whether the provided region is in the list of eight EU regions. If someone passes us-east-1, Terraform refuses to run.

This is your first compliance guardrail. No human error can accidentally deploy resources outside the EU.

  • Save variables.tf.

Seeing a syntax error about trailing commas?

HCL (Terraform's language) allows trailing commas in lists. Make sure you have a comma after "eu-south-2" and that the closing bracket ] is on its own line. Also confirm that each region string is wrapped in double quotes.

Help me fix syntax errors in my variables.tf file

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

variable "project_name" {
  type        = string
  description = "Name of the compliance project"
  default     = "eu-compliance"
}

variable "environment" {
  type        = string
  description = "Environment name"
  default     = "dev"
}

variable "aws_region" {
  type        = string
  description = "AWS region - must be an EU region for GDPR compliance"
  default     = "eu-central-1"

  validation {
    condition = contains([
      "eu-west-1",
      "eu-west-2",
      "eu-west-3",
      "eu-central-1",
      "eu-central-2",
      "eu-north-1",
      "eu-south-1",
      "eu-south-2",
    ], var.aws_region)
    error_message = "Region must be an EU region for GDPR compliance."
  }
}
Initialize the Terraform workspace

With your configuration files in place, Terraform needs to download the AWS provider plugin before it can do anything. The terraform init command reads your terraform.tf file and fetches everything it needs.

  • Open the terminal in VS Code by pressing Ctrl+` (backtick key, usually above Tab).
  • Make sure you are inside the eu-compliance-infra folder. If not, navigate there by running:
cd ~/Desktop/eu-compliance-infra
  • Initialize Terraform by running:
terraform init

You should see output confirming the AWS provider was downloaded, ending with the message Terraform has been successfully initialized!.

What is the .terraform folder?

Terraform creates a .terraform directory in your project folder. This is where it stores downloaded provider plugins and module caches. You never edit this folder directly. If something goes wrong, you can delete it and re-run terraform init to start fresh.

Seeing a provider error or network timeout?

  • If you see a network error, check your internet connection. Terraform needs to download the provider from the Terraform Registry.
  • If you see an error about no configuration files, confirm you are running the command from the eu-compliance-infra folder (the one containing terraform.tf).
  • If you see a credentials error, confirm your AWS CLI is configured correctly by running aws sts get-caller-identity. You should see your account ID in the response.

Help me troubleshoot terraform init failures

Your Terraform workspace is initialized and locked to the EU. Next up, you'll create an encrypted S3 bucket that stores data in compliance with GDPR Article 32.

Create an Encrypted S3 Bucket for GDPR Data Storage

Your Terraform workspace is initialized and locked to EU regions.

Now you need somewhere to actually store data. Under GDPR Article 32, personal data must be encrypted both at rest and in transit. In this step, you will create a customer-managed encryption key and an S3 bucket that enforces encryption, versioning, and SSL-only access.

In this step, get ready to:
  • Create a customer-managed KMS encryption key restricted to your AWS account.
  • Define an S3 bucket with versioning, encryption, public access block, and SSL enforcement.
  • Deploy the storage layer with terraform apply.
Create the KMS encryption key

AWS KMS (Key Management Service) lets you create encryption keys that you control. A customer-managed key gives you full visibility over who can use the key and when it rotates.

  • In VS Code, create a new file called kms.tf inside your eu-compliance-infra folder.
  • Paste the following code into kms.tf:
data "aws_caller_identity" "current" {}

resource "aws_kms_key" "gdpr_data" {
  description             = "KMS key for GDPR-compliant data encryption"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableRootAccountAccess"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      }
    ]
  })
}

resource "aws_kms_alias" "gdpr_data" {
  name          = "alias/${var.project_name}-gdpr-key"
  target_key_id = aws_kms_key.gdpr_data.key_id
}

What does this code do?

The data "aws_caller_identity" block looks up your AWS account ID at plan time. This lets you reference it dynamically in the key policy without hardcoding.

The aws_kms_key resource creates a customer-managed encryption key with automatic yearly rotation. The key policy grants full access only to the root account of your AWS account.

The aws_kms_alias gives the key a human-readable name so you can find it easily in the AWS console.

  • Save kms.tf.

Seeing a syntax error?

Make sure the jsonencode block uses curly braces {} for objects and square brackets [] for arrays. HCL's jsonencode function converts HCL syntax into JSON automatically.

Also check that the interpolation ${data.aws_caller_identity.current.account_id} is inside double quotes within the policy string.

help me fix a syntax error in my kms.tf file

✔️ Awesome, I've got everything!

Great. Make sure you have saved kms.tf before moving on.

ⓧ I'd like to double check the full code

data "aws_caller_identity" "current" {}

resource "aws_kms_key" "gdpr_data" {
  description             = "KMS key for GDPR-compliant data encryption"
  deletion_window_in_days = 7
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableRootAccountAccess"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      }
    ]
  })
}

resource "aws_kms_alias" "gdpr_data" {
  name          = "alias/${var.project_name}-gdpr-key"
  target_key_id = aws_kms_key.gdpr_data.key_id
}
Define the S3 bucket with GDPR controls

The S3 bucket needs multiple layers of protection. Versioning preserves previous versions of objects for data integrity. Server-side encryption ensures data is encrypted at rest. Public access blocks and SSL enforcement control who can reach the data and how.

  • Create a new file called storage.tf inside your eu-compliance-infra folder.
  • Paste the bucket, versioning, and encryption configuration into storage.tf:
resource "aws_s3_bucket" "gdpr_data" {
  bucket_prefix = "${var.project_name}-data-"
  force_destroy = true
}

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

  versioning_configuration {
    status = "Enabled"
  }
}

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

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.gdpr_data.arn
    }
    bucket_key_enabled = true
  }
}

What does this code do?

The aws_s3_bucket resource creates the bucket with a prefixed name. The force_destroy = true setting lets Terraform delete the bucket even when it contains objects, which is useful for cleanup later.

Versioning keeps a history of every object change. This satisfies GDPR data integrity requirements by preventing accidental or malicious data loss.

The encryption configuration applies your KMS key by default to every object uploaded to this bucket. The bucket_key_enabled = true setting reduces KMS API costs by caching a bucket-level key.

  • Add the public access block and bucket policy below the encryption configuration in storage.tf:
resource "aws_s3_bucket_public_access_block" "gdpr_data" {
  bucket = aws_s3_bucket.gdpr_data.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_policy" "gdpr_data_ssl_only" {
  bucket = aws_s3_bucket.gdpr_data.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "EnforceSSLOnly"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource = [
          aws_s3_bucket.gdpr_data.arn,
          "${aws_s3_bucket.gdpr_data.arn}/*"
        ]
        Condition = {
          Bool = {
            "aws:SecureTransport" = "false"
          }
        }
      }
    ]
  })
}

What does this code do?

The public access block sets all four protection flags to true. This prevents anyone from accidentally making the bucket or its objects public through ACLs or policies.

The bucket policy denies all S3 actions when the request does not use HTTPS (aws:SecureTransport = false). This enforces encryption in transit for every connection to the bucket.

  • Save storage.tf.

Seeing a reference error for aws_kms_key?

If Terraform reports that aws_kms_key.gdpr_data is not defined, make sure you saved kms.tf first. Terraform reads all .tf files in the same directory, so both files must be present.

help me fix a reference error between my storage.tf and kms.tf files

✔️ Awesome, I've got everything!

Great. Make sure you have saved storage.tf before moving on.

ⓧ I'd like to double check the full code

resource "aws_s3_bucket" "gdpr_data" {
  bucket_prefix = "${var.project_name}-data-"
  force_destroy = true
}

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

  versioning_configuration {
    status = "Enabled"
  }
}

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

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm     = "aws:kms"
      kms_master_key_id = aws_kms_key.gdpr_data.arn
    }
    bucket_key_enabled = true
  }
}

resource "aws_s3_bucket_public_access_block" "gdpr_data" {
  bucket = aws_s3_bucket.gdpr_data.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_policy" "gdpr_data_ssl_only" {
  bucket = aws_s3_bucket.gdpr_data.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "EnforceSSLOnly"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource = [
          aws_s3_bucket.gdpr_data.arn,
          "${aws_s3_bucket.gdpr_data.arn}/*"
        ]
        Condition = {
          Bool = {
            "aws:SecureTransport" = "false"
          }
        }
      }
    ]
  })
}
Deploy the storage layer

Both files are ready. Time to preview what Terraform will create and then deploy the resources to your AWS account.

  • Preview the resources Terraform will create by running this command in your terminal:
terraform plan

What should I see?

Terraform displays a list of resources it plans to create, each marked with a + symbol. You should see entries for the KMS key, KMS alias, S3 bucket, versioning, encryption configuration, public access block, and bucket policy. The summary line at the bottom shows how many resources will be added.

  • Deploy the storage layer by running:
terraform apply

What happens when I run this?

Terraform shows the same plan and then asks you to confirm. Type yes and press Enter to proceed. Terraform creates the resources in the correct order (KMS key first, then the bucket resources that depend on it).

  • Type yes when prompted and press Enter.
  • Wait for the apply to complete. You should see a message saying resources were added and zero destroyed.

Verify in the AWS console

You can confirm the bucket exists by opening the S3 console in your browser and checking that a bucket starting with eu-compliance-data- appears in the eu-central-1 region.

Seeing an access denied error?

Make sure your AWS CLI credentials have permission to create KMS keys and S3 buckets. If you are using an IAM user, it needs at minimum the AmazonS3FullAccess and AWSKeyManagementServicePowerUser managed policies.

Also confirm that your AWS CLI is configured for eu-central-1 by running aws configure get region.

help me fix an access denied error when running terraform apply

Your encrypted storage layer is live. Next up, you will learn why this encryption alone is not enough to protect data from the US CLOUD Act and build a client-side encryption workflow that closes the gap.

Mitigate the US CLOUD Act with Client-Side Encryption

Your S3 bucket is encrypted with a customer-managed KMS key in eu-central-1. That sounds secure, but there is a critical legal gap.

The US CLOUD Act (2018) allows US authorities to compel any US-incorporated company to hand over data, regardless of where it is physically stored. AWS is US-incorporated. Your KMS key sits on AWS infrastructure. If compelled, AWS could decrypt your data. In this step, you will add a layer of client-side encryption that makes your data useless even if AWS is forced to hand it over.

In this step, get ready to:
  • Understand the CLOUD Act gap in server-side-only encryption.
  • Create a Terraform file that documents CLOUD Act risk and enforces encrypted uploads.
  • Write a PowerShell helper script that uploads data with client-side encryption keys.
Understand the CLOUD Act gap

In the previous step, you encrypted your bucket with a KMS key. AWS manages that key inside their infrastructure. Here is the problem.

Why is KMS encryption not enough?

AWS KMS keys are stored and operated by AWS. If a US court issues a CLOUD Act warrant, AWS can be compelled to use the key to decrypt your data and hand it over. The data is in the EU, but the legal obligation follows the company, not the data center.

The defense is encrypting data before it ever reaches AWS. You generate the encryption key on your own machine, encrypt the data locally, and send only ciphertext to S3. AWS never sees the key. Even under legal compulsion, AWS can only hand over encrypted data they cannot decrypt.

What is SSE-C?

SSE-C stands for Server-Side Encryption with Customer-Provided Keys. You provide your encryption key with each upload and download request over TLS. AWS uses it to encrypt/decrypt on the fly but does not store the key. The stored object is always ciphertext.

This is different from KMS encryption where AWS manages the key lifecycle. With SSE-C, you own the key entirely.

Create the CLOUD Act mitigation Terraform file

You will now create a new Terraform file that embeds the CLOUD Act risk as a documented local value and enforces a bucket policy requiring encryption on all uploads.

  • In VS Code, create a new file called cloud-act-mitigation.tf inside your eu-compliance-infra folder.
  • Add the following locals block that documents the CLOUD Act risk directly in your infrastructure code:
locals {
  cloud_act_notice = <<-EOT
    CLOUD ACT RISK NOTICE:
    AWS (Amazon.com Inc.) is a US-incorporated company subject to the US CLOUD Act
    (Clarifying Lawful Overseas Use of Data Act, 2018). This law allows US authorities
    to compel US companies to hand over data regardless of where the data is physically
    stored. Storing data in eu-central-1 does NOT protect it from CLOUD Act requests.

    MITIGATION STRATEGY:
    This infrastructure uses client-side encryption (SSE-C) for sensitive data.
    The encryption key is generated and stored locally, never transmitted to AWS in
    plaintext at rest. AWS only stores ciphertext. Even if compelled by CLOUD Act,
    AWS can only provide encrypted data they cannot decrypt.

    For maximum sovereignty (SEAL-3/4), consider migrating sensitive workloads to
    EU-incorporated providers (OVHcloud, Scaleway, STACKIT) where CLOUD Act does
    not apply.
  EOT
}

Why embed a risk notice in Terraform code?

This locals block is not consumed by any resource. It serves as living documentation that travels with your infrastructure code. Anyone reading your Terraform configuration immediately understands the legal risk and the mitigation strategy in place.

This is a best practice for compliance-sensitive projects. Regulatory context belongs next to the controls that implement it.

  • Below the locals block in the same file, add the bucket policy resource that enforces encrypted uploads:
resource "aws_s3_bucket_policy" "enforce_client_encryption" {
  bucket = aws_s3_bucket.gdpr_data.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "EnforceSSLOnly"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource = [
          aws_s3_bucket.gdpr_data.arn,
          "${aws_s3_bucket.gdpr_data.arn}/*"
        ]
        Condition = {
          Bool = {
            "aws:SecureTransport" = "false"
          }
        }
      },
      {
        Sid       = "DenyUnencryptedUploads"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:PutObject"
        Resource  = "${aws_s3_bucket.gdpr_data.arn}/*"
        Condition = {
          StringNotEquals = {
            "s3:x-amz-server-side-encryption" = ["aws:kms", "AES256"]
          }
        }
      }
    ]
  })
}

What does this policy do?

This policy has two statements that work together:

  • EnforceSSLOnly denies any S3 operation made over plain HTTP. All traffic must use HTTPS (TLS encryption in transit).
  • DenyUnencryptedUploads denies any PutObject request that does not include server-side encryption (either aws:kms or AES256). This ensures nothing is ever stored unencrypted.

Together, these statements guarantee that data is encrypted both in transit and at rest. The new policy supersedes the original SSL-only policy from storage.tf by combining that rule with the new encryption enforcement.

  • Save cloud-act-mitigation.tf.
  • Apply the updated configuration by running:
terraform apply
  • When prompted, type yes and press Enter to confirm.
  • You should see Terraform create the new bucket policy resource. Look for Apply complete! in the output.

Seeing a policy conflict error?

If Terraform reports a conflict between the existing gdpr_data_ssl_only policy and the new enforce_client_encryption policy, remove the aws_s3_bucket_policy resource block from storage.tf (the one named gdpr_data_ssl_only). The new policy in cloud-act-mitigation.tf already includes the SSL enforcement rule, so nothing is lost.

Help me resolve a Terraform bucket policy conflict between gdpr_data_ssl_only and enforce_client_encryption

Create the client-side encryption upload script

The bucket policy enforces that uploads must be encrypted. Now you need a way to actually perform those encrypted uploads. This script demonstrates the SSE-C workflow where the encryption key never leaves your machine.

  • Create a new file called upload-encrypted.ps1 in your eu-compliance-infra folder.
  • Paste the following script into the file:
# CLOUD Act Mitigation: Client-Side Encryption Upload Script
# This script encrypts data BEFORE sending it to AWS S3
# The encryption key never leaves your local machine
# AWS only stores ciphertext they cannot decrypt

# Generate a 256-bit encryption key (store this securely - NOT on AWS!)
$EncryptionKey = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }) )

# Display the key (in production, store in an EU-based key vault)
Write-Host "Generated encryption key (KEEP THIS SAFE, NOT ON AWS):"
Write-Host $EncryptionKey
Write-Host ""

# Calculate MD5 of the key for AWS SSE-C requirement
$KeyBytes = [Convert]::FromBase64String($EncryptionKey)
$MD5 = [System.Security.Cryptography.MD5]::Create()
$KeyMD5 = [Convert]::ToBase64String($MD5.ComputeHash($KeyBytes))

# Create a sample file with EU citizen data
$SampleData = "name: Hans Mueller`nemail: hans@example.eu`nid: EU-CITIZEN-12345"
$SampleData | Out-File -FilePath "sample-gdpr-data.txt" -Encoding UTF8

# Get the bucket name from Terraform output
$BucketName = terraform output -raw gdpr_data_bucket_name

# Upload with client-side encryption (SSE-C)
# AWS receives ONLY the encrypted version - they cannot decrypt it
aws s3api put-object `
    --bucket $BucketName `
    --key "gdpr-data/sample-gdpr-data.txt" `
    --body "sample-gdpr-data.txt" `
    --sse-customer-algorithm AES256 `
    --sse-customer-key $EncryptionKey `
    --sse-customer-key-md5 $KeyMD5

Write-Host ""
Write-Host "File uploaded with client-side encryption."
Write-Host "AWS CANNOT decrypt this data - even under CLOUD Act compulsion."
Write-Host ""
Write-Host "To download and decrypt, you MUST provide the same key:"
Write-Host "aws s3api get-object --bucket $BucketName --key gdpr-data/sample-gdpr-data.txt --sse-customer-algorithm AES256 --sse-customer-key $EncryptionKey --sse-customer-key-md5 $KeyMD5 downloaded.txt"

# Clean up local sample file
Remove-Item "sample-gdpr-data.txt"

What does this script do?

This script demonstrates the full SSE-C workflow:

  • Generates a random 256-bit encryption key on your local machine. This key never gets stored on AWS.
  • Computes the key's MD5 hash, which AWS requires to verify the key was transmitted correctly over TLS.
  • Creates a sample file with mock EU citizen data to demonstrate the upload.
  • Uploads the file using aws s3api put-object with the --sse-customer-algorithm, --sse-customer-key, and --sse-customer-key-md5 flags. AWS encrypts the data with your key, then discards the key from memory.

Without the original key, no one can decrypt the stored object. Not AWS, not a CLOUD Act warrant, not anyone.

  • Save upload-encrypted.ps1.

You will run this script later

This script depends on a Terraform output (gdpr_data_bucket_name) that you will define in Step 5. For now, just save the file. You will run it after outputs are configured.

Not sure about PowerShell scripts?

This script uses PowerShell (.ps1), which is built into Windows. You do not need to install anything extra. When you run it later, you will execute it from your terminal with .\upload-encrypted.ps1.

Help me understand how PowerShell scripts work with AWS CLI and SSE-C encryption

✔️ Awesome, I've got everything!

Great. Double check you have saved both cloud-act-mitigation.tf and upload-encrypted.ps1.

ⓧ I'd like to double check the full code

Here is the complete cloud-act-mitigation.tf file:

locals {
  cloud_act_notice = <<-EOT
    CLOUD ACT RISK NOTICE:
    AWS (Amazon.com Inc.) is a US-incorporated company subject to the US CLOUD Act
    (Clarifying Lawful Overseas Use of Data Act, 2018). This law allows US authorities
    to compel US companies to hand over data regardless of where the data is physically
    stored. Storing data in eu-central-1 does NOT protect it from CLOUD Act requests.

    MITIGATION STRATEGY:
    This infrastructure uses client-side encryption (SSE-C) for sensitive data.
    The encryption key is generated and stored locally, never transmitted to AWS in
    plaintext at rest. AWS only stores ciphertext. Even if compelled by CLOUD Act,
    AWS can only provide encrypted data they cannot decrypt.

    For maximum sovereignty (SEAL-3/4), consider migrating sensitive workloads to
    EU-incorporated providers (OVHcloud, Scaleway, STACKIT) where CLOUD Act does
    not apply.
  EOT
}

resource "aws_s3_bucket_policy" "enforce_client_encryption" {
  bucket = aws_s3_bucket.gdpr_data.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid       = "EnforceSSLOnly"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:*"
        Resource = [
          aws_s3_bucket.gdpr_data.arn,
          "${aws_s3_bucket.gdpr_data.arn}/*"
        ]
        Condition = {
          Bool = {
            "aws:SecureTransport" = "false"
          }
        }
      },
      {
        Sid       = "DenyUnencryptedUploads"
        Effect    = "Deny"
        Principal = "*"
        Action    = "s3:PutObject"
        Resource  = "${aws_s3_bucket.gdpr_data.arn}/*"
        Condition = {
          StringNotEquals = {
            "s3:x-amz-server-side-encryption" = ["aws:kms", "AES256"]
          }
        }
      }
    ]
  })
}

Here is the complete upload-encrypted.ps1 file:

# CLOUD Act Mitigation: Client-Side Encryption Upload Script
# This script encrypts data BEFORE sending it to AWS S3
# The encryption key never leaves your local machine
# AWS only stores ciphertext they cannot decrypt

# Generate a 256-bit encryption key (store this securely - NOT on AWS!)
$EncryptionKey = [Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Maximum 256 }) )

# Display the key (in production, store in an EU-based key vault)
Write-Host "Generated encryption key (KEEP THIS SAFE, NOT ON AWS):"
Write-Host $EncryptionKey
Write-Host ""

# Calculate MD5 of the key for AWS SSE-C requirement
$KeyBytes = [Convert]::FromBase64String($EncryptionKey)
$MD5 = [System.Security.Cryptography.MD5]::Create()
$KeyMD5 = [Convert]::ToBase64String($MD5.ComputeHash($KeyBytes))

# Create a sample file with EU citizen data
$SampleData = "name: Hans Mueller`nemail: hans@example.eu`nid: EU-CITIZEN-12345"
$SampleData | Out-File -FilePath "sample-gdpr-data.txt" -Encoding UTF8

# Get the bucket name from Terraform output
$BucketName = terraform output -raw gdpr_data_bucket_name

# Upload with client-side encryption (SSE-C)
# AWS receives ONLY the encrypted version - they cannot decrypt it
aws s3api put-object `
    --bucket $BucketName `
    --key "gdpr-data/sample-gdpr-data.txt" `
    --body "sample-gdpr-data.txt" `
    --sse-customer-algorithm AES256 `
    --sse-customer-key $EncryptionKey `
    --sse-customer-key-md5 $KeyMD5

Write-Host ""
Write-Host "File uploaded with client-side encryption."
Write-Host "AWS CANNOT decrypt this data - even under CLOUD Act compulsion."
Write-Host ""
Write-Host "To download and decrypt, you MUST provide the same key:"
Write-Host "aws s3api get-object --bucket $BucketName --key gdpr-data/sample-gdpr-data.txt --sse-customer-algorithm AES256 --sse-customer-key $EncryptionKey --sse-customer-key-md5 $KeyMD5 downloaded.txt"

# Clean up local sample file
Remove-Item "sample-gdpr-data.txt"

Your data bucket now rejects any upload that is not encrypted. Next up, you will enable CloudTrail audit logging to satisfy DORA's incident management requirements.

Enable CloudTrail Audit Logging

Your encrypted data storage is in place with client-side encryption protecting against the CLOUD Act. Now you need to prove that your infrastructure can detect and record security incidents.

DORA Pillar 2 requires financial entities to have incident management capabilities. CloudTrail is AWS's audit logging service that records every API call made in your account. In this step, you'll create a dedicated audit bucket and a CloudTrail trail that logs all management events in your EU region.

In this step, get ready to:
  • Create an audit log S3 bucket with its own encryption and access policies.
  • Deploy a CloudTrail trail that records management events to the audit bucket.
  • Add Terraform outputs that display key resource identifiers and a CLOUD Act warning.
Create the audit log bucket and CloudTrail trail

CloudTrail needs its own S3 bucket to store log files. This bucket must have a specific policy that grants the CloudTrail service permission to write logs into it.

  • In VS Code, create a new file called audit.tf in your eu-compliance-infra folder.
  • Add the audit bucket infrastructure by pasting this code:
resource "aws_s3_bucket" "audit_logs" {
  bucket_prefix = "${var.project_name}-audit-"
  force_destroy = true
}

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

  versioning_configuration {
    status = "Enabled"
  }
}

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

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

resource "aws_s3_bucket_public_access_block" "audit_logs" {
  bucket = aws_s3_bucket.audit_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

What does this code do?

This creates a dedicated S3 bucket for audit logs with the same security hardening as your data bucket.

  • bucket_prefix generates a unique name starting with eu-compliance-audit-.
  • force_destroy = true lets Terraform delete the bucket even if it contains log files (useful for cleanup).
  • AES256 encryption protects the logs at rest. Versioning preserves the full history so logs cannot be silently overwritten.
  • Save audit.tf.

Next, the bucket needs a policy that explicitly allows CloudTrail to write into it. Without this, CloudTrail has no permission to deliver logs.

  • Add the bucket policy below your existing code in audit.tf:
resource "aws_s3_bucket_policy" "audit_logs" {
  bucket = aws_s3_bucket.audit_logs.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.audit_logs.arn
        Condition = {
          StringEquals = {
            "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-audit-trail"
          }
        }
      },
      {
        Sid    = "AWSCloudTrailWrite"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.audit_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl"  = "bucket-owner-full-control"
            "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-audit-trail"
          }
        }
      }
    ]
  })
}

What does this policy do?

This grants the CloudTrail service two specific permissions:

  • AWSCloudTrailAclCheck lets CloudTrail verify it has permission to write to this bucket before it starts delivering logs.
  • AWSCloudTrailWrite lets CloudTrail place log files under the AWSLogs/ prefix. The aws:SourceArn condition ensures only YOUR specific trail can write here.

This satisfies GDPR Article 30's requirement for records of processing activities. Every API call in your account is now traceable.

  • Save audit.tf.

Now add the CloudTrail trail itself, which is the resource that actually captures API events and delivers them to the bucket.

  • Add the trail resource below the bucket policy in audit.tf:
resource "aws_cloudtrail" "audit" {
  name                          = "${var.project_name}-audit-trail"
  s3_bucket_name                = aws_s3_bucket.audit_logs.id
  is_multi_region_trail         = false
  include_global_service_events = true
  enable_log_file_validation    = true

  depends_on = [aws_s3_bucket_policy.audit_logs]
}

What does this trail configuration do?

  • is_multi_region_trail = false restricts logging to eu-central-1 only. This keeps your audit data in the EU.
  • include_global_service_events = true captures IAM and STS calls that occur globally but affect your account.
  • enable_log_file_validation = true creates a digest file so you can verify logs haven't been tampered with. This supports DORA's requirement for integrity of incident records.
  • depends_on ensures the bucket policy exists before Terraform creates the trail. Without this, CloudTrail would fail to deliver its first log.
  • Save audit.tf.

Seeing a syntax error in audit.tf?

  • Check that every opening { has a matching closing }. The bucket policy has deeply nested braces that are easy to miss.
  • Make sure jsonencode( has a matching ) at the end of the policy block.
  • Verify you have a blank line between each resource block.

help me fix a syntax error in my audit.tf Terraform file

Add Terraform outputs

Terraform outputs let you easily retrieve important values after a deploy. You'll add outputs for every key resource so you can reference bucket names and ARNs without digging through the AWS console.

  • Create a new file called outputs.tf in your eu-compliance-infra folder.
  • Paste the following outputs:
output "gdpr_data_bucket_name" {
  description = "Name of the GDPR-compliant data bucket"
  value       = aws_s3_bucket.gdpr_data.id
}

output "audit_logs_bucket_name" {
  description = "Name of the audit logs bucket"
  value       = aws_s3_bucket.audit_logs.id
}

output "kms_key_arn" {
  description = "ARN of the GDPR data encryption key"
  value       = aws_kms_key.gdpr_data.arn
}

output "cloudtrail_arn" {
  description = "ARN of the CloudTrail audit trail"
  value       = aws_cloudtrail.audit.arn
}

output "region_restriction_policy_arn" {
  description = "ARN of the EU region restriction policy"
  value       = aws_iam_policy.eu_region_restriction.arn
}

output "cloud_act_risk_notice" {
  description = "CLOUD Act compliance notice"
  value       = "WARNING: AWS is US-incorporated. Data encrypted with SSE-C (client-side keys) is protected. Data encrypted with KMS only has reduced protection under CLOUD Act. For SEAL-3+ sovereignty, use EU-native providers."
}

What do these outputs do?

Each output exports a value that Terraform displays after every apply. They also enable other Terraform configurations to reference these resources.

  • The bucket names and ARNs let you quickly find your resources without searching the console.
  • The cloud_act_risk_notice output is a compliance reminder embedded directly in your infrastructure code. It warns that CloudTrail logs are visible to AWS, so you should never pass personal data in API parameters.

The region restriction output won't work yet

The region_restriction_policy_arn output references a resource you'll create in the next step. Comment it out for now by adding # before each of its four lines. You'll uncomment it after creating region-restriction.tf in Step 6.

  • Save outputs.tf.
Apply and verify the trail is active

With both files saved, you can deploy the audit infrastructure and confirm CloudTrail is recording events.

  • Deploy the audit resources by running:
terraform apply
  • Terraform will show a plan with several new resources (the audit bucket, its policies, and the CloudTrail trail).
  • Type yes when prompted to confirm.

What should I see?

Terraform displays an "Apply complete" message with the number of resources added. You should see your outputs printed at the bottom, including the bucket names, KMS key ARN, CloudTrail ARN, and the CLOUD Act risk notice.

  • Verify the trail is active by running:
aws cloudtrail get-trail-status --name eu-compliance-audit-trail

What does this command do?

This queries AWS for the current status of your trail. Look for "IsLogging": true in the output. That confirms CloudTrail is actively recording API events and delivering them to your audit bucket.

  • Confirm you see "IsLogging": true in the JSON response.

Trail not logging or apply failed?

  • If you see an error about aws_iam_policy.eu_region_restriction, make sure you commented out the region_restriction_policy_arn output block in outputs.tf.
  • If CloudTrail fails to create, check that the bucket policy was applied first. Terraform's depends_on should handle this, but if you modified the order, the trail may try to write before the policy exists.
  • If IsLogging shows false, wait 30 seconds and run the status command again. CloudTrail can take a moment to start.

help me troubleshoot my CloudTrail trail not logging in Terraform

✔️ Awesome, I've got everything!

Great. Double check you've saved both audit.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete audit.tf:

resource "aws_s3_bucket" "audit_logs" {
  bucket_prefix = "${var.project_name}-audit-"
  force_destroy = true
}

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

  versioning_configuration {
    status = "Enabled"
  }
}

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

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

resource "aws_s3_bucket_public_access_block" "audit_logs" {
  bucket = aws_s3_bucket.audit_logs.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_bucket_policy" "audit_logs" {
  bucket = aws_s3_bucket.audit_logs.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "AWSCloudTrailAclCheck"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:GetBucketAcl"
        Resource = aws_s3_bucket.audit_logs.arn
        Condition = {
          StringEquals = {
            "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-audit-trail"
          }
        }
      },
      {
        Sid    = "AWSCloudTrailWrite"
        Effect = "Allow"
        Principal = {
          Service = "cloudtrail.amazonaws.com"
        }
        Action   = "s3:PutObject"
        Resource = "${aws_s3_bucket.audit_logs.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*"
        Condition = {
          StringEquals = {
            "s3:x-amz-acl"  = "bucket-owner-full-control"
            "aws:SourceArn" = "arn:aws:cloudtrail:${var.aws_region}:${data.aws_caller_identity.current.account_id}:trail/${var.project_name}-audit-trail"
          }
        }
      }
    ]
  })
}

resource "aws_cloudtrail" "audit" {
  name                          = "${var.project_name}-audit-trail"
  s3_bucket_name                = aws_s3_bucket.audit_logs.id
  is_multi_region_trail         = false
  include_global_service_events = true
  enable_log_file_validation    = true

  depends_on = [aws_s3_bucket_policy.audit_logs]
}

Here is the complete outputs.tf (uncomment region_restriction_policy_arn after Step 6):

output "gdpr_data_bucket_name" {
  description = "Name of the GDPR-compliant data bucket"
  value       = aws_s3_bucket.gdpr_data.id
}

output "audit_logs_bucket_name" {
  description = "Name of the audit logs bucket"
  value       = aws_s3_bucket.audit_logs.id
}

output "kms_key_arn" {
  description = "ARN of the GDPR data encryption key"
  value       = aws_kms_key.gdpr_data.arn
}

output "cloudtrail_arn" {
  description = "ARN of the CloudTrail audit trail"
  value       = aws_cloudtrail.audit.arn
}

output "region_restriction_policy_arn" {
  description = "ARN of the EU region restriction policy"
  value       = aws_iam_policy.eu_region_restriction.arn
}

output "cloud_act_risk_notice" {
  description = "CLOUD Act compliance notice"
  value       = "WARNING: AWS is US-incorporated. Data encrypted with SSE-C (client-side keys) is protected. Data encrypted with KMS only has reduced protection under CLOUD Act. For SEAL-3+ sovereignty, use EU-native providers."
}

Your audit trail is now recording every API call in eu-central-1. Next up, you'll create a region restriction policy that prevents anyone from accidentally creating resources outside EU boundaries.

Enforce EU Region Restriction

Your encrypted storage layer and audit trail are deployed. There is one more critical control missing: preventing anyone from accidentally creating resources outside the EU.

DORA's Pillar 4 (Third-Party Risk Management) requires organizations to control where their cloud provider operates on their behalf. An IAM policy that denies resource creation outside EU regions enforces this at the infrastructure level.

In this step, get ready to:
  • Create a region-restriction IAM policy that denies resource creation outside EU regions.
  • Deploy and attach the policy to your IAM user.
  • Test the restriction by attempting to create a resource in us-east-1.
Create the region restriction policy

This IAM policy uses a Deny effect with a region condition. Any attempt to create key resource types outside the approved EU region list gets blocked automatically.

  • Create a new file called region-restriction.tf in your eu-compliance-infra folder by pasting the following code:
resource "aws_iam_policy" "eu_region_restriction" {
  name        = "${var.project_name}-eu-region-only"
  description = "Deny resource creation outside EU regions - DORA compliance"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyNonEURegions"
        Effect = "Deny"
        Action = [
          "ec2:RunInstances",
          "rds:CreateDBInstance",
          "s3:CreateBucket",
          "dynamodb:CreateTable",
          "lambda:CreateFunction"
        ]
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:RequestedRegion" = [
              "eu-west-1",
              "eu-west-2",
              "eu-west-3",
              "eu-central-1",
              "eu-central-2",
              "eu-north-1",
              "eu-south-1",
              "eu-south-2"
            ]
          }
        }
      }
    ]
  })
}

What does this policy do?

The StringNotEquals condition checks the aws:RequestedRegion for every API call. If the request targets a region NOT in the approved EU list, the policy denies it outright.

The five actions covered are the most common resource-creation operations: launching EC2 instances, creating RDS databases, creating S3 buckets, creating DynamoDB tables, and creating Lambda functions. In production, you would expand this list to cover all services your team uses.

  • Save region-restriction.tf.

Seeing a syntax error?

Make sure every opening bracket has a matching closing bracket. The nested jsonencode block has three levels of nesting (policy, Statement array, Condition). Check that the StringNotEquals key is inside the Condition block, not at the Statement level.

help me debug a Terraform syntax error in my region-restriction.tf file

Deploy and attach the policy

Creating the policy in IAM does not enforce it on anyone. You need to attach the policy to your IAM user to activate the restriction.

  • Deploy the region restriction policy by running:
terraform apply -auto-approve

What does this create?

Terraform creates a new IAM managed policy in your AWS account. The outputs.tf file you created earlier already references this policy's ARN, so the region_restriction_policy_arn output now resolves correctly.

  • You should see Terraform output showing 1 resource added (the IAM policy) and all outputs displayed, including region_restriction_policy_arn.
  • Find your IAM username by running:
aws iam get-user --query "User.UserName" --output text
  • Note the username that appears in your terminal output.
  • Attach the region restriction policy to your IAM user by running:
aws iam attach-user-policy --user-name [[IAM_USERNAME="your IAM username from the previous command"]] --policy-arn $(terraform output -raw region_restriction_policy_arn)

Why attach via CLI instead of Terraform?

Attaching a Deny policy to your own user through Terraform is risky. If the Terraform state drifts or you lose access, you could lock yourself out. Doing it via CLI makes it easy to reverse manually if something goes wrong.

Getting an access denied error?

Your IAM user needs the iam:AttachUserPolicy permission. If you are using an admin user or a user with the IAMFullAccess managed policy, this should work. If not, ask your account administrator to attach the policy for you.

help me resolve an access denied error when attaching an IAM policy

Test the restriction

The policy is now active on your user. Any attempt to create resources outside EU regions should be denied.

  • Test the restriction by attempting to create an S3 bucket in us-east-1:
aws s3api create-bucket --bucket test-eu-restriction-check --region us-east-1
  • You should see an AccessDenied error. This confirms your region restriction policy is working.

What should I see?

The AWS CLI returns an error indicating the request was denied. This is the expected outcome. The DenyNonEURegions statement in your policy blocked the s3:CreateBucket action because us-east-1 is not in the approved EU regions list.

  • Confirm all your Terraform resources remain within EU boundaries by running:
terraform plan
  • You should see a message indicating no changes are needed. All your infrastructure is already deployed in eu-central-1 and compliant with the region restriction.

Detach the policy after testing

For this learning project, you may want to detach the policy so it does not interfere with other AWS work. Run the following command to remove it from your user:

aws iam detach-user-policy --user-name [[IAM_USERNAME="your IAM username"]] --policy-arn $(terraform output -raw region_restriction_policy_arn)

The policy still exists in your account (managed by Terraform). You can re-attach it anytime.

Bucket creation succeeded instead of being denied?

If the bucket was created, the policy may not be attached correctly. Run aws iam list-attached-user-policies --user-name YOUR_USERNAME to check that the eu-compliance-eu-region-only policy appears in the list. If it does not, re-run the attach command from the previous substep.

If the bucket was created accidentally, delete it immediately by running aws s3api delete-bucket --bucket test-eu-restriction-check --region us-east-1.

help me figure out why my IAM region restriction policy is not blocking resource creation outside EU

✔️ Awesome, I've got everything!

Great. Make sure you have saved region-restriction.tf and that terraform plan shows no pending changes.

ⓧ I'd like to double check the full code

resource "aws_iam_policy" "eu_region_restriction" {
  name        = "${var.project_name}-eu-region-only"
  description = "Deny resource creation outside EU regions - DORA compliance"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "DenyNonEURegions"
        Effect = "Deny"
        Action = [
          "ec2:RunInstances",
          "rds:CreateDBInstance",
          "s3:CreateBucket",
          "dynamodb:CreateTable",
          "lambda:CreateFunction"
        ]
        Resource = "*"
        Condition = {
          StringNotEquals = {
            "aws:RequestedRegion" = [
              "eu-west-1",
              "eu-west-2",
              "eu-west-3",
              "eu-central-1",
              "eu-central-2",
              "eu-north-1",
              "eu-south-1",
              "eu-south-2"
            ]
          }
        }
      }
    ]
  })
}

Your compliance infrastructure is complete. You have encryption, audit logging, CLOUD Act mitigation, and now an enforceable region boundary. Next up, you will take on the Secret Mission to add data lifecycle automation and monitoring.

Secret mission

Add Data Lifecycle and Monitoring

Your infrastructure enforces data residency and encryption. But GDPR Article 5 also requires data minimization. Data that is no longer needed must be deleted. And DORA requires continuous monitoring for security incidents. Can you add automatic data expiration and an alarm that fires when unauthorized API calls are detected?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS resources that cost approximately $1/month for the KMS key, so deleting promptly keeps your costs at zero.

Cost warning

Your customer-managed KMS key costs $1/month (prorated hourly). If you leave it running for a full month, you will be charged $1. The S3 buckets and CloudTrail trail are covered by the AWS Free Tier for minimal storage, but the KMS key is not.

Run terraform destroy at the end of this section to stop all charges immediately.

Resources you used:

  • 1 KMS customer-managed key (eu-central-1).
  • 2 S3 buckets (GDPR data bucket and audit logs bucket).
  • 1 CloudTrail trail (eu-compliance-audit-trail).
  • 1 IAM policy (EU region restriction).
  • Local project folder (eu-compliance-infra).

Keep everything running

No action needed. Choose this if you are still actively building on this infrastructure or plan to extend it with the Secret Mission.

  • Your KMS key will cost approximately $1/month while active.
  • S3 storage and CloudTrail remain within the Free Tier for minimal usage.
  • Terraform and AWS CLI remain installed on your machine.

Pause - I'll come back to this later

These AWS resources cannot be paused individually. The KMS key will continue to cost $1/month as long as it exists. If you want to stop charges but keep your Terraform code for later, delete the AWS resources and redeploy when you are ready.

  • Run terraform destroy to remove all AWS resources and stop KMS charges.
  • Keep your local eu-compliance-infra folder intact so you can redeploy with terraform apply later.

Delete - I don't want to use this again

Remove all project resources and stop all charges. This deletes everything from AWS and your local machine.

  • Open your terminal and navigate to your eu-compliance-infra folder.
  • Destroy all AWS resources by running:
terraform destroy
  • Type yes when prompted to confirm the destruction.
  • Verify the output shows all resources have been destroyed.
  • Delete your local project folder by running:

macOS

cd ~/Desktop
rm -rf eu-compliance-infra

Windows

cd ~\Desktop
Remove-Item -Recurse -Force eu-compliance-infra
  • Uninstall Terraform by running:
winget uninstall HashiCorp.Terraform
  • Uninstall the AWS CLI by opening Settings, selecting Apps, then Installed apps.
  • Search for AWS Command Line Interface, click the three-dot menu, and select Uninstall.
  • Remove your stored AWS credentials by running:

macOS

rm -rf ~/.aws

Windows

Remove-Item -Recurse -Force $env:USERPROFILE\.aws

That's a Wrap!

That's a Wrap!

Nice work! You just built a compliance-ready cloud infrastructure that addresses the real challenges EU cloud engineers face every day.

What you learned:

  • Built a GDPR-compliant encrypted data storage layer locked to EU regions, with a KMS customer-managed key and S3 bucket policies enforcing SSL-only access.
  • Implemented a CLOUD Act mitigation strategy using client-side encryption so data is useless even if AWS is compelled to hand it over.
  • Deployed DORA-aligned audit logging with CloudTrail and an EU region restriction policy, all codified as Infrastructure as Code with Terraform.
  • Added data lifecycle automation and CloudWatch security monitoring for continuous GDPR data minimization and DORA compliance.

Ready to quiz yourself?