{"id":3650,"date":"2025-12-29T15:46:24","date_gmt":"2025-12-29T12:46:24","guid":{"rendered":"https:\/\/www.dchost.com\/blog\/zero-downtime-deployments-to-a-vps-with-github-actions-for-php-and-node-js\/"},"modified":"2025-12-29T15:46:24","modified_gmt":"2025-12-29T12:46:24","slug":"zero-downtime-deployments-to-a-vps-with-github-actions-for-php-and-node-js","status":"publish","type":"post","link":"https:\/\/www.dchost.com\/blog\/en\/zero-downtime-deployments-to-a-vps-with-github-actions-for-php-and-node-js\/","title":{"rendered":"Zero\u2011Downtime Deployments to a VPS with GitHub Actions for PHP and Node.js"},"content":{"rendered":"<div class=\"dchost-blog-content-wrapper\"><p>Deploying directly to a live <a href=\"https:\/\/www.dchost.com\/vps\">VPS<\/a> over SSH and hoping for the best is how many projects start. A quick <code>git pull<\/code>, a manual <code>composer install<\/code> or <code>npm install<\/code>, maybe a service restart, and you are done \u2013 until a deployment hangs in the middle, users see errors, and rolling back becomes a guessing game. Zero\u2011downtime deployments solve this by treating your VPS like a mini production platform: every release is built, tested, shipped and activated in a predictable, reversible way. In this article, we will walk through how to build that pipeline using GitHub Actions and a VPS running PHP or Node.js. We will design a simple folder structure for releases, use atomic symlink switches, wire in systemd or PM2, and tie everything together with GitHub Actions workflows. The goal is clear: push to your main branch and let automation roll out safe, fast, zero\u2011downtime releases to your dchost VPS.<\/p>\n<div id=\"toc_container\" class=\"toc_transparent no_bullets\"><p class=\"toc_title\">\u0130&ccedil;indekiler<\/p><ul class=\"toc_list\"><li><a href=\"#What_ZeroDowntime_Deployment_Really_Means_on_a_VPS\"><span class=\"toc_number toc_depth_1\">1<\/span> What Zero\u2011Downtime Deployment Really Means on a VPS<\/a><ul><li><a href=\"#Definition_in_practical_terms\"><span class=\"toc_number toc_depth_2\">1.1<\/span> Definition in practical terms<\/a><\/li><li><a href=\"#Why_it_matters_for_PHP_and_Nodejs_apps\"><span class=\"toc_number toc_depth_2\">1.2<\/span> Why it matters for PHP and Node.js apps<\/a><\/li><\/ul><\/li><li><a href=\"#Core_Building_Blocks_of_ZeroDowntime_on_a_VPS\"><span class=\"toc_number toc_depth_1\">2<\/span> Core Building Blocks of Zero\u2011Downtime on a VPS<\/a><ul><li><a href=\"#Directory_layout_releases_shared_and_current\"><span class=\"toc_number toc_depth_2\">2.1<\/span> Directory layout: releases, shared and current<\/a><\/li><li><a href=\"#Atomic_symlink_switch\"><span class=\"toc_number toc_depth_2\">2.2<\/span> Atomic symlink switch<\/a><\/li><li><a href=\"#Process_management_systemd_and_PM2\"><span class=\"toc_number toc_depth_2\">2.3<\/span> Process management: systemd and PM2<\/a><\/li><li><a href=\"#Nginx_as_a_stable_longlived_entry_point\"><span class=\"toc_number toc_depth_2\">2.4<\/span> Nginx as a stable, long\u2011lived entry point<\/a><\/li><\/ul><\/li><li><a href=\"#Preparing_Your_VPS_for_GitHub_Actions_Deployments\"><span class=\"toc_number toc_depth_1\">3<\/span> Preparing Your VPS for GitHub Actions Deployments<\/a><ul><li><a href=\"#Create_a_dedicated_deploy_user_and_SSH_key\"><span class=\"toc_number toc_depth_2\">3.1<\/span> Create a dedicated deploy user and SSH key<\/a><\/li><li><a href=\"#Set_up_the_application_folders\"><span class=\"toc_number toc_depth_2\">3.2<\/span> Set up the application folders<\/a><\/li><li><a href=\"#systemd_service_for_PHP_workers_or_Nodejs\"><span class=\"toc_number toc_depth_2\">3.3<\/span> systemd service for PHP workers or Node.js<\/a><\/li><\/ul><\/li><li><a href=\"#GitHub_Actions_Basics_for_VPS_Deployments\"><span class=\"toc_number toc_depth_1\">4<\/span> GitHub Actions Basics for VPS Deployments<\/a><ul><li><a href=\"#What_GitHub_Actions_does_in_this_setup\"><span class=\"toc_number toc_depth_2\">4.1<\/span> What GitHub Actions does in this setup<\/a><\/li><li><a href=\"#Storing_VPS_credentials_as_GitHub_Secrets\"><span class=\"toc_number toc_depth_2\">4.2<\/span> Storing VPS credentials as GitHub Secrets<\/a><\/li><li><a href=\"#A_generic_deploy_job_structure\"><span class=\"toc_number toc_depth_2\">4.3<\/span> A generic deploy job structure<\/a><\/li><\/ul><\/li><li><a href=\"#ZeroDowntime_Workflow_for_PHP_Laravel_Generic_PHP\"><span class=\"toc_number toc_depth_1\">5<\/span> Zero\u2011Downtime Workflow for PHP (Laravel \/ Generic PHP)<\/a><ul><li><a href=\"#Build_and_deploy_flow\"><span class=\"toc_number toc_depth_2\">5.1<\/span> Build and deploy flow<\/a><\/li><li><a href=\"#GitHub_Actions_example_for_a_Laravel_app\"><span class=\"toc_number toc_depth_2\">5.2<\/span> GitHub Actions example for a Laravel app<\/a><\/li><li><a href=\"#The_activation_script_on_the_VPS\"><span class=\"toc_number toc_depth_2\">5.3<\/span> The activation script on the VPS<\/a><\/li><\/ul><\/li><li><a href=\"#ZeroDowntime_Workflow_for_Nodejs_Apps\"><span class=\"toc_number toc_depth_1\">6<\/span> Zero\u2011Downtime Workflow for Node.js Apps<\/a><ul><li><a href=\"#Deployment_flow_for_Nodejs_APIs_and_SPAs\"><span class=\"toc_number toc_depth_2\">6.1<\/span> Deployment flow for Node.js APIs and SPAs<\/a><\/li><li><a href=\"#GitHub_Actions_example_for_a_Nodejs_app\"><span class=\"toc_number toc_depth_2\">6.2<\/span> GitHub Actions example for a Node.js app<\/a><\/li><li><a href=\"#Nodejs_activation_script_with_systemd\"><span class=\"toc_number toc_depth_2\">6.3<\/span> Node.js activation script with systemd<\/a><\/li><\/ul><\/li><li><a href=\"#Handling_Database_and_Breaking_Changes_Safely\"><span class=\"toc_number toc_depth_1\">7<\/span> Handling Database and Breaking Changes Safely<\/a><ul><li><a href=\"#Backwardcompatible_database_migrations\"><span class=\"toc_number toc_depth_2\">7.1<\/span> Backward\u2011compatible database migrations<\/a><\/li><li><a href=\"#Feature_flags_and_configuration_changes\"><span class=\"toc_number toc_depth_2\">7.2<\/span> Feature flags and configuration changes<\/a><\/li><\/ul><\/li><li><a href=\"#Observability_Rollbacks_and_Hardening_the_Pipeline\"><span class=\"toc_number toc_depth_1\">8<\/span> Observability, Rollbacks and Hardening the Pipeline<\/a><ul><li><a href=\"#Monitoring_and_alerts_around_deployments\"><span class=\"toc_number toc_depth_2\">8.1<\/span> Monitoring and alerts around deployments<\/a><\/li><li><a href=\"#Designing_a_fast_rollback\"><span class=\"toc_number toc_depth_2\">8.2<\/span> Designing a fast rollback<\/a><\/li><li><a href=\"#Security_and_reliability_considerations\"><span class=\"toc_number toc_depth_2\">8.3<\/span> Security and reliability considerations<\/a><\/li><\/ul><\/li><li><a href=\"#Bringing_It_All_Together_on_Your_dchost_VPS\"><span class=\"toc_number toc_depth_1\">9<\/span> Bringing It All Together on Your dchost VPS<\/a><\/li><\/ul><\/div>\n<h2><span id=\"What_ZeroDowntime_Deployment_Really_Means_on_a_VPS\">What Zero\u2011Downtime Deployment Really Means on a VPS<\/span><\/h2>\n<h3><span id=\"Definition_in_practical_terms\">Definition in practical terms<\/span><\/h3>\n<p><strong>Zero\u2011downtime deployment<\/strong> means users never see a broken or half\u2011updated version of your application while you deploy a new release. On a single VPS this usually comes down to three rules:<\/p>\n<ul>\n<li>Never modify the currently running code in place.<\/li>\n<li>Prepare the new release in a separate directory, fully ready to serve traffic.<\/li>\n<li>Switch traffic to the new release with one fast, atomic action and keep a rollback path.<\/li>\n<\/ul>\n<p>Instead of copying files over the top of your app, you deploy into timestamped <strong>release directories<\/strong> and point a <code>current<\/code> symlink to the active one. Swapping that symlink is virtually instantaneous, so the window where things can go wrong is extremely small.<\/p>\n<h3><span id=\"Why_it_matters_for_PHP_and_Nodejs_apps\">Why it matters for PHP and Node.js apps<\/span><\/h3>\n<p>On a VPS hosting PHP (for example Laravel, Symfony, WordPress) or Node.js (REST APIs, real\u2011time apps, dashboards), brief outages during deploys can be surprisingly costly:<\/p>\n<ul>\n<li><strong>Abandoned carts<\/strong> on e\u2011commerce checkouts that error out mid\u2011request.<\/li>\n<li><strong>Broken API clients<\/strong> that cache a 500 response or treat a short outage as a hard failure.<\/li>\n<li><strong>Lost trust<\/strong> when admin panels or dashboards are frequently unavailable during work hours.<\/li>\n<\/ul>\n<p>Zero\u2011downtime deployment patterns remove these spikes in errors. Your PHP\u2011FPM pool or Node.js process continues serving the old release until the instant you switch symlinks or reload processes. If you want to see a deeper dive into these techniques, we already use the same patterns in <a href=\"https:\/\/www.dchost.com\/blog\/en\/vpse-sifir-kesinti-ci-cd-nasil-kurulur-rsync-sembolik-surumler-ve-systemd-ile-sicacik-bir-yolculuk\/\">our detailed rsync + symlink + systemd CI\/CD playbook for VPS servers<\/a>.<\/p>\n<h2><span id=\"Core_Building_Blocks_of_ZeroDowntime_on_a_VPS\">Core Building Blocks of Zero\u2011Downtime on a VPS<\/span><\/h2>\n<h3><span id=\"Directory_layout_releases_shared_and_current\">Directory layout: releases, shared and current<\/span><\/h3>\n<p>A simple, battle\u2011tested directory structure under <code>\/var\/www\/myapp<\/code> works for both PHP and Node.js:<\/p>\n<ul>\n<li><code>releases\/<\/code> \u2013 every deployment gets its own timestamped directory, e.g. <code>2025-12-29_120000\/<\/code><\/li>\n<li><code>shared\/<\/code> \u2013 persistent data shared across releases (e.g. <code>storage\/<\/code>, <code>uploads\/<\/code>, <code>.env<\/code>)<\/li>\n<li><code>current<\/code> \u2013 a symlink pointing to the active release, e.g. <code>current -&gt; releases\/2025-12-29_120000<\/code><\/li>\n<\/ul>\n<p>Deployments create a new directory under <code>releases\/<\/code>, sync code into it, run build steps (<code>composer install<\/code>, <code>npm ci &amp;&amp; npm run build<\/code>), then update the symlink.<\/p>\n<h3><span id=\"Atomic_symlink_switch\">Atomic symlink switch<\/span><\/h3>\n<p>On Linux, changing what a symlink points to is atomic from the perspective of other processes. Nginx, PHP\u2011FPM and Node.js read the new path on the next request or restart, without ever seeing a half\u2011copied directory. The typical activation step is:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">ln -sfn \/var\/www\/myapp\/releases\/2025-12-29_120000 \/var\/www\/myapp\/current<\/code><\/pre>\n<p>The <code>-sfn<\/code> flags ensure the old symlink is replaced in one go. This pattern is one of the reasons we like VPS\u2011based workflows so much: you get full control over the filesystem and can implement robust release management with a handful of commands.<\/p>\n<h3><span id=\"Process_management_systemd_and_PM2\">Process management: systemd and PM2<\/span><\/h3>\n<p>For PHP web frontends, Nginx or Apache talk to <strong>PHP\u2011FPM<\/strong>, which is always running and does not need restarts for every deploy. You only restart or reload PHP\u2011FPM when you upgrade PHP or change its configuration. Background workers (e.g. Laravel queues) should be supervised by <strong>systemd<\/strong> so you can reload them cleanly after a deploy.<\/p>\n<p>For Node.js, you have two common options:<\/p>\n<ul>\n<li><strong>systemd<\/strong> unit that runs <code>node server.js<\/code> in the <code>current<\/code> directory, and you run <code>systemctl restart myapp<\/code> or <code>systemctl reload myapp<\/code> at the end of the deployment.<\/li>\n<li><strong>PM2<\/strong> process manager, using <code>pm2 reload<\/code> for zero\u2011downtime restarts.<\/li>\n<\/ul>\n<p>We explain these patterns step\u2011by\u2011step for real Node.js projects in <a href=\"https:\/\/www.dchost.com\/blog\/en\/node-jsi-canliya-alirken-panik-yapma-pm2-systemd-nginx-ssl-ve-sifir-kesinti-deploy-nasil-kurulur\/\">how to host Node.js in production without drama<\/a>.<\/p>\n<h3><span id=\"Nginx_as_a_stable_longlived_entry_point\">Nginx as a stable, long\u2011lived entry point<\/span><\/h3>\n<p>On a typical dchost VPS, Nginx is the public entry point:<\/p>\n<ul>\n<li>For PHP apps, Nginx serves static assets and forwards dynamic requests to PHP\u2011FPM.<\/li>\n<li>For Node.js apps, Nginx acts as a reverse proxy to a Node.js backend running on <code>localhost:3000<\/code> or similar.<\/li>\n<\/ul>\n<p>Your GitHub Actions deployments never touch Nginx\u2019s main configuration, only the code Nginx points to. As long as Nginx stays up, switching releases under <code>\/var\/www\/myapp\/current<\/code> does not interrupt active connections.<\/p>\n<h2><span id=\"Preparing_Your_VPS_for_GitHub_Actions_Deployments\">Preparing Your VPS for GitHub Actions Deployments<\/span><\/h2>\n<h3><span id=\"Create_a_dedicated_deploy_user_and_SSH_key\">Create a dedicated deploy user and SSH key<\/span><\/h3>\n<p>Start by creating a non\u2011root user on your VPS to handle deployments:<\/p>\n<ol>\n<li>Create a user, e.g. <code>deploy<\/code>, and give it ownership of <code>\/var\/www\/myapp<\/code>.<\/li>\n<li>Add the user to a limited <code>sudo<\/code> group if it needs to run <code>systemctl<\/code> (only for specific commands).<\/li>\n<li>Generate an SSH keypair and add the public key to <code>~deploy\/.ssh\/authorized_keys<\/code>.<\/li>\n<\/ol>\n<p>If you need a refresher on safe SSH setups, we have a full checklist in our article on <a href=\"https:\/\/www.dchost.com\/blog\/en\/vps-guvenlik-sertlestirme-kontrol-listesi-sshd_config-fail2ban-ve-root-erisimini-kapatmak\/\">VPS security hardening with SSH configuration, Fail2ban and disabling direct root access<\/a>.<\/p>\n<h3><span id=\"Set_up_the_application_folders\">Set up the application folders<\/span><\/h3>\n<p>On the VPS, prepare the layout once:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">sudo mkdir -p \/var\/www\/myapp\/{releases,shared}\nsudo chown -R deploy:deploy \/var\/www\/myapp<\/code><\/pre>\n<p>Then create shared resources, for example for a Laravel app:<\/p>\n<ul>\n<li><code>\/var\/www\/myapp\/shared\/storage<\/code><\/li>\n<li><code>\/var\/www\/myapp\/shared\/.env<\/code><\/li>\n<\/ul>\n<p>Each new release will symlink these shared paths so logs, file uploads and configuration persist across deployments.<\/p>\n<h3><span id=\"systemd_service_for_PHP_workers_or_Nodejs\">systemd service for PHP workers or Node.js<\/span><\/h3>\n<p>For PHP queue workers (Laravel, Symfony Messenger, etc.), a typical systemd unit might look like:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">[Unit]\nDescription=Laravel Queue Worker\nAfter=network.target\n\n[Service]\nUser=deploy\nWorkingDirectory=\/var\/www\/myapp\/current\nExecStart=\/usr\/bin\/php artisan queue:work --sleep=3 --tries=3\nRestart=always\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n<p>For a Node.js API:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">[Unit]\nDescription=My Node.js API\nAfter=network.target\n\n[Service]\nUser=deploy\nWorkingDirectory=\/var\/www\/myapp\/current\nExecStart=\/usr\/bin\/node server.js\nRestart=always\nEnvironment=NODE_ENV=production\n\n[Install]\nWantedBy=multi-user.target<\/code><\/pre>\n<p>Notice that both use <code>WorkingDirectory=\/var\/www\/myapp\/current<\/code>. When we switch the symlink, a restart or reload makes them run the new codebase without touching the unit file.<\/p>\n<h2><span id=\"GitHub_Actions_Basics_for_VPS_Deployments\">GitHub Actions Basics for VPS Deployments<\/span><\/h2>\n<h3><span id=\"What_GitHub_Actions_does_in_this_setup\">What GitHub Actions does in this setup<\/span><\/h3>\n<p><strong>GitHub Actions<\/strong> is GitHub\u2019s built\u2011in CI\/CD service. For zero\u2011downtime VPS deployments, we use it to:<\/p>\n<ul>\n<li>Trigger on <code>push<\/code> to specific branches (e.g. <code>main<\/code> for production, <code>develop<\/code> for staging).<\/li>\n<li>Checkout the repository code.<\/li>\n<li>Install dependencies and run tests or linters.<\/li>\n<li>Build assets (for SPAs or Tailwind, for example).<\/li>\n<li>Sync the prepared release to the VPS via <code>rsync<\/code> over SSH.<\/li>\n<li>Run a remote script to update the symlink and restart services.<\/li>\n<\/ul>\n<p>This keeps your VPS clean and predictable: all heavy build work happens on GitHub\u2019s runners, your dchost VPS only receives ready\u2011to\u2011run artifacts.<\/p>\n<h3><span id=\"Storing_VPS_credentials_as_GitHub_Secrets\">Storing VPS credentials as GitHub Secrets<\/span><\/h3>\n<p>Never hard\u2011code IP addresses, usernames or private keys in your repository. Instead, define them as <strong>Actions secrets<\/strong> in your GitHub project:<\/p>\n<ul>\n<li><code>VPS_HOST<\/code> \u2013 the IP or hostname of your dchost VPS<\/li>\n<li><code>VPS_USER<\/code> \u2013 typically <code>deploy<\/code><\/li>\n<li><code>VPS_SSH_KEY<\/code> \u2013 the private SSH key matching the public key on the server<\/li>\n<li><code>VPS_APP_PATH<\/code> \u2013 e.g. <code>\/var\/www\/myapp<\/code><\/li>\n<\/ul>\n<p>GitHub Actions runners can then load these values at runtime without exposing them in logs or code.<\/p>\n<h3><span id=\"A_generic_deploy_job_structure\">A generic deploy job structure<\/span><\/h3>\n<p>Here is a simplified, language\u2011agnostic GitHub Actions job that prepares a release and syncs it to the VPS:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">name: Deploy\n\non:\n  push:\n    branches: [ &quot;main&quot; ]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - name: Checkout code\n        uses: actions\/checkout@v4\n\n      - name: Set up SSH key\n        run: |\n          mkdir -p ~\/.ssh\n          echo &quot;${{ secrets.VPS_SSH_KEY }}&quot; &gt; ~\/.ssh\/id_rsa\n          chmod 600 ~\/.ssh\/id_rsa\n          ssh-keyscan -H ${{ secrets.VPS_HOST }} &gt;&gt; ~\/.ssh\/known_hosts\n\n      - name: Install dependencies and build\n        run: |\n          # this section will differ for PHP vs Node.js\n          echo &quot;Run composer\/npm here&quot;\n\n      - name: Create release archive\n        run: |\n          RELEASE=$(date +&quot;%Y-%m-%d_%H%M%S&quot;)\n          echo &quot;RELEASE=$RELEASE&quot; &gt;&gt; $GITHUB_ENV\n          tar czf myapp-$RELEASE.tgz .\n\n      - name: Upload release to VPS\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;mkdir -p ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE&quot;\n          scp myapp-$RELEASE.tgz \n            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE\/\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE &amp;&amp; tar xzf myapp-$RELEASE.tgz &amp;&amp; rm myapp-$RELEASE.tgz&quot;\n\n      - name: Activate release on VPS\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }} &amp;&amp; .\/bin\/activate_release.sh $RELEASE&quot;<\/code><\/pre>\n<p>The last step calls a script on the VPS (<code>bin\/activate_release.sh<\/code>) that we will write once and reuse for both PHP and Node.js apps.<\/p>\n<h2><span id=\"ZeroDowntime_Workflow_for_PHP_Laravel_Generic_PHP\">Zero\u2011Downtime Workflow for PHP (Laravel \/ Generic PHP)<\/span><\/h2>\n<h3><span id=\"Build_and_deploy_flow\">Build and deploy flow<\/span><\/h3>\n<p>For a modern PHP app (e.g. Laravel), a typical zero\u2011downtime deployment pipeline looks like this:<\/p>\n<ol>\n<li>Developer pushes to <code>main<\/code> branch.<\/li>\n<li>GitHub Actions checks out code and installs Composer dependencies (without dev packages).<\/li>\n<li>Front\u2011end assets are built with <code>npm ci &amp;&amp; npm run build<\/code> if applicable.<\/li>\n<li>The build output is archived (or rsynced) to a new release directory on the VPS.<\/li>\n<li>On the VPS, shared directories are symlinked into the new release (e.g. <code>storage<\/code>, <code>.env<\/code>).<\/li>\n<li>Database migrations run in a backward\u2011compatible way.<\/li>\n<li><code>current<\/code> symlink is switched to the new release.<\/li>\n<li>PHP queue workers are gracefully restarted via systemd.<\/li>\n<\/ol>\n<p>If you are interested in a full Laravel\u2011specific runbook, we already describe it in detail in <a href=\"https:\/\/www.dchost.com\/blog\/en\/laravel-uygulamalarini-vpste-nasil-yayinlarim-nginx-php%e2%80%91fpm-horizon-ve-sifir-kesinti-dagitim-nasil-sicacik-bir-yol-haritasi\/\">deploying Laravel on a VPS with truly zero\u2011downtime releases<\/a>.<\/p>\n<h3><span id=\"GitHub_Actions_example_for_a_Laravel_app\">GitHub Actions example for a Laravel app<\/span><\/h3>\n<p>Here is a more concrete PHP\u2011focused <code>deploy.yml<\/code> snippet:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">name: Deploy Laravel to VPS\n\non:\n  push:\n    branches: [ &quot;main&quot; ]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions\/checkout@v4\n\n      - name: Set up PHP\n        uses: shivammathur\/setup-php@v2\n        with:\n          php-version: '8.2'\n          extensions: mbstring, intl, pdo_mysql\n\n      - name: Install PHP dependencies\n        run: |\n          composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader\n\n      - name: Build frontend assets\n        run: |\n          npm ci\n          npm run build\n\n      - name: Prepare release package\n        run: |\n          RELEASE=$(date +&quot;%Y-%m-%d_%H%M%S&quot;)\n          echo &quot;RELEASE=$RELEASE&quot; &gt;&gt; $GITHUB_ENV\n          tar czf laravel-$RELEASE.tgz . --exclude=&quot;storage&quot; --exclude=&quot;node_modules&quot; --exclude=&quot;tests&quot;\n\n      - name: Configure SSH\n        run: |\n          mkdir -p ~\/.ssh\n          echo &quot;${{ secrets.VPS_SSH_KEY }}&quot; &gt; ~\/.ssh\/id_rsa\n          chmod 600 ~\/.ssh\/id_rsa\n          ssh-keyscan -H ${{ secrets.VPS_HOST }} &gt;&gt; ~\/.ssh\/known_hosts\n\n      - name: Upload release to VPS\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;mkdir -p ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE&quot;\n          scp laravel-$RELEASE.tgz \n            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE\/\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE &amp;&amp; tar xzf laravel-$RELEASE.tgz &amp;&amp; rm laravel-$RELEASE.tgz&quot;\n\n      - name: Activate release\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }} &amp;&amp; .\/bin\/activate_laravel_release.sh $RELEASE&quot;<\/code><\/pre>\n<h3><span id=\"The_activation_script_on_the_VPS\">The activation script on the VPS<\/span><\/h3>\n<p>An example <code>\/var\/www\/myapp\/bin\/activate_laravel_release.sh<\/code>:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -euo pipefail\n\nAPP_PATH=\/var\/www\/myapp\nRELEASE=$1\n\ncd &quot;$APP_PATH&quot;\n\n# Link shared resources\nln -sfn &quot;$APP_PATH\/shared\/.env&quot; &quot;releases\/$RELEASE\/.env&quot;\nrm -rf &quot;releases\/$RELEASE\/storage&quot;\nln -sfn &quot;$APP_PATH\/shared\/storage&quot; &quot;releases\/$RELEASE\/storage&quot;\n\n# Run migrations (ensure they are backwards compatible)\ncd &quot;releases\/$RELEASE&quot;\nphp artisan migrate --force\nphp artisan config:cache\nphp artisan route:cache\nphp artisan view:cache\n\n# Atomic switch\nln -sfn &quot;$APP_PATH\/releases\/$RELEASE&quot; &quot;$APP_PATH\/current&quot;\n\n# Reload queue workers\nsudo systemctl restart laravel-queue.service<\/code><\/pre>\n<p>This script assumes your migrations are safe to run while the old release is still serving traffic. For complex schema changes (dropping columns, renaming fields), you should follow <a href=\"https:\/\/www.dchost.com\/blog\/en\/mysqlde-sifir-kesinti-sema-degisiklikleri-gh-ost-ve-pt-online-schema-change-ile-blue-green-nasil-kurulur\/\">our guide to Zero\u2011Downtime MySQL schema migrations<\/a> to avoid locking tables during deploys.<\/p>\n<h2><span id=\"ZeroDowntime_Workflow_for_Nodejs_Apps\">Zero\u2011Downtime Workflow for Node.js Apps<\/span><\/h2>\n<h3><span id=\"Deployment_flow_for_Nodejs_APIs_and_SPAs\">Deployment flow for Node.js APIs and SPAs<\/span><\/h3>\n<p>For Node.js, the structure is very similar but the runtime behaviour is different:<\/p>\n<ol>\n<li>GitHub Actions installs Node.js and dependencies with <code>npm ci<\/code> or <code>yarn install --frozen-lockfile<\/code>.<\/li>\n<li>It builds production bundles (<code>npm run build<\/code>).<\/li>\n<li>The built app is packaged and uploaded to a timestamped release directory on the VPS.<\/li>\n<li>On the VPS, environment configuration and uploads are symlinked from <code>shared<\/code>.<\/li>\n<li>Node.js process is reloaded via systemd or PM2 in a way that keeps active connections alive where possible.<\/li>\n<\/ol>\n<h3><span id=\"GitHub_Actions_example_for_a_Nodejs_app\">GitHub Actions example for a Node.js app<\/span><\/h3>\n<p>Here is a Node\u2011focused workflow that still uses the same general pattern:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">name: Deploy Node.js App to VPS\n\non:\n  push:\n    branches: [ &quot;main&quot; ]\n\njobs:\n  deploy:\n    runs-on: ubuntu-latest\n\n    steps:\n      - uses: actions\/checkout@v4\n\n      - name: Use Node.js\n        uses: actions\/setup-node@v4\n        with:\n          node-version: '20'\n\n      - name: Install dependencies\n        run: npm ci\n\n      - name: Build app\n        run: npm run build\n\n      - name: Prepare release package\n        run: |\n          RELEASE=$(date +&quot;%Y-%m-%d_%H%M%S&quot;)\n          echo &quot;RELEASE=$RELEASE&quot; &gt;&gt; $GITHUB_ENV\n          tar czf nodeapp-$RELEASE.tgz . --exclude=&quot;node_modules&quot; --exclude=&quot;tests&quot;\n\n      - name: Configure SSH\n        run: |\n          mkdir -p ~\/.ssh\n          echo &quot;${{ secrets.VPS_SSH_KEY }}&quot; &gt; ~\/.ssh\/id_rsa\n          chmod 600 ~\/.ssh\/id_rsa\n          ssh-keyscan -H ${{ secrets.VPS_HOST }} &gt;&gt; ~\/.ssh\/known_hosts\n\n      - name: Upload release to VPS\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;mkdir -p ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE&quot;\n          scp nodeapp-$RELEASE.tgz \n            ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE\/\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }}\/releases\/$RELEASE &amp;&amp; tar xzf nodeapp-$RELEASE.tgz &amp;&amp; rm nodeapp-$RELEASE.tgz &amp;&amp; npm ci --omit=dev&quot;\n\n      - name: Activate release\n        run: |\n          RELEASE=${{ env.RELEASE }}\n          ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \n            &quot;cd ${{ secrets.VPS_APP_PATH }} &amp;&amp; .\/bin\/activate_node_release.sh $RELEASE&quot;<\/code><\/pre>\n<h3><span id=\"Nodejs_activation_script_with_systemd\">Node.js activation script with systemd<\/span><\/h3>\n<p>A simple <code>activate_node_release.sh<\/code> could look like this:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -euo pipefail\n\nAPP_PATH=\/var\/www\/myapp\nRELEASE=$1\n\ncd &quot;$APP_PATH&quot;\n\n# Link shared env and uploads\nln -sfn &quot;$APP_PATH\/shared\/.env&quot; &quot;releases\/$RELEASE\/.env&quot;\nrm -rf &quot;releases\/$RELEASE\/uploads&quot;\nln -sfn &quot;$APP_PATH\/shared\/uploads&quot; &quot;releases\/$RELEASE\/uploads&quot;\n\n# Optional: run database migrations here (with the same care as for PHP)\n\n# Atomic switch\nln -sfn &quot;$APP_PATH\/releases\/$RELEASE&quot; &quot;$APP_PATH\/current&quot;\n\n# Restart Node.js service (short blip, acceptable for many APIs)\nsudo systemctl restart nodeapp.service<\/code><\/pre>\n<p>If you need true zero\u2011blip reloads for long\u2011lived WebSocket connections or real\u2011time dashboards, PM2\u2019s <code>reload<\/code> mode or a rolling restart pattern can help. We cover these options in more depth in <a href=\"https:\/\/www.dchost.com\/blog\/en\/node-jsi-canliya-alirken-panik-yapma-pm2-systemd-nginx-ssl-ve-sifir-kesinti-deploy-nasil-kurulur\/\">our Node.js production deployment playbook<\/a>.<\/p>\n<h2><span id=\"Handling_Database_and_Breaking_Changes_Safely\">Handling Database and Breaking Changes Safely<\/span><\/h2>\n<h3><span id=\"Backwardcompatible_database_migrations\">Backward\u2011compatible database migrations<\/span><\/h3>\n<p>Zero\u2011downtime deployments are only truly zero\u2011downtime if your database schema changes do not break the currently running code. Some practical rules:<\/p>\n<ul>\n<li><strong>Add, do not remove<\/strong> in the first step: add new columns or tables; do not drop old ones yet.<\/li>\n<li><strong>Deploy in two phases<\/strong>: first deploy code that works with both old and new schema, then clean up in a later deploy.<\/li>\n<li><strong>Avoid long\u2011running locks<\/strong>: for huge tables, use online migration tools and carefully planned indexes.<\/li>\n<\/ul>\n<p>Our article on <a href=\"https:\/\/www.dchost.com\/blog\/en\/mysqlde-sifir-kesinti-sema-degisiklikleri-gh-ost-ve-pt-online-schema-change-ile-blue-green-nasil-kurulur\/\">Zero\u2011Downtime MySQL schema migrations<\/a> shows how to use tools like <code>gh\u2011ost<\/code> or <code>pt-online-schema-change<\/code> to avoid blocking production traffic while tables are altered.<\/p>\n<h3><span id=\"Feature_flags_and_configuration_changes\">Feature flags and configuration changes<\/span><\/h3>\n<p>For larger teams or high\u2011risk changes, you can decouple deploys from feature releases:<\/p>\n<ul>\n<li>Introduce <strong>feature flags<\/strong> (in config or database) so new code paths can be turned on gradually after a stable deploy.<\/li>\n<li>Keep <strong>configuration backward\u2011compatible<\/strong>: avoid renaming keys in <code>.env<\/code> files during the same deploy; instead, read both old and new names temporarily.<\/li>\n<li>Version your APIs: if you must break clients, serve <code>\/v1<\/code> and <code>\/v2<\/code> concurrently for a while.<\/li>\n<\/ul>\n<p>This approach reduces pressure on each individual deployment. GitHub Actions only has to ship code; product decisions about when to enable changes can happen later.<\/p>\n<h2><span id=\"Observability_Rollbacks_and_Hardening_the_Pipeline\">Observability, Rollbacks and Hardening the Pipeline<\/span><\/h2>\n<h3><span id=\"Monitoring_and_alerts_around_deployments\">Monitoring and alerts around deployments<\/span><\/h3>\n<p>Once you automate deployments, visibility becomes even more important. At minimum, you should monitor:<\/p>\n<ul>\n<li>Uptime and HTTP status codes (spikes in 5xx after a deploy are a signal to rollback).<\/li>\n<li>CPU, RAM and disk usage on your VPS.<\/li>\n<li>Application logs for uncaught exceptions or connection errors.<\/li>\n<\/ul>\n<p>If you want a structured approach, see our guide on <a href=\"https:\/\/www.dchost.com\/blog\/en\/vps-izleme-ve-alarm-kurulumu-prometheus-grafana-ve-uptime-kuma-ile-baslangic\/\">VPS monitoring and alerts with Prometheus, Grafana and Uptime Kuma<\/a>, which fits nicely next to a GitHub Actions deployment pipeline.<\/p>\n<h3><span id=\"Designing_a_fast_rollback\">Designing a fast rollback<\/span><\/h3>\n<p>The same symlink pattern that enables zero\u2011downtime deploys also gives you instant rollbacks. Because you keep several older releases in <code>releases\/<\/code>, a rollback script can simply point <code>current<\/code> back to the previous one:<\/p>\n<pre class=\"language-bash line-numbers\"><code class=\"language-bash\">#!\/usr\/bin\/env bash\nset -euo pipefail\n\nAPP_PATH=\/var\/www\/myapp\n\ncd &quot;$APP_PATH\/releases&quot;\n\n# list releases sorted by name (timestamp) and get the two newest\nLATEST=$(ls -1 | sort | tail -n 1)\nPREVIOUS=$(ls -1 | sort | tail -n 2 | head -n 1)\n\nln -sfn &quot;$APP_PATH\/releases\/$PREVIOUS&quot; &quot;$APP_PATH\/current&quot;\n\n# restart services if needed\nsudo systemctl restart laravel-queue.service || true\nsudo systemctl restart nodeapp.service || true\n\necho &quot;Rolled back from $LATEST to $PREVIOUS&quot;<\/code><\/pre>\n<p>Because you are not deleting anything during deployment, rollbacks are just another symlink switch. This is a huge operational advantage over in\u2011place updates.<\/p>\n<h3><span id=\"Security_and_reliability_considerations\">Security and reliability considerations<\/span><\/h3>\n<p>To keep your GitHub Actions to VPS pipeline safe and reliable:<\/p>\n<ul>\n<li>Use <strong>deploy\u2011only SSH keys<\/strong> with no interactive shell and limited <code>sudo<\/code> permissions.<\/li>\n<li>Rotate keys periodically and update GitHub Secrets.<\/li>\n<li>Run tests (unit, integration, even smoke tests) before the deploy step; fail fast if something is wrong.<\/li>\n<li>Start with deploying to a <strong>staging VPS<\/strong> before production, using the same workflow but different secrets and host.<\/li>\n<\/ul>\n<p>If you are interested in alternative deployment mechanisms (cPanel Git integration, Plesk, bare VPS), we cover them more broadly in <a href=\"https:\/\/www.dchost.com\/blog\/en\/git-ile-otomatik-deploy-cpanel-plesk-ve-vpste-adim-adim-kurulum\/\">our guide to Git deployment workflows on cPanel, Plesk and VPS<\/a>.<\/p>\n<h2><span id=\"Bringing_It_All_Together_on_Your_dchost_VPS\">Bringing It All Together on Your dchost VPS<\/span><\/h2>\n<p>Zero\u2011downtime deployments to a VPS with GitHub Actions are not reserved for huge teams or complex container setups. With a straightforward folder layout (<code>releases<\/code>, <code>shared<\/code>, <code>current<\/code>), a couple of small shell scripts and a GitHub Actions workflow, you can give your PHP and Node.js apps the same predictable, reversible deployment experience as much larger platforms. Your dchost VPS becomes a stable, scriptable target: Nginx stays up, PHP\u2011FPM or Node.js run under systemd, and every push to your main branch can safely roll out a new release without disturbing users.<\/p>\n<p>From here, you can extend the pipeline with staging environments, canary rollouts, database migration automation and richer monitoring dashboards. We already use these patterns daily across many customer projects, and they scale well from a single small VPS up to more complex multi\u2011server setups. If you are running your applications on dchost.com VPS, <a href=\"https:\/\/www.dchost.com\/dedicated-server\">dedicated server<\/a> or colocation infrastructure, you have all the control you need to implement this workflow today. Start by setting up the directory structure and activation scripts on your server, then wire in a simple GitHub Actions workflow. Once your first zero\u2011downtime deployment lands smoothly, you will not want to go back to manual uploads again.<\/p>\n<\/div>","protected":false},"excerpt":{"rendered":"<p>Deploying directly to a live VPS over SSH and hoping for the best is how many projects start. A quick git pull, a manual composer install or npm install, maybe a service restart, and you are done \u2013 until a deployment hangs in the middle, users see errors, and rolling back becomes a guessing game. [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":3651,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[26],"tags":[],"class_list":["post-3650","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-teknoloji"],"_links":{"self":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/3650","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/comments?post=3650"}],"version-history":[{"count":0,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/posts\/3650\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media\/3651"}],"wp:attachment":[{"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/media?parent=3650"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/categories?post=3650"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.dchost.com\/blog\/en\/wp-json\/wp\/v2\/tags?post=3650"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}