How to Deploy From Git to Your Host Without FTP
You deploy from git to hosting by giving the server a copy of the repository and a way to update it on demand, usually over SSH. The manual FTP upload disappears because the server pulls the revision you have already reviewed and committed, so every release maps to a commit hash you can point at and roll back to.
There are two shapes this takes. In the first, a post receive hook on a bare repository checks out your branch into the document root the moment you push. In the second, you connect to the account over SSH and run a pull in the working copy that already lives there. Both remove the file transfer client from the loop. Which one fits depends on whether the host gives you shell access and whether you are willing to keep a repository on the account.
What you need before you can deploy from git to hosting
Shell access is the deciding factor. Without SSH you are limited to whatever deploy step the control panel exposes, and those vary enough that you should read the panel documentation for your own account rather than assume. With SSH you need three things: a Git binary on the server, a directory the web server can read, and a key pair so the account can authenticate to your remote repository without a password prompt.
Check the first two from the terminal once you are logged in. The command is short and the output tells you whether to continue.
$ ssh user@example-host
$ git --version
git version 2.x
$ echo $HOME
/home/user
If git --version returns a version and $HOME resolves to a writable path, you have enough to work with. Generate the key pair on the server, not on your laptop, because the server is the party that needs to authenticate. Print the public half and add it to the repository host as a read only deploy key. A read only key is the right default: the server needs to fetch, not to write back.
Pulling into a working copy on the server
The simplest arrangement is a normal clone on the account. You clone once, then each release is a fetch and a fast forward. This is the one to try first, because nothing about it is clever and clever is what breaks at the wrong moment.
$ cd ~/app
$ git remote -v
origin [email protected]:you/site.git (fetch)
origin [email protected]:you/site.git (push)
$ git fetch origin
$ git merge --ff-only origin/main
The --ff-only flag is the part that matters. It refuses to create a merge commit on the server, so if the local history has drifted you get a failure instead of a surprise. A failure is what you want here, because it means a human needs to look. If the pull keeps failing, the usual cause is that someone edited a file directly on the server, and now the working tree is dirty. Resolve that by committing the change on a branch or discarding it deliberately, not by forcing the pull every time.
Point the document root at the checked out directory, or at a subdirectory of it if your project keeps public assets separate from application code. Whichever you choose, keep it consistent, because the web server configuration and the repository layout have to agree.
Using a post receive hook instead
The hook approach puts a bare repository on the account. A bare repository has no working tree, so it cannot be the document root, and that is the point. You push to it, and a script checks the branch out into a separate directory. The advantage is that the push either succeeds and deploys, or fails and does not, with no window where a half updated tree is being served.
Create the bare repository, then write the hook. The file must be named exactly post-receive and must be executable.
$ git init --bare ~/repos/site.git
$ cat > ~/repos/site.git/hooks/post-receive <<'EOF'
#!/bin/sh
TARGET=/home/user/public_html
GIT_WORK_TREE="$TARGET" git checkout -f main
EOF
$ chmod +x ~/repos/site.git/hooks/post-receive
On your laptop, add that repository as a remote and push the branch. The hook runs on the server after the objects are received, and git checkout -f writes the tree into the target directory. The -f discards local modifications in the target, which is correct here because the target should never be edited by hand. If you find yourself wanting to edit it by hand, that is a sign the deployment is missing a build step.
Two details are easy to get wrong. The first is permissions: the hook runs as the account that owns the repository, so the files it writes must be readable by the web server user, which is often a different account. The second is the branch name. Hard coding main is fine until the day you deploy a hotfix branch, at which point you want the hook to read the branch from standard input and act on it.
Keeping secrets and generated files out of the tree
Credentials, local configuration and build output should not be in the repository, so the deployment has to put them in place after the checkout. The usual pattern is a file the repository ignores plus a step that copies it into position. Add the path to .gitignore so an accidental commit is at least unlikely, and keep a template in the repository so a new environment can be built from it.
# .gitignore
.env
config/local.php
public/build/
Anything generated, such as compiled assets or a dependency directory, belongs in the deploy step rather than in version control. Run the build on the server after the checkout if the account has the toolchain, or build locally and deploy the artifact if it does not. Mixing the two, where some generated files are committed and others are not, is the source of most "it works on my machine" reports that follow a deployment.
Making the deployment reversible
Because every release is a commit, rolling back is a checkout of the previous revision. Record which commit is live so you do not have to guess under pressure. Writing the hash to a file at the end of the deploy step costs nothing and answers the question immediately.
$ git rev-parse --short HEAD > ~/app/REVISION
$ cat ~/app/REVISION
a1b2c3d
To roll back, check out the previous hash explicitly rather than relying on a branch pointer that has already moved. If the deploy also ran a database migration, the code rollback is only half the job, and that is a reason to keep migrations backward compatible for one release.
What to do next
Pick the pull based approach if your account has SSH and a Git binary, and only move to a post receive hook when you want the deploy to be atomic. Set up the deploy key as read only, make the first deployment by hand so you can watch each step, and write down the exact commands you ran. Then commit a small change, deploy it, and confirm the revision file changed. Once that round trip is boring, you have a repeatable process, and boring is the goal.
