Bootstrapping UniFi Controller on AWS
As part of a recent project to upgrade some legacy infrastructure and split services across both Azure and AWS I found myself needing to migrate a UniFi instance. The old setup was a Windows 2019 VM in Azure. The new target: AWS, Ubuntu, fully bootstrapped via Infrastructure as Code.
What is Bootstrapping?
Bootstrapping is the first piece of code that runs when a machine starts — used to trigger software installation and configuration. The goal here was a fully repeatable deployment: running terraform apply should result in either a setup page or login page appearing in the browser at the provisioned public IP, with no manual steps.
High-Level Steps
- Provision S3 bucket
- Provision EC2 instance
- Update/upgrade Ubuntu and set hostname
- Install prerequisites (MongoDB, AWS CLI, jq)
- Install UniFi
- Restore from latest S3 backup (if available)
- Configure UniFi (guest portal ports)
- Set up a sync service to push backups to S3 every 5 minutes
- Start UniFi
Terraform Integration
The bootstrap script is a shell template rendered by Terraform's templatefile function:
user_data = templatefile("../_bootstrap/bootstrap.sh.tpl", {
server_name = var.server_name
env_name = var.env_name
unifi_version = var.unifi_version
mongo_version = var.mongo_version
portal_https_port = local.unifi_portal_https_port
portal_http_port = local.unifi_portal_http_port
bucket_name = aws_s3_bucket.unifi.id
})
Step 3: Ubuntu Updates and Hostname
sudo apt-get update
sudo apt-get upgrade -y
hostname "${server_name}-${env_name}"
echo "${server_name}-${env_name}" > /etc/hostname
Step 4: Prerequisites — MongoDB
The script loops through Ubuntu distros from newest to oldest to find the right MongoDB repository, ensuring it works across Ubuntu versions:
for distro in jammy focal bionic xenial trusty precise; do
result=$(curl -s "https://repo.mongodb.org/apt/ubuntu/dists/$distro/")
if echo "$result" | grep -q "mongodb-org-${mongo_version}"; then
# Add the repo for this distro
break
fi
done
Step 6: Backup Restoration (The Tricky Bit)
This is the most involved step. The script:
- Copies all backups from S3 to a temp directory
- Parses backup metadata JSON with
jqto find the most recent backup by timestamp - Uploads the backup file to UniFi's local HTTPS endpoint
- Issues a restore command using the returned backup ID
- Copies all backups to the UniFi backup directory with correct ownership
A critical detail: the upload must include the X-Requested-With: XMLHttpRequest header, and since UniFi uses a self-signed cert on localhost, --insecure is required:
backup_id=$(curl --insecure -s \
-H "X-Requested-With: XMLHttpRequest" \
-F "backupFile=@${backup_file}" \
"https://localhost:8443/api/cmd/backup" \
| jq -r '.data.backup_id')
curl --insecure -s \
-H "X-Requested-With: XMLHttpRequest" \
-d "{\"cmd\":\"restore\",\"backup\":\"${backup_id}\"}" \
"https://localhost:8443/api/cmd/system"
Step 8: S3 Backup Sync Service
A simple systemd service keeps backups continuously synced to S3:
cat <<EOF > /etc/systemd/system/unifi-backup-sync.service
[Unit]
Description=Sync UniFi backups to S3
[Service]
Type=simple
ExecStart=/usr/local/bin/unifi-backup-sync.sh
Restart=always
[Install]
WantedBy=multi-user.target
EOF
cat <<EOF > /usr/local/bin/unifi-backup-sync.sh
#!/bin/sh
while true; do
sudo aws s3 sync /var/lib/unifi/backup s3://${bucket_name}/backups \
--only-show-errors --delete
sleep 300
done
EOF
Key Considerations
Port restrictions — UniFi runs as a non-root user and cannot bind to privileged ports below 1024. The module configures alternative ports (2083/HTTPS, 8880/HTTP) via system.properties.
File ownership — After copying backups into the UniFi backup directory, ownership must be set to the unifi user or UniFi won't write new backup metadata.
Immutability — The only persistent element across rebuilds is the S3 bucket. Everything else is recreated on each terraform apply.
Full Code
Available on GitHub: mhosker/terraform-unifi-bootstrap