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
- You need a small, single-server workload — a brochure site, a WordPress blog, a Discord bot, a cron host, a staging box — and you want flat, predictable pricing instead of EC2’s à-la-carte bill.
- You want a paved-road VPS that ships with a persistent static IP and a tight firewall by default, so teams stop hand-rolling Lightsail in the console and forgetting to lock down port 22.
- You are prototyping an app and want a real public server in minutes, with the option to graduate to an EC2 + VPC module later without throwing away your Terraform discipline.
- You run a fleet of identical small instances (one per client, one per demo) and want a single versioned module so every box has the same blueprint, firewall posture, and tagging.
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 thepublic_portslist 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 config — live/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 config — live/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
- Never leave SSH open to the internet. The default
public_portsopens only 80/443; when you add port 22, scope itscidrsto a bastion or office range (203.0.113.10/32), never0.0.0.0/0. The firewall is declarative, so the open-port set is exactly what you list. - Use a static IP for anything DNS-backed. Lightsail’s dynamic public IP can change across stop/start;
create_static_ip = truepins it so your A records survive reboots and snapshots/restores. - Encrypt or import your keys. Prefer importing an existing
public_key; if you must let Lightsail generate one, setpgp_keyso the private key is not stored in plaintext interraform.tfstate. Treat theprivate_keyoutput as sensitive. - Keep user_data single-line. Lightsail rejects EC2-style multi-line scripts — chain commands with
&&on one line, or fetch and run a script from object storage for anything substantial. - Right-size the bundle and snapshot for cost. Bundles are flat monthly prices; start small (
nano_3_0/micro_3_0) and resize deliberately. Enableauto_snapshot_timefor a cheap recovery point, and remember data transfer beyond the bundle allowance is billed separately. - Know when to graduate to EC2. Tag and name by
<service>-<env>convention for chargeback, but once you need private subnets, multiple SGs, autoscaling, or load balancers, migrate to a full EC2 + VPC module — Lightsail intentionally hides those controls.