An EC2 Mac instance bills for the Dedicated Host it runs on, and AWS won’t let that host go until 24 hours have passed. The costly mistake isn’t the hourly rate. It’s the host that keeps billing for days because nobody came back to release it.
This guide takes a mac-m4.metal host from a zero quota to SSH, VNC and teardown, with an EventBridge Scheduler job that releases it at 24 hours 15 minutes. Built and run in October 2026 in eu-west-1 on the macOS 26.7 AMI, so every output below comes from that host.
Check the mac-m4.metal 24-hour cost
A mac-m4 Dedicated Host costs $1.353 an hour On-Demand in eu-west-1 and $1.23 in us-east-1, per the AWS Price List API in October 2026. The Dedicated Hosts pricing page sets a 24-hour minimum for allocation and billing on EC2 Mac, so one day in Ireland is $32.47 whether you use it for ten minutes or the full day. The instance on the host adds nothing, while EBS and the public IPv4 address bill as usual.
Prices differ by region and AWS changes them, so read the current rate before you allocate rather than trusting a figure from a blog. The Price List API only runs in a few regions, us-east-1 among them, but it prices every region. This query returns the On-Demand line for a mac-m4 host in Ireland.
aws pricing get-products --region us-east-1 --service-code AmazonEC2 --filters Type=TERM_MATCH,Field=productFamily,Value="Dedicated Host" Type=TERM_MATCH,Field=instanceType,Value=mac-m4 Type=TERM_MATCH,Field=regionCode,Value=eu-west-1 --query 'PriceList[0]' --output text | jq -r '.terms.OnDemand[].priceDimensions[] | .description'
Its description line carries the hourly host rate.
$1.353 per On Demand MAC-M4 Dedicated Host Hour
The test host was allocated at 06:49:34 UTC and the scheduled release fired at 07:05:41 UTC the next day, which is 24 hours, 16 minutes and 7 seconds. The macOS instance on it ran for about 70 minutes on a 500 GB gp3 volume before it was terminated. Cost Explorer usage for that run, priced at the Price List rates, breaks down like this.
| Line item (eu-west-1) | Rate | Usage | Cost |
|---|---|---|---|
| mac-m4 Dedicated Host, 24 h minimum | $1.353/h | 24 h | $32.47 |
| Host, as allocated in the lab | $1.353/h | 24 h 16 m 7 s | $32.84 at most |
| gp3 storage, 500 GB | $0.088/GB-month | 0.81 GB-month | $0.07 |
| gp3 IOPS above 3,000 (8,000 set) | $0.0055/IOPS-month | 8.08 IOPS-month | $0.04 |
| gp3 throughput above 125 MiB/s (600 set) | $0.044/MiBps-month | 0.77 MiBps-month | $0.03 |
| Public IPv4 address | $0.005/h | 1.2 h | $0.01 |
The host is 99% of the bill, and everything else came to about 15 cents. The 16-minute buffer past the minimum cost $0.36, which is cheap insurance, because AWS refuses a release call made before the 24 hours are up (AWS: stop or terminate a Mac instance). AWS also pauses host metering while the host scrubs after the instance is terminated, and Cost Explorer showed only 20 metered host-hours on the evening of the release, still flagged as estimated. The 24-hour billing minimum is the floor either way, so budget $32.47 per day in Ireland and treat anything past 24 hours as money you chose to spend.
Get the mac-m4 host quota raised
New accounts start at zero for every Mac host type, so the first allocate attempt fails no matter what else the account can run. The quota is set per region and per Mac family, which means an approved mac2 quota does nothing for a mac-m4 host. The path through it has four stops.
- Read the current Mac quotas for the region.
- Request one mac-m4 host with quota code
L-2CBA8B92. - Poll the request until it reads
CASE_CLOSED. - Confirm the applied quota value is 1.0.
Set the region and zone once, because every command below repeats them. You need the AWS CLI v2 with credentials that can call Service Quotas, EC2, IAM and EventBridge Scheduler, and the AWS CLI v2 install guide covers that setup.
export REGION=eu-west-1
export AZ=eu-west-1a
List the Mac quotas for the region.
aws service-quotas list-service-quotas --region $REGION --service-code ec2 --query "Quotas[?contains(QuotaName,'mac')].[QuotaName,QuotaCode,Value,Adjustable]" --output text
Every Mac family in eu-west-1 sat at 0.0, and the one that matters here is L-2CBA8B92.
Running Dedicated mac2 Hosts L-5D8DADF5 0.0 True
Running Dedicated mac1 Hosts L-A8448DC5 0.0 True
Running Dedicated mac-m4 Hosts L-2CBA8B92 0.0 True
With the quota at zero, allocate-hosts fails with the error most people end up searching for. It reads like a capacity problem, but it’s only the account limit.
aws: [ERROR]: An error occurred (HostLimitExceeded) when calling the AllocateHosts operation: The number of Dedicated hosts you've requested (1) would bring your total up to 1 for the specified group of instance types (mac-m4). This exceeds your current limit of 0. If you've recently released Dedicated hosts, it may take some time for them to stop counting towards your limit. Try allocating your hosts again after a few minutes. Please visit http://aws.amazon.com/contact-us/ec2-request to request an adjustment to this limit.
Ask for one host. The quota counts hosts rather than vCPUs, and one host runs exactly one macOS instance, so 1 is the right number for a single Mac.
aws service-quotas request-service-quota-increase --region $REGION --service-code ec2 --quota-code L-2CBA8B92 --desired-value 1 --output json
The request starts as PENDING. Keep the Id, since that’s the only handle you have for polling it.
{
"RequestedQuota": {
"Id": "0123456789abcdef0123456789abcdefEXAMPLE",
"ServiceCode": "ec2",
"ServiceName": "Amazon Elastic Compute Cloud (Amazon EC2)",
"QuotaCode": "L-2CBA8B92",
"QuotaName": "Running Dedicated mac-m4 Hosts",
"DesiredValue": 1.0,
"Status": "PENDING",
"Created": "2026-10-29T22:45:34.861000+03:00",
"Requester": "{\"accountId\":\"123456789012\",\"callerArn\":\"arn:aws:iam::123456789012:user/admin\"}",
"QuotaArn": "arn:aws:servicequotas:eu-west-1:123456789012:ec2/L-2CBA8B92",
"GlobalQuota": false,
"Unit": "None",
"QuotaRequestedAtLevel": "ACCOUNT"
}
}
Within 40 seconds the status moved to CASE_OPENED, so a support case now exists and a person reviews it. Nothing on the CLI side speeds that up, and polling every few minutes is plenty.
aws service-quotas get-requested-service-quota-change --region $REGION --request-id 0123456789abcdef0123456789abcdefEXAMPLE --query 'RequestedQuota.[Status,CaseId]' --output text
The case reference comes back next to the status.
CASE_OPENED 170000000000001
This request closed 1 hour 39 minutes after it was filed. It ended as CASE_CLOSED, not APPROVED, so do not read that status as a refusal. Check the applied value instead, because that number is what allocate-hosts is held against.
aws service-quotas get-service-quota --region $REGION --service-code ec2 --quota-code L-2CBA8B92
A Value of 1.0 means one mac-m4 host can now be allocated in this region.
{
"Quota": {
"ServiceCode": "ec2",
"ServiceName": "Amazon Elastic Compute Cloud (Amazon EC2)",
"QuotaArn": "arn:aws:servicequotas:eu-west-1:123456789012:ec2/L-2CBA8B92",
"QuotaCode": "L-2CBA8B92",
"QuotaName": "Running Dedicated mac-m4 Hosts",
"Value": 1.0,
"Unit": "None",
"Adjustable": true,
"GlobalQuota": false,
"QuotaAppliedAtLevel": "ACCOUNT",
"Description": "Maximum number of running dedicated mac-m4 hosts."
}
}
Approval only covers eu-west-1. Before any request, the same account also showed 0.0 in us-east-1 for mac-m4, mac-m4pro, mac-m4max and mac-m3ultra, so file a separate request for every region you plan to use, and file it a day ahead of when you need the Mac.
Build a VPC in a region without one
The test account had no default VPC in eu-west-1, so describe-vpcs --filters Name=isDefault,Values=true came back empty and run-instances had no subnet to land in. Skip this section if your region still has its default VPC. Otherwise build a minimal public network in the same zone you’ll allocate the host in, because a host in eu-west-1a can only take an instance from a subnet in eu-west-1a.
- Create a VPC with DNS hostnames enabled.
- Add a subnet in
$AZthat maps a public IP on launch. - Attach an internet gateway and route
0.0.0.0/0to it. - Create a security group that allows SSH from your own IP only.
The block below does all four and prints the IDs at the end. Port 22 opens to your current public address as a /32 and nothing else opens at all, since VNC rides the SSH tunnel later.
MYIP=$(curl -s https://checkip.amazonaws.com)
VPC=$(aws ec2 create-vpc --region $REGION --cidr-block 10.77.0.0/16 --query Vpc.VpcId --output text)
aws ec2 modify-vpc-attribute --region $REGION --vpc-id $VPC --enable-dns-hostnames
SUB=$(aws ec2 create-subnet --region $REGION --vpc-id $VPC --cidr-block 10.77.1.0/24 --availability-zone $AZ --query Subnet.SubnetId --output text)
aws ec2 modify-subnet-attribute --region $REGION --subnet-id $SUB --map-public-ip-on-launch
IGW=$(aws ec2 create-internet-gateway --region $REGION --query InternetGateway.InternetGatewayId --output text)
aws ec2 attach-internet-gateway --region $REGION --vpc-id $VPC --internet-gateway-id $IGW
RT=$(aws ec2 describe-route-tables --region $REGION --filters Name=vpc-id,Values=$VPC --query 'RouteTables[0].RouteTableId' --output text)
aws ec2 create-route --region $REGION --route-table-id $RT --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW >/dev/null
SG=$(aws ec2 create-security-group --region $REGION --vpc-id $VPC --group-name mac-lab-ssh --description "mac lab ssh" --query GroupId --output text)
aws ec2 authorize-security-group-ingress --region $REGION --group-id $SG --protocol tcp --port 22 --cidr $MYIP/32 >/dev/null
echo "VPC=$VPC SUB=$SUB IGW=$IGW RT=$RT SG=$SG"
Save the line it prints. The launch needs the subnet and security group, and the cleanup at the end needs the VPC, subnet, gateway and security group. Keep this shell open so the variables survive, or export them again later. If you’d like a record of every connection attempt against that SSH rule while the Mac is up, VPC flow logs to CloudWatch work on this VPC like any other.
VPC=vpc-0a1b2c3d4e5f67890 SUB=subnet-0a1b2c3d4e5f67890 IGW=igw-0a1b2c3d4e5f67890 RT=rtb-0a1b2c3d4e5f67890 SG=sg-0a1b2c3d4e5f67890
Generate a dedicated key and import the public half as an EC2 key pair. Password logins are off on the macOS AMI, so this key is the only way in over SSH, and a throwaway key keeps your main one out of an account you’ll tear down tomorrow.
ssh-keygen -q -t ed25519 -N '' -f ~/.ssh/ec2-mac -C ec2-mac
aws ec2 import-key-pair --region $REGION --key-name mac-lab --public-key-material fileb://$HOME/.ssh/ec2-mac.pub >/dev/null
Both commands are silent on success. To check the import, aws ec2 describe-key-pairs --key-names mac-lab lists the key pair.
Allocate a mac-m4.metal Dedicated Host
Confirm the instance type is offered in your zone before you allocate. A zone without mac-m4 capacity can’t take the host, and the zone also has to match the subnet you just built.
aws ec2 describe-instance-type-offerings --region $REGION --location-type availability-zone --filters Name=instance-type,Values=mac-m4.metal --query 'InstanceTypeOfferings[].Location'
All three Ireland zones offered it.
[
"eu-west-1b",
"eu-west-1c",
"eu-west-1a"
]
Now allocate the host. The 24-hour clock and the billing start the moment this returns, so have the schedule commands from the next section ready before you press Enter.
aws ec2 allocate-hosts --region $REGION --availability-zone $AZ --instance-type mac-m4.metal --quantity 1
You get back just the host ID.
{
"HostIds": [
"h-0a1b2c3d4e5f67890"
]
}
Export it, then read the host’s state and exact allocation time, since the release schedule is built from that timestamp.
export HOST_ID=h-0a1b2c3d4e5f67890
aws ec2 describe-hosts --region $REGION --host-ids $HOST_ID --query 'Hosts[0].{s:State,a:AllocationTime}'
An available host with an allocation time is yours and billing.
{
"s": "available",
"a": "2026-10-30T06:49:34+00:00"
}
That allocation time is the number to keep. The release, the $32.47 minimum and every minute of overrun all count from it, so write it down somewhere you’ll see tomorrow.
Set an automatic host release
Do this straight after allocation, before you launch anything. A one-time EventBridge Scheduler job calls ec2:ReleaseHosts through the AWS SDK integration, so the host is released even if your laptop is shut and the lab is forgotten. The scheduler needs an IAM role it can assume, with permission for that one action and nothing more.
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
vim trust.json
Paste the trust policy with your own account ID in the condition, which limits the role to schedules in your account.
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"scheduler.amazonaws.com"},"Action":"sts:AssumeRole","Condition":{"StringEquals":{"aws:SourceAccount":"123456789012"}}}]}
Then open the permission policy file.
vim pol.json
It allows only ec2:ReleaseHosts.
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"ec2:ReleaseHosts","Resource":"*"}]}
Create the role and attach the inline policy. The guide to IAM users and groups with the AWS CLI covers the same IAM commands in more depth.
aws iam create-role --role-name release-mac-host --assume-role-policy-document file://trust.json --query Role.Arn --output text
aws iam put-role-policy --role-name release-mac-host --policy-name release-hosts --policy-document file://pol.json
Only the role ARN prints, and put-role-policy stays silent.
arn:aws:iam::123456789012:role/release-mac-host
Next, work out the release time by adding 24 hours 15 minutes (87,300 seconds) to the allocation time. On Linux, GNU date does it in one line, and the minute rounding leaves a margin of just under 15 minutes. On a Mac, the stock BSD date has no -d, so install coreutils with Homebrew and use gdate in its place.
A=$(aws ec2 describe-hosts --region $REGION --host-ids $HOST_ID --query 'Hosts[0].AllocationTime' --output text)
export RELEASE_AT=$(date -u -d "@$(( $(date -d "$A" +%s) + 87300 ))" +%Y-%m-%dT%H:%M:00)
echo $RELEASE_AT
For a 06:49:34 allocation that’s 07:04 the next day. Your value will differ, and the schedule below reads it from $RELEASE_AT, so never paste a date from someone else’s run.
2026-10-31T07:04:00
Create the schedule. Mode=OFF fires it at the exact time instead of inside a flexible window, and --action-after-completion DELETE removes the schedule once it has run, so nothing is left behind afterwards.
aws scheduler create-schedule --region $REGION --name release-$HOST_ID --schedule-expression "at($RELEASE_AT)" --schedule-expression-timezone UTC --flexible-time-window Mode=OFF --action-after-completion DELETE --target "{\"Arn\":\"arn:aws:scheduler:::aws-sdk:ec2:releaseHosts\",\"RoleArn\":\"arn:aws:iam::$ACCOUNT_ID:role/release-mac-host\",\"Input\":\"{\\\"HostIds\\\":[\\\"$HOST_ID\\\"]}\"}"
IAM changes are eventually consistent, so if create-schedule rejects the role straight after create-role, wait a few seconds and run it again. On success you get the schedule ARN back.
{
"ScheduleArn": "arn:aws:scheduler:eu-west-1:123456789012:schedule/default/release-h-0a1b2c3d4e5f67890"
}
Read the schedule back and check the time, the target and the host ID in the input.
aws scheduler get-schedule --region $REGION --name release-$HOST_ID --query '{e:ScheduleExpression,s:State,t:Target.Arn,i:Target.Input}'
An ENABLED schedule pointing at aws-sdk:ec2:releaseHosts with your host ID is armed. The lab’s schedule was set one minute later than the helper’s value, at 07:05.
{
"e": "at(2026-10-31T07:05:00)",
"s": "ENABLED",
"t": "arn:aws:scheduler:::aws-sdk:ec2:releaseHosts",
"i": "{\"HostIds\":[\"h-0a1b2c3d4e5f67890\"]}"
}
Why 15 minutes and not zero? AWS refuses to release a Mac host before the 24-hour minimum, so the margin absorbs any gap between the recorded allocation time and the moment the call lands. Fifteen minutes costs about 34 cents in Ireland. The host also can’t be released while an instance is still on it, which means the instance has to be gone, and the host scrubbed, before this time.
Run the macOS AMI on the host
Find the newest Amazon macOS AMI for Apple silicon. Mac AMIs use the arm64_mac architecture rather than plain arm64, and the filter just returns nothing if you get that wrong, so copy it exactly.
aws ec2 describe-images --region $REGION --owners amazon --filters 'Name=name,Values=amzn-ec2-macos-26*' 'Name=architecture,Values=arm64_mac' --query 'sort_by(Images,&CreationDate)[-3:].{id:ImageId,n:Name,d:CreationDate}'
Sorted by date, the last entry is the newest, and in October 2026 that was macOS 26.7.
[
{
"id": "ami-0ea38c7e5314d3d03",
"n": "amzn-ec2-macos-26.6.1-20260818-033129-arm64",
"d": "2026-08-20T20:15:07.000Z"
},
{
"id": "ami-00b03e9841d17c8e7",
"n": "amzn-ec2-macos-26.6.2-20260827-205824-arm64",
"d": "2026-09-01T19:11:12.000Z"
},
{
"id": "ami-02c5643e36ed9d81f",
"n": "amzn-ec2-macos-26.7-20260918-025529-arm64",
"d": "2026-09-22T18:48:39.000Z"
}
]
Launch onto the host with --placement HostId=. The root volume here is 500 GB gp3 at 8,000 IOPS and 600 MiB/s, while AWS recommends 10,000 IOPS and 400 MiB/s for Mac instances. The subnet and security group come from the variables the network block set, and the instance ID lands in INSTANCE_ID.
export INSTANCE_ID=$(aws ec2 run-instances --region $REGION --image-id ami-02c5643e36ed9d81f --instance-type mac-m4.metal --placement HostId=$HOST_ID --subnet-id $SUB --security-group-ids $SG --key-name mac-lab --block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=500,VolumeType=gp3,Iops=8000,Throughput=600,DeleteOnTermination=true}' --query 'Instances[0].InstanceId' --output text)
echo $INSTANCE_ID
It returns the instance ID.
i-0a1b2c3d4e5f67890
Check the state and public IP next.
aws ec2 describe-instances --region $REGION --instance-ids $INSTANCE_ID --query 'Reservations[0].Instances[0].{s:State.Name,ip:PublicIpAddress}'
aws ec2 describe-instance-status --region $REGION --instance-ids $INSTANCE_ID --query 'InstanceStatuses[0].{i:InstanceStatus.Status,s:SystemStatus.Status}'
The instance reports running long before macOS is ready, so initializing status checks are normal at this point.
{
"s": "running",
"ip": "203.0.113.25"
}
{
"i": "initializing",
"s": "initializing"
}
AWS quotes 6 to 20 minutes before an Apple silicon Mac accepts connections. This one answered SSH about seven minutes after run-instances returned, which is short next to the 24 hours you’re paying for.
Connect to the Mac over SSH
Log in as ec2-user with the key you imported. The first line loops until sshd answers, and the second reads the OS, chip, memory and disk in one go.
until ssh -i ~/.ssh/ec2-mac -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 [email protected] true 2>/dev/null; do sleep 15; done
ssh -i ~/.ssh/ec2-mac [email protected] 'sw_vers; sysctl -n machdep.cpu.brand_string hw.memsize; df -h /; uname -m; which brew; brew --version | head -1'
You are on an Apple M4 with 24 GB (25769803776 bytes) and the full 500 GB volume as root. Look at the last two lines, though.
ProductName: macOS
ProductVersion: 26.7
BuildVersion: 25G229
Apple M4
25769803776
Filesystem Size Used Avail Capacity iused ifree %iused Mounted on
/dev/disk6s2s1 500Gi 12Gi 475Gi 3% 459k 4.3G 0% /
arm64
brew not found
zsh:1: command not found: brew
Homebrew is preinstalled at /opt/homebrew, but a one-shot SSH command doesn’t load a login profile, so brew is not on the PATH. Scripts that drive the Mac over SSH hit this first. Add the shell environment to both ~/.zprofile and ~/.zshenv, which covers login shells and one-shot commands alike.
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zshenv
zsh -lc 'brew --version'
Brew answers from then on.
Homebrew 6.0.22
Homebrew also refreshes itself before installs, which eats minutes on a machine you only keep for a day, and turning off Homebrew auto update stops that. Then check what hardware you’re actually renting.
system_profiler SPHardwareDataType
It’s a Mac mini (Mac16,10) with the 10-core M4. The block below is trimmed to the hardware overview, with the model number and serials removed.
Hardware:
Hardware Overview:
Model Name: Mac mini
Model Identifier: Mac16,10
Chip: Apple M4
Total Number of Cores: 10 (4 Performance and 6 Efficiency)
Memory: 24 GB
System Firmware Version: 20457.1.29
OS Loader Version: 18000.161.10.701.1
Serial Number (system): REDACTED
Hardware UUID: REDACTED
Provisioning UDID: REDACTED
Activation Lock Status: Disabled
One more check is worth a few seconds. On the 26.7 AMI, softwareupdate --list already offered macOS Tahoe 26.7.2 and macOS 27.2, both needing a restart. On a host billed by the day, do not start a major upgrade unless the upgrade is the thing you’re testing.
Open VNC through an SSH tunnel
Don’t open a VNC port in the security group. Keep port 22 as the only way in and forward the VNC port over SSH, so the screen session rides on the key you already trust. For the host’s own desktop, AWS’s connection guide gives these steps on the Mac. This lab didn’t switch on the host’s Screen Sharing, so they are AWS’s documented commands with no captured output.
sudo passwd ec2-user
sudo launchctl enable system/com.apple.screensharing
sudo launchctl load -w /System/Library/LaunchDaemons/com.apple.screensharing.plist
The password is what Screen Sharing asks for, since your SSH key doesn’t help there. Exit, then reconnect from your own machine with port 5900 forwarded.
ssh -i ~/.ssh/ec2-mac -L 5900:localhost:5900 [email protected]
Connect a client that speaks Apple Remote Desktop to vnc://localhost:5900 (Screen Sharing on a Mac does) and log in as ec2-user with that password. AWS notes that macOS 10.14 and later only allow control, not just viewing, when Screen Sharing is enabled through System Settings, so expect a view-only session if the launchctl route isn’t enough. If local or remote forwarding is new to you, the SSH tunnels guide explains the flags.
The lab used the same tunnel for a different VNC server: a Tart macOS guest VM on the host, started with tart run --vnc-experimental, which printed vnc://:<password>@127.0.0.1:60613 on the Mac’s loopback. Forwarding that port worked the same way.
ssh -i ~/.ssh/ec2-mac -N -L 15901:127.0.0.1:60613 [email protected]
A VNC client pointed at 127.0.0.1:15901 then showed the guest’s desktop, with Terminal running inside the VM on the EC2 Mac.

That guest only booted after the keychain fix in the next section, which applies to any macOS VM you run on the host.
Create a login keychain over SSH
This is the trap that costs most people an hour of their 24. A fresh EC2 Mac that has only ever been reached over SSH has no login keychain for ec2-user, because nobody has logged in at the console to create one. Check it first.
security default-keychain
Only the System keychain exists.
security: SecKeychainCopyDefault: A default keychain could not be found.
Anything that stores a secret then fails in ways that never mention a keychain. Starting a macOS VM through Virtualization.framework died with a security error, and the only clue, “Failed to create new HostKey”, sits three errors deep.
Error Domain=VZErrorDomain Code=-9 "The virtual machine encountered a security error." UserInfo={NSLocalizedFailure=Unable to access security information., NSLocalizedFailureReason=The virtual machine encountered a security error., NSUnderlyingError=0x942c48f90 {Error Domain=NSPOSIXErrorDomain Code=22 "Invalid argument" UserInfo={NSLocalizedFailureReason=Failed to get current host key., NSUnderlyingError=0x942c48f60 {Error Domain=NSPOSIXErrorDomain Code=22 "Invalid argument" UserInfo={NSLocalizedFailureReason=Failed to create new HostKey., NSUnderlyingError=0x942c48f30 {Error Domain=NSPOSIXErrorDomain Code=22 "Invalid argument" UserInfo=0x9431fa840 (not displayed)}}}}}}
A container registry login against a locked keychain failed differently, but just as unhelpfully.
Error: Failed(message: "Keychain failed to add item: User interaction is not allowed.")
The fix takes two steps from the SSH session.
- Create a login keychain for
ec2-user. - Unlock it, then confirm it sits in the search list.
The empty password is acceptable on a single-user host you’ll destroy tomorrow. If anyone else can reach the account, pass a real password with -p instead.
security create-keychain -p "" login.keychain
security unlock-keychain -p "" login.keychain
security list-keychains
The new login keychain now sits in the search list ahead of the System one.
"/Users/ec2-user/Library/Keychains/login.keychain-db"
"/Library/Keychains/System.keychain"
After those two commands the same macOS VM booted and had an IP in 8 seconds. The keychain re-locks itself, though: security show-keychain-info login.keychain reported lock-on-sleep timeout=300s, and a locked keychain sent the registry login straight back to the “User interaction is not allowed” error. Clear the timeout for the rest of the lab, and unlock in the same breath, because once the keychain has re-locked, set-keychain-settings fails with that same error.
security unlock-keychain -p "" login.keychain
security set-keychain-settings login.keychain
security show-keychain-info login.keychain
The keychain now stays unlocked until you lock it.
Keychain "login.keychain" no-timeout
If a VM start or a registry login suddenly fails with one of the errors above later in the day, run the unlock again in that session before you dig any further. It is almost always the keychain.
Terminate the EC2 Mac instance and verify release
Copy anything you need off the Mac first, because the root volume is deleted with the instance. Then terminate it at least 4.5 hours before the scheduled release, so the host has time to finish scrubbing (up to 4.5 hours on Apple silicon) before the release call lands. The lab terminated the instance about 23 hours early.
aws ec2 terminate-instances --region $REGION --instance-ids $INSTANCE_ID --query 'TerminatingInstances[0].CurrentState.Name' --output text
Expect shutting-down straight away.
shutting-down
The host now scrubs itself, erasing the internal SSD and NVRAM and updating firmware, and AWS says that can take up to 4.5 hours on Apple silicon. You can’t launch a new Mac instance on it until it’s available again, but you aren’t metered while it scrubs. Leave it alone and let the schedule fire, then check three things after the release time.
- The host reads
released. - The schedule no longer exists.
- CloudTrail shows the scheduler’s role made the call.
Start with the host.
aws ec2 describe-hosts --region $REGION --host-ids $HOST_ID --query 'Hosts[0].{State:State,Allocated:AllocationTime,Released:ReleaseTime}'
It was released 41 seconds after the scheduled minute.
{
"State": "released",
"Allocated": "2026-10-30T06:49:34+00:00",
"Released": "2026-10-31T07:05:41+00:00"
}
Then confirm the schedule removed itself. Here a “does not exist” error is the result you want.
aws scheduler get-schedule --region $REGION --name release-$HOST_ID
ActionAfterCompletion=DELETE did its job.
aws: [ERROR]: An error occurred (ResourceNotFoundException) when calling the GetSchedule operation: Schedule release-h-0a1b2c3d4e5f67890 does not exist.
Finally, ask CloudTrail who released the host. This pulls the caller role and user agent out of the event.
aws cloudtrail lookup-events --region $REGION --lookup-attributes AttributeKey=EventName,AttributeValue=ReleaseHosts --query 'Events[0].CloudTrailEvent' --output text | jq '{eventTime, eventName, role: .userIdentity.sessionContext.sessionIssuer.userName, agent: (.userAgent | split(" ")[0]), released: .responseElements.ReleaseHostsResponse.successful.item}'
The scheduler assumed the release role and called ReleaseHosts with nobody logged in.
{
"eventTime": "2026-10-31T07:05:41Z",
"eventName": "ReleaseHosts",
"role": "release-mac-host",
"agent": "AmazonEventBridgeScheduler",
"released": "h-0a1b2c3d4e5f67890"
}
If the event is missing and the host still reads available, release it by hand with aws ec2 release-hosts --region $REGION --host-ids $HOST_ID. Check the instance is really terminated first, because a host with an instance on it can’t be released.
Delete the network and IAM role
Nothing left here costs money by itself, but a VPC with an SSH rule and a role that can release hosts shouldn’t linger in an account you’ll forget about. Once the host reads released, delete in dependency order with the IDs the network block set. The VPC itself can’t be deleted while its subnet, security group or attached gateway still exist.
aws ec2 delete-security-group --region $REGION --group-id $SG
aws ec2 delete-subnet --region $REGION --subnet-id $SUB
aws ec2 detach-internet-gateway --region $REGION --internet-gateway-id $IGW --vpc-id $VPC
aws ec2 delete-internet-gateway --region $REGION --internet-gateway-id $IGW
aws ec2 delete-vpc --region $REGION --vpc-id $VPC
aws ec2 delete-key-pair --region $REGION --key-name mac-lab
aws iam delete-role-policy --role-name release-mac-host --policy-name release-hosts
aws iam delete-role --role-name release-mac-host
rm ~/.ssh/ec2-mac ~/.ssh/ec2-mac.pub trust.json pol.json
The main route table goes with the VPC, so it needs no command of its own. For the next run, the whole loop fits in a short checklist.
- Check the quota value is 1.0 in your region.
- Allocate the host and note its
AllocationTime. - Create the release schedule at allocation plus 24 hours 15 minutes, before launching anything.
- Launch, connect, create and unlock the login keychain, then work.
- Terminate the instance before the release time and confirm the host reads
releasedafterwards.
Follow that order and the host bills for one day plus the 15-minute margin, about $32.81 in eu-west-1 at most, less any scrubbing time AWS doesn’t meter. If you need the Mac for longer, push the release later with aws scheduler update-schedule rather than deleting it, so the release stays armed. Pass every field again, including --target, --flexible-time-window and --action-after-completion DELETE, because an update replaces the whole schedule definition.