✨ IaC: Deploying a Node Secrets Viewer with Terraform βœ¨πŸ‘©πŸ½β€πŸ’»

✨ IaC: Deploying a Node Secrets Viewer with Terraform βœ¨πŸ‘©πŸ½β€πŸ’»

Β·

10 min read

Introduction

Learn how I built a secure cloud application by integrating Node.js with AWS Secrets Manager and automating the infrastructure deployment using Terraform. This project demonstrates modern cloud development practices and security implementation.

Table of contents

  • Project Overview

  • The Challenge

  • Solution Architecture

  • The Node Application

  • Infrastructure as Code with Terraform

    • Basic Setup

    • Setup The Provider

    • EC2 Instance Setup

    • IAM Roles and Policies

    • Security Groups Configuration

    • AWS Secrets Manager Setup

    • User Data Script Implementation

  • Deployment Process

    • Prerequisites

    • Step-by-Step Deployment Guide

    • Deployment Verification

  • Cost Considerations

  • Cleanup Process

  • Future Improvements

  • Conclusion

Project Overview

This project showcases a practical implementation of cloud security and infrastructure automation with Terraform. At its core, it combines a Node application that interacts with AWS Secrets ManagerπŸ” and a complete infrastructure setup automated through Terraform.

The project consist of two layers:

The application layer: A Node application that interacts directly with AWS Secrets Manager using the AWS SDK.

Infrastructure layer: The infrastructure is fully automated using Terraform, which provisions and configure the following components:

    • An EC2 instance to host the Node application

      • AWS Secrets Manager for secure secrets storage

      • IAM roles and policies following the principle of least privilege

      • Security groups with strictly controlled access

      • Instance profiles for secure EC2 AWS services communication

What makes this project particularly interesting is how it connects application development with infrastructure management 🫰🫰🫰.

The Challenge

Last week, I was deploying a Node application on an EC2 instance and wondered how to provide environment variables from the .env file to my project. Here's where AWS Secrets Manager becomes useful.

πŸ’‘
AWS Secrets Manager helps you manage, retrieve, and rotate database credentials, application credentials, OAuth tokens, API keys, and other secrets. For more information, visit this link.

I was curious about securely sharing secrets in my applications using Secrets Manager πŸ€”πŸ‘©πŸ½β€πŸ’», and I also wanted to learn Terraform for Infrastructure as Code (IaC) πŸ˜ƒ. This was my first experience with Terraform and I really enjoyed it!

Solution Architecture

The diagram below shows the solution architecture I designed for this project.

This is a Node application that retrieves secrets from AWS Secrets Manager. This Node application is hosted on an EC2 instance with appropriate IAM roles and policies attached to it.

The Node Application

You can find the application in my GitHub Repository. This is a lightweight Node application with a simple UI that serves only to display the secrets, demonstrating the ability to access them from the Secrets Manager. The app uses the AWS SDK to connect with AWS.

Infrastructure as Code with Terraform

For the infrastructure layer of the application, I created this GitHub repository where you can find all my code.

πŸ’‘
Terraform is an infrastructure as code tool that lets you build, change, and version infrastructure safely and efficiently. This includes low-level components like compute instances, storage, and networking.

Basic Setup ✨

For the setup, I first visited the Terraform Docs to learn how to install it on my local machine. Since I am working with AWS, I read the AWS Provider documentation to understand how to interact with various resources.

If you are using a different provider, you can find the registry here πŸ™ƒ: https://developer.hashicorp.com/terraform/language/providers#how-to-find-providers

Once you have this set up, make sure you are also logged in to the AWS CLI with the correct credentials.

About the code: For each Terraform configuration block, I created a file named after the block it references. Why? πŸ€” Here are some of the reasons:

  • βœ… This approach creates a modular and organized structure.

  • βœ… Each file has a specific purpose.

  • βœ… It makes it easier for team members to understand and navigate the codebase, as they can quickly find specific configurations.

.
β”œβ”€β”€ data.tf
β”œβ”€β”€ output.tf
β”œβ”€β”€ provider.tf
β”œβ”€β”€ resource.tf
β”œβ”€β”€ userdata.sh
└── .gitignore

Setup The Provider ☁️

πŸ’‘
Terraform relies on plugins called "providers" to interact with remote systems. Terraform configurations must declare which providers they require, so that Terraform can install and use them.

To set up the AWS provider, I created a file named provider.tf with the following content:

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

  required_version = ">= 1.2.0"
}

# Configure the AWS Provider
provider "aws" {
  region = "us-west-2"
}

EC2 Instance Setup πŸ‘©πŸ½β€πŸ’»

For the EC2 Instance, I have to tackle different configurations.

First, I created a resource.tf file with all the resources, one of the is the EC2 instance with the respective role to access the Secrets Manager and the Security groups.

This resource creates an EC2 instance with a Linux AMI, instance type t2.micro, an instance profile name, security groups for the instance, and user data to be injected during instance creation.

I also added a tag for billing tracking πŸ˜‰.

resource "aws_instance" "app_server" {
  ami           = data.aws_ami.linux_ami.id
  instance_type = "t2.micro"
  iam_instance_profile = aws_iam_instance_profile.ec2_instance_profile.name
  key_name = "aws-terraform-challenge"
  security_groups = [ aws_security_group.aws_terraform_challenge_security_group.name ]
  user_data = file("userdata.sh")

  tags = {
    Name = "aws_terraform_challenge"
  }
}

Keep in mind that the following lines in the code use values from data blocks that retrieve this information.

I'll explain each part for better understanding:

The AMI ID is retrieved from the following data block, where I set the most recent argument to true to ensure it brings the latest one. The owner is amazon, and I filter by the Linux 2023 AMI.

data "aws_ami" "linux_ami" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*"]
  }
}

IAM Roles and Policies

The aws_iam_instance_profile provides an IAM instance profile.

resource "aws_iam_instance_profile" "ec2_instance_profile" {
  name = "terraform_challenge_instance_profile"
  role = aws_iam_role.ec2_secrets_role.name
}

The Role:

resource "aws_iam_role" "ec2_secrets_role" {
  name = "secretsrole"

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

  tags = {
    Name    = "EC2SecretsManagerRole"
    Purpose = "Allow EC2 to access Secrets Manager"
  }
}

✨ PRO TIP: Note that I added the tags argument for tracking billing purposes.

The policies:

resource "aws_iam_policy" "my_secrets_policy" {
  name   = "my_secrets_policy"
  path   = "/"
  policy = data.aws_iam_policy_document.my_secrets_policy.json
}

In the code above, the policy argument refers to a reference to a JSON-formatted IAM policy that is generated using a data block for an aws_iam_policy_document. This policy is created following the Least Privilege principle.

data "aws_iam_policy_document" "my_secrets_policy" {
 statement {

    actions = [
      "kms:DescribeKey",
      "kms:ListAliases",
      "kms:ListKeys"
    ]

    resources = [
      data.aws_kms_key.by_alias.arn,
    ]
  }

  statement {
    actions = [
      "secretsmanager:GetSecretValue",
      "secretsmanager:ListSecretVersionIds"
    ]

    resources = [
        join("", [aws_secretsmanager_secret.secret_terraform_challenge.id, "*"])

    ]
  }
}

The resources argument in the code above refers to a data.aws_kms_key piece of information. The AWS KMS Key data block is used to obtain detailed information about the specified KMS Key.

data "aws_kms_key" "by_alias" {
  key_id = "alias/aws/secretsmanager"
}

Then create the resource that attaches a managed IAM Policy to an IAM role:

resource "aws_iam_role_policy_attachment" "secrets_policy" {
  role       = aws_iam_role.ec2_secrets_role.name
  policy_arn = aws_iam_policy.my_secrets_policy.arn
}

Security Groups Configuration ✨

For the security groups, I need to do the following by creating a security group with the right rules:

βœ… Create a security group.

βœ… Associate the security group with a specific VPC. As you can see, the vpc_id:

  • ✨Allow all outbound rule.

  • ✨Set up inbound rules to:

    • Allow HTTP from my IP.

    • Allow SSH from my IP to connect to my EC2.

    • Allow traffic from my IP on port 3000.

resource "aws_security_group" "aws_terraform_challenge_security_group" {
  name        = "aws_terraform_challenge_security_group"
  vpc_id      = data.aws_vpc.default_vpc.id

  tags = {
    Name = "aws_terraform_challenge_security_group"
  }
}

resource "aws_vpc_security_group_ingress_rule" "allow_http" {
  security_group_id = aws_security_group.aws_terraform_challenge_security_group.id
  cidr_ipv4         = "YOUR_IP_HERE"
  from_port         = 80
  ip_protocol       = "tcp"
  to_port           = 80
}

resource "aws_vpc_security_group_ingress_rule" "allow_ssh_from_my_ip" {
  security_group_id = aws_security_group.aws_terraform_challenge_security_group.id
  cidr_ipv4         = "YOUR_IP_HERE"
  from_port         = 22
  ip_protocol       = "tcp"
  to_port           = 22
}

resource "aws_vpc_security_group_ingress_rule" "allow_3000_traffic_from_my_ip" {
  security_group_id = aws_security_group.aws_terraform_challenge_security_group.id
  cidr_ipv4         = "YOUR_IP_HERE"
  from_port         = 3000
  ip_protocol       = "tcp"
  to_port           = 3000
}

resource "aws_vpc_security_group_egress_rule" "allow_all_egress_rule" {
  security_group_id = aws_security_group.aws_terraform_challenge_security_group.id
  cidr_ipv4   = "0.0.0.0/0"
  ip_protocol = -1
}

This security group is defined within aws_instance.

The vpc_id in the code above is obtained from the following data block, which gives details about a specific VPC, in this case, the default VPC.

data "aws_vpc" "default_vpc"{
  default = true
}

AWS Secrets Manager SetupπŸ‘©πŸ½β€πŸ’»

For the Secrets Manager, I created an aws_secretsmanager_secret, which is a resource to manage AWS Secrets Manager secret metadata. I set the recovery window to 0 days. Why 0? Because setting it to 0 forces deletion without recovery (This is for the DEMO purposes only).

resource "aws_secretsmanager_secret" "secret_terraform_challenge" {
  name = "secret_terraform_challenge"
  recovery_window_in_days = 0
}

Then, set up the aws_secretsmanager_secret_version to manage the version of the AWS Secrets Manager secret, including its secret value, which in this case is secret.

resource "aws_secretsmanager_secret_version" "secret_terraform_challenge_version" {
  secret_id     = aws_secretsmanager_secret.secret_terraform_challenge.id
  secret_string = "secret"
}

User Data Script Implementation

To run the script when the EC2 instance is created, I created the following user-data bash script to run the application:

#!/bin/bash
yum update -y
curl -fsSL https://rpm.nodesource.com/setup_23.x | bash -
yum install -y nodejs git
mkdir -p /home/ec2-user/app && cd /home/ec2-user/app
git clone https://github.com/lalidiaz/terraform-aws-secrets-manager.git .
npm install
npm run start

🩡 There's something really cool about Terraform 🩡: it has blocks called output.

πŸ’‘
Output values let you see information about your infrastructure on the command line.

In this case, they will display the AWS Instance AMI and the Instance Public IP in the console. The public IP will be useful when we deploy the application, as you'll see later.

output "aws_instance_ami" {
    value = aws_instance.app_server.ami 
}

output "aws_instance_public_ip" {
  value = aws_instance.app_server.public_ip
}

Deployment Process

Prerequisites

Make sure you have the following prerequisites before running the project:

  1. An AWS Account

  2. AWS CLI installed and configured with appropriate credentials

  3. Terraform installed on your local machine

  4. Your IPv4 address (to configure security group rules)

Step-by-Step Deployment Guide

To deploy the Node application, please clone my repository:

git clone https://github.com/lalidiaz/node-terraform-infrastructure/tree/main
cd node-terraform-infrastructure

Once you are inside the project's folder, let's deploy the app with Terraform and watch the magic happen βœ¨πŸ™Œ!

Step 1

πŸ’‘
The terraform init command initializes a working directory containing Terraform configuration files.
terraform init

Step 2

πŸ’‘
The terraform plan command creates an execution plan, which lets you preview the changes that Terraform plans to make to your infrastructure.

By default, when Terraform creates a plan it:

  • Reads the current state of any already-existing remote objects to make sure that the Terraform state is up-to-date.

  • Compares the current configuration to the prior state and noting any differences.

  • Proposes a set of change actions that should, if applied, make the remote objects match the configuration.

terraform plan

Step 3

If everything looks correct, you can run the following command to apply the changes:

πŸ’‘
The terraform apply command executes the actions proposed in a Terraform plan.
terraform apply

And that's it, the project is deployed successfully! βœ¨πŸ™ŒπŸŽ‰

Deployment Verification

To verify the application, you will see the output in the console displaying the AWS EC2 Public IP. Just copy the IP and head over to the browser, paste the ip <aws_instance_public_ip>:3000 you should see the following screen:

Cost Considerations

Please note that we are creating an EC2 instance within the free tier, but the AWS Secrets Manager does have associated costs. Check the documentation pricing page for more details.

Cleanup Process

Finally, remember to always clean up so you don’t incur any changes.

πŸ’‘
The terraform destroy command is a convenient way to destroy all remote objects managed by a particular Terraform configuration.
terraform destroy

Future Improvements

After talking with my mentor Mariano GonzΓ‘lez ,he suggested the following improvements for the app, which will be included in the next iterations:

  • Create a Terraform Backend block

  • Create a Module

  • Add CI/CD with GitHub Actions

  • Refactor the solution to enhance security

  • Add a local executor to calculate my IP on the fly instead of hardcoding it

Conclusion

While this was my first experience with Terraform, I really enjoyed it and took this challenge very seriously. I spent a few hours working on this solution, and I'm quite happy with the results.Thanks to my mentor Mariano GonzΓ‘lez πŸ™ for guiding me and taking the time to explain concepts to me.

I must admit I was excited when I started working on this project. Although I know I need to keep studying, I learned a lot from this hands-on experience, which gave me the confidence to tackle future challenges!

Thanks for reading! See you in the next post! πŸ‘‹

Β