刚重装完系统那会儿,我盯着空白的终端,心想又要把 nginx、PHP、MySQL 这一整套重新过一遍,头都是大的。如果你也经历过 MacOS 重装后开发环境全部推倒重来的滋味,或者每次换新机器都要花一下午在 brew install 和改配置文件之间来回折腾,那你应该能理解我为什么要把这些流程全部塞进一个 Shell 脚本里。
这篇文章就把我在 MacOS 上用 Shell 脚本一步到位配置 nginx+PHP 环境的完整方法写出来。没有花哨操作,全是日常真在用的东西:从系统工具链检测、Homebrew 安装、nginx 和 PHP 的自动部署,到站点配置自动生成、自定义域名、多端口访问,再到最后我把虚拟机联调、日志排查这些场景也揉了进来。读完你不仅能得到一段能直接跑的脚本,还能理解每一步为什么要这么写,以及踩到 404、502 这类经典问题该怎么定位。
1. 重装系统后的环境重建困境:为什么选择 Shell 脚本这条路
1.1 手动配 nginx+PHP 到底慢在哪
MacOS 上装 nginx 和 PHP,看起来就是两条 brew install 命令的事,但真正把本地开发环境跑起来,远没这么简单。我最早也是照着网上的教程一步步点,每次新建一个 PHP 项目,就要去改一次 nginx 配置,改完还要记得nginx -s reload。配置写错了没有直观提示,只能瞪着浏览器里的 404 not found nginx 发呆。日子久了,/usr/local/etc/nginx/nginx.conf被我改得面目全非,加了一堆注释掉的 server 块,根本不敢乱动。
重装系统时这个问题会被无限放大。你不仅要把 nginx、PHP 重新装一遍,还要重新建目录、重新配 php-fpm、重新设置 sites-enabled 的软链接、重新把 hosts 里那一堆本地域名加回来。手动来一遍,少说半小时,多则一个下午。中间只要漏了一步,比如忘了启动 php-fpm,后端页面全给你 502 Bad Gateway。
1.2 面板、手动、Docker、脚本四种方式的实际对比
我身边同事常用的方案有这么几类,各有利弊,我放个表直接说清楚:
| 方案 | 核心操作 | 耗时预估 | 可重复性 | 主要痛点 |
|---|---|---|---|---|
| 面板类工具 | 下载 App、图形界面点按钮 | 10 分钟 | 好 | 版本往往滞后;资源占用高;和命令行工作流脱节 |
| 手动 brew 安装 | 逐条执行命令、手改配置文件 | 30 分钟以上 | 差 | 步骤零散,重装系统后全得重来 |
| Docker 容器 | 写 docker-compose、拉镜像 | 15 分钟 | 很好 | 本地文件权限、端口映射偶尔让人头大;内存开销偏高 |
| Shell 脚本 | 一条命令自动执行完整流程 | 5 分钟内 | 极好 | 需要维护脚本本身 |
一开始我用的是 Docker,后来发现本地开发频繁调整 nginx 配置时,容器内和宿主机之间的路径映射要反复确认,不太顺手。而且一旦遇到要在宿主机的hosts文件和多个域名之间做联调,容器方案反而多了一层要翻译的逻辑。用 Shell 脚本是更贴近 MacOS 原生习惯的做法:装完系统,拉下脚本,跑一遍,环境回来了。
1.3 脚本方案的深层价值
脚本方案最大的价值不是“省那几十分钟”,而是把环境配置从“手动记忆”变成了“代码资产”。你的 nginx 配置规则、PHP 版本选择、监听端口、站点目录这些约束,全部固化在脚本里。下次重装,或者给另一台 Mac 配置,克隆仓库下来一条命令跑完,环境一致。我在公司里就是这么干的,新同事入职领的是同一份脚本,大家的本地环境完全同步,出问题都好交流。
2. 动手前的准备:工具链、目录约定与版本选型
2.1 先补上系统最基础的编译工具
写脚本之前,得明确一个前提:MacOS 的 nginx 虽然可以用 brew 直接装二进制包,但 brew 本身依赖 Xcode Command Line Tools 提供的基础编译环境。新系统上这个未必装好了,所以脚本的第一件事就是检测它。
xcode-select -p会输出 Command Line Tools 的安装路径,如果为空,就执行xcode-select --install触发系统安装。这一步虽然不能完全做到无人值守,但能把流程堵在第一关,比跑到 install 到一半报“缺少编译器”要舒服得多。
2.2 Homebrew 的架构差异与安装判断
Homebrew 在 Intel Mac 和 Apple Silicon Mac 上的安装路径不一样。Intel 是/usr/local,M 系列芯片是/opt/homebrew。这个差异直接决定了 nginx 配置、PHP 配置的读取路径,脚本里必须兼容,否则换一台机器就跑不通。
我的做法是先让脚本检测芯片架构,把前缀存进一个变量:
if [[ $(uname -m) == "arm64" ]]; then HOMEBREW_PREFIX="/opt/homebrew" else HOMEBREW_PREFIX="/usr/local" fi然后判断 brew 是否已存在,不存在才去执行安装命令。这么写还有一个好处:无论你机器上的 brew 是用官方脚本装的、还是国内镜像装的,脚本都不会因为重复安装而报错。
安装 brew 本身在国内网络环境下容易卡,如果遇到下载很慢的情况,可以用中科大或清华的镜像源,但脚本内部建议保留官方源的注释,别写死,方便你自己切换。
2.3 PHP 版本怎么选不踩雷
brew 现在提供php和php@8.2、php@8.3这类版本化包。直接装不带版本的php,当前 brew 主版本是什么就会装什么,短期没问题,但过两年 brew 更新主版本,你项目里跑得好好的东西可能突然行为变了。我脚本里用的是php@8.3,它是当前兼容性最好、跑 WordPress、ThinkPHP、Laravel 都稳的一个版本。
在 MacOS 上同时装多个 PHP 版本也常见,切换靠brew link --force完成。但注意,一旦用版本化包名安装,php-fpm 的服务名也是版本化的,比如php@8.3对应的服务是brew services start php@8.3。我见过不少人在这一步栽跟头,装的是php@8.3,启动时却敲brew services start php,报错说服务不存在。
2.4 目录约定:一开始就定好,省得后面全乱
我建议在脚本里定义一个标准的站点根目录,比如~/www。所有项目都放在~/www/project-name下,每个项目一个子目录。这样做的好处是 nginx 配置生成脚本可以根据项目名自动拼出 root 路径,不用每次手动填。
目录规划示例:
SITES_ROOT="$HOME/www" SITE_NAME="demo" SITE_ROOT="$SITES_ROOT/$SITE_NAME" mkdir -p "$SITE_ROOT/public" mkdir -p "$HOMEBREW_PREFIX/var/log/nginx"日志目录我也单独建好。nginx 默认日志会写到 brew 的 var/log 下,这个路径在 Apple Silicon 机器上属于/opt/homebrew/var/log/nginx,提前确认它存在可以避免后续权限问题。千万别把日志直接放站点目录里,后面日志一多,备份站点时痛苦死。
2.5 端口规划要提前想清楚
MacOS 上做本地 PHP 开发,80 端口一般是够用的,但不少人会遇到两个问题:一是 AirPlay 接收器会占用 5000 和 7000 端口,你如果把服务监听到 5000,大概率跟系统服务撞车;二是多个项目同时跑,一个 80 端口不够分。
我的规划很简单:本机默认唯一入口走 80 端口,配合域名区分项目;如果要用到虚拟机联调或者多实例并行,第二个及以后的站点就走 8080、8081 这类高位端口。端口从一开始就在脚本里参数化,后面扩展不会改得手忙脚乱。
3. 脚本初版:从 brew 安装到 nginx+php-fpm 联通
3.1 完整初版脚本逐段解析
先把第一版脚本完整贴出来,这段做的是最基础的事:装工具、启动服务、验证可访问。
#!/bin/bash set -e # 架构检测:Intel 和 Apple Silicon 的 brew 路径不同 if [[ $(uname -m) == "arm64" ]]; then HOMEBREW_PREFIX="/opt/homebrew" else HOMEBREW_PREFIX="/usr/local" fi # 1. 检测 Xcode Command Line Tools if ! xcode-select -p &>/dev/null; then echo "正在安装 Command Line Tools,请稍候..." xcode-select --install # 等待用户手动完成安装 until xcode-select -p &>/dev/null; do sleep 5 done fi # 2. 安装 Homebrew(如果不存在) if ! command -v brew &>/dev/null; then echo "正在安装 Homebrew..." /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" # Apple Silicon 需要把 brew 路径加入 PATH if [[ $(uname -m) == "arm64" ]]; then echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zshrc export PATH="/opt/homebrew/bin:$PATH" fi fi # 3. 安装 nginx 和 php@8.3 echo "正在安装 nginx 和 php@8.3..." brew install nginx php@8.3 # 4. 确认 php-fpm 监听地址,脚本统一用 127.0.0.1:9000 PHP_FPM_CONF="$HOMEBREW_PREFIX/etc/php/8.3/php-fpm.d/www.conf" if [[ -f "$PHP_FPM_CONF" ]]; then sed -i '' 's/^listen = .*/listen = 127.0.0.1:9000/' "$PHP_FPM_CONF" echo "php-fpm 监听地址已设置为 127.0.0.1:9000" fi # 5. 启动服务 brew services start nginx brew services start php@8.3 # 6. 验证基础可用性 curl -s -o /dev/null -w "nginx 状态码: %{http_code}\n" http://localhost php -v | head -1这个脚本里每一段都有原因。set -e必不可少,任何一条命令失败立即终止,避免带着残缺环境往下跑,报错时你一眼能定位到哪一步。sed -i ''是 MacOS 的 BSD sed 语法,后面必须有一个空字符串参数,Linux 的 GNU sed 不这么写,这条新手很容易抄错。
3.2 php-fpm 监听地址:socket 还是 TCP
脚本里我把 php-fpm 统一改成了127.0.0.1:9000。brew 装完的 PHP 默认监听可能不是这个地址,不同版本行为不完全一样,所以显式设置一次最稳妥。
关于用 Unix socket 还是 TCP 地址,这里多说一句。很多人推荐 socket 方式,说性能更好,这在高并发下确实成立。但本地开发环境,TCP 的127.0.0.1:9000有一个不可替代的优势:排错方便。你随时可以用lsof -i :9000确认 php-fpm 是否在监听,也可以用nc -vz 127.0.0.1 9000直接测试端口连通性。而 socket 方式一旦路径写错,页面上一律 502,排查起来你只能去翻error.log。本地开发图的就是省心,我选 TCP。
3.3 跑通后的第一轮验证
脚本执行完,先看输出内容:nginx 状态码应该是 200,php -v会打印版本号。如果 nginx 状态码是 404,别慌,因为 brew 装完 nginx 后默认 root 指向的是/usr/local/var/www或/opt/homebrew/var/www,这个目录下有个默认的 index.html,正常会返回 200。如果是 404 not found nginx,很可能上一次安装残留了以前改过的配置,root 指到了不存在的目录。
接着验证 PHP 是否真的被 nginx 处理了。在站点根目录放一个info.php,里面写<?php phpinfo(); ?>,然后在浏览器访问http://localhost/info.php。如果看到的是 PHP 信息页,说明 fastcgi 通路已经打通。如果直接变成下载文件,那说明 nginx 没有把.php结尾的请求交给 php-fpm,问题基本锁定在配置里的location ~ \.php$块。
4. 一步到位的关键:Nginx 与 PHP-FPM 的配置自动生成
4.1 手工维护配置文件的麻烦
brew 装完 nginx 后,主配置文件在$HOMEBREW_PREFIX/etc/nginx/nginx.conf。第一次打开这个文件你会发现里面满是示例配置,注释占了半屏。如果你直接往里面堆 server 块,前几次还行,等站点多了,改一个就要小心别碰另一个。
我的做法是主配置只留一个骨架,http 块里加一行include servers/*.conf;,然后在一个独立的servers目录下给每个站点建独立配置文件。站点增删不影响主配置,想停哪个站点直接删对应文件再 reload,干净利落。
4.2 脚本里的 add_site 函数
下面这段是脚本里最有价值的部分,它把“新建一个 PHP 站点”这个动作封装成了一个函数:
add_site() { local site_name="$1" local site_port="$2" local site_root="$3" local server_names="$4" local nginx_conf="$HOMEBREW_PREFIX/etc/nginx/servers/$site_name.conf" if [[ -f "$nginx_conf" ]]; then echo "配置文件已存在,跳过生成: $nginx_conf" return 0 fi mkdir -p "$site_root/public" cat > "$nginx_conf" <<EOF server { listen $site_port; server_name $server_names; root $site_root/public; index index.php index.html; location / { try_files \$uri \$uri/ /index.php?\$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name; } access_log $HOMEBREW_PREFIX/var/log/nginx/$site_name.access.log; error_log $HOMEBREW_PREFIX/var/log/nginx/$site_name.error.log; } EOF echo "已生成站点配置: $nginx_conf" }用的时候这样调用:
add_site "demo" 80 "$HOME/www/demo" "demo.test" add_site "blog" 8081 "$HOME/www/blog" "blog.test"有几个细节必须解释清楚。第一,try_files $uri $uri/ /index.php?$query_string;这一行的作用是把不存在的静态路径统一交给index.php处理,这是 Laravel、ThinkPHP 这类单入口框架能跑起来的核心,不用这行,前端路由全部 404。第二,fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;很关键,它告诉 php-fpm 要执行的脚本路径,很多人配完出现“No input file specified”就是漏了这行,或者写错了变量名。第三,heredoc 里我把$uri、$query_string、$document_root都加了转义,因为不加转义的话,这些都是当前 Shell 的空变量,生成出来的配置会是空的,这在脚本里属于很容易出现但又最阴的坑。
4.3 一次执行、重复执行都不出错
脚本要做到“一步到位”,还得保证幂等性,也就是同一条命令跑十次,结果是一样的,不会因为重复执行把环境搞坏。
关键在于每步操作前都要做判断。command -v brew判断 brew 是否存在,-f "$nginx_conf"判断配置是否已存在,存在就跳过。日志文件已经存在时mkdir -p也不会报错。这样即便是误操作重跑了一遍脚本,它顶多是确认服务已经启动,不会把你自己后加的 nginx 配置给覆盖了。脚本能自动“识别增量”,才是真正能日常放心用的脚本。
4.4 生成配置后的重载与验证
配置文件生成完之后,不能直接就算完,要强制过一遍 nginx 的校验:
nginx -t nginx -s reloadnginx -t会提示配置是否有语法错误,如果报错会精确到文件路径和行号。看到syntax is ok之后再 reload,线上改动配置的时候我也是这个习惯,先测再生效,这习惯能避免把正在跑的服务弄挂。
顺手再验证一下:
curl -I http://demo.test curl -I http://blog.test这里有个小地方提醒一下:如果用的是自定义域名比如demo.test,那么访问它之前必须先在/etc/hosts里加一行127.0.0.1 demo.test,否则域名解析会跑到公共 DNS 去,拿到的很可能是一个不存在的公网 IP。
5. 多站点与自定义域名:从单机到“本地+虚拟机”联调
5.1 hosts 与 server_name 的配合逻辑
MacOS 上做本地多站点,核心思路就两条:域名映射靠/etc/hosts,请求分发靠 nginx 的server_name。浏览器访问demo.test时先向本机发起请求,nginx 拿到请求后用 Host 头匹配定义的 server 块。匹配规则是精确优先于通配符和正则的,所以只要 server_name 不重复,就不会有路由错乱。
在 hosts 里加静态映射:
127.0.0.1 demo.test 127.0.0.1 blog.test ::1 demo.test ::1 blog.testIPv6 的::1也加上是有原因的,MacOS 的 curl 在某些网络环境下默认优先::1解析,只加127.0.0.1时可能出现“偶尔通、偶尔超时”的怪现象。
5.2 多端口方案:同机并行和虚拟机访问
自定义域名解决的是“多项目识别”问题,但如果你本地还要跑 Vue 的 dev server,或者要起一个 mock 服务,那 80 端口就可能不够用。此时第二个、第三个站点可以用高位端口来跑。
比如:
add_site "api" 8080 "$HOME/www/api" "api.test"访问方式就是http://localhost:8080或者http://api.test:8080。注意端口在 listen 里写了,URL 里也要带;hosts 只管域名,不管端口,端口由 nginx 监听决定。
我实际开发中还经常用到“本地+虚拟机”的联调场景,就是在虚拟机里跑一个测试站,宿主机这边通过局域网 IP 去访问。这种情况下 nginx 的 listen 要把监听地址放宽:
listen 0.0.0.0:8080;因为默认 listen 80 在 MacOS 上不需要 root 权限,但在虚拟机里访问宿主机时,请求是走局域网网卡进来的,不是走回环地址。只监听localhost的话,虚拟机访问宿主机 IP 就会连接被拒。
宿主机 IP 可以通过ipconfig getifaddr en0查到,en0 一般是 Wi-Fi 网卡。在虚拟机的浏览器里访问http://宿主机IP:8080,就能访问到宿主机上的 PHP 站点。这个组合很实用,比如你要在 Windows 虚拟机里测试 IE 兼容的页面,或者用虚拟机跑一个旧版浏览器做调试,都能靠这个方案接上。
5.3 用 for 循环批量创建站点
多站点如果都是一条条 add_site 命令写,脚本会变得很长。我用一个关联数组把站点信息集中起来,后面有 for 循环自动遍历创建:
declare -A SITES=( ["demo"]="80 demo.test" ["blog"]="8081 blog.test" ["api"]="8080 api.test" ) for site_name in "${!SITES[@]}"; do read -r site_port server_names <<< "${SITES[$site_name]}" add_site "$site_name" "$site_port" "$HOME/www/$site_name" "$server_names" done # 统一写入 hosts for line in "${!SITES[@]}"; do read -r site_port server_names <<< "${SITES[$line]}" for domain in $server_names; do grep -q "$domain" /etc/hosts || echo "127.0.0.1 $domain" | sudo tee -a /etc/hosts done done写这段时我用到了两个 Shell 考点,正好对应很多人在学的 for 循环和 if 判断。第一个考点是关联数组的遍历方式,for site_name in "${!SITES[@]}",注意感叹号在前是取键列表,没有感叹号取的是值列表,别写反。第二个考点是read -r port server_names <<< "${SITES[$site_name]}",这是把字符串按空白拆成两个变量的常用写法。第三个考点是grep -q ... || echo ...,这个模式相当于“如果不存在才写入”,用在幂等脚本里很干净。
批量创建完记得统一nginx -t && nginx -s reload。以后加新站点,只需要改数组再跑一次,比手写配置效率高一截。
6. 调试与排错:我从实际使用中遇到的几个典型问题
6.1 404 not found nginx:先分清楚是哪一层 404
浏览器里看到 404 not found nginx 时,说明请求已经到 nginx 了,问题出在配置或者文件路径上。最常见的是 root 路径写错、目录没建对、或者 index 文件不存在。
排查链路我建议这样走:
tail -f "$HOMEBREW_PREFIX/var/log/nginx/error.log"同时再访问一次出错页面。日志会明确告诉你open() "/xxx/xxx/index.php" failed (2: No such file or directory),路径对不上就一目了然。
另外一个隐蔽原因是 nginx 的 location 匹配优先级。很多人手动配置时会这样写:
location / { root /path/to/site; } location ~ \.php$ { root /path/to/site; }看着没问题,但当顶层另有location /app/带二级路径时,PHP 文件的SCRIPT_FILENAME会根据 URI 拼接,二级路径被重复拼到目录里,同样会 404。这种带二级路径的站点我在公司见过不少次,排查到后面基本都是在看路径拼接规则。
6.2 502 Bad Gateway:十有八九是 php-fpm 没起来
502 和 404 不同,502 说明 nginx 已经找到了虚拟主机配置,但请求往后转发时,php-fpm 那边没有响应。最常见的死法是,重装 PHP 或者升级 brew 后忘了重新启动php@8.3服务。
排查命令很简单:
lsof -i :9000没有任何输出说明监听不存在,那就启动:
brew services start php@8.3还有一种情况是 php-fpm 起来了,但监听地址和 nginx 配置里写的不一致。你手动改过 php-fpm 的listen参数,但 nginx 那段fastcgi_pass 127.0.0.1:9000还是老配置,也会 502。解决方式就是配置生成脚本里强制统一,用一个变量管理这个地址,让两边同时生成,从源头避免不一致。
6.3 脚本重复执行时的小坑
我在写脚本时遇到的另一个问题是,重复执行时sed会反复修改 php-fpm 配置。这个其实问题不大,因为sed -i '' 's/^listen = .*/listen = 127.0.0.1:9000/'是“匹配到就去替换”,重复执行也是原来的结果。真正要小心的是站点配置的生成函数:如果不检查配置是否已存在就覆盖写入,你自己手动改过的特殊配置会被脚本冲掉。所以函数里必须有[[ -f "$nginx_conf" ]] && return这步判断。
6.4 端口被 AirPlay 和系统服务占用
如果你发现 nginx 一直启动失败,日志提示bind() to 0.0.0.0:5000 failed,那几乎可以确定是 macOS 的 AirPlay 接收器占了 5000 和 7000 端口。你可以在系统设置的“通用”里关掉“AirPlay 接收器”,或者干脆让 nginx 换端口。窝里斗没意义,直接错开。
再看一眼所有监听端口可以用:
lsof -iTCP -sTCP:LISTEN -P -n | grep -E "nginx|php"这个输出会列出当前所有 nginx 和 php-fpm 的监听状态,哪个端口没起来一目了然。
6.5 站点目录的访问权限问题
PHP 页面偶尔会有权限相关报错,例如提示Permission denied,通常是站点目录对其他用户不可读。brew 服务默认以当前用户身份运行,正常情况下没这个问题,但如果你的站点文件是 sudo 创建或者从压缩包解压出来的,属主都变成 root 了,nginx 可能读不了。
我的习惯是在脚本里顺手把站点的属主改回当前用户:
chown -R "$USER":staff "$SITE_ROOT"这句执行完,权限问题九成都能消掉。
最后分享一个我写脚本时的体会:一开始只想着“跑通就行”,后来慢慢加了幂等判断、日志输出和错误退出,这个脚本才真正从一个一次性工具变成日常依赖的“环境还原脚本”。每次重装系统后跑一遍,看着终端里刷刷刷的输出,那种安心感是手动配置给不了的。你现在拿到的这套流程,如果按自己的站点目录和端口习惯改一改,基本就能直接入仓使用。后面我还在计划把 MySQL 的自动安装也加进去,到时候再单独更新一篇。