Pushing Tags to a Remote
Creating a tag with git tag only affects your local repository. Until you explicitly push it, nobody else can see it — not your teammates, not GitHub, not your CI/CD pipeline waiting to build a release. This lesson covers exactly how tags travel from your machine to a remote like origin, why Git makes you push them separately from commits, and how to avoid the mistakes that trip up almost everyone the first time they cut a release.
Overview: how it works
When you run a plain git push, Git only updates branch refs (things under refs/heads/) on the remote. Tags live in a separate namespace, refs/tags/, and Git deliberately excludes them from a default push. This is intentional: tags are often used for personal bookmarks, experiments, or local markers you may never want to share, so Git won’t broadcast them without you asking.
Recall that a tag is just a ref — a name pointing at an object. A lightweight tag is nothing more than a pointer directly at a commit, stored as a single line in refs/tags/<name>. An annotated tag is a full object in Git’s object database (like a commit or blob) that itself points at a commit, and additionally stores the tagger’s name, email, date, a message, and optionally a GPG signature. When you push an annotated tag, Git has to transfer that tag object too, not just move a pointer.
Pushing a tag means: Git creates (or updates) a ref under refs/tags/ on the remote repository, and it uploads any objects the remote doesn’t already have — the tag object itself (for annotated tags) and, if the remote hasn’t seen the tagged commit yet, that commit and everything it depends on (its tree and blobs).
Why this matters for releases
Tags are how most teams mark release points — v1.0.0, v2.3.1 — and how GitHub’s Releases feature and CI pipelines know what to build and publish. If a tag never reaches the remote, GitHub Actions triggered on: push: tags: will never fire, and a "release" that only exists on your laptop isn’t a release at all.
Syntax
git push <remote> <tagname>
git push <remote> --tags
git push <remote> --follow-tags
git push <remote> --delete <tagname>
<remote>— the remote name, almost alwaysorigin.<tagname>— push (or delete) exactly one named tag.--tags— push every local tag that the remote doesn’t already have. This does not push any branches or unrelated commits, but it does push every tag, even ones you didn’t mean to share.--follow-tags— push commits as normal, and additionally push any annotated tags that point at commits being pushed. Lightweight tags are ignored by this flag.--delete(or the shorthand-d) — remove a tag ref from the remote. You can also write this as pushing an empty ref:git push <remote> :refs/tags/<tagname>.
| Command | What it sends |
|---|---|
git push origin v1.0.0 |
One specific tag |
git push origin --tags |
All local tags not yet on the remote |
git push origin --follow-tags |
Commits on the current branch, plus any annotated tags reachable from them |
git push origin --delete v1.0.0 |
Removes v1.0.0 from the remote only |
Examples
Example 1: Push a single annotated tag
You’ve just tagged a release locally and want to share only that tag.
git tag -a v1.0.0 -m "chore: release v1.0.0"
git push origin v1.0.0
Output:
Enumerating objects: 1, done.
Counting objects: 100% (1/1), done.
Writing objects: 100% (1/1), 178 bytes | 178.00 KiB/s, done.
Total 1 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:yourname/webapp.git
* [new tag] v1.0.0 -> v1.0.0
Git uploads the annotated tag object (the commit it points to was likely already on the remote, since it belongs to a branch you’d already pushed) and creates refs/tags/v1.0.0 on origin. The [new tag] line confirms the ref was created, not just updated.
Example 2: Push every local tag at once
After tagging several old milestones locally that you never shared, you want the remote to catch up on all of them.
git tag
# v0.9.0
# v0.9.1
# v1.0.0
git push origin --tags
Output:
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 512 bytes | 512.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0
To github.com:yourname/webapp.git
* [new tag] v0.9.0 -> v0.9.0
* [new tag] v0.9.1 -> v0.9.1
* [new tag] v1.0.0 -> v1.0.0
Every tag Git knows about locally that the remote lacks gets pushed in one go. This is convenient for a bulk catch-up, but be careful — see Common Mistakes below.
Example 3: Commit, tag, and push in a release workflow
A common release flow: finish work on main, tag it, and push both the commit and the tag together.
git switch main
git commit -m "feat: add CSV export to reports page"
git tag -a v1.1.0 -m "chore: release v1.1.0"
git push origin main --follow-tags
Output:
To github.com:yourname/webapp.git
a1b2c3d..e4f5g6h main -> main
* [new tag] v1.1.0 -> v1.1.0
--follow-tags is the safest habit for release workflows: it pushes your branch commits as usual, and automatically includes any annotated tag that points at one of those commits — without also dragging along every unrelated local tag the way --tags would.
How it works step by step
- Git determines which refs you’re asking to update on the remote (a branch, a specific tag, or all tags).
- Git compares your local object database against what the remote already has, and figures out the minimal set of missing objects: for an annotated tag, that’s the tag object itself, plus the commit/tree/blob objects it points to if the remote doesn’t have them yet.
- Those objects are packed and transferred over the network (via HTTPS or SSH, depending on your remote URL).
- The remote’s ref database is updated: a new file (or packed-ref entry) is written under
refs/tags/<name>pointing at the uploaded object. - The remote reports back which refs were created (
[new tag]) or updated, which is what you see printed after the push.
Nothing about your working tree, index, or local commit history changes during this process — pushing tags is purely a remote ref and object-transfer operation.
Common Mistakes
Mistake 1: Assuming tags push automatically
git tag -a v2.0.0 -m "chore: release v2.0.0"
git push origin main
This pushes your branch commits but not the tag — nobody else, and no CI trigger watching for tags, will ever see v2.0.0. Fix it by pushing the tag explicitly:
git push origin v2.0.0
Mistake 2: Blindly running –tags
Running git push origin --tags pushes every local tag, including scratch tags you created to bookmark a spot before an experimental rebase, or tags you never intended to share. Prefer pushing a specific tag by name, or use --follow-tags in a normal release flow so only annotated tags tied to pushed commits go up.
Mistake 3: Trying to "move" a tag by re-pushing it
git tag -f v1.0.0 HEAD
git push origin v1.0.0
Output:
! [rejected] v1.0.0 -> v1.0.0 (already exists)
error: failed to push some refs to 'github.com:yourname/webapp.git'
hint: Updates were rejected because the tag already exists in the remote.
Git refuses to silently overwrite an existing remote tag, because tags are expected to be stable, immutable markers — anyone who already pulled v1.0.0 would otherwise end up with a different commit under the same name with no warning. If you genuinely need to re-point a tag (rare, and worth a heads-up to your team first), you must force it explicitly:
git push --force origin v1.0.0
Do this only when you’re certain nobody has already built on the old tag, since it silently overwrites what the remote had.
Mistake 4: Forgetting the remote still has a deleted local tag
git tag -d v0.1.0-beta
Deleting a tag locally does nothing to the remote’s copy — it still exists on GitHub. You must delete it there separately:
git push origin --delete v0.1.0-beta
Best Practices
- Use annotated tags (
git tag -a) for anything you’ll push and share, especially releases — they carry an author, date, and message that lightweight tags lack. - Push tags explicitly by name, or use
git push <remote> --follow-tagsas part of your normal release routine, rather than defaulting to--tags. - Follow semantic versioning for release tags (
v1.4.2), so both humans and tools likenpm versionor CI scripts can parse them predictably. - Treat pushed tags as immutable. If a release build was wrong, cut a new tag (
v1.4.3) instead of force-moving the old one. - On GitHub, use branch/tag protection rules so a force-push to an existing release tag requires explicit permission, not an accident.
- Before deleting a shared tag, confirm with your team — someone’s deployment scripts or Docker image tags may reference it by name.
- Combine a tag push with a GitHub Release (
gh release create v1.0.0or the Releases UI) so the tag also gets human-readable release notes and downloadable assets.
Practice Exercises
- In a scratch repository, create three commits, then create an annotated tag
v0.1.0on the middle commit. Push only that tag tooriginand confirm withgit ls-remote --tags originthat it arrived. - Create two more local tags without pushing them. Use one command to push all outstanding tags at once, then verify with
git ls-remote --tags originthat all three now exist remotely. - Delete one of those tags both locally and on the remote, then run
git ls-remote --tags originagain to confirm it’s gone. What’s the minimum pair of commands needed to fully remove a tag from both places?
Summary
git pushnever sends tags by default — they live in a separaterefs/tags/namespace and must be pushed explicitly.git push <remote> <tagname>pushes one tag;git push <remote> --tagspushes every local tag the remote lacks;git push <remote> --follow-tagspushes annotated tags reachable from the commits being pushed.- Annotated tags are real objects in Git’s database and get uploaded like any other object; lightweight tags are just a ref pointing at a commit.
- Git rejects re-pushing a tag name that already exists remotely to protect against silently rewriting a shared release marker; only force it deliberately.
- Deleting a tag locally (
git tag -d) and deleting it on the remote (git push <remote> --delete <tagname>) are two separate, independent steps.
