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
- Enforce IMDSv2, restrict SSH ingress to corporate CIDR ranges, and enable CloudWatch Logs or S3 session transcripts. These steps are emphasised in re:Inforce SEC311 and the Security Hub Foundational Best Practices standard.
- Rotate SSH keys automatically with AWS Secrets Manager or Session Manager hybrid access, following the blueprint in the AWS blog “Automating key rotation for bastion hosts.”
Cost & operations
- Use the smallest burstable instance type (t3.nano) for ad-hoc access, stop instances when idle, and tag every bastion with owner and expiry metadata. Schedule automation via EventBridge, replicating the workflow described by AWS Hero Mark Nunnikhoven.
Prefer AWS Systems Manager Session Manager
- Eliminates inbound ports, centralises access in IAM, and provides CloudTrail-backed audit trails. Complete the Session Manager Workshop (4.9★ rating) to migrate off legacy bastions.
Further learning
- *AWS re:Inforce SEC311* – Hardening administrative access.
- *Scott Piper – Common AWS security mistakes* (summitroute.com).
- *Session Manager Immersion Day* – Official AWS hands-on lab.
- *Well-Architected Security Pillar* – Administration and access control section.
Document the access policy, monitor session activity, and track shutdown automation so your bastion hosts stay compliant and short-lived.
<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>
<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>
<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>
<summary>Sets the VPC for the Bastion Host.</summary>
<summary>Sets the instance name.</summary>
<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>
<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>
<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>
<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>
<summary>Sets the security group.</summary>
<summary>Sets the instance type.</summary>
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
<summary>Sets the machine image.</summary>
<summary>Sets whether to require IMDSv2.</summary>
<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>
<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>
<summary>Sets the subnet selection.</summary>
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
FsCDK