← All posts

Tutorial CI/CD

How to Deploy a PHP App to a Remote Server via GitHub Actions

Deploy a PHP app to a remote server using SSH and GitHub Actions with zero-downtime directory swaps on every push.

Difficulty Intermediate Time 30 min
Code packages flowing from a CI pipeline through a secure connection to a remote server during automated deployment

I will walk through setting up a GitHub Actions workflow that deploys a PHP application to a remote Linux server over SSH every time you push to the main branch. By the end, your pipeline will sync files with rsync, install production Composer dependencies on the server, and swap the live directory atomically so the site stays available during the entire deploy.

Prerequisites

  • A PHP application hosted in a GitHub repository.
  • A remote Linux server with SSH access, a web server such as Nginx or Apache running, and PHP installed.
  • A dedicated deploy user on the server with write access to the application directory.
  • Composer installed on the remote server.
  • rsync installed on the remote server (most distributions include it by default).
  • A domain or IP address you can reach from the public internet.
  • GitHub Actions enabled on the repository.

Step 1 - Generate an SSH key pair for the deploy user

The GitHub Actions runner needs to authenticate with your remote server over SSH. I use a dedicated key pair that exists only for deployments so it can be rotated or revoked independently from any personal keys.

On your local machine, generate a new key pair. Use a strong algorithm like ed25519. Do not set a passphrase because the workflow needs to use the key non-interactively.

After generating the key pair, copy the public key to the remote server. Add it to the authorized_keys file of the deploy user. This allows the GitHub Actions runner to connect to the server without a password.

Keep the private key ready because you will add it as a secret in the next step.

Note: Never commit the private key to the repository. It should only exist as a GitHub Actions secret and nowhere else.

Step 2 - Store the SSH credentials as GitHub secrets

Go to your repository on GitHub and open Settings, then Secrets and variables, then Actions. Create the following repository secrets:

  • SSH_PRIVATE_KEY - paste the full contents of the private key you just generated.
  • SSH_HOST - the IP address or hostname of your remote server.
  • SSH_USER - the deploy username on the remote server.
  • SSH_PORT - the SSH port, usually 22 unless you have changed it.

These secrets are encrypted by GitHub and only exposed to workflow runs. They never appear in logs even if a step tries to print them.

Step 3 - Prepare the remote server directory structure

I use a release-based directory layout on the server that makes zero-downtime deploys simple. The web server always points at a symlink, and each deploy creates a new release directory. When the deploy is complete, the symlink is swapped to the new release in a single atomic operation.

On the remote server, create the base directory structure. You need a releases directory that will hold timestamped release folders, a shared directory for files that persist across releases such as uploads or environment config, and a current symlink that your web server document root points to.

Point your web server document root to the current symlink followed by the public directory of your app. For example, if the base path is /var/www/myapp, the document root should be /var/www/myapp/current/public. This way the web server always serves whichever release the symlink points to.

Gotcha: Make sure the deploy user owns the entire base directory and can create new directories inside releases. Permission issues are the most common reason deploys fail silently.

Step 4 - Create shared files and directories

Some files need to survive across deploys. A typical PHP app has an environment config file and possibly an uploads or storage directory that should not be wiped on each release.

On the remote server, create these inside the shared directory. For example, place your production environment file inside shared and create a storage directory inside shared if your framework uses one.

During the deploy, the workflow will create symlinks from each new release back to these shared resources. This pattern keeps persistent data safe while still giving each release a clean copy of the application code.

Step 5 - Write the deployment workflow

Now I create the GitHub Actions workflow that ties everything together. The workflow triggers on pushes to the main branch, syncs the code to a new release directory on the server, installs dependencies, links shared files, and swaps the current symlink.

Create the workflow file at .github/workflows/deploy.yml in your repository. The workflow should contain a single job that runs on ubuntu-latest.

The job needs the following steps in order:

  • Check out the repository code.
  • Set up the SSH agent with the private key from the GitHub secret so all subsequent SSH and rsync commands authenticate automatically.
  • Add the remote server host key to the known_hosts file to prevent interactive prompts during the first connection.
  • Use rsync to transfer the application files to a new timestamped release directory under the releases folder on the server.
  • Run a remote SSH command to install Composer production dependencies inside the new release directory on the server.
  • Run a remote SSH command to create symlinks from the new release to the shared environment file and shared storage directory.
  • Run a remote SSH command to swap the current symlink to point at the new release directory.
  • Optionally run a remote SSH command to remove old release directories, keeping only the most recent few for quick rollback.

Each SSH command runs on the remote server. The rsync step runs from the runner and pushes files to the server. This approach keeps the workflow straightforward and avoids needing to install additional deployment tools.

Step 6 - Understand the key parts of the workflow

Several design decisions in this workflow are worth understanding so you can adapt them to your own project.

The SSH agent step writes the private key to a temporary file on the runner, starts the SSH agent, and adds the key. This approach is more reliable than inlining the key into individual SSH commands because every subsequent step inherits the agent automatically.

The rsync step uses the archive and compress flags to transfer files efficiently. I exclude the .git directory, the vendor directory, and any test or CI files from the transfer because they are not needed in production. Composer installs dependencies on the server using the lockfile, which guarantees the exact same versions that passed CI.

The symlink swap is atomic on Linux. When the current symlink is replaced, the web server instantly starts serving the new release. There is no moment where the application is partially deployed or missing files.

Keeping the last three to five releases on the server allows instant rollback by simply pointing the current symlink back to a previous release directory.

Note: If your application needs a build step such as compiling frontend assets, add that step on the runner before rsync so only production-ready files are transferred to the server.

Step 7 - Trigger the first deployment

Commit the workflow file and push it to the main branch.

Open the Actions tab in your repository and watch the deploy workflow run. On the first run, rsync will transfer all files because no previous release exists on the server. Subsequent runs will only transfer changed files, which makes the process much faster.

After the workflow completes, visit your application in a browser using your domain or server IP. The site should be live and serving from the new release through the current symlink.

If anything goes wrong, check the workflow logs step by step. The most common issues are SSH connection failures caused by incorrect secrets, permission errors on the server directory, or a missing Composer installation on the remote server.

Step 8 - Add a rollback strategy

Because each deploy creates a separate release directory and the current symlink controls which release is live, rolling back is as simple as pointing the symlink to a previous release.

On the remote server, list the contents of the releases directory to find the previous release. Then update the current symlink to point at that directory instead. The web server picks up the change immediately.

This is one of the biggest advantages of the symlink deploy pattern. A rollback takes seconds and does not require rebuilding or redeploying anything. Once you confirm the rollback is stable, you can investigate the failed release at your own pace.

Gotcha: If your deploy ran a database migration, rolling back the code does not roll back the database. Always make sure your migrations are backward compatible so the previous release can still work with the updated database schema.

Wrap-up

You now have a GitHub Actions workflow that deploys your PHP application to a remote server over SSH on every push to main. The release directory pattern gives you zero-downtime deploys through atomic symlink swaps and instant rollbacks by pointing the symlink at a previous release. Combined with the CI workflows from the previous tutorials in this series, your pipeline now runs static analysis, enforces code style, executes database tests, and deploys automatically, all triggered by a single git push.