<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Urvin Sanghavi</title>
    <description>The latest articles on DEV Community by Urvin Sanghavi (@urvin).</description>
    <link>https://dev.to/urvin</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3984063%2F7c048fb0-4524-4396-8697-74ef7fe5bfa1.png</url>
      <title>DEV Community: Urvin Sanghavi</title>
      <link>https://dev.to/urvin</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/urvin"/>
    <language>en</language>
    <item>
      <title>Automatically Deploy Your App to a VPS with GitHub Actions</title>
      <dc:creator>Urvin Sanghavi</dc:creator>
      <pubDate>Fri, 11 Sep 2026 00:04:23 +0000</pubDate>
      <link>https://dev.to/urvin/automatically-deploy-your-app-to-a-vps-with-github-actions-4k5o</link>
      <guid>https://dev.to/urvin/automatically-deploy-your-app-to-a-vps-with-github-actions-4k5o</guid>
      <description>&lt;p&gt;If your deploy process is "SSH into the server, &lt;code&gt;cd&lt;/code&gt; to the app, &lt;code&gt;git pull&lt;/code&gt;, restart something, hope" — this post replaces it with &lt;code&gt;git push&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you'll have by the end:&lt;/strong&gt; every push to your &lt;code&gt;main&lt;/code&gt; branch updates the app on your server automatically. You can also trigger a deploy by hand from the GitHub UI.&lt;/p&gt;

&lt;p&gt;This is the &lt;em&gt;simple&lt;/em&gt; version — it has a few seconds of downtime during the restart and it builds on the server. &lt;a href="https://dev.toPASTE_URL_HERE"&gt;Post 6 in this series&lt;/a&gt; upgrades it to a zero-downtime setup. Start here; the simple version is enough for a long time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is "deploying with GitHub Actions"?
&lt;/h2&gt;

&lt;p&gt;GitHub Actions is a task runner built into every GitHub repo. You describe a job in a YAML file, and GitHub runs it on a fresh virtual machine when something happens — in our case, "when code lands on &lt;code&gt;main&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;The job we want is tiny: connect to our server over SSH and run the same commands we'd run by hand. The value isn't magic — it's that the steps are &lt;em&gt;written down&lt;/em&gt;, &lt;em&gt;always run in the same order&lt;/em&gt;, and &lt;em&gt;don't depend on you being awake&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;An app in a GitHub repo.&lt;/li&gt;
&lt;li&gt;A VPS (any provider) you can SSH into, with your app already cloned and running once — e.g. in &lt;code&gt;/srv/snip&lt;/code&gt;, served by nginx, run by a process manager. If you don't have that yet, set it up manually first; this post automates the &lt;em&gt;update&lt;/em&gt;, not the first install.&lt;/li&gt;
&lt;li&gt;A dedicated Linux user for deploys (don't use &lt;code&gt;root&lt;/code&gt;). We'll call it &lt;code&gt;deploy&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Make an SSH key just for deploys
&lt;/h2&gt;

&lt;p&gt;On your &lt;strong&gt;local machine&lt;/strong&gt;, generate a key pair that only GitHub Actions will use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; ed25519 &lt;span class="nt"&gt;-f&lt;/span&gt; ~/.ssh/snip_deploy &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"github-actions-deploy"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;code&gt;~/.ssh/snip_deploy&lt;/code&gt; (private) and &lt;code&gt;~/.ssh/snip_deploy.pub&lt;/code&gt; (public).&lt;/p&gt;

&lt;p&gt;Add the &lt;strong&gt;public&lt;/strong&gt; key to your server so the &lt;code&gt;deploy&lt;/code&gt; user accepts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/snip_deploy.pub deploy@YOUR_SERVER_IP
&lt;span class="c"&gt;# or manually: append the .pub line to /home/deploy/.ssh/authorized_keys on the server&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; ~/.ssh/snip_deploy deploy@YOUR_SERVER_IP &lt;span class="s2"&gt;"echo connected"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Give the deploy user permission to restart the app
&lt;/h2&gt;

&lt;p&gt;The deploy needs to restart your app process without a password prompt. If you run the app as a systemd service (&lt;code&gt;snip.service&lt;/code&gt;), allow exactly that one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server, as root:&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart snip, /usr/bin/systemctl reload nginx'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sudoers.d/snip-deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Adjust for your stack:&lt;/strong&gt; if you use PM2, supervisor, Docker Compose, etc., substitute the restart command you actually run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Store the secrets in GitHub
&lt;/h2&gt;

&lt;p&gt;In your repo: &lt;strong&gt;Settings → Secrets and variables → Actions → New repository secret&lt;/strong&gt;. Add three:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_HOST&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;your server's IP or hostname&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_USER&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deploy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;VPS_SSH_KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the &lt;strong&gt;entire contents&lt;/strong&gt; of &lt;code&gt;~/.ssh/snip_deploy&lt;/code&gt; (the private key, including the &lt;code&gt;BEGIN&lt;/code&gt;/&lt;code&gt;END&lt;/code&gt; lines)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Secrets are encrypted and never printed in logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The workflow file
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.github/workflows/deploy.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# adds a "Run workflow" button in the Actions tab&lt;/span&gt;

&lt;span class="c1"&gt;# Never run two deploys at once.&lt;/span&gt;
&lt;span class="na"&gt;concurrency&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy-main&lt;/span&gt;
  &lt;span class="na"&gt;cancel-in-progress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy to VPS&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deploy over SSH&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;appleboy/ssh-action@v1.2.2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_HOST }}&lt;/span&gt;
          &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_USER }}&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.VPS_SSH_KEY }}&lt;/span&gt;
          &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;set -euo pipefail&lt;/span&gt;
            &lt;span class="s"&gt;cd /srv/snip&lt;/span&gt;

            &lt;span class="s"&gt;# Get the exact code that's on origin/main.&lt;/span&gt;
            &lt;span class="s"&gt;git fetch origin main&lt;/span&gt;
            &lt;span class="s"&gt;git reset --hard origin/main&lt;/span&gt;

            &lt;span class="s"&gt;# Install dependencies and build.&lt;/span&gt;
            &lt;span class="s"&gt;# Adjust for your stack:&lt;/span&gt;
            &lt;span class="s"&gt;npm ci&lt;/span&gt;
            &lt;span class="s"&gt;npm run build&lt;/span&gt;

            &lt;span class="s"&gt;# Apply database migrations.&lt;/span&gt;
            &lt;span class="s"&gt;# Adjust for your stack:&lt;/span&gt;
            &lt;span class="s"&gt;npm run migrate&lt;/span&gt;

            &lt;span class="s"&gt;# Restart the app.&lt;/span&gt;
            &lt;span class="s"&gt;sudo systemctl restart snip&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit and push it to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What each part does
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;on.push.branches: [main]&lt;/code&gt;&lt;/strong&gt; — run only when commits land on &lt;code&gt;main&lt;/code&gt;. Pull requests don't trigger it. (&lt;a href="https://dev.toPASTE_URL_HERE"&gt;Post 2&lt;/a&gt; adds a test job that &lt;em&gt;does&lt;/em&gt; run on pull requests.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;concurrency&lt;/code&gt;&lt;/strong&gt; — if you push twice quickly, the second deploy waits for the first to finish instead of racing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git reset --hard origin/main&lt;/code&gt;&lt;/strong&gt; — not &lt;code&gt;git pull&lt;/code&gt;. This guarantees the server's code is &lt;em&gt;exactly&lt;/em&gt; &lt;code&gt;origin/main&lt;/code&gt;, even if something on the server got modified. Nothing should ever be edited directly on the server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;set -euo pipefail&lt;/code&gt;&lt;/strong&gt; — stop on the first error instead of charging ahead. If &lt;code&gt;npm run build&lt;/code&gt; fails, the deploy stops &lt;em&gt;before&lt;/em&gt; the restart, and your old version keeps running.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Watch it run
&lt;/h2&gt;

&lt;p&gt;Push any commit to &lt;code&gt;main&lt;/code&gt;, then open the repo's &lt;strong&gt;Actions&lt;/strong&gt; tab. You'll see the workflow run live. Click it to see the SSH output.&lt;/p&gt;

&lt;p&gt;To deploy without a new commit (e.g. to retry): &lt;strong&gt;Actions → Deploy → Run workflow&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Permission denied (publickey)&lt;/code&gt;&lt;/strong&gt; — the private key in &lt;code&gt;VPS_SSH_KEY&lt;/code&gt; doesn't match the public key in &lt;code&gt;authorized_keys&lt;/code&gt;, or you pasted only part of it. Re-copy the whole file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;could not read Username for 'https://github.com'&lt;/code&gt;&lt;/strong&gt; — your server's clone uses an HTTPS remote, which can't authenticate non-interactively. Switch it to SSH with a &lt;a href="https://docs.github.com/en/authentication/connecting-to-github-with-ssh" rel="noopener noreferrer"&gt;deploy key&lt;/a&gt;, or make the repo public, or add a step that sets a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;npm: command not found&lt;/code&gt;&lt;/strong&gt; — the non-interactive SSH session has a minimal &lt;code&gt;PATH&lt;/code&gt;. Use full paths, or add &lt;code&gt;source ~/.profile&lt;/code&gt; / &lt;code&gt;source ~/.nvm/nvm.sh&lt;/code&gt; at the top of the script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build gets killed on a small server&lt;/strong&gt; — &lt;code&gt;npm run build&lt;/code&gt; can run out of memory on a 1 GB VPS. Add swap, or build in GitHub Actions and copy the result over — that's covered in &lt;a href="https://dev.toPASTE_URL_HERE"&gt;post 2&lt;/a&gt; and &lt;a href="https://dev.toPASTE_URL_HERE"&gt;post 6&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The site is broken for a few seconds on every deploy&lt;/strong&gt; — expected with this approach. &lt;a href="https://dev.toPASTE_URL_HERE"&gt;Post 6&lt;/a&gt; fixes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Right now a commit that breaks the app will still deploy — the workflow doesn't know the difference between working code and broken code. &lt;a href="https://dev.toPASTE_URL_HERE"&gt;Next: run your test suite in CI and only deploy if it passes.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>githubactions</category>
      <category>cicd</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
