Migrate Launch Configurations to Launch Templates in Auto Scaling

Close-up of a server rack with neatly bundled blue network cables plugged into switches

Photo by Michel Didier Joomun on Unsplash

To migrate launch configurations to launch templates, find the Auto Scaling groups that still set LaunchConfigurationName, copy each launch configuration into a launch template, point the group at a numbered template version with UpdateAutoScalingGroup, then replace the running instances with an instance refresh. The script below finds the groups and drafts each template for you to review.

Launch configurations are on their way out. They can’t use EC2 instance types released since the start of 2023, newer accounts can’t create them at all, and they miss features such as mixed instance types and Spot allocation strategies. Groups that still use one keep running, but the day you need a new instance type, or to rebuild in a fresh account, the launch configuration becomes a blocker. Now is a better time to migrate launch configurations to launch templates than during an incident.

This example is for platform engineers who own EC2 fleets. It’s report only: it lists the groups, flags risky settings inherited from old launch configurations and writes one CreateLaunchTemplate draft per configuration. You review the drafts and run the change yourself.

What is the launch configuration deprecation timeline?

From the Amazon EC2 Auto Scaling launch configurations documentation:

Date What changed
1 January 2023 New EC2 instance types are no longer supported in launch configurations, including types added to a Region after its launch
1 June 2023 Accounts created on or after this date can’t create launch configurations in the console
1 October 2024 Accounts created on or after this date can’t create launch configurations by any method: console, API, CLI or CloudFormation

Older accounts can still create launch configurations through the API, but you also can’t edit one after creating it, so every change means a new configuration. Launch templates have versions, support every current instance type and are required for a mixed instances policy.

Why is this a security fix, not just housekeeping?

Launch configurations were often written years ago, and the settings in them are copied to every instance a group launches. The script flags four you should change during the migration:

  • IMDSv1 allowed. A configuration without MetadataOptions.HttpTokens: required launches instances that answer unauthenticated metadata requests. Pass --require-imdsv2 to set it in the drafts, after checking your software with the script to find EC2 instances without IMDSv2.
  • Public IP addresses. AssociatePublicIpAddress: true gives every instance a public address. Most groups behind a load balancer don’t need one; see find EC2 instances with public IP addresses.
  • Unencrypted volumes. Block devices without Encrypted: true rely on the account’s EBS encryption-by-default setting, which the script to find unencrypted EBS volumes and turn on default encryption checks and enables.
  • Spot max price. A fixed SpotPrice from years ago can stop launches. Launch templates let you drop it.

Launch templates also bring a permissions check of their own. When a group uses a template, Auto Scaling validates ec2:RunInstances and iam:PassRole for whoever calls CreateAutoScalingGroup, UpdateAutoScalingGroup or StartInstanceRefresh, against the template version at that moment. Later launches of $Latest or $Default use the Auto Scaling service-linked role, so someone who can add a template version can change what the group launches. Pin numbered versions, as the policy below enforces.

What does the script do?

  1. Lists launch configurationspaginateDescribeLaunchConfigurations per Region.
  2. Lists Auto Scaling groupspaginateDescribeAutoScalingGroups, reading LaunchConfigurationName, LaunchTemplate and MixedInstancesPolicy to label each group’s source.
  3. Flags risky settingsIMDSv1, public IPs, unencrypted new volumes and a Spot max price on each configuration still in use.
  4. Finds orphansLaunch configurations no group references, which you can delete once nothing else (such as a template in a CI pipeline) refers to them.
  5. Drafts templates with --outOne JSON file per configuration in CreateLaunchTemplate input format, ready for aws ec2 create-launch-template --cli-input-json. Nothing is created.

Prerequisites

  • Node.js 18 or later, npm and tsx, plus @aws-sdk/client-auto-scaling and @aws-sdk/client-ec2 (the second for types only).
  • A read-only profile. AWS’s ReadOnlyAccess managed policy covers both describe calls.
  • The list of Regions you run fleets in. The inventory from the script to report EC2 instances by type, launch time and Region tells you which ones.

Which IAM permissions does it need?

The report needs two read actions:

launch-config-report-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReportLaunchConfigurations",
      "Effect": "Allow",
      "Action": [
        "autoscaling:DescribeAutoScalingGroups",
        "autoscaling:DescribeLaunchConfigurations"
      ],
      "Resource": "*"
    }
  ]
}

The person or pipeline doing the migration needs more. This policy lets it create templates and update groups only when a numbered version is given, and pass only the instance role:

launch-template-migration-policy.json

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "CreateTemplates",
      "Effect": "Allow",
      "Action": ["ec2:CreateLaunchTemplate", "ec2:CreateLaunchTemplateVersion", "ec2:DescribeLaunchTemplates", "ec2:DescribeLaunchTemplateVersions"],
      "Resource": "*"
    },
    {
      "Sid": "UpdateGroupsOnlyWithPinnedVersion",
      "Effect": "Allow",
      "Action": "autoscaling:UpdateAutoScalingGroup",
      "Resource": "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:*:autoScalingGroupName/*",
      "Condition": { "Bool": { "autoscaling:LaunchTemplateVersionSpecified": "true" } }
    },
    {
      "Sid": "RefreshInstances",
      "Effect": "Allow",
      "Action": "autoscaling:StartInstanceRefresh",
      "Resource": "arn:aws:autoscaling:us-east-1:123456789012:autoScalingGroup:*:autoScalingGroupName/*"
    },
    {
      "Sid": "RunInstancesValidation",
      "Effect": "Allow",
      "Action": ["ec2:RunInstances", "ec2:CreateTags"],
      "Resource": "*"
    },
    {
      "Sid": "PassInstanceRole",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "arn:aws:iam::123456789012:role/web-instance-role",
      "Condition": { "StringEquals": { "iam:PassedToService": "ec2.amazonaws.com" } }
    }
  ]
}

Tighten ec2:RunInstances to your AMIs, subnets and security groups once the migration works; the guide to review a generated IAM policy for least privilege walks through that. If a call is denied, the steps to troubleshoot AWS IAM access denied errors decode the message.

The script to find Auto Scaling groups on launch configurations

find-auto-scaling-launch-configurations.ts

// find-auto-scaling-launch-configurations.ts
// Report only. Lists Auto Scaling groups that still launch from a launch configuration, flags risky
// settings (IMDSv1, public IPs, unencrypted volumes), lists launch configurations no group uses,
// and writes a CreateLaunchTemplate input file per launch configuration for you to review.
// Usage:
//   npx tsx find-auto-scaling-launch-configurations.ts [--regions us-east-1,eu-west-1] [--out lt-drafts] [--require-imdsv2]
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
  AutoScalingClient,
  paginateDescribeAutoScalingGroups,
  paginateDescribeLaunchConfigurations,
  type LaunchConfiguration,
} from "@aws-sdk/client-auto-scaling";
import type { CreateLaunchTemplateCommandInput, RequestLaunchTemplateData, VolumeType } from "@aws-sdk/client-ec2";

const args = process.argv.slice(2);
const flag = (name: string): string | undefined => {
  const i = args.indexOf(name);
  return i >= 0 ? args[i + 1] : undefined;
};
const regions = (flag("--regions") ?? process.env.AWS_REGION ?? "us-east-1").split(",").map((r) => r.trim()).filter(Boolean);
const outDir = flag("--out");
const requireImdsV2 = args.includes("--require-imdsv2");

interface GroupRow {
  Region: string;
  Group: string;
  Source: string;
  Name: string;
  InstanceType: string;
  Instances: number;
  Risks: string;
}

/** Settings in a launch configuration worth fixing while you migrate. */
function risks(lc: LaunchConfiguration): string[] {
  const found: string[] = [];
  if (lc.MetadataOptions?.HttpTokens !== "required" && lc.MetadataOptions?.HttpEndpoint !== "disabled") found.push("IMDSv1 allowed");
  if (lc.AssociatePublicIpAddress) found.push("public IP");
  if ((lc.BlockDeviceMappings ?? []).some((b) => b.Ebs && !b.Ebs.SnapshotId && b.Ebs.Encrypted !== true)) found.push("unencrypted EBS");
  if (lc.SpotPrice) found.push("spot max price");
  return found;
}

/** Map a launch configuration onto launch template data (same AMI, type, network, disks, user data). */
function toTemplate(lc: LaunchConfiguration): CreateLaunchTemplateCommandInput {
  const profile = lc.IamInstanceProfile;
  const data: RequestLaunchTemplateData = {
    ImageId: lc.ImageId,
    InstanceType: lc.InstanceType as RequestLaunchTemplateData["InstanceType"],
    KeyName: lc.KeyName || undefined,
    UserData: lc.UserData || undefined, // already base64, which launch templates also expect
    IamInstanceProfile: profile ? (profile.startsWith("arn:") ? { Arn: profile } : { Name: profile }) : undefined,
    EbsOptimized: lc.EbsOptimized,
    Monitoring: { Enabled: lc.InstanceMonitoring?.Enabled ?? true },
    KernelId: lc.KernelId || undefined,
    RamDiskId: lc.RamdiskId || undefined,
    Placement: lc.PlacementTenancy ? { Tenancy: lc.PlacementTenancy as "default" | "dedicated" | "host" } : undefined,
    MetadataOptions: {
      HttpTokens: requireImdsV2 ? "required" : lc.MetadataOptions?.HttpTokens ?? "optional",
      HttpPutResponseHopLimit: lc.MetadataOptions?.HttpPutResponseHopLimit,
      HttpEndpoint: lc.MetadataOptions?.HttpEndpoint,
    },
    BlockDeviceMappings: (lc.BlockDeviceMappings ?? []).map((b) => ({
      DeviceName: b.DeviceName,
      VirtualName: b.VirtualName,
      NoDevice: b.NoDevice ? "" : undefined, // boolean in launch configurations, empty string in templates
      Ebs: b.Ebs
        ? {
            SnapshotId: b.Ebs.SnapshotId,
            VolumeSize: b.Ebs.VolumeSize,
            VolumeType: b.Ebs.VolumeType as VolumeType | undefined,
            Iops: b.Ebs.Iops,
            Throughput: b.Ebs.Throughput,
            Encrypted: b.Ebs.Encrypted,
            DeleteOnTermination: b.Ebs.DeleteOnTermination,
          }
        : undefined,
    })),
  };
  if (lc.SpotPrice) data.InstanceMarketOptions = { MarketType: "spot", SpotOptions: { MaxPrice: lc.SpotPrice } };
  // A public IP needs a network interface, and the security groups then move onto it.
  if (lc.AssociatePublicIpAddress !== undefined) {
    data.NetworkInterfaces = [{ DeviceIndex: 0, AssociatePublicIpAddress: lc.AssociatePublicIpAddress, Groups: lc.SecurityGroups }];
  } else {
    data.SecurityGroupIds = lc.SecurityGroups;
  }
  return {
    LaunchTemplateName: lc.LaunchConfigurationName,
    VersionDescription: `Copied from launch configuration ${lc.LaunchConfigurationName}`,
    LaunchTemplateData: JSON.parse(JSON.stringify(data)), // drop undefined keys
  };
}

async function main(): Promise<void> {
  const groups: GroupRow[] = [];
  const unused: { Region: string; LaunchConfiguration: string; Created: string }[] = [];
  let drafts = 0;
  if (outDir) mkdirSync(outDir, { recursive: true });

  for (const region of regions) {
    const client = new AutoScalingClient({ region });
    const configs = new Map<string, LaunchConfiguration>();
    for await (const page of paginateDescribeLaunchConfigurations({ client }, {})) {
      for (const lc of page.LaunchConfigurations ?? []) configs.set(lc.LaunchConfigurationName ?? "", lc);
    }

    const used = new Set<string>();
    for await (const page of paginateDescribeAutoScalingGroups({ client }, {})) {
      for (const g of page.AutoScalingGroups ?? []) {
        const lt = g.LaunchTemplate ?? g.MixedInstancesPolicy?.LaunchTemplate?.LaunchTemplateSpecification;
        const lcName = g.LaunchConfigurationName;
        if (lcName) used.add(lcName);
        const lc = lcName ? configs.get(lcName) : undefined;
        groups.push({
          Region: region,
          Group: g.AutoScalingGroupName ?? "",
          Source: lcName ? "launch configuration" : g.MixedInstancesPolicy ? "template (mixed)" : "launch template",
          Name: lcName ?? `${lt?.LaunchTemplateName ?? lt?.LaunchTemplateId ?? "?"}:${lt?.Version ?? "$Default"}`,
          InstanceType: lc?.InstanceType ?? "",
          Instances: g.Instances?.length ?? 0,
          Risks: lc ? risks(lc).join(", ") : "",
        });
      }
    }

    for (const [name, lc] of configs) {
      if (!used.has(name)) unused.push({ Region: region, LaunchConfiguration: name, Created: lc.CreatedTime?.toISOString().slice(0, 10) ?? "" });
      if (outDir) {
        writeFileSync(join(outDir, `${region}-${name.replace(/[^\w.-]/g, "_")}.json`), JSON.stringify(toTemplate(lc), null, 2) + "\n");
        drafts++;
      }
    }
  }

  const legacy = groups.filter((g) => g.Source === "launch configuration");
  console.table(legacy.length ? legacy : groups);
  console.log(`${groups.length} Auto Scaling groups, ${legacy.length} still on launch configurations`);
  if (unused.length) {
    console.log(`${unused.length} launch configurations no group uses (safe to delete after migration):`);
    console.table(unused);
  }
  if (outDir) console.log(`Wrote ${drafts} CreateLaunchTemplate drafts to ${outDir}/ (review before use)`);
  console.log("Report only: nothing was created, changed or deleted.");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

How do you run it?

Terminal

npm install @aws-sdk/client-auto-scaling @aws-sdk/client-ec2
npm install --save-dev tsx typescript @types/node

AWS_PROFILE=readonly npx tsx find-auto-scaling-launch-configurations.ts --regions us-east-1,eu-west-1 --out lt-drafts --require-imdsv2

Sample output

Output

┌─────────┬─────────────┬───────────────────┬────────────────────────┬──────────────────────┬──────────────┬───────────┬─────────────────────────────────────────┐
│ (index) │ Region      │ Group             │ Source                 │ Name                 │ InstanceType │ Instances │ Risks                                   │
├─────────┼─────────────┼───────────────────┼────────────────────────┼──────────────────────┼──────────────┼───────────┼─────────────────────────────────────────┤
│ 0       │ 'us-east-1' │ 'web-prod'        │ 'launch configuration' │ 'web-lc-2021-08'     │ 'm5.large'   │ 6         │ 'IMDSv1 allowed'                        │
│ 1       │ 'us-east-1' │ 'batch-workers'   │ 'launch configuration' │ 'batch-lc-v7'        │ 'c5.xlarge'  │ 0         │ 'IMDSv1 allowed, public IP, spot max price' │
│ 2       │ 'eu-west-1' │ 'bastion'         │ 'launch configuration' │ 'bastion-lc'         │ 't3.micro'   │ 1         │ 'public IP, unencrypted EBS'            │
└─────────┴─────────────┴───────────────────┴────────────────────────┴──────────────────────┴──────────────┴───────────┴─────────────────────────────────────────┘
11 Auto Scaling groups, 3 still on launch configurations
2 launch configurations no group uses (safe to delete after migration):
┌─────────┬─────────────┬───────────────────────┬──────────────┐
│ (index) │ Region      │ LaunchConfiguration   │ Created      │
├─────────┼─────────────┼───────────────────────┼──────────────┤
│ 0       │ 'us-east-1' │ 'web-lc-2020-11'      │ '2020-11-03' │
│ 1       │ 'us-east-1' │ 'batch-lc-v6'         │ '2022-02-17' │
└─────────┴─────────────┴───────────────────────┴──────────────┘
Wrote 5 CreateLaunchTemplate drafts to lt-drafts/ (review before use)
Report only: nothing was created, changed or deleted.

A draft for web-lc-2021-08, with --require-imdsv2 applied (IDs illustrative):

lt-drafts/us-east-1-web-lc-2021-08.json

{
  "LaunchTemplateName": "web-lc-2021-08",
  "VersionDescription": "Copied from launch configuration web-lc-2021-08",
  "LaunchTemplateData": {
    "ImageId": "ami-0abcdef1234567890",
    "InstanceType": "m5.large",
    "UserData": "IyEvYmluL2Jhc2gKc3lzdGVtY3RsIHN0YXJ0IG5naW54Cg==",
    "IamInstanceProfile": { "Name": "web-instance-profile" },
    "EbsOptimized": true,
    "Monitoring": { "Enabled": true },
    "MetadataOptions": { "HttpTokens": "required", "HttpPutResponseHopLimit": 2, "HttpEndpoint": "enabled" },
    "BlockDeviceMappings": [
      { "DeviceName": "/dev/xvda", "Ebs": { "VolumeSize": 30, "VolumeType": "gp3", "Encrypted": true, "DeleteOnTermination": true } }
    ],
    "SecurityGroupIds": ["sg-0123456789abcdef0"]
  }
}

Check Monitoring: launch configurations created with the AWS CLI turn detailed (one-minute) monitoring on by default, and detailed monitoring has extra charges. Set it to false unless a scaling policy’s alarms use 60-second periods; if you switch, change those alarms to 300 seconds. While you’re editing, this is also the moment to choose an instance type released after 2022, which the launch configuration couldn’t use; the script to find previous-generation EC2 instances to upgrade lists groups on old families.

How do you migrate launch configurations to launch templates, step by step?

  1. Create the template from the draftaws ec2 create-launch-template --cli-input-json file://lt-drafts/us-east-1-web-lc-2021-08.json --region us-east-1 returns version 1. The EC2 console’s Copy to launch template action does the same for one or all configurations.
  2. Test one instanceLaunch from the template outside the group and check that user data, the instance profile and IMDSv2 all work.
  3. Point the group at the pinned versionaws autoscaling update-auto-scaling-group --auto-scaling-group-name web-prod --launch-template LaunchTemplateName=web-lc-2021-08,Version=1. Existing instances aren’t touched.
  4. Replace running instancesaws autoscaling start-instance-refresh --auto-scaling-group-name web-prod rolls through the group, respecting its minimum healthy percentage.
  5. Delete the old configurationOnce no group or template refers to it: aws autoscaling delete-launch-configuration --launch-configuration-name web-lc-2021-08.

If the group is defined in CloudFormation, CDK or Terraform, change the code instead: replace LaunchConfigurationName with a LaunchTemplate property, or launch_configuration with a launch_template block, or the next deploy puts the old configuration back. Afterwards, the check to detect CloudFormation drift across all stacks confirms stacks match what’s running.

Troubleshooting

  • You are not authorized to use launch template on update. The caller lacks ec2:RunInstances or iam:PassRole for what the template contains; Auto Scaling checks both with a dry run.
  • Launches fail with InvalidParameterCombination. Security groups are set both on the template and on its network interface, usually after hand-editing a draft. When a public IP setting exists, the script puts the groups on the interface only; keep them in one place.
  • Instances fail health checks after the refresh. Usually IMDSv2: older agents and SDKs call the metadata service without a token. Roll back to the previous group settings, update the software, then retry.
  • Security group names instead of IDs. Very old configurations can store names. Replace them with sg- IDs in the draft.

Ask ChatWithCloud instead

For a quick check in one Region, ask ChatWithCloud “Which Auto Scaling groups still use launch configurations?” It writes AWS SDK for JavaScript v2 code, runs it on your machine with your profile and summarizes the answer, as described on how ChatWithCloud answers AWS questions. Changes run without a confirmation step, so connect ChatWithCloud with a read-only AWS profile and do the migration through the steps above.

Frequently asked questions

Are AWS launch configurations deprecated?

They’re being phased out. Since 1 January 2023 they don’t support new instance types, and accounts created on or after 1 October 2024 can’t create them by any method. AWS recommends migrating to launch templates.

Does switching an Auto Scaling group to a launch template restart instances?

No. Only new instances use the template. Start an instance refresh, or let scaling replace instances over time, to move the running ones.

Should an Auto Scaling group use $Latest or a version number?

A version number. With $Latest or $Default, anyone who can create a template version changes what the group launches. The autoscaling:LaunchTemplateVersionSpecified condition key enforces pinned versions.

Can I convert a launch configuration to a launch template with the API?

Not in one call; the copy action exists only in the console. With the API, read the configuration with DescribeLaunchConfigurations and call CreateLaunchTemplate with the mapped fields, which is what the drafts above contain.

Related guides

Ask your AWS account in plain English

Your first 15 runs are free, with no OpenAI key needed.

npx chatwithcloud