AUTOMATE A PERSONAL MACBOOK SETUP
Oct'25
Overview
The goal here is to automate the setup of a personal MacBook — i.e. on first boot, after Setup Assistant — with a single command.
Notice 'personal' — in a corporate environment, automating the provisioning of employees' laptops uses a different strategy, mainly due to the variety of needs; here we assume the needs are well known (the software and other settings you expect.)
The automation will be mainly based on Ansible, a MDM profile, plus a couple of Bash and Python scripts.
This is a small project but it involves nonetheless a handful of assets described in order in the following section. The last section, Usage, shows how to assemble everything.
Scripts and templates are provided, but as the final user, you'll have to generate your MDM profile and finalize the playbooks according to your needs.
Assets Directory
If you use an Apple Account on your devices, then the most convenient location to store the assets is certainly your iCloud Drive as it will be available immediately on first boot. Simply create a subdirectory named, e.g. setup/.
An external disk is a less convenient but good alternative.
Assets
Ansible Helper
Out of the box, macOS does not provide any development tool, only the shell is available (actually 2, Bash 3.x and Zsh.) The first asset needed is a generic helper shell script able to install Ansible and its dependencies (on macOS, the Command Line Tools), and able to call Ansible tools easily.
You can download and review the following script: ansible.sh
Inventory
A minimal inventory is required for the Ansible playbook. The goal is to setup localhost, so it's the only entry, plus associated variables (to be completed later depending on the sub playbooks which well be used, see below.)
---
all:
hosts:
localhost:
ansible_connection: local
MDM Profile
Some parts of macOS can only be configured using a profile.
A profile is an XML file with the .mobileconfig extension which is mainly designed to enforce various settings and policies in a corporate environment via a MDM system, which is an entirely other topic.
We're not going to use a MDM but we're going to leverage some automation opportunities exposed by a profile, for instance:
- Proxies
- Certificates
- WiFi networks
- VPN
- Email Accounts
- …
Create a new profile from a workstation already having Apple Configurator installed:
- In the "File" menu, click "New Profile"
- Fill in the "Mandatory" section identifying and describing the profile
- Fill in the configuration sections you're interested in, e.g. "Mail", for email accounts
- Save the profile in your assets directory
Programmatically, a MDM profile can be registered (called Downloaded on popups) easily, but the ability to install them as been dropped since a few versions of macOS and now either require a user interaction for confirmation, or a completely managed device (not our case.)
You can download and review the following script to partially ease the management of a profile: profiles.py
Playbooks
Design Overview
For each software, and macOS itself, we will craft small independent playbooks (called "sub playbooks" hereafter for clarity.) This could be implemented as regular Ansible roles, however, as this is a personal project, to keep the maintenance overhead as low as possible, we'll use single-file standalone playbooks instead.
A (sub) playbook can be invoked from another playbook with the import_playbook: directive. The strategy will be to call the (sub) paybooks from a main playbook in order to set the invocation order properly and simplify the overall usage.
The downside to this approach is the variables precedence: variables set in a play in a playbook cannot be overidden by inventory variables, and there is no equivalent to the "default" variables offered by roles. That being said, the automation material here is provided for a specific use case so the majority of variables can be set directly. For the couple of variables needing to be set in the inventory for one reason or another (e.g. two laptops need to be setup, so the hostname will be set at the inventory level, allowing the same playbook(s) to be used for the 2 laptops), the corresponding variable in the playbook will remain commented out and only used for documentation purposes. This is emphasized in the templates provided below.
A (sub) playbook will be named setup_$name when it's two-fold: installing software and configuring software.
The installation part will be mostly generic, depending on the source, and is described in the following sections (from the App Store, from a disk image, etc.), the configuration part will be specific to the software and left as an exercise to the reader. Some programs use a single configuration file, some use several configuration files, some use the macOS Preferences service — to find out, the best place is to start with the official software documentation. For a small text-based configuration file, you can bundle its content in the playbook and use the copy:content: module, for larger files or binary files, you'll have to keep a copy as an asset.
The Preferences service is based on the cfprefsd service managing plist files stored in various places (/Library/Preferences, ~/Library/Preferences and many others), and the corresponding command line tool defaults. The plist files are not supposed to be accessed directly as the service uses a cache, instead you use the CLI to CRUD the settings.
Ansible has a module to wrap the defaults CLI named community.general.osx_defaults:.
At last, software for macOS is largely distributed as "apps" now, dropped in /Applications or $HOME/Applications, but you can also find command line tools dropped somewhere else on the filesystem. The installation templates provided below will mainly target apps, but they can easily be tweaked to cover other layouts.
For Builtin Apps
All macOS built-in apps and services (except those inherited from its BSD family and not yet rewritten by Apple) are using the Preferences service and API to manage their configuration.
A lot of macOS settings exposed in the Settings.app (AKA System Preferences) are accessible via defaults, except there's no official documentation indexing all the configuration keys. There are many websites and forums discussing those keys (e.g. https://macos-defaults.com) but they are not exhaustive, and beyond that, the said keys can change from one version of macOS to the other, meaning you'll have to check what still works or not after an update.
On top of system settings, macOS includes a lot of built-in apps which you can configure and therefore write a (sub) playbook for. Those playbooks, contrary to the ones for 3rd-party apps discussed below, won't have an "installation" part and consequently there's no generic template to provide here. The file can be named configure_$name for clarity in this case.
As a starting point, here are some examples:
- darwin.configure_macos_26.yml — use the profiles.py script mentioned above
- darwin.configure_vim.yml — Vim is typically a program inherited from the BSD family and does not use the Preferences system
For 3rd-party Apps
From App Store
The automation options for apps from the App Store are extremely limited, it seems there's only a partial API provided by Apple and mas_cli available as third-party tool, with serious limitations at this point in time -- mainly the fact that you must have have paid/installed an app at least once from the app store to be able to (re)install it via mas_cli. On the bright side, this tool has a corresponding Ansible module community.general.mas:.
You can download and review the following playbook to setup MAS CLI: darwin.setup_mas_cli.yml
The next step is to prepare the (sub) playbook for each app you want and coming from the App Store.
From a system with mas_cli already installed, find the ID of the app on the App Store:
mas search $APP_NAME
You can use the following template to craft the playbook:
---
- name: "App is set up: {{ app_name }}"
hosts: "all"
vars:
##
## NOTICE!
## variables defined here take precedence over inventory variables;
## keep user-defined/able variables commented out.
## Use set_fact + | default if a default value is needed.
##
app_name: # str, to be completed
app_store_id: # int, to be completed
handlers: []
tasks:
- when: ansible_system != "Darwin"
fail:
msg: "not macOS"
### PROGRAM ################################################################
- name: "User program is present"
community.general.mas:
id: "{{ app_store_id }}"
state: "present"
### CONFIGURATION ##########################################################
#
# to be completed depending on the app
#
There's no control over the target version, you get the last version published on the App Store.
From Disk Images
A disk image is a regular file used as if it were a storage device — i.e. formatted and mounted.
The steps to automate the setup of software from a disk image are the following:
- Download the disk image
- Assess the current version and uninstall on mismatch
- Install the new version by mounting the disk image and copying the app — the subtlety being to double check the exact paths used in the dmg, as they may vary
- Configure the app if needed
You can use the following template to craft the playbook:
---
- name: "App is set up: {{ app_name }}"
hosts: "all"
vars:
##
## NOTICE!
## variables defined here take precedence over inventory variables;
## keep user-defined/able variables commented out.
## Use set_fact + | default if a default value is needed.
##
user_downloads_rdir: "{{ artifacts_rdir | default('{{ ansible_env.HOME }}/Downloads') }}"
user_apps_rdir: "{{ ansible_env.HOME }}/Applications"
app_version: # str, to be completed
app_dmg_url: # str, to be completed
app_dmg_rfile: # str, to be completed, should match "{{ user_downloads_rdir }}/*.dmg"
app_volume_rdir: # str, to be completed, should match "/Volumes/*"
app_name: # str, to be completed
app_rdir: "{{ user_apps_rdir }}/{{ app_name }}.app"
handlers: []
tasks:
- when: ansible_system != "Darwin"
fail:
msg: "not macOS"
### PROGRAM ################################################################
- name: "User downloads directory is present"
file:
path: "{{ user_downloads_rdir }}"
state: "directory"
- name: "User package is present"
get_url:
url: "{{ app_dmg_url }}"
dest: "{{ app_dmg_rfile }}"
- name: "User apps directory is present"
file:
path: "{{ user_apps_rdir }}"
state: "directory"
- block:
- name: "Current version is assessed"
changed_when: false
register: cmd
shell: |
set -euo pipefail
if [[ -e "{{ app_rdir }}" ]]
then
case $(plutil -extract CFBundleShortVersionString raw -o - "{{ app_rdir }}/Contents/Info.plist") in
*"{{ app_version }}"*)
echo "expected version is installed"
;;
*)
echo "unexpected version is installed"
esac
else
echo "no version is installed"
fi
- name: "Current version is uninstalled"
when: ("unexpected version is installed" in cmd.stdout)
shell:
removes: "{{ app_rdir }}"
cmd: |
set -euo pipefail
if pgrep -a "{{ app_name }}"; then pkill -a "{{ app_name }}"; fi
rm -rf "{{ app_rdir }}"
- name: "User app is present"
shell:
creates: "{{ app_rdir }}"
cmd: |
set -euo pipefail
hdiutil mount "{{ app_dmg_rfile }}"
cp -R "{{ app_volume_rdir }}/{{ app_rdir | basename }}" "{{ user_apps_rdir }}"
hdiutil unmount "{{ app_volume_rdir }}"
### CONFIGURATION ##########################################################
#
# to be completed depending on the app
#
From Packages
macOS software can be distributed as a package, i.e. an archive with the .pkg extension.
This type of package is managed with the built-in tooling:
installerto install a packagepkgutilto query installed packages
The steps to automate the setup of an app from a package are the following:
- Download the package
- Assess the current version and uninstall on mismatch
- Install the new version with
installer - Configure the app if needed
You can use the following template to craft the playbook:
---
- name: "Program is set up: {{ program_name }}"
hosts: "all"
vars:
##
## NOTICE!
## variables defined here take precedence over inventory variables;
## keep user-defined/able variables commented out.
## Use set_fact + | default if a default value is needed.
##
user_downloads_rdir: "{{ artifacts_rdir | default('{{ ansible_env.HOME }}/Downloads') }}"
system_programs_rdir: "/usr/local/bin"
program_version: "2.3.0"
program_pkg_url: "https://github.com/mas-cli/mas/releases/download/v{{ program_version }}/mas-{{ program_version }}.pkg"
program_pkg_rfile: "{{ user_downloads_rdir }}/mas-{{ program_version }}.pkg"
program_name: "mas"
program_rfile: "{{ system_programs_rdir }}/mas"
handlers: []
tasks:
- when: ansible_system != "Darwin"
fail:
msg: "not macOS"
### PROGRAM ################################################################
- name: "User downloads directory is present"
file:
path: "{{ user_downloads_rdir }}"
state: "directory"
- name: "User package is present"
get_url:
url: "{{ program_pkg_url }}"
dest: "{{ program_pkg_rfile }}"
- block:
- name: "Current version is assessed"
changed_when: false
register: cmd
shell: |
set -euo pipefail
if [[ -e "{{ program_rfile }}" ]]
then
case $({{ program_rfile }} version) in
*"{{ program_version }}"*)
echo "expected version is installed"
;;
*)
echo "unexpected version is installed"
esac
else
echo "no version is installed"
fi
- name: "Current version is uninstalled"
when: ("unexpected version is installed" in cmd.stdout)
shell:
removes: "{{ program_rfile }}"
cmd: |
set -euo pipefail
exit 1
# FIXME -- uninstall pkg non-interactively
- name: "User program is present"
become: true
args:
creates: "{{ program_rfile }}"
command: "installer -pkg '{{ program_pkg_rfile }}' -target /"
### CONFIGURATION ##########################################################
#
# to be completed depending on the app
#
From Archives
From a regular tarball or zip file.
---
- name: "App is set up: {{ app_name }}"
hosts: "all"
vars:
##
## NOTICE!
## variables defined here take precedence over inventory variables;
## keep user-defined/able variables commented out.
## Use set_fact + | default if a default value is needed.
##
user_downloads_rdir: "{{ artifacts_rdir | default('{{ ansible_env.HOME }}/Downloads') }}"
user_apps_rdir: "{{ ansible_env.HOME }}/Applications"
app_version: # str, to be completed
app_zip_url: # str, to be completed
app_zip_rfile: # str, to be completed
app_name: # str, to be completed
app_rdir: "{{ user_apps_rdir }}/{{ app_name }}.app"
handlers: []
tasks:
- when: ansible_system != "Darwin"
fail:
msg: "not macOS"
### PROGRAM ################################################################
- name: "User downloads directory is present"
file:
path: "{{ user_downloads_rdir }}"
state: "directory"
- name: "User package is present"
get_url:
url: "{{ app_zip_url }}"
dest: "{{ app_zip_rfile }}"
- name: "User apps directory is present"
file:
path: "{{ user_apps_rdir }}"
state: "directory"
- block:
- name: "Current version is assessed"
changed_when: false
register: cmd
shell: |
set -euo pipefail
if [[ -e "{{ app_rdir }}" ]]
then
case $(plutil -extract CFBundleShortVersionString raw -o - "{{ app_rdir }}/Contents/Info.plist") in
*"{{ app_version }}"*)
echo "expected version is installed"
;;
*)
echo "unexpected version is installed"
esac
else
echo "no version is installed"
fi
- name: "Current version is uninstalled"
when: ("unexpected version is installed" in cmd.stdout)
shell:
removes: "{{ app_rdir }}"
cmd: |
set -euo pipefail
pkill "{{ app_name }}" || true
rm -rf "{{ app_rdir }}"
- name: "User app is present"
unarchive:
src: "{{ app_zip_rfile }}"
dest: "{{ user_apps_rdir }}"
creates: "{{ app_rdir }}"
remote_src: true
### CONFIGURATION ##########################################################
#
# to be completed depending on the app
#
Main Playbook
The main playbook is simply a list of import_playbook: directives.
As explained earlier, the goal is to invoke the (sub) playbooks in the correct order when there are dependencies.
In our case the only dependency is to install mas_cli before invoking the playbooks installing apps from the App Store.
Example:
---
- import_playbook: "vendor/configure_git.yml"
- import_playbook: "vendor/darwin.configure_macos_26.yml"
- import_playbook: "vendor/darwin.configure_vim.yml"
- import_playbook: "vendor/darwin.setup_app1.yml"
- import_playbook: "vendor/darwin.setup_app2.yml"
- import_playbook: "vendor/darwin.setup_mas_cli.yml"
# after setup_mas_cli:
- import_playbook: "vendor/darwin.setup_app3.yml"
- import_playbook: "vendor/darwin.setup_app4.yml"
Usage
The end result is a directory populated with all your assets:
- Bash scripts, in particular ansible.sh
- The inventory
- The MDM profile(s)
- All the playbooks, subs and main
- All the apps configuration files (if any)
Keep in mind Ansible will look up assets relative to the playbook path, in case you're using sub-directories.
You can perform a quick smoke test to check that at least the Ansible tooling and inventory are good to go.
Command:
./ansible.sh adhoc -m ping
Expected output:
com.apple.pkg.CLTools_Executables
CLT already installed, skipping
Using Python 3.9.6
Ansible venv already installed, skipping
localhost | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Craft the final script:
#!/bin/bash
exec ansible.sh playbook -K playbook.yml
Now, setting up your MacBook is as easy as running ./setup.sh.