<?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: Jabed Hossain</title>
    <description>The latest articles on DEV Community by Jabed Hossain (@jabedhossainxyz).</description>
    <link>https://dev.to/jabedhossainxyz</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%2F4193554%2F48588388-5185-4306-bfc1-2fe2609a7d71.jpg</url>
      <title>DEV Community: Jabed Hossain</title>
      <link>https://dev.to/jabedhossainxyz</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jabedhossainxyz"/>
    <language>en</language>
    <item>
      <title>The Server Was Up. It Was Also Compromised.</title>
      <dc:creator>Jabed Hossain</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:20:22 +0000</pubDate>
      <link>https://dev.to/jabedhossainxyz/the-server-was-up-it-was-also-compromised-g7p</link>
      <guid>https://dev.to/jabedhossainxyz/the-server-was-up-it-was-also-compromised-g7p</guid>
      <description>&lt;p&gt;The sites were still loading. Forms worked, pages opened, nothing was throwing errors. From the outside, everything looked fine.&lt;/p&gt;

&lt;p&gt;It wasn't. Someone else had been living on that server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A rootkit&lt;/strong&gt; hiding processes from the usual tools&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backdoor files&lt;/strong&gt; planted in web directories, made to look like ordinary PHP files&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An SSH key I had never added&lt;/strong&gt;, sitting in an authorized_keys file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron jobs&lt;/strong&gt; quietly running malicious scripts on a schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of it broke anything. That was the point. Attackers who break things get noticed and kicked out. Attackers who stay quiet keep access for months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning it up
&lt;/h2&gt;

&lt;p&gt;I removed the backdoors, the unknown key and every cron job I couldn't explain, got rid of the rootkit, and audited the installed packages for changed files.&lt;/p&gt;

&lt;p&gt;Then I worked on making sure it couldn't happen the same way again:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSH:&lt;/strong&gt; keys only, no passwords, no root login&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firewall:&lt;/strong&gt; UFW on, with only the ports that need to be open&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity checks and login audits&lt;/strong&gt;, so changes and strange logins get noticed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Least privilege:&lt;/strong&gt; services run as their own users, not as root&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Watching it
&lt;/h2&gt;

&lt;p&gt;A clean server only stays clean if someone notices when it changes. So I added off site backups with rclone, a watchdog that runs every 5 minutes, uptime and SSL checks, and alerts to Discord and email.&lt;/p&gt;

&lt;p&gt;That setup now watches 29 client sites.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checks I run now
&lt;/h2&gt;

&lt;p&gt;Nothing advanced. That's exactly why they get skipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who can log in?&lt;/strong&gt;&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="nb"&gt;cat&lt;/span&gt; /root/.ssh/authorized_keys /home/&lt;span class="k"&gt;*&lt;/span&gt;/.ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What runs on a schedule?&lt;/strong&gt;&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="k"&gt;for &lt;/span&gt;u &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;: &lt;span class="nt"&gt;-f1&lt;/span&gt; /etc/passwd&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;crontab &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$u&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What is listening?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ss &lt;span class="nt"&gt;-tulpn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Which PHP files changed this week?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find /var/www &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"*.php"&lt;/span&gt; &lt;span class="nt"&gt;-mtime&lt;/span&gt; &lt;span class="nt"&gt;-7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One warning: if a rootkit is already on the machine, the tools on that machine can lie to you. That's why integrity checks and backups stored somewhere else matter so much.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"The site is up" says nothing about whether the server is safe.&lt;/strong&gt; Uptime monitoring and security monitoring are different jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attackers don't break things. They move in and stay quiet.&lt;/strong&gt; The most dangerous compromise looks like a normal day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you don't know what normal looks like, you can't spot what isn't.&lt;/strong&gt; Write down the expected SSH keys, cron jobs and open ports for every server you run, and check against that list.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Jabed, a DevOps engineer who also builds the Laravel, React and Next.js apps I deploy.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>linux</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>One Pipeline, Many Servers: How I Deploy 19+ Repos With One GitHub Actions Template</title>
      <dc:creator>Jabed Hossain</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:12:35 +0000</pubDate>
      <link>https://dev.to/jabedhossainxyz/one-pipeline-many-servers-how-i-deploy-19-repos-with-one-github-actions-template-ef4</link>
      <guid>https://dev.to/jabedhossainxyz/one-pipeline-many-servers-how-i-deploy-19-repos-with-one-github-actions-template-ef4</guid>
      <description>&lt;p&gt;Every deploy at my job used to be a small ritual. SSH into the right server, pull the code, install dependencies, build the assets, run the migrations, fix the permissions, clear the cache, reload PHP. Then do it again for the next project.&lt;/p&gt;

&lt;p&gt;Today I look after &lt;strong&gt;19+ repositories across 8 products&lt;/strong&gt;, running on several Linux servers, and none of them gets deployed by hand. They all use the same GitHub Actions template.&lt;/p&gt;

&lt;p&gt;This post covers how that template works, the small bot I run next to it, and the honest list of what it still can't do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h8f0i518km9dwilez8t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2h8f0i518km9dwilez8t.png" alt=" " width="800" height="532"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why manual deploys stop working
&lt;/h2&gt;

&lt;p&gt;The steps themselves are easy. The problem is that they live in someone's memory.&lt;/p&gt;

&lt;p&gt;Forget &lt;code&gt;composer install&lt;/code&gt; after a dependency change and the site throws errors. Skip a migration and a page breaks. Run the commands in a terminal connected to the wrong server and you've just updated the wrong client's site. And when something goes wrong, nobody else knows what was run, or when.&lt;/p&gt;

&lt;p&gt;With one project that's manageable. With dozens of client sites spread across several servers, it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  One template, two environments
&lt;/h2&gt;

&lt;p&gt;Every repository gets the same workflow file. Two branches matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;develop&lt;/code&gt; deploys to the &lt;strong&gt;staging&lt;/strong&gt; environment&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;main&lt;/code&gt; deploys to the &lt;strong&gt;live&lt;/strong&gt; environment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each one is a GitHub Environment with its own secrets: the server host, the SSH user, the deploy key, the app path and a Discord webhook. That's what makes one template work across many servers. &lt;strong&gt;The workflow file never changes. The secrets decide where the code lands.&lt;/strong&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;develop&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;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-${{ github.ref_name }}&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;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;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.ref_name == 'main' &amp;amp;&amp;amp; 'live' || 'staging' }}&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&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.SERVER_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.SERVER_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.SERVER_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 -e&lt;/span&gt;
            &lt;span class="s"&gt;cd ${{ secrets.APP_PATH }}&lt;/span&gt;
            &lt;span class="s"&gt;git pull origin ${{ github.ref_name }}&lt;/span&gt;
            &lt;span class="s"&gt;composer install --no-dev --optimize-autoloader&lt;/span&gt;
            &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
            &lt;span class="s"&gt;php artisan migrate --force&lt;/span&gt;
            &lt;span class="s"&gt;php artisan config:cache &amp;amp;&amp;amp; php artisan route:cache&lt;/span&gt;
            &lt;span class="s"&gt;sudo chown -R www-data:www-data storage bootstrap/cache&lt;/span&gt;
            &lt;span class="s"&gt;sudo systemctl reload php8.3-fpm&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;Notify Discord&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;curl -s -H "Content-Type: application/json" \&lt;/span&gt;
            &lt;span class="s"&gt;-d "{\"content\": \"Deploy ${{ job.status }}: ${{ github.repository }} (${{ github.ref_name }})\"}" \&lt;/span&gt;
            &lt;span class="s"&gt;"${{ secrets.DISCORD_WEBHOOK }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;set -e&lt;/code&gt; stops the script at the first command that fails, instead of carrying on and leaving the app half updated.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;concurrency&lt;/code&gt; makes sure two pushes never deploy to the same environment at the same time.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;if: always()&lt;/code&gt; on the Discord step means the team hears about failures, not only successes.&lt;/li&gt;
&lt;li&gt;Each server has its own deploy user and SSH key. GitHub never holds a root login.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WordPress repos use the same file with the Laravel lines removed. Adding a new project means copying the template, creating the two environments and filling in their secrets.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos57w2z9kwbi8pxnmlgv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fos57w2z9kwbi8pxnmlgv.png" alt=" " width="800" height="492"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A bot for the problems that show up before deploy
&lt;/h2&gt;

&lt;p&gt;Once deploys were automated, a different problem became visible. With this many repositories, pull requests got left open, branches drifted, and merge conflicts only surfaced when someone finally tried to merge, often right before a release.&lt;/p&gt;

&lt;p&gt;So every repo also gets a small scheduled workflow. Once a day it checks the open pull requests and posts to Discord if any of them has a merge conflict or has been waiting too long.&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;PR check&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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1-5"&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&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;check&lt;/span&gt;&lt;span class="pi"&gt;:&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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/github-script@v7&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;DISCORD_WEBHOOK&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.DISCORD_WEBHOOK }}&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;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;const { owner, repo } = context.repo;&lt;/span&gt;
            &lt;span class="s"&gt;const prs = await github.paginate(github.rest.pulls.list, { owner, repo, state: "open" });&lt;/span&gt;
            &lt;span class="s"&gt;const lines = [];&lt;/span&gt;
            &lt;span class="s"&gt;for (const pr of prs) {&lt;/span&gt;
              &lt;span class="s"&gt;const { data } = await github.rest.pulls.get({ owner, repo, pull_number: pr.number });&lt;/span&gt;
              &lt;span class="s"&gt;const days = Math.floor((Date.now() - new Date(pr.created_at)) / 86400000);&lt;/span&gt;
              &lt;span class="s"&gt;if (data.mergeable === false) lines.push(`Conflict: #${pr.number} ${pr.title}`);&lt;/span&gt;
              &lt;span class="s"&gt;else if (days &amp;gt;= 3) lines.push(`Open ${days} days: #${pr.number} ${pr.title}`);&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
            &lt;span class="s"&gt;if (lines.length) {&lt;/span&gt;
              &lt;span class="s"&gt;await fetch(process.env.DISCORD_WEBHOOK, {&lt;/span&gt;
                &lt;span class="s"&gt;method: "POST",&lt;/span&gt;
                &lt;span class="s"&gt;headers: { "Content-Type": "application/json" },&lt;/span&gt;
                &lt;span class="s"&gt;body: JSON.stringify({ content: `**${repo}**\n` + lines.join("\n") }),&lt;/span&gt;
              &lt;span class="s"&gt;});&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's not clever. It just makes sure someone notices while the problem is still small.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A deploy is a &lt;code&gt;git push&lt;/code&gt;.&lt;/strong&gt; Nobody has to remember the steps, because the steps are written down in the workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging gets every change first.&lt;/strong&gt; Clients see work on staging before it reaches their live site.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;New projects and new servers are a template, not a process.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;There's a record.&lt;/strong&gt; The Actions log shows every deploy: which commit, which environment, and whether it worked. Discord shows the same thing to the whole team.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Automation isn't about being fast. It's about making the boring steps impossible to forget.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What it still can't do
&lt;/h2&gt;

&lt;p&gt;Imagine the migration fails on live. &lt;code&gt;set -e&lt;/code&gt; stops the script and Discord tells me something broke. But by then, &lt;code&gt;git pull&lt;/code&gt; has already replaced the code. The live site is now running new code against an old database. Stopping early is better than carrying on, but it doesn't put anything back.&lt;/p&gt;

&lt;p&gt;Two things are missing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No backup right before the deploy.&lt;/strong&gt; The servers have nightly off site backups, but those can be almost a day old.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rollback.&lt;/strong&gt; Going back means reverting the commit and deploying again, while the site is broken.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'm adding next
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68i0olwo84sx6dlt2j5g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F68i0olwo84sx6dlt2j5g.png" alt=" " width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  1. A database dump before every live deploy
&lt;/h3&gt;

&lt;p&gt;The code is already in git. What can't be recreated is the data. So the first step on the server becomes a timestamped dump, keeping only the last five. Credentials come from a protected &lt;code&gt;~/.my.cnf&lt;/code&gt;, not the script.&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="nv"&gt;TS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/backups/deploys/my-app
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
mysqldump &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; my_app_db | &lt;span class="nb"&gt;gzip&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;/db-&lt;/span&gt;&lt;span class="nv"&gt;$TS&lt;/span&gt;&lt;span class="s2"&gt;.sql.gz"&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1t&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/db-&lt;span class="k"&gt;*&lt;/span&gt;.sql.gz | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +6 | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Release folders and a symlink
&lt;/h3&gt;

&lt;p&gt;Instead of pulling into the live folder, each deploy builds into its own folder under &lt;code&gt;releases/&lt;/code&gt;. Files that must survive deploys, like &lt;code&gt;.env&lt;/code&gt;, Laravel's &lt;code&gt;storage&lt;/code&gt; and WordPress uploads, live in &lt;code&gt;shared/&lt;/code&gt; and get linked in. Nginx serves &lt;code&gt;current&lt;/code&gt;, which is just a symlink. The new release only goes live when that link moves, and moving it back is the rollback.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrvpzlrey3ja95ch8ycb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvrvpzlrey3ja95ch8ycb.png" alt=" " width="799" height="464"&gt;&lt;/a&gt;&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="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="nv"&gt;APP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/www/my-app
&lt;span class="nv"&gt;REL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$APP&lt;/span&gt;/releases/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;

git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 &lt;span class="nt"&gt;--branch&lt;/span&gt; main git@github.com:your-org/my-app.git &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/shared/.env &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;/.env"&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;/storage"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/shared/storage &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;/storage"&lt;/span&gt;

&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
composer &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-dev&lt;/span&gt; &lt;span class="nt"&gt;--optimize-autoloader&lt;/span&gt;
php artisan migrate &lt;span class="nt"&gt;--force&lt;/span&gt;
php artisan config:cache

&lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/current.tmp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-Tf&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/current.tmp &lt;span class="nv"&gt;$APP&lt;/span&gt;/current
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload php8.3-fpm

&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1dt&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/releases/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +6 | xargs &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I'll probably use &lt;a href="https://deployer.org/" rel="noopener noreferrer"&gt;Deployer&lt;/a&gt; for this rather than maintaining the script myself. It does the release and symlink pattern out of the box, has recipes for Laravel and WordPress, and rolls back with &lt;code&gt;dep rollback&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A health check that rolls back on its own
&lt;/h3&gt;

&lt;p&gt;After the switch, the pipeline requests a health endpoint. If it doesn't get a success response within ten seconds, it points &lt;code&gt;current&lt;/code&gt; back at the previous release and fails the job, which sends the Discord alert.&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="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 10 https://example.com/health &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;PREV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1dt&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/releases/&lt;span class="k"&gt;*&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 2p&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;ln&lt;/span&gt; &lt;span class="nt"&gt;-sfn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PREV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/current.tmp &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="nt"&gt;-Tf&lt;/span&gt; &lt;span class="nv"&gt;$APP&lt;/span&gt;/current.tmp &lt;span class="nv"&gt;$APP&lt;/span&gt;/current
  &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl reload php8.3-fpm
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Health check failed, rolled back to &lt;/span&gt;&lt;span class="nv"&gt;$PREV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One limit to keep in mind: &lt;strong&gt;the symlink rolls back code, not the database.&lt;/strong&gt; A migration that drops a column won't be undone by moving a link. That's why the dump comes first, and why migrations should stay backward compatible. Add the new column in one release, stop using the old one in the next, and remove it only after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The first version of a pipeline doesn't need to be perfect. Mine started as "run the same commands I used to type," and that alone removed most of the mistakes. The next version is about making failure cheap: keep a copy of the data, and make going back a single command.&lt;/p&gt;

&lt;p&gt;How do you handle deploys across many small projects? I'd like to hear what other setups look like, especially for WordPress.&lt;/p&gt;

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