Bastion Host Banner

Deploy Bastion Host on AWS Using Infrastructure as Code: Complete Guide with Terraform, CloudFormation and Ansible

Table of Contents

A Bastion host is a special purpose server used to provide secure access to private resources within a network, such as virtual machines in a private subnet. In cloud environments like AWS, Azure, or GCP, Bastion hosts act as controlled entry points through which administrators can SSH or RDP into instances that are not directly accessible from the internet. These hosts are typically placed in a public subnet with strict security group rules and hardened configurations to minimize attack surfaces. By funneling all administrative access through a single, monitored, and auditable point, Bastion hosts help enforce the principle of least privilege, reduce exposure of internal systems, and improve overall security posture in cloud architectures.

Why Automate Bastion Host Deployment?

Manual bastion host deployment is error-prone, time-consuming, and difficult to replicate across environments. Infrastructure as Code (IaC) solves these challenges by providing:

  • Consistency: Identical configurations across dev, staging, and production
  • Version Control: Track changes and roll back when needed
  • Speed: Deploy in minutes, not hours
  • Compliance: Enforce security standards automatically
  • Documentation: Code serves as living documentation

The Hidden Cost of Manual Deployment

Choosing the Right IaC Tool

  • Terraform: Best for multi-cloud environments and complex infrastructures
  • CloudFormation: Ideal for AWS-native deployments with deep service integration
  • Ansible: Perfect for configuration management and hybrid deployments

Or better yet, for “ClickOps” admins, you can get our Bastion AMI straight from the AWS MarketPlace below.

Prerequisites and Setup

Before we begin, ensure you have:

Required Tools

# Check versions
terraform --version  # 1.5+ recommended
aws --version       # 2.x required
ansible --version   # 2.14+ recommended

Project Structure

If you would like to get access to the code described below, you can access it on github.

#https://github.com/solvedevops/aws-bastion-automation.git
.
├── ansible
│   └── playbook.yml
├── cloudformation
│   └── bastion-stack.yaml
└── terraform
    ├── bastion.tf
    └── vars.tf

4 directories, 4 files

Terraform Implementation

bastion.tf

# Provider configuration
provider "aws" {
  region = var.aws_region
}

# Data source for latest Bastion AMI
data "aws_ami" "bastion" {
  most_recent = true
  owners      = ["679593333241"] # AWS MarketPlace ID

  filter {
    name   = "name"
    values = ["SolveDevOps-Bastion-Host-Ubuntu24.04*"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }
}

# Elastic IP
resource "aws_eip" "bastion" {
  domain = "vpc"
  
  tags = {
    Name = "Bastion-EIP"
  }
}

# EC2 Instance
resource "aws_instance" "bastion" {
  ami           = data.aws_ami.bastion.id
  instance_type = var.instance_type
  key_name      = var.key_name
  
  #subnet_id                   = aws_subnet.bastion_public.id
  vpc_security_group_ids      = var.security_groups_ids
  #associate_public_ip_address = true

  root_block_device {
    volume_type = "gp3"
    volume_size = 60
    encrypted   = true
  }

  tags = {
    Name = "Bastion-Server"
  }
}

# EIP Association
resource "aws_eip_association" "bastion" {
  instance_id   = aws_instance.bastion.id
  allocation_id = aws_eip.bastion.id
}

# Data source for availability zones
##data "aws_availability_zones" "available" {
#  state = "available"
#}

# Outputs
output "bastion_public_ip" {
  value = aws_eip.bastion.public_ip
}

output "bastion_admin_url" {
  value = "http://${aws_eip.bastion.public_ip}/admin"
}

output "ssh_connection_string" {
  value = "ssh -i ${var.key_name}.pem ubuntu@${aws_eip.bastion.public_ip}"
}

vars.tf

# Variables
variable "aws_region" {
  description = "AWS region for FreePBX deployment"
  default     = "us-east-1"
}

variable "instance_type" {
  description = "EC2 instance type for FreePBX"
  default     = "t3.medium"
}

variable "key_name" {
  description = "AWS key pair name for SSH access"
  type        = string
}

variable "security_groups_ids" {
  description = "List of Subnet Group Ids"
  type        = list(string)
  default     = []
}

Deployment Commands

# Initialize Terraform
cd terraform/
terraform init

# Create a terraform plan
terraform plan

# Apply configuration
terraform apply

CloudFormation Templates

Complete CloudFormation Stack

bastion-stack.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Bastion Host deployment using AWS MarketPlace Image'

Parameters:
  KeyName:
    Description: Name of an existing EC2 KeyPair to enable SSH access
    Type: AWS::EC2::KeyPair::KeyName
    ConstraintDescription: Must be the name of an existing EC2 KeyPair
  
  InstanceType:
    Description: EC2 instance type for Bastion
    Type: String
    Default: t3.large
    AllowedValues:
      - t3.large
      - t3.xlarge
    ConstraintDescription: Must be a valid EC2 instance type

Mappings:
  RegionMap:
    ap-south-1:
      AMI: ami-0612aad4569163896
    eu-north-1:
      AMI: ami-01ce554ddd66861f8
    eu-west-3:
      AMI: ami-0db7186514a13af34
    eu-west-2:
      AMI: ami-02363a79364adb260
    eu-west-1:
      AMI: ami-04655665a14615370
    ap-northeast-3:
      AMI: ami-0dbd54a2497d40ff3
    ap-northeast-2:
      AMI: ami-03ad6d2ea6683e5c4
    ap-northeast-1:
      AMI: ami-0e18c9237d66e2c6e
    ca-central-1:
      AMI: ami-0e31524ec38891006
    sa-east-1:
      AMI: ami-0437cda56731c4c8a
    ap-southeast-1:
      AMI: ami-014b8d7ca9ad783ba
    ap-southeast-2:
      AMI: ami-0080b9a2a51e2d894
    eu-central-1:
      AMI: ami-0af0b4a9dad36131a
    us-east-1:
      AMI: ami-07873de158f43fe0e
    us-east-2:
      AMI: ami-02c4f6020f83bc8e2
    us-west-1:
      AMI: ami-0e7b363658a343952
    us-west-2:
      AMI: ami-0149473c34e05b8bf


Resources:
  # Elastic IP
  BastionEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: Name
          Value: Bastion-EIP

  # EC2 Instance
  BastionInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !FindInMap [RegionMap, !Ref 'AWS::Region', AMI]
      InstanceType: !Ref InstanceType
      KeyName: !Ref KeyName
      SecurityGroupIds:
        - sg-0343512454
      BlockDeviceMappings:
        - DeviceName: /dev/xvda
          Ebs:
            VolumeType: gp3
            VolumeSize: 60
            Encrypted: true
      Tags:
        - Key: Name
          Value: Bastion-Server

  # EIP Association
  EIPAssociation:
    Type: AWS::EC2::EIPAssociation
    Properties:
      InstanceId: !Ref BastionInstance
      EIP: !Ref BastionEIP

Outputs:
  PublicIP:
    Description: Public IP address of Bastion instance
    Value: !Ref BastionEIP
  
  AdminURL:
    Description: Bastion Admin URL
    Value: !Sub 'http://${BastionEIP}/admin'
  
  SSHConnection:
    Description: SSH connection string
    Value: !Sub 'ssh -i ${KeyName}.pem admin@${BastionEIP}'

Deploying with CloudFormation

aws cloudformation create-stack \
  --stack-name bastion-host \
  --template-body file://bastion-stack.yaml \
  --parameters \
    ParameterKey=KeyName,ParameterValue=my_key \
    ParameterKey=InstanceType,ParameterValue=t3.large

Check stack system

# Check stack status
aws cloudformation describe-stacks --stack-name bastion-host

Ansible Playbooks

Complete Ansible Automation

playbook.yml

---
- name: Deploy Bastion host on AWS
  hosts: localhost
  connection: local
  gather_facts: false
  
  vars:
    aws_region: us-east-1
    instance_type: t3.large
    key_name: "solve-useast1"
    ami_id: "ami-07873de158f43fe0e" # marketplace image for Bastion Host

  tasks:
    - name: Allocate Elastic IP
      amazon.aws.ec2_eip:
        region: "{{ aws_region }}"
        in_vpc: yes
      register: eip

    - name: Launch Bastion Host Instance
      amazon.aws.ec2_instance:
        key_name: "{{ key_name }}"
        instance_type: "{{ instance_type }}"
        image_id: "{{ ami_id }}"
        wait: yes
        region: "{{ aws_region }}"
        security_group: "sg-012345654"
        volumes:
          - device_name: /dev/xvda
            ebs:
              volume_type: gp3
              volume_size: 60
              encrypted: yes
        tags:
          Name: Bastion Host-Server
      register: ec2

    - name: Associate Elastic IP
      amazon.aws.ec2_eip:
        device_id: "{{ ec2.instances[0].instance_id }}"
        ip: "{{ eip.public_ip }}"
        region: "{{ aws_region }}"

    - name: Add host to inventory
      add_host:
        hostname: "{{ eip.public_ip }}"
        groups: Bastion
        ansible_user: admin
        ansible_ssh_private_key_file: "{{ key_name }}.pem"

Running the Ansible Playbook

# Install required collections
ansible-galaxy collection install amazon.aws

# Run the playbook
ansible-playbook playbook.yml

Conclusion

Infrastructure as Code transforms bastion host deployment from a manual, error-prone process into a reliable, repeatable, and secure operation. Whether you choose Terraform’s flexibility, CloudFormation’s AWS integration, or Ansible’s configuration power, the key is to start automating today.

Key Takeaways:

  1. Automate Everything: Manual processes don’t scale and introduce errors
  2. Version Control: Treat infrastructure like code with proper versioning
  3. Test Thoroughly: Validate changes before production deployment
  4. Monitor Continuously: Automate monitoring and alerting
  5. Document as Code: Let your IaC serve as living documentation

Ready to Automate?

While these IaC patterns will get you started, for Proof of Concept installations, you might prefer to use “ClickOps” and get the Bastion Host deployed straight from the AWS MarketPlace. If this is the case, please click on the image below to get redirected to AWS MarketPlace.

Deploy production-ready bastion hosts with a single Terraform resource or CloudFormation stack.