How to run a LAMP stack on macOS Tahoe in 2026
For years the standard way to run PHP locally on a Mac was to use the Apache that shipped with macOS and load Homebrew’s PHP module into it. On recent versions that meant code-signing the module yourself, following guides like this one, because the system Apache would only load signed modules.
In May 2025 a macOS Sequoia security update broke that workflow for me. There was no prompt and no warning. The self-signed module simply stopped being accepted, and later updates removed the system Apache I had been relying on altogether.
You can keep fighting that. You can dig into System Integrity Protection, re-sign things after every update, and hope the next release doesn’t move the goalposts again. Or you can stop running the stack on macOS at all.
I did the second. My whole LAMP stack now runs inside a small Ubuntu VM managed by Lima, and I haven’t thought about macOS security policy since.
Why Lima
Lima runs Linux virtual machines on macOS with very little ceremony, and a few of its defaults happen to be exactly what a local web stack wants.
- It uses Apple’s own virtualisation. On macOS 13.5 and later the default
driver is
vz, Apple’s Virtualization framework, and it is very efficient. - Your Mac’s home folder is already there. Lima mounts your home directory inside the VM at the same path, read-only by default. You edit code on the Mac with whatever editor you like, and Apache inside the VM serves it.
- Ports come to you. When a service inside the VM listens on a port, Lima
forwards it to
localhoston the Mac automatically. - The disk only takes what it uses. The VM’s disk is sparse. It is 100 GiB by default, but only occupies as much space as the VM has actually written.
- You can cap everything. CPU, memory and disk are one line each in the config.
- It is scriptable.
limaruns any command inside the default VM, so scripts, Makefiles and even AI coding agents can drive the stack from the macOS terminal.
Setting it up
1. Install Lima
brew install lima
2. Create a persistent Ubuntu 24.04 VM
limactl create --name=default template:ubuntu-24.04
limactl edit default
Naming it default matters, because the short lima command always talks to
the instance called default.
These are the parts of the config worth changing:
vmType: "vz"
cpus: 4
memory: "4GiB"
disk: "60GiB"
mounts:
- location: "~" # your macOS home, mounted at the same path
writable: false # the VM can read your code, never change it
portForwards:
- guestPort: 80 # Apache inside the VM
hostPort: 8080 # http://localhost:8080 on the Mac
The port mapping is there for a reason. Lima forwards ports to 127.0.0.1 on
the Mac, and macOS won’t let a normal user bind a privileged port like 80 on
that address. Mapping Apache’s port 80 to 8080 keeps everything on localhost
and avoids exposing it to your network.
Then start it:
limactl start default
3. Install the stack inside the VM
lima sudo apt update
lima sudo apt install -y apache2 mysql-server php libapache2-mod-php php-mysql
Apache and MySQL start on their own. Open http://localhost:8080 on the Mac and you should see the Ubuntu Apache page.
4. Serve your code from the Mac
Because your home folder is mounted at the same path inside the VM, the VM can see your projects without copying anything. I also link my macOS home into the VM user’s home, so paths line up whichever side I’m on:
lima bash -c 'ln -s "/Users/$USER/Sites" ~/Sites'
Then point an Apache virtual host at your project. Create
/etc/apache2/sites-available/myproject.conf inside the VM:
<VirtualHost *:80>
DocumentRoot /Users/you/Sites/myproject/public
<Directory /Users/you/Sites/myproject/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Enable it in place of the default site, and reload:
lima sudo a2dissite 000-default
lima sudo a2ensite myproject
lima sudo systemctl reload apache2
One thing to keep in mind is that the mount is read-only. That is a feature,
because nothing in the VM can damage your source tree. But anything your app
writes, such as uploads, caches, sessions and logs, needs to live inside the
VM, for example under /var/lib/myproject.
Day to day
Anything you would type inside the VM, you can run from the Mac:
lima bash -c "echo hi"
lima sudo systemctl restart apache2
lima sudo mysql -e "show databases"
lima tail -f /var/log/apache2/error.log
This is also what makes Lima friendly to AI coding agents. An agent working in
your macOS terminal can inspect logs, restart services and run migrations
inside the VM with the same lima … commands, with no extra tooling.
When you’re done for the day, limactl stop default shuts it down cleanly.
When it breaks: a full Mac disk
So far I’ve hit exactly one real problem, and it’s worth knowing about before it happens to you.
If your Mac runs almost out of storage while the VM is running, the VM can no longer finish writing its filesystem journal. Ubuntu’s ext4 filesystem is then left inconsistent, and on the next start it refuses to boot until it is repaired.
The symptom
limactl start gets as far as starting the VM, and then waits forever:
INFO[0003] [hostagent] [VZ] - vm state change: running
DEBU[0006] [hostagent] Waiting for port to become available on 192.168.5.15:22
DEBU[0010] [hostagent] Waiting for port to become available on 192.168.5.15:22
DEBU[0014] [hostagent] Waiting for port to become available on 192.168.5.15:22
Port 22 is SSH inside the VM. It never comes up because Ubuntu never finishes
booting. It has stopped at an emergency prompt, waiting for someone to run
fsck, and the Lima host agent can’t tell you that.
Seeing what the VM sees
Before anything else, give yourself eyes on the boot. Lima writes the VM’s
serial console to ~/.lima/default/serial*.log, and you can also ask for a
real display window. Add this to the config with limactl edit default:
video:
display: "vz"
With the vz driver this opens a window titled “Lima: default” that shows the
VM’s screen, including whatever error the boot stopped at.
The rescue
1. Free up space on the Mac first. Clear temporary files, caches and old downloads. Repairing a filesystem while the host is still nearly full is asking for the same failure again.
df -h /System/Volumes/Data
2. Force the VM to stop, and make sure nothing holds its disk.
limactl stop -f default
lsof ~/.lima/default/disk
lsof should print nothing.
3. Boot the disk with QEMU instead of Lima. Lima’s host agent keeps waiting
for SSH, but plain QEMU gives you the console directly. Install it with
brew install qemu, and have any small aarch64 Linux ISO to hand as a fallback
boot device. I used Alpine’s virt image.
qemu-system-aarch64 \
-machine virt \
-accel hvf \
-cpu host \
-smp 4 \
-m 2048 \
-bios /opt/homebrew/opt/qemu/share/qemu/edk2-aarch64-code.fd \
-drive file="$HOME/.lima/default/disk",format=raw,if=virtio,readonly=off \
-drive file="$HOME/Downloads/alpine-virt-3.24.1-aarch64.iso",format=raw,media=cdrom,if=virtio \
-boot order=d \
-nographic \
-device virtio-net-pci,netdev=n0 \
-netdev user,id=n0
It will most likely boot Ubuntu’s own disk and drop you at the same emergency prompt the VM was stuck on:
(initramfs)
4. Repair the root filesystem, then check it again.
fsck.ext4 -f -y /dev/vda1
fsck.ext4 -f /dev/vda1
The first pass fixes things. The second pass should find nothing to fix. If it still offers repairs, run the first command again.
5. Do the same for /boot.
fsck.ext4 -f -y /dev/vda16
fsck.ext4 -f /dev/vda16
Leave /dev/vda15 alone. It is the EFI partition, which is FAT, not ext4.
6. Shut down and hand the disk back to Lima.
poweroff -f
Wait for QEMU to exit, then confirm the disk is free and start Lima normally:
lsof ~/.lima/default/disk
limactl start default --debug
limactl shell default -- uname -a
7. Check for new errors straight away.
limactl shell default -- sudo dmesg | grep -Ei 'I/O error|EXT4-fs error|aborted journal' | tail -50
If new errors appear, stop the VM again. The Mac still doesn’t have enough free space to run it safely.
What the repair output actually means
The first fsck pass on my root filesystem looked alarming:
Inodes that were part of a corrupted orphan linked list found. Fix? yes
Inode 1564 was part of the orphaned inode list. FIXED.
Inode 1890 was part of the orphaned inode list. FIXED.
...
Block bitmap differences: -(860704--860720) -(860832--860848) ...
Free blocks count wrong (3256564, counted=3257917).
Free inodes count wrong (2349553, counted=2349572).
cloudimg-rootfs: ***** FILE SYSTEM WAS MODIFIED *****
cloudimg-rootfs: 140796/2490368 files (0.7% non-contiguous), 1722558/4980475 blocks
None of it means your data is gone or your SSD is failing.
- An inode is the filesystem’s record of a file: its owner, permissions,
size and where its contents live.
Inode 1564is an ID, not a location on disk. - Orphaned inodes belong to files that were being deleted or truncated,
often while a program still had them open. ext4 tracks them so it can finish
the job after a crash. Here the VM went down before that bookkeeping
completed, and
fsckfinished it. - Block and inode bitmap differences mean the maps of which blocks and
inodes are in use no longer matched reality.
fsckwalks the whole filesystem, works out the true state, and rewrites the maps and the free counts. - “0.7% non-contiguous” is not an error at all. It is how fragmented the files are, and 0.7% is nothing.
The second pass finishing clean is the proof that it worked:
Pass 5: Checking group summary information
cloudimg-rootfs: 140796/2490368 files (0.7% non-contiguous), 1722558/4980475 blocks
What I’d tell you before you start
- Leave your Mac real headroom. The VM’s disk grows as it’s used, and macOS swap grows under memory pressure. Together they can fill a nearly full drive faster than you’d expect.
- Keep
video.displayhandy. When the VM won’t boot, a window showing its console turns a mystery into a one-line fix. - Keep your source on the Mac and your state backed up. Code lives in your macOS home, so it is safe even if the VM is not. Dump your databases regularly, and take regular backups of anything the VM writes.
Lima turned “my local server broke after an update” from a recurring weekend project into something I set up once and forgot about. The one time it did break, the rescue was easy once I knew what to look for.