{"id":4339,"date":"2026-09-12T16:11:54","date_gmt":"2026-09-12T10:41:54","guid":{"rendered":"https:\/\/cpanelfree.com\/blog\/how-to-deploy-web-apps-github-actions-linux-vps-ssh\/"},"modified":"2026-09-12T16:11:54","modified_gmt":"2026-09-12T10:41:54","slug":"how-to-deploy-web-apps-github-actions-linux-vps-ssh","status":"publish","type":"post","link":"https:\/\/cpanelfree.com\/blog\/how-to-deploy-web-apps-github-actions-linux-vps-ssh\/","title":{"rendered":"How to Deploy Web Apps from GitHub Actions to Linux VPS via SSH"},"content":{"rendered":"<p>Continuous Integration and Continuous Deployment (CI\/CD) eliminates repetitive manual deployments, human configuration errors, and unexpected downtime. While managed cloud platforms offer convenient git-push integrations, deploying directly to your own self-hosted <a href=\"https:\/\/cpanelfree.com\/\">Linux VPS<\/a> provides complete hardware ownership, unlimited deployment frequency, and drastically lower infrastructure costs.<\/p>\n<p>In this guide, you will learn how to architect an automated, production-grade deployment pipeline using <strong>GitHub Actions<\/strong> and <strong>SSH keys<\/strong>. We will establish dedicated deployment users, configure hardened cryptographic authentication, deploy code using atomic symlinks and <code>rsync<\/code>, and reload production services without dropping client connections.<\/p>\n<h2>1. The Production CI\/CD Deployment Architecture<\/h2>\n<p>A resilient CI\/CD pipeline separates build-time validation from deployment orchestration. Below is the end-to-end execution workflow:<\/p>\n<ol>\n<li>A developer merges code into the <code>main<\/code> production branch.<\/li>\n<li>GitHub Actions triggers an ephemeral runner to execute automated linting, security scanning, and unit tests.<\/li>\n<li>Upon passing tests, the runner establishes an encrypted SSH connection to your remote VPS using an isolated deployment key.<\/li>\n<li>The runner synchronizes release artifacts using <code>rsync<\/code> into a versioned release folder.<\/li>\n<li>The pipeline executes database migrations, switches an atomic symlink to point to the new release, and reloads systemd or Docker services in zero downtime.<\/li>\n<\/ol>\n<h2>2. Configuring a Hardened Deploy User on Linux VPS<\/h2>\n<p>Never execute CI\/CD deployments using the <code>root<\/code> administrative account. If an attacker compromises your repository secrets or a malicious pull request slips through, they gain unrestricted root privileges across your server. Instead, create an isolated deploy user with tightly constrained sudo permissions:<\/p>\n<pre><code># Create an unprivileged deploy user\nsudo adduser --disabled-password --gecos \"\" deployer\n\n# Create SSH configuration directory\nsudo mkdir -p \/home\/deployer\/.ssh\nsudo chmod 700 \/home\/deployer\/.ssh\n\n# Configure target release web directory\nsudo mkdir -p \/var\/www\/my-app\/releases\nsudo mkdir -p \/var\/www\/my-app\/shared\nsudo chown -R deployer:deployer \/var\/www\/my-app<\/code><\/pre>\n<p>Next, grant the <code>deployer<\/code> user permission to reload your web server or systemd services without requesting an interactive password:<\/p>\n<pre><code># Add sudoers rule using visudo\necho \"deployer ALL=(ALL) NOPASSWD: \/bin\/systemctl reload nginx, \/bin\/systemctl restart my-app.service\" | sudo tee \/etc\/sudoers.d\/deployer-service-reload<\/code><\/pre>\n<h2>3. Generating and Securing Ed25519 SSH Keys<\/h2>\n<p>Generate a modern Ed25519 SSH keypair on your local administrative machine specifically for GitHub Actions deployment:<\/p>\n<pre><code>ssh-keygen -t ed25519 -C \"github-actions-deploy\" -f .\/id_deploy_github<\/code><\/pre>\n<p>Append the public key (<code>id_deploy_github.pub<\/code>) to the VPS deployer authorized keys:<\/p>\n<pre><code>cat .\/id_deploy_github.pub | ssh administrator@your-vps-ip \"sudo tee -a \/home\/deployer\/.ssh\/authorized_keys\"\nssh administrator@your-vps-ip \"sudo chmod 600 \/home\/deployer\/.ssh\/authorized_keys &amp;&amp; sudo chown -R deployer:deployer \/home\/deployer\/.ssh\"<\/code><\/pre>\n<p>Next, open your GitHub repository, navigate to <strong>Settings &gt; Secrets and variables &gt; Actions<\/strong>, and configure the following Repository Secrets:<\/p>\n<ul>\n<li><code>SSH_PRIVATE_KEY<\/code>: The entire private key content (including <code>-----BEGIN OPENSSH PRIVATE KEY-----<\/code>).<\/li>\n<li><code>SSH_HOST<\/code>: The public IP address or hostname of your VPS.<\/li>\n<li><code>SSH_USER<\/code>: <code>deployer<\/code><\/li>\n<li><code>SSH_PORT<\/code>: Your custom SSH port (e.g., <code>22<\/code> or hardened custom port like <code>2222<\/code>).<\/li>\n<\/ul>\n<h2>4. Complete Production GitHub Actions Workflow<\/h2>\n<p>Create the workflow definition file inside your repository at <code>.github\/workflows\/deploy.yml<\/code>:<\/p>\n<pre><code>name: Production Deployment Pipeline\n\non:\n  push:\n    branches:\n      - main\n\njobs:\n  test_and_lint:\n    name: Run Test Suite &amp; Linters\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Code\n        uses: actions\/checkout@v4\n\n      - name: Setup Node.js Environment\n        uses: actions\/setup-node@v4\n        with:\n          node-version: 20\n          cache: 'npm'\n\n      - name: Install Dependencies\n        run: npm ci\n\n      - name: Execute Tests\n        run: npm test\n\n  deploy_to_vps:\n    name: Zero-Downtime VPS Deployment\n    needs: test_and_lint\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Source Code\n        uses: actions\/checkout@v4\n\n      - name: Configure SSH Private Key\n        uses: webfactory\/ssh-agent@v0.9.0\n        with:\n          ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}\n\n      - name: Add Server to Known Hosts\n        run: |\n          mkdir -p ~\/.ssh\n          ssh-keyscan -p ${{ secrets.SSH_PORT }} -H ${{ secrets.SSH_HOST }} &gt;&gt; ~\/.ssh\/known_hosts\n\n      - name: Execute Atomic Release Deployment\n        env:\n          HOST: ${{ secrets.SSH_HOST }}\n          USER: ${{ secrets.SSH_USER }}\n          PORT: ${{ secrets.SSH_PORT }}\n        run: |\n          RELEASE_TIMESTAMP=$(date +%Y%m%d%H%M%S)\n          TARGET_DIR=\"\/var\/www\/my-app\"\n          NEW_RELEASE=\"$TARGET_DIR\/releases\/$RELEASE_TIMESTAMP\"\n\n          echo \"Deploying release $RELEASE_TIMESTAMP to $HOST...\"\n\n          # 1. Create release directory\n          ssh -p $PORT $USER@$HOST \"mkdir -p $NEW_RELEASE\"\n\n          # 2. Sync application files via rsync\n          rsync -avz -e \"ssh -p $PORT\" --exclude='.git' --exclude='.github' --exclude='node_modules' .\/ $USER@$HOST:$NEW_RELEASE\/\n\n          # 3. Execute installation and atomic symlink swap\n          ssh -p $PORT $USER@$HOST &lt;&lt; 'EOF'\n            cd '\"$NEW_RELEASE\"'\n            \n            # Symlink persistent shared configuration\n            ln -nfs \/var\/www\/my-app\/shared\/.env '\"$NEW_RELEASE\"'\/.env\n            \n            # Install production dependencies\n            npm ci --omit=dev\n            \n            # Run database migrations\n            npm run db:migrate --if-present\n            \n            # Atomic symlink swap\n            ln -sfn '\"$NEW_RELEASE\"' \/var\/www\/my-app\/current\n            \n            # Purge older releases (keep last 5)\n            cd \/var\/www\/my-app\/releases &amp;&amp; ls -t | tail -n +6 | xargs -r rm -rf\n            \n            # Gracefully reload system service\n            sudo systemctl restart my-app.service\n            sudo systemctl reload nginx\n          EOF\n\n          echo \"Deployment successfully certified!\"<\/code><\/pre>\n<h2>5. Why Atomic Symlink Switching Prevents Downtime<\/h2>\n<p>Traditional deployment scripts that run <code>git pull &amp;&amp; npm install<\/code> directly in the live web root introduce serious downtime windows. If an HTTP request arrives while files are being replaced, users encounter broken classes, 404 assets, and incomplete database transactions.<\/p>\n<p>By preparing the new release in an isolated timestamped directory (<code>\/releases\/20260912160000<\/code>) and executing the Linux atomic symlink switch command:<\/p>\n<pre><code>ln -sfn \/var\/www\/my-app\/releases\/20260912160000 \/var\/www\/my-app\/current<\/code><\/pre>\n<p>The Linux filesystem swaps the pointer in a single atomic inode update. Incoming HTTP requests are seamlessly routed from the previous version to the new version without a millisecond of service disruption.<\/p>\n<div style=\"background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);border: 1px solid #334155;border-radius: 12px;padding: 28px;margin: 36px 0;text-align: center\">\n<h3 style=\"color: #38bdf8;margin-top: 0;font-size: 22px\">Power Your CI\/CD Pipelines with CpanelFree Cloud VPS<\/h3>\n<p style=\"color: #cbd5e1;font-size: 16px;line-height: 1.6;max-width: 680px;margin: 12px auto 24px auto\">Deploy modern Git-driven pipelines without limits. Enjoy unmetered bandwidth, enterprise SSD\/NVMe performance, and dedicated IPv4 addresses built for production uptime.<\/p>\n<p>    <a href=\"https:\/\/cpanelfree.com\/\" style=\"background: #38bdf8;color: #0f172a;font-weight: 700;padding: 12px 28px;border-radius: 6px;text-decoration: none;display: inline-block;font-size: 15px\">Get Started with CpanelFree VPS &rarr;<\/a>\n<\/div>\n","protected":false},"excerpt":{"rendered":"<p>Continuous Integration and Continuous Deployment (CI\/CD) eliminates repetitive manual deployments, human configuration errors, and unexpected downtime. While managed cloud platforms offer convenient git-push integrations, deploying directly to your own self-hosted Linux VPS provides complete hardware ownership, unlimited deployment frequency, and drastically lower infrastructure costs. In this guide, you will learn how to architect an automated, &#8230; <a title=\"How to Deploy Web Apps from GitHub Actions to Linux VPS via SSH\" class=\"read-more\" href=\"https:\/\/cpanelfree.com\/blog\/how-to-deploy-web-apps-github-actions-linux-vps-ssh\/\" aria-label=\"Read more about How to Deploy Web Apps from GitHub Actions to Linux VPS via SSH\">Read more<\/a><\/p>\n","protected":false},"author":1,"featured_media":4338,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-4339","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-web-hosting-news"],"_links":{"self":[{"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/posts\/4339","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/comments?post=4339"}],"version-history":[{"count":0,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/posts\/4339\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/media\/4338"}],"wp:attachment":[{"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/media?parent=4339"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/categories?post=4339"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/cpanelfree.com\/blog\/wp-json\/wp\/v2\/tags?post=4339"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}