IaC AWS

Terraform Module: AWS Lightsail Instance — The Simplified VPS, Codified With Static IPs and Firewall Rules

Quick take — A reusable hashicorp/aws ~> 5.0 Terraform module for aws_lightsail_instance covering the key pair, a persistent static IP, and per-port firewall rules — a paved-road VPS without the EC2/VPC sprawl. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "aws" {
  region = "us-east-1"
}

module "lightsail" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lightsail?ref=v1.0.0"

  name              = "..."   # Lightsail instance name (unique per Region in your account).
  availability_zone = "..."   # AZ such as us-east-1a — must be in the provider's Region.
  blueprint_id      = "..."   # OS/app image, e.g. ubuntu_22_04 or amazon_linux_2.
  bundle_id         = "..."   # Plan/size, e.g. nano_3_0, micro_3_0, small_3_0.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Amazon Lightsail is AWS’s simplified Virtual Private Server (VPS). Where EC2 hands you a kit of parts — a VPC, subnets, security groups, an EBS volume, an Elastic IP, an EC2 key pair, and an instance to wire them all together — Lightsail bundles compute, SSD storage, a fixed monthly data-transfer allowance, and a built-in firewall into a single bundle (its word for an instance plan, e.g. nano_3_0) at a flat, predictable monthly price. You pick a blueprint (an OS image like ubuntu_22_04, or a preconfigured stack like WordPress or LAMP), pick a bundle, and you have a server. It is the right tool for a small website, a marketing landing page, a dev sandbox, or a low-traffic internal app where the full weight of EC2 + VPC is overkill.

The catch is that Lightsail’s resources are deliberately separate from the EC2/VPC world: it has its own key pair resource (aws_lightsail_key_pair, not aws_key_pair), its own static IP (aws_lightsail_static_ip, not an Elastic IP), and its own firewall model (aws_lightsail_instance_public_ports, not a security group). Each is a distinct Terraform resource, and the firewall resource is declarative and destructive: it closes every public port that is not listed in its port_info blocks. Get the wiring wrong and you either lock yourself out or leave SSH open to the world.

This module bundles the four resources that make up a production-ready VPS — the instance, an optional key pair, an optional persistent static IP and its attachment, and the public-ports firewall — into one opinionated call. You hand it a name, a blueprint, and a bundle; it returns a server with a stable public IP and exactly the ports you asked for open, nothing more.

When to use it

Reach for a full EC2 + VPC module instead when you need private subnets, multiple security groups, autoscaling, load balancers, IAM instance profiles, custom VPC peering, or instance types beyond Lightsail’s fixed bundles. Lightsail intentionally hides those knobs — when you start wanting them, you have outgrown it.

Module structure

terraform-module-aws-lightsail/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # key pair, instance, static IP + attachment, public ports
├── variables.tf     # var-driven inputs with validations
└── outputs.tf       # ids, public IP, username, key material

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

main.tf

locals {
  # Use a module-managed key pair unless the caller passes an existing one.
  key_pair_name = var.create_key_pair ? aws_lightsail_key_pair.this[0].name : var.key_pair_name

  tags = merge(
    {
      "Name"      = var.name
      "ManagedBy" = "terraform"
      "Module"    = "terraform-module-aws-lightsail"
    },
    var.tags,
  )
}

# Lightsail has its OWN key pair resource — aws_key_pair (EC2) will not work.
# When public_key is supplied we import it; otherwise Lightsail generates one
# and exposes the private key in state (encrypt with pgp_key for production).
resource "aws_lightsail_key_pair" "this" {
  count = var.create_key_pair ? 1 : 0

  name       = "${var.name}-key"
  public_key = var.public_key
  pgp_key    = var.pgp_key
  tags       = local.tags
}

resource "aws_lightsail_instance" "this" {
  name              = var.name
  availability_zone = var.availability_zone
  blueprint_id      = var.blueprint_id
  bundle_id         = var.bundle_id

  # Lightsail key pairs are referenced by name. Null = use the default key.
  key_pair_name = local.key_pair_name

  # User data is a SINGLE line for Lightsail (unlike EC2's multi-line scripts).
  user_data = var.user_data

  # dualstack (default), ipv4, or ipv6.
  ip_address_type = var.ip_address_type

  # Optional daily auto-snapshot for a cheap point-in-time recovery point.
  dynamic "add_on" {
    for_each = var.auto_snapshot_time == null ? [] : [1]
    content {
      type          = "AutoSnapshot"
      snapshot_time = var.auto_snapshot_time
      status        = "Enabled"
    }
  }

  tags = local.tags
}

# A static IP is Lightsail's equivalent of an Elastic IP: a public IP that
# survives instance stop/start so DNS records stay valid across reboots.
resource "aws_lightsail_static_ip" "this" {
  count = var.create_static_ip ? 1 : 0
  name  = "${var.name}-static-ip"
}

resource "aws_lightsail_static_ip_attachment" "this" {
  count = var.create_static_ip ? 1 : 0

  static_ip_name = aws_lightsail_static_ip.this[0].name
  instance_name  = aws_lightsail_instance.this.name
}

# The public-ports firewall is DECLARATIVE: AWS closes every public port that
# is not listed here. Define exactly what you want open and nothing else.
resource "aws_lightsail_instance_public_ports" "this" {
  instance_name = aws_lightsail_instance.this.name

  dynamic "port_info" {
    for_each = var.public_ports
    content {
      protocol  = port_info.value.protocol
      from_port = port_info.value.from_port
      to_port   = port_info.value.to_port
      cidrs     = port_info.value.cidrs
    }
  }
}

variables.tf

variable "name" {
  description = "Lightsail instance name (unique within the Region in your account)."
  type        = string

  validation {
    condition     = can(regex("^[A-Za-z0-9][A-Za-z0-9_.-]{0,253}$", var.name))
    error_message = "name must start with an alphanumeric and contain only letters, digits, periods, underscores, and hyphens."
  }
}

variable "availability_zone" {
  description = "Availability Zone for the instance, e.g. us-east-1a. Must be in the provider's Region."
  type        = string

  validation {
    condition     = can(regex("^[a-z]{2}-[a-z]+-\\d[a-z]$", var.availability_zone))
    error_message = "availability_zone must look like us-east-1a (region + AZ letter)."
  }
}

variable "blueprint_id" {
  description = "OS or application blueprint, e.g. ubuntu_22_04, amazon_linux_2, debian_12, wordpress."
  type        = string
}

variable "bundle_id" {
  description = "Instance plan/size, e.g. nano_3_0, micro_3_0, small_3_0, medium_3_0. The _3_0 suffix is the current generation."
  type        = string

  validation {
    condition     = can(regex("_[0-9]+_[0-9]+$", var.bundle_id))
    error_message = "bundle_id must be a Lightsail bundle such as nano_3_0 or small_3_0."
  }
}

variable "create_key_pair" {
  description = "Create a Lightsail key pair for this instance. Set false to reuse an existing key via key_pair_name."
  type        = bool
  default     = true
}

variable "key_pair_name" {
  description = "Name of an existing Lightsail key pair to use when create_key_pair is false. Null = Lightsail default key."
  type        = string
  default     = null
}

variable "public_key" {
  description = "Public key material to import into the managed key pair (e.g. file(\"~/.ssh/id_rsa.pub\")). Null = Lightsail generates a key."
  type        = string
  default     = null
}

variable "pgp_key" {
  description = "PGP key (e.g. keybase:user) to encrypt the generated private key in state. Strongly recommended when public_key is null."
  type        = string
  default     = null
}

variable "user_data" {
  description = "Single-line launch script run at first boot. Lightsail does NOT accept multi-line scripts like EC2."
  type        = string
  default     = null
}

variable "ip_address_type" {
  description = "IP address type of the instance: dualstack, ipv4, or ipv6."
  type        = string
  default     = "dualstack"

  validation {
    condition     = contains(["dualstack", "ipv4", "ipv6"], var.ip_address_type)
    error_message = "ip_address_type must be one of: dualstack, ipv4, ipv6."
  }
}

variable "auto_snapshot_time" {
  description = "Daily UTC time for automatic snapshots in HH:00 format (e.g. 06:00). Null disables auto-snapshots."
  type        = string
  default     = null

  validation {
    condition     = var.auto_snapshot_time == null || can(regex("^([01][0-9]|2[0-3]):00$", var.auto_snapshot_time))
    error_message = "auto_snapshot_time must be in HH:00 format (hourly increments), e.g. 06:00."
  }
}

variable "create_static_ip" {
  description = "Allocate and attach a persistent static IP so the public IP survives stop/start."
  type        = bool
  default     = true
}

variable "public_ports" {
  description = <<-EOT
    Firewall rules for the instance. This list REPLACES all open public ports —
    anything not listed is closed by AWS. Each entry:
      protocol  - tcp | udp | icmp | icmpv6 | all
      from_port - first port in the range
      to_port   - last port in the range (equal to from_port for a single port)
      cidrs     - allowed IPv4 CIDRs (default ["0.0.0.0/0"]; tighten for SSH)
  EOT
  type = list(object({
    protocol  = string
    from_port = number
    to_port   = number
    cidrs     = optional(list(string), ["0.0.0.0/0"])
  }))
  default = [
    { protocol = "tcp", from_port = 80, to_port = 80 },
    { protocol = "tcp", from_port = 443, to_port = 443 },
  ]

  validation {
    condition = alltrue([
      for p in var.public_ports :
      contains(["tcp", "udp", "icmp", "icmpv6", "all"], p.protocol)
    ])
    error_message = "Each public_ports.protocol must be one of: tcp, udp, icmp, icmpv6, all."
  }

  validation {
    condition = alltrue([
      for p in var.public_ports :
      p.from_port >= 0 && p.to_port >= p.from_port && p.to_port <= 65535
    ])
    error_message = "Each port_info must have 0 <= from_port <= to_port <= 65535."
  }
}

variable "tags" {
  description = "Additional tags merged onto the instance and key pair."
  type        = map(string)
  default     = {}
}

outputs.tf

output "id" {
  description = "ARN of the Lightsail instance (its id)."
  value       = aws_lightsail_instance.this.id
}

output "arn" {
  description = "ARN of the Lightsail instance."
  value       = aws_lightsail_instance.this.arn
}

output "name" {
  description = "Name of the Lightsail instance."
  value       = aws_lightsail_instance.this.name
}

output "public_ip_address" {
  description = "Public IP — the static IP when one is attached, otherwise the instance's dynamic public IP."
  value       = try(aws_lightsail_static_ip.this[0].ip_address, aws_lightsail_instance.this.public_ip_address)
}

output "private_ip_address" {
  description = "Private IP address of the instance."
  value       = aws_lightsail_instance.this.private_ip_address
}

output "username" {
  description = "User name for SSH (e.g. ubuntu, ec2-user) based on the blueprint."
  value       = aws_lightsail_instance.this.username
}

output "key_pair_name" {
  description = "Name of the key pair used by the instance."
  value       = local.key_pair_name
}

output "private_key" {
  description = "Generated private key (base64), only present when the module created an unencrypted key. Sensitive."
  value       = try(aws_lightsail_key_pair.this[0].private_key, null)
  sensitive   = true
}

output "static_ip_name" {
  description = "Name of the attached static IP, or null when none was created."
  value       = try(aws_lightsail_static_ip.this[0].name, null)
}

How to use it

module "blog" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lightsail?ref=v1.0.0"

  name              = "marketing-blog-prod"
  availability_zone = "us-east-1a"
  blueprint_id      = "ubuntu_22_04"
  bundle_id         = "small_3_0"

  # Import an existing SSH public key instead of letting Lightsail generate one.
  create_key_pair = true
  public_key      = file("~/.ssh/blog_ed25519.pub")

  # Single-line bootstrap: install nginx and drop a landing page.
  user_data = "apt-get update && apt-get install -y nginx && echo '<h1>Deployed via Terraform</h1>' > /var/www/html/index.html"

  ip_address_type    = "ipv4"
  auto_snapshot_time = "06:00"
  create_static_ip   = true

  public_ports = [
    { protocol = "tcp", from_port = 80, to_port = 80 },
    { protocol = "tcp", from_port = 443, to_port = 443 },
    # Restrict SSH to the office egress range — never leave 22 open to 0.0.0.0/0.
    { protocol = "tcp", from_port = 22, to_port = 22, cidrs = ["203.0.113.10/32"] },
  ]

  tags = {
    Environment = "prod"
    Team        = "marketing"
    CostCenter  = "MKT-9920"
  }
}

# Downstream: point a Route 53 A record at the instance's stable public IP.
resource "aws_route53_record" "blog" {
  zone_id = aws_route53_zone.primary.zone_id
  name    = "blog.example.com"
  type    = "A"
  ttl     = 300
  records = [module.blog.public_ip_address]
}

Pin the module with ?ref=<tag> so a stack never silently picks up a breaking module change — and remember the public_ports list is the complete open-port set, so add new rules rather than expecting them to merge.

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...s3 state bucket/container + key per path...
  }
}

2. Module configlive/prod/lightsail/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-aws-lightsail?ref=v1.0.0"
}

inputs = {
  name = "..."
  availability_zone = "..."
  blueprint_id = "..."
  bundle_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/lightsail && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
name string Yes Lightsail instance name (unique per Region).
availability_zone string Yes AZ such as us-east-1a; must match the provider Region.
blueprint_id string Yes OS/app image, e.g. ubuntu_22_04, amazon_linux_2.
bundle_id string Yes Plan/size, e.g. nano_3_0, small_3_0.
create_key_pair bool true No Create a managed Lightsail key pair for the instance.
key_pair_name string null No Existing key pair name when create_key_pair is false.
public_key string null No Public key to import; null lets Lightsail generate one.
pgp_key string null No PGP key to encrypt the generated private key in state.
user_data string null No Single-line first-boot script (no multi-line scripts).
ip_address_type string dualstack No dualstack, ipv4, or ipv6.
auto_snapshot_time string null No Daily UTC HH:00 auto-snapshot time; null disables it.
create_static_ip bool true No Allocate and attach a persistent static IP.
public_ports list(object) [80,443] No Complete set of open public ports (replaces all others).
tags map(string) {} No Additional tags merged onto instance and key pair.

Outputs

Name Description
id ARN/id of the Lightsail instance.
arn ARN of the Lightsail instance.
name Name of the Lightsail instance.
public_ip_address Static IP if attached, else the dynamic public IP.
private_ip_address Private IP address of the instance.
username SSH user name derived from the blueprint.
key_pair_name Name of the key pair used by the instance.
private_key Generated private key (sensitive); null when not generated.
static_ip_name Name of the attached static IP, or null.

Enterprise scenario

A digital agency hosts roughly forty small client sites — brochure pages, WordPress blogs, and low-traffic internal tools — each on its own Lightsail box. The platform team publishes this module at v1.0.0 so every site is spun up identically: ubuntu_22_04 on a small_3_0 bundle, a persistent static IP wired to the client’s DNS, a daily 06:00 auto-snapshot for recovery, and a firewall that opens only 80/443 to the world while restricting SSH to the agency’s office CIDR. When a client signs, an engineer adds one module "lightsail" block, sets the name and a CostCenter tag, and ships a fully firewalled server in a single reviewed PR — no console clicks, no forgotten open ports, and a flat monthly cost per client that the finance team can attribute straight from tags.

Best practices

TerraformAWSLightsailModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading