1. 为什么自建Git仓库而不是直接用Gitee或GitHub
很多人听到"在服务器上搭建Git仓库"第一反应是:现在Gitee、GitHub这么方便,为什么还要自己折腾?这个问题我一开始也觉得犯不上,直到真正工作中遇到了几个场景。
第一个场景是内网环境。公司的研发网和公网隔离,代码出不去也进不来,但团队又想享受版本管理、分支合并这些Git带来的好处,这时候本地服务器就是唯一选择。第二个场景是私有仓库的代码量敏感。有些项目连私有托管平台都不想用,比如涉及客户数据的处理脚本,放哪里都觉得不踏实,自己服务器上划一块地方存,权限捏在自己手里。第三个场景是调试和自动化。自建仓库配合Hook可以做很多事情——push完代码自动部署到测试环境,自动打标签,触发构建,这在很多半自动化的团队里非常实用。
那Ubuntu是不是唯一选择?当然不是,CentOS、Debian都能干这活,但Ubuntu胜在两个点:一是用户基数大,遇到问题搜索解决方案几乎一查一个准;二是软件包版本比较新,Git官方维护的PPA也更新及时。我用的服务器是Ubuntu 22.04 LTS,下面所有操作都基于这个版本,如果你是20.04或者24.04,命令基本通用,个别差异我会在文中指出来。
还要说明一点:这篇文章不是让你在服务器上把GitLab全家桶装一遍。就我个人经验,80%的自建仓库场景根本用不到GitLab那么重的方案。一个裸仓库(bare repository)加SSH访问,就能解决绝大多数需求。装GitLab那个内存占用,我自己在2GB的小服务器上试过,跑起来卡得不行,后来老老实实换回裸仓库方案,省心太多。
所以这篇文章的路线是:服务器端怎么初始化环境、怎么建仓库、怎么配权限,然后是客户端怎么连接、日常提交的完整流程,最后是我实际使用中踩过的一些坑和几个让仓库更好用的进阶操作。无论你是个人开发者想把代码备份到自己的服务器,还是小团队内网协作,照着走一遍都能跑通。
2. 服务器端准备:从SSH到Git安装
2.1 第一步先搞定SSH连接
搭建Git仓库的前提是你能顺畅地操作这台服务器。我见过不少新手卡在这一步:系统装了,Git也装了,但本地连不上服务器,后面全白搭。
Ubuntu服务器装好之后,默认是开了SSH服务的。如果你装的是Desktop版本,那默认还真不一定装openssh-server,需要手动确认一下:
sudo apt update sudo apt install openssh-server -y sudo systemctl status ssh看到active (running)就说明SSH服务在跑。然后在本机测试连接:
ssh 用户名@服务器IP这里要提醒一个细节:root用户直接用密码登录在很多Ubuntu版本上默认是禁止的。我在配置服务器时通常会创建一个专门用户来管理Git仓库,而不是用root。原因后面讲权限的时候会细说。
2.2 用SSH密钥免密登录
搭建Git仓库之后,你会频繁地push/pull代码,如果每次连接都输密码,体验很差。更关键的是,用密钥认证比密码登录安全得多——私钥留本地,公钥放服务器,别人无法通过暴力破解密码闯进来。
生成本机的SSH密钥对:
ssh-keygen -t ed25519 -C "你的注释,比如邮箱或机器名"现在新机器我基本用ed25519算法,比传统的RSA 2048短且安全。如果你的Git服务器比较老,或者客户端环境特殊,再用RSA:
ssh-keygen -t rsa -b 4096 -C "备注"生成的公钥在~/.ssh/id_ed25519.pub,私钥在~/.ssh/id_ed25519。把公钥内容追加到服务器的授权列表:
ssh-copy-id 用户名@服务器IP或者手动把公钥内容追加到服务器上~/.ssh/authorized_keys文件里。加完之后测试:
ssh 用户名@服务器IP如果能直接登上去不用输密码,密钥认证就配好了。
2.3 安装Git并验证版本
接下来在服务器上装Git。Ubuntu的默认源里就有Git,直接:
sudo apt update sudo apt install git -y git --versionUbuntu 22.04默认源的Git版本大概是2.34,日常用完全够了。如果你需要更新版本,可以用Git官方维护的PPA:
sudo add-apt-repository ppa:git-core/ppa sudo apt update sudo apt install git -y装完后顺手配置一下服务器端Git的基本信息。这个配置其实主要用于git commit时记录作者信息,虽然服务器端很少直接提交代码,但建议还是配上:
sudo -u git git config --global user.name "git" sudo -u git git config --global user.email "git@服务器IP"这里的git用户后面会详细介绍。
3. 创建Git仓库:选择目录结构与初始化方式
3.1 为什么推荐创建专门的git用户
在服务器上管理Git仓库,我强烈建议创建一个独立的系统用户,比如就叫git。这么做有几个好处:
- 权限隔离。仓库文件归
git用户所有,其他普通用户没有直接读写权限,只能通过Git协议访问。 - 方便管理。所有仓库集中在
/home/git或/srv/git下,一目了然。 - 安全。即使某个Web应用被攻破,攻击者拿到的也不是root权限,无法直接篡改仓库。
创建用户:
sudo adduser --system --shell /usr/bin/git-shell --group git这里用--shell /usr/bin/git-shell是一个安全增强。Git官方自带一个git-shell,它限制用户只能执行Git相关命令,不能登录服务器执行任意Shell命令。如果用户不小心公钥泄露,攻击者也没法通过这个账号拿到Shell。不过需要确认git-shell存在:
which git-shell如果路径不对,可以手动指定一下,或者先不给--shell参数,创建成功后再改:
sudo usermod -s /usr/bin/git-shell git3.2 初始化裸仓库
这是整个搭建过程最关键的一步。先解释一下什么叫裸仓库。普通的仓库有一个工作目录,你可以看到文件、修改文件;而裸仓库没有工作目录,它只保存Git的版本历史数据,相当于一个"纯服务器端"的仓库。我们push到服务器上的就应该是裸仓库,因为服务器不需要直接在这个目录里改文件。
创建裸仓库的命令:
sudo mkdir -p /srv/git sudo chown git:git /srv/git sudo -u git git init --bare /srv/git/awesome-project.git注意仓库命名约定:通常以.git结尾,这样一看就知道是裸仓库。--bare参数就是创建裸仓库的意思。
创建完可以在本地验证一下:
ls /srv/git/awesome-project.git你会看到HEAD、branches、config、objects、refs这些目录和文件。这就是一个最小的可用Git服务端。
3.3 分支与HEAD设置
裸仓库默认的分支通常叫master,现在Git社区普遍用main。我自己更喜欢把新仓库默认分支设为main,避免后面团队开发时产生分支命名混乱。设置方法:
sudo -u git git --git-dir=/srv/git/awesome-project.git symbolic-ref HEAD refs/heads/main这行命令的作用是把裸仓库的HEAD指针指向main分支。如果设置了这一步,客户端第一次clone下来时默认分支就是main。
另外,如果你希望服务器端仓库允许接收任何分支的推送,保持默认就好。如果希望固定分支,可以配置config文件。不过对小型团队来说,保留默认行为更灵活。
3.4 仓库的目录规划建议
如果团队项目多,建议按一定的目录结构组织仓库。我的习惯是这样:
/srv/git/ ├── awesome-project.git ├── backend-service.git └── docs-site.git每个项目一个裸仓库,互不干扰。配合后面讲的gitolite或gitea可以做到更细的权限控制,但对大多数人来说,裸仓库加SSH已经足够了。
4. 本地开发流程:Clone、Commit、Push与分支管理
4.1 从服务器克隆仓库
服务器端配好了,现在切到自己的开发机。
如果你是从零开始一个新项目,先在Gitee或者GitHub上建好项目也行,但我们要的是走自己的服务器,所以直接在本地初始化然后推送到远程。
本地初始化:
mkdir awesome-project cd awesome-project git init -b main echo "# awesome-project" > README.md git add README.md git commit -m "initial commit"然后添加远程仓库地址:
git remote add origin git@服务器IP:/srv/git/awesome-project.git这里的git@服务器IP表示以git用户的身份连接服务器,路径是/srv/git/awesome-project.git。第一次连接SSH可能会提示确认服务器指纹,输入yes即可。
推送:
git push -u origin main-u参数设置上游分支,以后直接git push和git pull就可以,不用每次带远程名和分支名。
如果仓库里已经有代码了,可以用clone的方式:
git clone git@服务器IP:/srv/git/awesome-project.git4.2 日常开发循环:add、commit、push
这是Git使用频率最高的三个命令。我见过很多初学者把顺序搞混,其实逻辑很简单:
git status查看当前状态,看哪些文件改了。git add把改动加入暂存区。git commit把暂存区的内容提交到本地仓库。git push把本地提交推送到远程服务器。
实际命令:
git status git add index.html git commit -m "更新首页标题" git push关于写commit message,我的建议是:不要用"update"、"fix"这种含混的标题,尽量写清楚这次改了什么、为什么要改。比如修复登录页在Safari下布局错乱的问题就比fix bug有价值得多。一个好的commit历史,可以在项目出问题时快速定位回滚点。
4.3 分支管理:从创建到合并
几乎每个团队都会有"主分支保持稳定,新功能在分支上开发"的需求。Git分支的开销非常小,创建合并都很方便。
创建并切换到新分支:
git checkout -b feature/login-page等价于两条命令:
git branch feature/login-page git checkout feature/login-page在新的分支上开发完成后,合并回主分支:
git checkout main git pull git merge feature/login-page git push如果合并时有冲突,Git会在冲突文件里标记出<<<<<<<和>>>>>>>的区域,你需要手动解决这些冲突,再add、commit。这里有个技巧:合并前先git pull把自己本地仓库更新到最新,能减少很多冲突。
4.4 拉取远程更新:pull和fetch的区别
git pull和git fetch经常有人搞混。简单说:
git fetch只把远程的更新下载到本地,但不合并到你当前的分支。适合先看看别人改了什么再决定怎么处理。git pull等于git fetch加git merge,直接拉取并合并到当前分支。日常开发中用pull更省事。
不过git pull在某些场景下会产生意外的合并提交。如果团队协作频繁,我建议用git pull --rebase,它会把你的本地提交"重放"到远程分支的最新提交之上,历史更线性,看起来也更清爽。
5. 多用户协同时的权限配置与常见报错
5.1 多用户共用git账号的权限方案
前面创建的git用户可以当作统一的Git访问入口。多个人要使用同一台服务器时,最简单的方式是把每个人的SSH公钥都添加到git用户的authorized_keys文件里。
具体做法是收集每个开发者的id_ed25519.pub公钥内容,然后追加到服务器上/home/git/.ssh/authorized_keys:
sudo -u git mkdir -p /home/git/.ssh sudo -u git touch /home/git/.ssh/authorized_keys # 编辑文件,把每个开发者的公钥逐行加入 sudo -u git vim /home/git/.ssh/authorized_keys这样所有开发者的push都会以git用户身份操作。优点是配置简单,缺点是无法区分是谁提交的。但这个局限可以通过约束commit信息来解决,也可以在服务端配gitolite做更细粒度的权限管理。
如果你需要仓库级别的权限控制,比如A只能访问项目A,B只能访问项目B,那gitolite是轻量级的好选择。它基于git用户再包一层权限控制逻辑,用配置文件管理用户与仓库的访问关系。比GitLab轻太多,适合小团队。不过配置有学习成本,这篇文章先不多展开,基础的裸仓库方案已经能解决大部分问题。
5.2 推送失败:Permission denied (publickey)
这是新手最常见的问题。执行git push时提示:
Permission denied (publickey). fatal: Could not read from remote repository排查步骤:
- 先确认本机的公钥有没有加到服务器的
authorized_keys里:ssh -T git@服务器IP。如果显示Welcome to Git或Hi说明认证通过。 - 确认本机当前使用的SSH密钥是不是你添加的那把。有时候本机有多个密钥,而SSH默认用的不是你加的那把。可以在
~/.ssh/config里指定:
Host mygit-server HostName 服务器IP User git IdentityFile ~/.ssh/id_ed25519- 确认服务器上
authorized_keys文件的权限。这个文件的属主必须是你连接的用户,且权限不能太宽松:
sudo chown git:git /home/git/.ssh/authorized_keys sudo chmod 600 /home/git/.ssh/authorized_keys sudo chmod 700 /home/git/.ssh5.3 推送到非裸仓库时报错
如果你初始化仓库时没用--bare,而是用普通的git init /srv/git/awesome-project,推代码时大概率会遇到这个错误:
remote: error: refusing to update checked out branch原因是服务器端仓库存在一个工作目录,Git默认拒绝在工作目录被检出的分支上接收推送,怕把你服务器上正在使用的文件搞乱。
解决办法有两种:
- 推荐做法:重新用裸仓库。把普通仓库复制成裸仓库:
git clone --bare /srv/git/awesome-project /srv/git/awesome-project.git- 或者,如果你确实需要服务器端能checkout一份最新代码过去(比如用于部署),那可以把仓库设置成允许接收推送并自动更新工作目录,这个就是后面要讲的Hook自动部署。
5.4 文件权限导致的推送问题
有时候push能成功,但服务器上查看仓库文件时发现权限不对,其他用户无法读取。这通常是因为创建仓库时用的是root用户,导致仓库目录属主不是git用户。
解决办法:
sudo chown -R git:git /srv/git这一步很容易被忽略,但影响了多人协作时能不能正常读写。
6. 进阶玩法:利用Hook实现自动部署
6.1 什么是Git Hook
Git Hook是Git在特定事件发生时自动执行的脚本。对于自建服务器来说,最常用的是post-receive——当服务器接收到一次push完成之后执行。这个Hook可以在push完成后自动把代码同步到指定的Web目录,实现"push即部署"。
这个功能解决的实际问题是:团队里经常有人push完代码后忘了去服务器上手动拉取更新,导致线上代码和仓库不一致。配置了自动部署之后,代码一到服务器,Web服务立刻就用上新版本了。
6.2 创建post-receive Hook实现自动部署
假设你的Web站点目录是/var/www/awesome-project,目标效果是:有人推送代码到仓库后,服务器自动把最新代码同步到这个目录。
第一次需要先准备部署目录:
sudo mkdir -p /var/www/awesome-project sudo chown -R www-data:www-data /var/www/awesome-project然后在裸仓库的hooks目录里创建post-receive文件:
sudo -u git vim /srv/git/awesome-project.git/hooks/post-receive写入以下内容:
#!/bin/bash TARGET="/var/www/awesome-project" GIT_WORK_TREE="$TARGET" git checkout -f保存后赋予执行权限:
sudo chmod +x /srv/git/awesome-project.git/hooks/post-receive sudo chown git:git /srv/git/awesome-project.git/hooks/post-receive原理是:把仓库的工作目录临时指定为/var/www/awesome-project,然后强制checkout最新代码。这个方案只适合静态站点或者纯前端项目。如果是Node、Python这种需要构建或重启服务的项目,还需要在Hook里加上重启服务、执行构建脚本的命令,逻辑会更复杂。
我实际项目中用的Hook比这个复杂一些,会先判断推到的是不是main分支,只有主干更新才触发部署:
#!/bin/bash TARGET="/var/www/awesome-project" while read oldrev newrev ref do if [ "$ref" = "refs/heads/main" ]; then echo "检测到main分支推送,开始部署..." GIT_WORK_TREE="$TARGET" git checkout -f # 在这里可以加上构建命令,例如: # cd $TARGET && npm install && npm run build sudo systemctl reload nginx else echo "非main分支推送,跳过自动部署" fi done写Hook脚本时注意:服务器环境里的命令路径可能和交互式Shell不一样,必要时写全路径,比如/usr/bin/php而不是php,避免脚本执行时报command not found。
6.3 服务器端不鼓励使用的工作流
前面说过,服务器上的裸仓库没有工作目录,不能直接在服务器上编辑代码。有些新手会在服务器上clone一份普通仓库,然后直接在服务器上改文件再commit。这种操作方式在自建Git服务器里其实很别扭,容易造成代码和远程仓库不一致。我的建议是:服务器端只做存储和分支管理,所有代码改动都在本地完成。这样能最大程度避免权限问题、文件锁问题,也让仓库的数据更干净。
7. 仓库备份与日常维护
7.1 为什么自建仓库要格外重视备份
用Gitee、GitHub这类托管平台时,平台本身会做多副本存储,数据丢失概率极低。但自建服务器不同,一台机器挂了就是所有代码都丢了。所以备份这一步不能省略。
备份Git仓库有两种思路:一是直接备份裸仓库的目录文件,二是通过在服务器上定期clone来保存一份只读副本。我两种都会用,双保险。
7.2 定时备份脚本
创建备份脚本/usr/local/bin/git-backup.sh:
#!/bin/bash BACKUP_DIR="/srv/git-backup" DATE=$(date +%Y%m%d_%H%M%S) mkdir -p "$BACKUP_DIR" for repo in /srv/git/*.git; do name=$(basename "$repo") tar -czf "$BACKUP_DIR/${name}_${DATE}.tar.gz" -C /srv/git "$name" done # 保留最近7天的备份,更早的删除 find "$BACKUP_DIR" -name "*.tar.gz" -mtime +7 -delete赋予执行权限,加入crontab:
sudo chmod +x /usr/local/bin/git-backup.sh sudo crontab -e在crontab里加一行:
0 2 * * * /usr/local/bin/git-backup.sh这样每天凌晨2点备份一次,保留7天。
7.3 仓库迁移
换服务器时,迁移仓库很简单。新服务器上创建好同样的/srv/git目录后,直接把旧的裸仓库目录打包拷贝过去,或者从旧服务器上clone一份裸仓库:
git clone --bare git@旧服务器IP:/srv/git/awesome-project.git然后把生成的awesome-project.git目录移动到新服务器的/srv/git/下就行。开发者本地的remote地址会从旧IP指向新IP,只需要改一下remote:
git remote set-url origin git@新服务器IP:/srv/git/awesome-project.git7.4 仓库体检与垃圾回收
Git仓库用久了,因为频繁的提交和分支操作,对象数据库里会有很多不可达对象。虽然不影响功能,但会占磁盘空间。可以定期执行:
sudo -u git git --git-dir=/srv/git/awesome-project.git gc --aggressive --prune=nowgit gc会清理冗余对象并压缩存储。个人经验是不要在开发高峰期执行,因为比较吃CPU和IO,在凌晨配合crontab跑比较合适。
7.5 磁盘空间监控
一个容易被忽视的点是服务器磁盘空间。代码本身不大,但仓库的.git目录会随着历史提交不断膨胀,尤其是包含大文件的时候。我遇到过一次服务器磁盘写满导致所有push失败的教训,从那以后我在服务器上加了简单的空间监控:
df -h /srv/git建议每周看一眼,或者在crontab里加一个磁盘使用率告警:
0 9 * * * df -h /srv/git | awk 'NR==2 {if ($5+0 > 80) print "磁盘空间告警", $5}' >> /var/log/git-disk.log8. 常见问题排查:从连接失败到push冲突
8.1 搭建完成后本地无法连接
搭建好服务器后进行测试,常见问题按下面顺序排查:
- 服务器SSH服务是否正常运行:
systemctl status ssh。 - 服务器防火墙是否放行22端口。Ubuntu默认的
ufw如果启用了,需要执行sudo ufw allow 22/tcp。 - 客户端是否能Ping通服务器。有些云服务器的安全组规则需要在控制台单独配置。
- 使用的用户名和密钥是否正确。
8.2 push时提示"Everything up-to-date"但没有推送成功
这个提示其实是正常的,意思是本地没有新的提交需要推送。如果你确认本地有提交但提示还是这样,检查一下当前分支是否设置了上游:
git branch -vv如果显示[origin/main]说明上游设置好了,如果显示[origin/main: gone]说明远程分支被删了或者未正确设置,重新设置一下:
git branch --set-upstream-to=origin/main main8.3 push时报错"non-fast-forward"
当远程仓库有本地没有的新提交,而你强制推送时,Git会拒绝这个操作,提示non-fast-forward。这是保护机制,避免覆盖别人的提交。
正确做法是先拉取远程更新:
git pull --rebase git push如果确定要覆盖远程历史(比如代码改错了想回退),可以用--force参数,但强烈建议不要在主分支上使用:
git push --force现在Git 2.30以上版本还提供了--force-with-lease,这个参数会在远程分支和本地记录一致时才允许强制推送,更安全:
git push --force-with-lease8.4 服务器重启后Git无法访问
这种情况基本是SSH服务没有设置开机自启。检查:
sudo systemctl enable ssh sudo systemctl start ssh如果用的是阿里云、腾讯云这类云服务器,还要确认安全组里有没有放行端口。这些平台即使你服务器内部防火墙开着,安全组不给放行也进不来。
8.5 一个奇特的坑:服务器时间不准导致Git操作失败
这个问题比较冷门但遇到过。Git的提交、SSH握手都会依赖系统时间,如果服务器时间偏差太大,可能出现证书校验失败或者奇怪的握手错误。排查方法:
date如果时间和实际时间差太多,安装ntpdate校准一下:
sudo apt install ntpdate -y sudo ntpdate ntp.aliyun.com也可以直接用systemd-timesyncd同步时间:
sudo timedatectl set-ntp true处理好之后Git仓库的push和clone就恢复正常了。
9. 给新手的几条实操建议
最后聊几个我实际使用下来觉得很有用的经验。
第一,写commit信息一定要认真。很多人刚开始觉得"能提交就行",结果项目做大了之后回看提交历史,全是fix、update、111这种,完全定位不了问题。我自己规定:commit message第一行不超过50个字符,简明扼要说清楚做了什么,如果要补充细节就空一行再写。这个习惯能让你在半年后依然看得懂自己当时改了什么。
第二,重视.gitignore。在项目一开始就创建.gitignore文件,把node_modules、target、.env这些不应该进仓库的目录和文件排除掉。不要等代码已经提交了再改,因为后面清理历史非常麻烦。我不止一次看到有人把数据库密码和密钥直接推到仓库里,最后被迫换密码的情况。
第三,大文件不要往普通Git仓库里塞。Git对二进制文件、视频、压缩包这些不友好,会让仓库体积迅速膨胀。如果团队确实需要存储大文件,可以考虑git-lfs扩展,或者把这些文件单独放到对象存储里。
第四,小团队的仓库管理可以简单,但分支策略要有。哪怕只有两个人协作,也建议约定好主干分支保持可发布状态,所有新功能开分支开发,合并前先review或用CI跑一遍测试。从项目第一天就养成这个习惯,后面协作效率会高很多。
我在Ubuntu服务器上搭裸仓库的经验就这样了。这套方案不复杂,但胜在稳定、轻量,运行几年也不会出大问题。如果你用下来感觉不够用,再往Gitea或GitLab走也来得及——至少基础的东西已经打好了。