Deploy From GitHub Actions With Git Push — No Vendor Action Needed
You can deploy from GitHub Actions with nothing but git push. JustRunMy.App gives each app an HTTPS deploy remote with its own credentials, so a workflow step pushes to it exactly the way you would from your laptop — no marketplace action to trust, no OAuth app authorized against your account, no SSH key to generate and rotate. Store the push URL as a repository secret and the whole deploy job is about five lines. There is one trap, and it's specific to CI: actions/checkout clones shallow by default, and you can't push from a shallow clone.
Why plain git push beats a vendor action
Deploy actions from a marketplace are convenient and they're also third-party code with access to your secrets, running inside your pipeline on every push. Most of them wrap an API call you could make yourself.
The git-remote approach has a smaller surface:
- Nothing to authorize. No OAuth grant against your GitHub account, no installed app with repository permissions.
- No key management. The remote is HTTPS with generated per-repository credentials, not SSH.
- Nothing to pin or update.
gitis already on the runner. There's no action version to bump when it breaks. - It works anywhere. The same three lines run on GitLab CI, Woodpecker, Jenkins, or your own machine. Nothing is GitHub-specific.
The credentials are scoped to one app's deploy endpoint — they aren't your account password, and revoking them doesn't touch anything else.
The workflow
Copy the push URL from the app's Deploy from Git panel — the same one you'd use to deploy from git by hand — and store it as a repository secret (Settings → Secrets and variables → Actions). The URL contains a password, so it must be a secret, not a variable.
name: Deploy
on:
push:
branches: [main]
concurrency:
group: deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # required — see below
- name: Push to deploy remote
run: git push --force "$DEPLOY_URL" HEAD:deploy
env:
DEPLOY_URL: ${{ secrets.JRNM_DEPLOY_URL }}
That's the whole thing. The platform builds the Dockerfile at your repository root and restarts the app, streaming the build output back into the Actions log — so a failed build shows up in the step that pushed it, not in a dashboard you have to go find.
The shallow clone trap
actions/checkout defaults to fetch-depth: 1 — "Number of commits to fetch. 0 indicates all history for all branches and tags. Default: 1" (actions/checkout). That gives you a shallow clone: the tip commit with its history cut off.
Git refuses to push from a shallow clone unless the receiving repository explicitly opts in with receive.shallowUpdate, and this remote doesn't. You get:
! [remote rejected] HEAD -> deploy (shallow update not allowed)
Setting fetch-depth: 0 fetches full history and the push works. It costs a little clone time on large repositories, and it's the single most common reason this workflow fails the first time. If you'd rather not fetch everything, git fetch --unshallow before the push does the same job.
Three details worth getting right
Use --force. The deploy remote isn't a code host — it doesn't track your history the way GitHub does, and CI can legitimately push commits that don't descend from what it last received (a rebase, a squash merge, a re-run of an older commit). Force-pushing to a deploy branch is normal; you're declaring what should be live, not preserving a history that lives on the remote.
Serialize your deploys. Only one push at a time per app is accepted — a second concurrent push is rejected as a conflict rather than queued. The concurrency block above is what stops two merges landing within seconds of each other from racing. Use cancel-in-progress: false so an in-flight deploy finishes instead of being killed halfway through a build.
Pick the right branch. Pushing to deploy builds and restarts the app. Pushing to build builds the image and attaches it without restarting, which is useful when you want CI to bake images on every merge and roll them out on your own schedule. Any other branch name does nothing at all — which is exactly what you want if you accidentally point a feature-branch workflow at the remote.
Deploying only on a tag
A common pattern is to build on every merge and deploy on a release. Two workflows, two branches:
# build on merges to main — image ready, nothing restarts
on:
push:
branches: [main]
# ...
- run: git push --force "$DEPLOY_URL" HEAD:build
# deploy on a version tag
on:
push:
tags: ['v*']
# ...
- run: git push --force "$DEPLOY_URL" HEAD:deploy
The branch name is the trigger, so which pushes go live is visible in the workflow file rather than hidden in a webhook configuration somewhere.
FAQ
Do I need a GitHub Action from the marketplace to deploy?
No. A deploy is a git push to an HTTPS remote. The runner already has git, so no action, no OAuth app, and no SSH key are involved.
Why does my deploy fail with "shallow update not allowed"?
actions/checkout clones shallow by default (fetch-depth: 1) and git won't push from a shallow clone here. Set fetch-depth: 0, or run git fetch --unshallow before pushing.
Where do I store the push URL?
As an encrypted repository secret. It embeds a password, so it must never be a plain variable or committed to the workflow file.
What happens if two deploys run at once?
The second is rejected as a conflict. Use a concurrency group so deploys queue instead of racing.
Can I build without restarting the app?
Yes — push to build instead of deploy. The image is built and attached; the running app is left alone until you deploy deliberately.
Does this work outside GitHub Actions?
Yes. It's ordinary HTTPS git, so GitLab CI, Jenkins, Woodpecker, or a local script all work the same way. Only the secret-storage syntax changes.
Wrapping up
CI deployment tends to accumulate machinery — an action, a token, a webhook, a dashboard to check afterwards. When the deploy target speaks git, most of that disappears: the pipeline pushes a commit, the build log comes back on the same connection, and the only piece of configuration is a secret URL. Set fetch-depth: 0, add a concurrency group, and it's a workflow you won't have to think about again.