Deploy to Vercel from a git pre-push hook: org repo, Hobby plan, no CI secrets
Vercel's Git integration refuses a private repo owned by a GitHub organization on the Hobby plan, and the CI route needs repo secrets you may not be allowed to add. Here's the lighter option: a local pre-push hook that deploys the exact pushed commit with the Vercel CLI, while everyone keeps pushing under their own name.
The repo moved into a GitHub organization, the Vercel project sits in a Hobby team, and everyone on the project pushes under their own GitHub name. I wanted every push to deploy — main to production, any other branch to a preview — without changing who the commits belong to.
My first plan was the native Git integration. It failed three times in a row, each time for a different reason:
Error: Failed to link your-org/your-repo. You need to add a Login Connection
to your GitHub account first. (400)
This action must be performed by an organization owner
Error: The repository "your-repo" is private and owned by an organization,
which is not supported on the Hobby plan. Upgrade to Pro to continue. (409)Three walls between you and the Git integration
- No GitHub login connection. The Vercel account that owns the team had never connected GitHub, so linking the repo fails with the first error above. The fix is Account Settings → Authentication — but think before you click: a GitHub account can be connected to one Vercel account at a time. Connecting yours to the service account moves it away from your personal Vercel, and Vercel then blocks deploys of your personal projects (“Git author must have access to the project”). The hook below needs no GitHub connection at all, so only do this if you're going for the Git integration.
- Installing the app needs an organization owner. The Vercel GitHub App has to be installed on the organization. A regular member can only request it, and the settings page answers “This action must be performed by an organization owner”.
- Hobby can't connect private organization repos. With everything above done, Vercel still answers 409. No setting fixes this one: the plan is the limit.
The first two are permissions you can chase. The third means the Git integration is off the table until the team is on Pro. Everything after this is a workaround — so pick one that is honest about being a workaround.
Deploy from the machine that pushes
The Vercel CLI authenticates with a login or a token, not with the commit author, so it never runs the membership check that blocks the integration. My previous post runs the CLI in GitHub Actions. That needs VERCEL_TOKEN in the repo's secrets, which means admin rights on the repo — plus an --amend of the author in the runner so the dashboard shows a commit.
A pre-push hook skips both. It runs on the developer's machine after git push, deploys the commit that was just pushed, and never touches the commit: the real author stays on GitHub and in the dashboard. The price is that it exists per machine — more on that below.
The hook
Save it as .git/hooks/pre-push and make it executable. Git calls it with the remote name and URL, and feeds it one line per pushed ref on stdin.
#!/bin/bash
# Deploy to Vercel after a successful push. Local only: git never pushes .git/hooks.
# main -> production, any other branch -> preview. Runs detached; log: .git/vercel-deploy.log
case "$2" in
*your-org/your-repo*) ;;
*) exit 0 ;;
esac
repo_root="$(git rev-parse --show-toplevel)"
gh_path="${2#*github.com[:/]}"; gh_path="${gh_path%.git}"
gh_org="${gh_path%%/*}"; gh_repo="${gh_path#*/}"
log="$repo_root/.git/vercel-deploy.log"
push_pid=$PPID
[ -d "$repo_root/.vercel" ] || { echo "vercel-deploy: run 'vercel link' first, skipping deploy" >&2; exit 0; }
while read -r _ local_sha remote_ref _; do
[ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue
branch="${remote_ref#refs/heads/}"
if [ "$branch" = "main" ]; then flag="--prod"; else flag=""; fi
(
# wait for the push itself to finish, then deploy exactly the pushed commit
while kill -0 "$push_pid" 2>/dev/null; do sleep 1; done
# a rejected or failed push must not deploy: only continue if the commit reached the remote
landed="$(git ls-remote "$2" "$remote_ref" 2>/dev/null | cut -f1)"
if [ "$landed" != "$local_sha" ]; then
echo "=== $(date '+%F %T') $branch @ ${local_sha:0:7} push did not land, deploy skipped" >> "$log"
exit 0
fi
tmp="$(mktemp -d)"
cd "$repo_root" || exit 1
git worktree add --detach "$tmp" "$local_sha" >/dev/null 2>&1 || exit 1
cp -R "$repo_root/.vercel" "$tmp/.vercel"
cd "$tmp" || exit 1
{
echo "=== $(date '+%F %T') $branch @ ${local_sha:0:7} ${flag:-preview}"
vercel deploy $flag --yes \
--build-env ENABLE_EXPERIMENTAL_COREPACK=1 \
-m "githubDeployment=1" \
-m "githubOrg=$gh_org" -m "githubRepo=$gh_repo" \
-m "githubCommitOrg=$gh_org" -m "githubCommitRepo=$gh_repo" \
-m "githubCommitMessage=$(git -C "$repo_root" log -1 --format=%s "$local_sha")" \
-m "githubCommitSha=$local_sha" \
-m "githubCommitRef=$branch" \
-m "githubCommitAuthorName=$(git -C "$repo_root" log -1 --format=%an "$local_sha")" \
-m "githubCommitAuthorEmail=$(git -C "$repo_root" log -1 --format=%ae "$local_sha")" \
2>&1 | tail -5
} >> "$log"
cd "$repo_root" && git worktree remove --force "$tmp"
) >/dev/null 2>&1 &
disown
done
exit 0Four details carry the weight:
- Match the remote URL, not its name. Git passes the URL as
$2. A push to any other remote exits immediately, so a mirror or a fork never deploys. - Wait for the push, then check that it landed. The hook runs before the push finishes, and a rejected push runs it too. So it detaches, waits for the
git pushprocess to exit, and deploys only ifgit ls-remoteshows your SHA on the remote. - Deploy the pushed commit, not your working tree. A throwaway
git worktreeat the pushed SHA means uncommitted edits and half-finished files never ship. It reuses your.vercellink. mainis production, everything else is a preview. One--prodflag, chosen from the pushed ref.
Make the dashboard show the commit, not a hash
My first hook deploys showed up in the dashboard as a random string like AowEm2XzF instead of the commit message. Reading the deployments back through the API showed why: the one that displayed a message carried githubOrg, githubRepo and githubDeployment in its metadata. The CLI adds those only when it recognizes a GitHub remote from a normal checkout — from a detached worktree I got gitCommit* keys and nothing else.
The fix is to pass them yourself with -m, derived from the remote URL, as the hook above does. Message, branch and author then come from those keys. This is what I observed on my own project; Vercel doesn't document these keys, so check your dashboard after the first deploy and treat them as something that can change.
Authentication: use a token, don't share a login
The hook calls vercel deploy, so the machine needs credentials for the account that owns the team. The CLI reads VERCEL_TOKEN from the environment on its own:
# A token created for this purpose; the CLI reads it automatically
export VERCEL_TOKEN="..."Prefer a token over vercel login. A token can be revoked by itself; a shared login can't be taken back from one person without changing it for everyone. Tokens are also what Vercel documents for automation.
Sharing it with the team
Commit the script and a one-command installer to the repo, so nobody copies files by hand:
pnpm add -g vercel
vercel link --project my-project --scope <team-slug>
bash scripts/vercel-deploy/install.sh # copies the hook into .git/hooksThe hook itself lives in .git/hooks, which git never pushes — that's why the installer exists. One line to change before you reuse the script: the repo URL pattern in the case at the top.
How it compares to the CI route
This is not a strictly better version of the previous post. It trades reach for simplicity:
| GitHub Actions (previous post) | pre-push hook (this post) | |
|---|---|---|
| Needs admin on the repo (secrets) | Yes | No |
| Deploys everyone's pushes | Yes | Only from machines with the hook |
| Rewrites the author in the runner | Yes (amend, never pushed) | No |
| Setup | Once per repo | Once per developer |
| Pushes from the GitHub web UI, bots, other machines | Deployed | Not deployed |
| Where failures show up | Actions log | A local log file |
Where the line is
A hook is a workaround, not a license. Before you adopt it, be straight with yourself about three things:
- Hobby is for non-commercial use. If this is a company's product, the plan is the real problem and the hook only hides it. The fix is Pro; the hook buys time until someone approves the invoice.
- Don't share a login to avoid paying for seats. One account holding the team's deploy token is fine. Several people logging in as the same person to get dashboard access is credential sharing.
- It only deploys what you push from your machine. Nothing stops a teammate without the hook from pushing and leaving production stale — decide who owns deploys.
Takeaways
- Vercel's Git integration is off the table for a private organization repo on Hobby: no permission fixes a plan limit.
- The CLI authenticates a token, not the commit author, so the membership check doesn't apply to it.
- A
pre-pushhook deploys from the developer's machine, needs no repo secrets and never rewrites a commit. - Wait for the push and verify that it landed with
git ls-remotebefore deploying. - Deploy a worktree of the pushed SHA, and pass the
github*metadata yourself so the dashboard shows the commit. - Use a token, not a shared login — and treat Hobby as a stopgap if the project is commercial.
- A GitHub login connects to one Vercel account at a time — don't move yours to the service account; the hook doesn't need it.
The CI route is still the better default when you can add secrets. The hook is for the day you can't: a few dozen lines of shell, set up once per developer, and every commit still carries its real author.
Spot a mistake?
A wrong fact, an off translation, something that reads false in this article? Tell me — in your own language.