Header menu logo FsCDK

Bastion Host Hardening bastion hosts with FsCDK

Bastion hosts should be a last resort: short-lived, tightly monitored entry points into private subnets. This notebook codifies the controls highlighted by AWS Heroes Scott Piper and Mark Nunnikhoven, plus guidance from the AWS re:Inforce session “Secure remote access architectures.” Whenever possible, migrate to AWS Systems Manager Session Manager for zero-port-access workflows; when you cannot, adopt the configurations below.

Quick-start templates

Each scenario references the Well-Architected Security Pillar and the AWS Prescriptive Guidance playbooks for operational excellence.

#r "../src/bin/Release/net8.0/publish/Amazon.JSII.Runtime.dll"
#r "../src/bin/Release/net8.0/publish/Constructs.dll"
#r "../src/bin/Release/net8.0/publish/Amazon.CDK.Lib.dll"
#r "../src/bin/Release/net8.0/publish/FsCDK.dll"

open FsCDK
open Amazon.CDK
open Amazon.CDK.AWS.EC2

Basic bastion host

Start with the bare minimum: a single-instance jump box inside a dedicated VPC. Treat this only as a temporary access point while you implement Session Manager, as recommended in the AWS Prescriptive Guidance article “Transitioning from bastions to Session Manager.”

stack "BasicBastion" {
    let! myVpc = vpc "MyVpc" { () }

    bastionHost "MyBastion" {
        vpc myVpc
        instanceName "dev-bastion"
    }
}

Production bastion with locked-down security groups

Restrict ingress to approved corporate CIDR ranges, deny outbound traffic by default, and log every session. This mirrors the defence-in-depth approach highlighted in re:Inforce SEC311 “Harden administration paths,” where enforced IMDSv2 and least-privilege security groups prevent lateral movement.

stack "SecureBastion" {
    let! myVpc = vpc "MyVpc" { () }

    let! bastionSG =
        securityGroup "BastionSG" {
            vpc myVpc
            description "Security group for bastion host"
            allowAllOutbound false
        }

    bastionHost "ProductionBastion" {
        vpc myVpc
        securityGroup bastionSG
        instanceName "production-bastion"
        instanceType (InstanceType.Of(InstanceClass.BURSTABLE3, InstanceSize.MICRO))
    }
}

Bastion with hardened AMI

Adopt CIS-hardened golden images or images produced by your pipeline so every bastion starts with patched packages, disabled unused services, and consistent audit tooling. This aligns with the AWS Security Blog series on golden AMIs and the Center for Internet Security benchmarks.

stack "CustomBastion" {
    let! myVpc = vpc "MyVpc" { () }

    let hardenedAMI = MachineImage.GenericLinux(dict [ "us-east-1", "ami-12345678" ])

    bastionHost "HardenedBastion" {
        vpc myVpc
        machineImage hardenedAMI
        instanceName "hardened-bastion"
        requireImdsv2 true
    }
}

Multi-AZ bastion fleet

Highly regulated or mission-critical environments sometimes require redundant bastions. Deploy one per Availability Zone, attach automation to rotate host keys daily, and integrate health checks that fail closed—echoing recommendations from the AWS Well-Architected Reliability Pillar.

stack "HABastion" {
    let! myVpc =
        vpc "MyVpc" {
            maxAzs 2
            natGateways 2
        }

    // Bastion in first AZ
    bastionHost "Bastion1" {
        vpc myVpc
        instanceName "bastion-az1"
        subnetSelection (SubnetSelection(SubnetType = SubnetType.PUBLIC, AvailabilityZones = [| "us-east-1a" |]))
    }

    // Bastion in second AZ
    bastionHost "Bastion2" {
        vpc myVpc
        instanceName "bastion-az2"
        subnetSelection (SubnetSelection(SubnetType = SubnetType.PUBLIC, AvailabilityZones = [| "us-east-1b" |]))
    }
}

Implementation checklist & learning resources

Security controls

Cost & operations

Prefer AWS Systems Manager Session Manager

Further learning

Document the access policy, monitor session activity, and track shutdown automation so your bastion hosts stay compliant and short-lived.

namespace FsCDK
namespace Amazon
namespace Amazon.CDK
namespace Amazon.CDK.AWS
namespace Amazon.CDK.AWS.EC2
val stack: name: string -> StackBuilder
<summary>Creates an AWS CDK Stack construct.</summary>
<param name="name">The name of the stack.</param>
<code lang="fsharp"> stack "MyStack" { lambda myFunction bucket myBucket } </code>
val myVpc: IVpc
val vpc: name: string -> VpcBuilder
<summary>Creates a VPC configuration with AWS best practices.</summary>
<param name="name">The VPC name.</param>
<code lang="fsharp"> vpc "MyVpc" { maxAzs 2 natGateways 1 cidr "10.0.0.0/16" } </code>
val bastionHost: name: string -> BastionHostBuilder
<summary>Creates a Bastion Host with AWS security best practices.</summary>
<param name="name">The bastion host name.</param>
<code lang="fsharp"> bastionHost "MyBastion" { vpc myVpc instanceType (InstanceType.Of(InstanceClass.BURSTABLE3, InstanceSize.NANO)) instanceName "bastion-host" } </code>
custom operation: vpc (IVpc) Calls BastionHostBuilder.Vpc
<summary>Sets the VPC for the Bastion Host.</summary>
custom operation: instanceName (string) Calls BastionHostBuilder.InstanceName
<summary>Sets the instance name.</summary>
val bastionSG: ISecurityGroup
val securityGroup: name: string -> SecurityGroupBuilder
<summary>Creates a Security Group configuration.</summary>
<param name="name">The Security Group name.</param>
<code lang="fsharp"> securityGroup "MySecurityGroup" { vpc myVpc description "Security group for my application" allowAllOutbound false } </code>
custom operation: vpc (IVpc) Calls SecurityGroupBuilder.Vpc
<summary>Sets the VPC for the Security Group.</summary>
<param name="config">The current Security Group configuration.</param>
<param name="vpc">The VPC.</param>
<code lang="fsharp"> securityGroup "MySecurityGroup" { vpc myVpc } </code>
custom operation: description (string) Calls SecurityGroupBuilder.Description
<summary>Sets the description for the Security Group.</summary>
<param name="config">The current Security Group configuration.</param>
<param name="description">The description.</param>
<code lang="fsharp"> securityGroup "MySecurityGroup" { description "Security group for my application" } </code>
custom operation: allowAllOutbound (bool) Calls SecurityGroupBuilder.AllowAllOutbound
<summary>Sets whether to allow all outbound traffic.</summary>
<param name="config">The current Security Group configuration.</param>
<param name="allow">Whether to allow all outbound (default: false for least privilege).</param>
<code lang="fsharp"> securityGroup "MySecurityGroup" { allowAllOutbound true } </code>
custom operation: securityGroup (ISecurityGroup) Calls BastionHostBuilder.SecurityGroup
<summary>Sets the security group.</summary>
custom operation: instanceType (InstanceType) Calls BastionHostBuilder.InstanceType
<summary>Sets the instance type.</summary>
Multiple items
type InstanceType = inherit DeputyBase new: instanceTypeIdentifier: string -> unit member IsBurstable: unit -> bool member SameInstanceClassAs: other: InstanceType -> bool member ToString: unit -> string static member Of: instanceClass: InstanceClass * instanceSize: InstanceSize -> InstanceType member Architecture: InstanceArchitecture

--------------------
InstanceType(instanceTypeIdentifier: string) : InstanceType
InstanceType.Of(instanceClass: InstanceClass, instanceSize: InstanceSize) : InstanceType
[<Struct>] type InstanceClass = | STANDARD3 = 0 | M3 = 1 | STANDARD4 = 2 | M4 = 3 | STANDARD5 = 4 | M5 = 5 | STANDARD5_NVME_DRIVE = 6 | M5D = 7 | STANDARD5_AMD = 8 | M5A = 9 ...
field InstanceClass.BURSTABLE3: InstanceClass = 172
[<Struct>] type InstanceSize = | NANO = 0 | MICRO = 1 | SMALL = 2 | MEDIUM = 3 | LARGE = 4 | XLARGE = 5 | XLARGE2 = 6 | XLARGE3 = 7 | XLARGE4 = 8 | XLARGE6 = 9 ...
field InstanceSize.MICRO: InstanceSize = 1
val hardenedAMI: IMachineImage
type MachineImage = inherit DeputyBase static member FromSsmParameter: parameterName: string * ?options: ISsmParameterImageOptions -> IMachineImage static member GenericLinux: amiMap: IDictionary<string,string> * ?props: IGenericLinuxImageProps -> IMachineImage static member GenericWindows: amiMap: IDictionary<string,string> * ?props: IGenericWindowsImageProps -> IMachineImage static member LatestAmazonLinux: ?props: IAmazonLinuxImageProps -> IMachineImage static member LatestAmazonLinux2: ?props: IAmazonLinux2ImageSsmParameterProps -> IMachineImage static member LatestAmazonLinux2022: ?props: IAmazonLinux2022ImageSsmParameterProps -> IMachineImage static member LatestAmazonLinux2023: ?props: IAmazonLinux2023ImageSsmParameterProps -> IMachineImage static member LatestWindows: version: WindowsVersion * ?props: IWindowsImageProps -> IMachineImage static member Lookup: props: ILookupMachineImageProps -> IMachineImage ...
MachineImage.GenericLinux(amiMap: System.Collections.Generic.IDictionary<string,string>, ?props: IGenericLinuxImageProps) : IMachineImage
val dict: keyValuePairs: ('Key * 'Value) seq -> System.Collections.Generic.IDictionary<'Key,'Value> (requires equality)
custom operation: machineImage (IMachineImage) Calls BastionHostBuilder.MachineImage
<summary>Sets the machine image.</summary>
custom operation: requireImdsv2 (bool) Calls BastionHostBuilder.RequireImdsv2
<summary>Sets whether to require IMDSv2.</summary>
custom operation: maxAzs (int) Calls VpcBuilder.MaxAzs
<summary>Sets the maximum number of Availability Zones to use.</summary>
<param name="config">The current VPC configuration.</param>
<param name="maxAzs">The maximum number of AZs (default: 2 for HA).</param>
<code lang="fsharp"> vpc "MyVpc" { maxAzs 3 } </code>
custom operation: natGateways (int) Calls VpcBuilder.NatGateways
<summary>Sets the number of NAT Gateways.</summary>
<param name="config">The current VPC configuration.</param>
<param name="natGateways">The number of NAT gateways (default: 1 for cost optimization).</param>
<code lang="fsharp"> vpc "MyVpc" { natGateways 2 } </code>
custom operation: subnetSelection (SubnetSelection) Calls BastionHostBuilder.SubnetSelection
<summary>Sets the subnet selection.</summary>
Multiple items
type SubnetSelection = interface ISubnetSelection new: unit -> unit member AvailabilityZones: string array member OnePerAz: Nullable<bool> member SubnetFilters: SubnetFilter array member SubnetGroupName: string member SubnetType: Nullable<SubnetType> member Subnets: ISubnet array

--------------------
SubnetSelection() : SubnetSelection
[<Struct>] type SubnetType = | PRIVATE_ISOLATED = 0 | PRIVATE_WITH_EGRESS = 1 | PRIVATE_WITH_NAT = 2 | PUBLIC = 3
field SubnetType.PUBLIC: SubnetType = 3

Type something to start searching.