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 always origin.
  • <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

  1. Git determines which refs you’re asking to update on the remote (a branch, a specific tag, or all tags).
  2. 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.
  3. Those objects are packed and transferred over the network (via HTTPS or SSH, depending on your remote URL).
  4. 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.
  5. 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-tags as 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 like npm version or 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.0 or the Releases UI) so the tag also gets human-readable release notes and downloadable assets.

Practice Exercises

  1. In a scratch repository, create three commits, then create an annotated tag v0.1.0 on the middle commit. Push only that tag to origin and confirm with git ls-remote --tags origin that it arrived.
  2. 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 origin that all three now exist remotely.
  3. Delete one of those tags both locally and on the remote, then run git ls-remote --tags origin again to confirm it’s gone. What’s the minimum pair of commands needed to fully remove a tag from both places?

Summary

  • git push never sends tags by default — they live in a separate refs/tags/ namespace and must be pushed explicitly.
  • git push <remote> <tagname> pushes one tag; git push <remote> --tags pushes every local tag the remote lacks; git push <remote> --follow-tags pushes 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.